diff --git a/scripts/launch_ui_raybig_desktop.sh b/scripts/launch_ui_raybig_desktop.sh index 465f18701..d03917bb6 100755 --- a/scripts/launch_ui_raybig_desktop.sh +++ b/scripts/launch_ui_raybig_desktop.sh @@ -53,6 +53,8 @@ export NOBOARD=1 export SIMULATION=1 export SKIP_FW_QUERY=1 export USE_WEBCAM=1 +export PRIME_TYPE="${PRIME_TYPE:-0}" +export SP_RAYBIG_FAKE_DRIVE_STATS="${SP_RAYBIG_FAKE_DRIVE_STATS:-1}" backup_dir="$(mktemp -d /tmp/starpilot_raybig_ui_backup.XXXXXX)" backup_manifest="${backup_dir}/.artifact_manifest" diff --git a/scripts/speed_limit_vision/evaluate_reviewed_route_events.py b/scripts/speed_limit_vision/evaluate_reviewed_route_events.py index e17268d9c..482ffc0e6 100644 --- a/scripts/speed_limit_vision/evaluate_reviewed_route_events.py +++ b/scripts/speed_limit_vision/evaluate_reviewed_route_events.py @@ -48,6 +48,11 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--window-before", type=float, default=4.0, help="Seconds replayed before the reviewed frame.") parser.add_argument("--window-after", type=float, default=3.0, help="Seconds replayed after the reviewed frame.") parser.add_argument("--dedupe-seconds", type=float, default=3.0, help="Collapse nearby reviewed rows with the same expected value.") + parser.add_argument( + "--review-timebase-fps", + type=float, + help="FPS used when review timestamps were generated. Use 25 for legacy raw-HEVC queues created before comma's 20 FPS timebase fix.", + ) parser.add_argument("--measured-base-inference-seconds", type=float, default=0.44, help="Measured no-proposal comma inference cost.") parser.add_argument("--measured-classifier-forward-seconds", type=float, default=0.066, help="Measured comma cost per classifier forward.") parser.add_argument("--measured-tracking-base-seconds", type=float, default=0.012, help="Measured optical-flow and crop preparation cost.") @@ -169,12 +174,13 @@ def replay_video_cases(cases: list[ReviewedCase], args: argparse.Namespace) -> d daemon.last_published_support_at = 0.0 capture = cv2.VideoCapture(str(cases[0].source_video_path)) fps = source_video_fps(cases[0].source_video_path, capture.get(cv2.CAP_PROP_FPS)) + review_timebase_fps = args.review_timebase_fps or fps frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0) duration_s = frame_count / fps if frame_count > 0 else 60.0 windows = { case.record_key: ( - max(case.frame_time_s - args.window_before, 0.0), - min(case.frame_time_s + args.window_after, duration_s), + max(case.frame_time_s * review_timebase_fps / fps - args.window_before, 0.0), + min(case.frame_time_s * review_timebase_fps / fps + args.window_after, duration_s), ) for case in cases } @@ -216,6 +222,8 @@ def replay_video_cases(cases: list[ReviewedCase], args: argparse.Namespace) -> d def main() -> int: args = parse_args() + if args.review_timebase_fps is not None and args.review_timebase_fps <= 0.0: + raise ValueError("--review-timebase-fps must be greater than zero") queue_path = args.queue.expanduser().resolve() labels_path = args.labels.expanduser().resolve() if args.labels else queue_path.with_name("manual_review_labels.csv") configure_models(args.models_dir) @@ -392,6 +400,7 @@ def main() -> int: )) summary = { "models_dir": str(args.models_dir.expanduser().resolve()), + "review_timebase_fps": args.review_timebase_fps, "crop_ocr": args.crop_ocr, "classifier_min_confidence": slv.US_CLASSIFIER_MIN_CONFIDENCE, "measured_base_inference_seconds": args.measured_base_inference_seconds, diff --git a/selfdrive/ui/layouts/home.py b/selfdrive/ui/layouts/home.py index f064dfcb7..445d84cfe 100644 --- a/selfdrive/ui/layouts/home.py +++ b/selfdrive/ui/layouts/home.py @@ -3,9 +3,11 @@ import pyray as rl from collections.abc import Callable from enum import IntEnum from openpilot.common.params import Params +from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAlert from openpilot.selfdrive.ui.widgets.exp_mode_button import ExperimentalModeButton -from openpilot.selfdrive.ui.widgets.setup import SetupWidget, StarPilotLogoWidget +from openpilot.selfdrive.ui.widgets.drive_stats import DriveStatsDashboard +from openpilot.selfdrive.ui.widgets.setup import SetupWidget from openpilot.selfdrive.ui.lib.starpilot_version import starpilot_display_description from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos @@ -55,7 +57,7 @@ class HomeLayout(Widget): self.update_notif_rect = rl.Rectangle(0, 0, 200, HEADER_HEIGHT - 10) self.alert_notif_rect = rl.Rectangle(0, 0, 220, HEADER_HEIGHT - 10) - self._starpilot_logo_widget = StarPilotLogoWidget() + self._drive_stats = DriveStatsDashboard(self.params) self._setup_widget = SetupWidget() self._exp_mode_button = ExperimentalModeButton() @@ -190,7 +192,7 @@ class HomeLayout(Widget): self.offroad_alert.render(self.content_rect) def _render_left_column(self): - self._starpilot_logo_widget.render(self.left_column_rect) + self._drive_stats.render_overview(self.left_column_rect) def _render_right_column(self): exp_height = 125 @@ -205,9 +207,13 @@ class HomeLayout(Widget): self.right_column_rect.width, self.right_column_rect.height - exp_height - SPACING, ) - self._setup_widget.render(setup_rect) + if ui_state.prime_state.is_paired(): + self._drive_stats.render_records(setup_rect) + else: + self._setup_widget.render(setup_rect) def _refresh(self): + self._drive_stats.refresh() self._version_text = self._get_version_text() update_available = self.update_alert.refresh() alert_count = self.offroad_alert.refresh() diff --git a/selfdrive/ui/tests/test_drive_stats_widget.py b/selfdrive/ui/tests/test_drive_stats_widget.py new file mode 100644 index 000000000..56752b53c --- /dev/null +++ b/selfdrive/ui/tests/test_drive_stats_widget.py @@ -0,0 +1,141 @@ +from datetime import datetime + +from openpilot.selfdrive.ui.widgets.drive_stats import demo_drive_stats_data, load_drive_stats_data + + +class FakeParams: + def __init__(self, values): + self.values = values + + def get(self, key): + return self.values.get(key) + + def get_bool(self, key): + return bool(self.values.get(key, False)) + + +def test_drive_stats_uses_cloud_totals_and_local_galaxy_week(): + params = FakeParams({ + "ApiCache_DriveStats": { + "all": {"distance": 100.0, "routes": 12, "minutes": 360}, + "week": {"distance": 25.0, "routes": 3, "minutes": 90}, + }, + "GalaxyDashboardStats": { + "ignoredRoutes": ["ignored"], + "routes": { + "previous-week": { + "date": "2026-07-19T09:00:00", + "distanceMeters": 8046.72, + "duration": 1800, + "engagedSeconds": 1200, + }, + "this-week": { + "date": "2026-07-20T09:00:00", + "distanceMeters": 16093.44, + "duration": 3600, + "engagedSeconds": 3000, + }, + "ignored": { + "date": "2026-07-20T10:00:00", + "distanceMeters": 160934.4, + "duration": 7200, + }, + }, + "personalRecords": { + "longestDrive": {"distanceMeters": 32186.88, "date": "2026-07-18T09:00:00"}, + "mostEngagedDay": {"percent": 0, "date": ""}, + }, + }, + }) + + data = load_drive_stats_data(params, now=datetime(2026, 7, 20, 12, 0, 0)) + + assert data.all_time.drives == 12 + assert data.all_time.distance == 100.0 + assert data.all_time.hours == 6.0 + assert data.past_week.drives == 3 + assert data.past_week.distance == 25.0 + assert data.this_week.drives == 1 + assert data.this_week.distance == 10.0 + assert data.this_week.hours == 1.0 + assert data.daily_distance[0].label == "Mon" + assert data.daily_distance[0].distance == 10.0 + assert data.daily_distance[0].is_today + assert data.daily_distance[1].is_future + assert data.records[0].value == "20.0 mi" + assert data.records[0].detail == "Jul 18" + assert data.records[1].value == "83%" + assert data.records[1].detail == "Jul 20" + + +def test_drive_stats_falls_back_to_local_totals_and_computed_records(): + params = FakeParams({ + "IsMetric": True, + "StarPilotStats": { + "StarPilotMeters": 25000.0, + "StarPilotSeconds": 7200, + "StarPilotDrives": 4, + }, + "GalaxyDashboardStats": { + "routes": { + "previous-week": { + "date": "2026-07-19T09:00:00", + "distanceMeters": 5000.0, + "duration": 1800, + "engagedSeconds": 900, + "clean": True, + "undistracted": True, + }, + "this-week": { + "date": "2026-07-20T09:00:00", + "distanceMeters": 10000.0, + "duration": 3600, + "engagedSeconds": 3000, + "clean": True, + "undistracted": True, + }, + }, + }, + }) + + data = load_drive_stats_data(params, now=datetime(2026, 7, 20, 12, 0, 0)) + + assert data.all_time.drives == 4 + assert data.all_time.distance == 25.0 + assert data.all_time.hours == 2.0 + assert data.past_week.drives == 2 + assert data.past_week.distance == 15.0 + assert data.this_week.drives == 1 + assert data.this_week.distance == 10.0 + assert data.records[0].value == "10.0 km" + assert data.records[2].value == "10.0 km" + assert data.records[3].value == "2 days" + assert data.records[5].value == "2 drives" + + +def test_drive_stats_uses_clean_empty_record_labels(): + data = load_drive_stats_data(FakeParams({}), now=datetime(2026, 7, 20, 12, 0, 0)) + + assert data.records[2].detail == "No drives" + assert data.records[3].value == "0 days" + assert data.records[3].detail == "No drives" + assert data.records[4].detail == "No clean drives" + assert data.records[5].value == "0 drives" + assert data.records[5].detail == "No clean drives" + + +def test_demo_drive_stats_populates_every_dashboard_section(): + data = demo_drive_stats_data(False, now=datetime(2026, 7, 20, 12, 0, 0)) + + assert data.all_time.drives == 584 + assert data.past_week.drives == 18 + assert data.this_week.drives == 7 + assert len(data.daily_distance) == 7 + assert all(day.distance > 0 for day in data.daily_distance) + assert data.daily_distance[0].is_today + assert len(data.records) == 6 + + metric_data = demo_drive_stats_data(True, now=datetime(2026, 7, 20, 12, 0, 0)) + assert metric_data.all_time.unit == "kilometers" + assert metric_data.all_time.distance > data.all_time.distance + assert metric_data.records[0].value.endswith(" km") diff --git a/selfdrive/ui/widgets/drive_stats.py b/selfdrive/ui/widgets/drive_stats.py new file mode 100644 index 000000000..4d430a0f0 --- /dev/null +++ b/selfdrive/ui/widgets/drive_stats.py @@ -0,0 +1,696 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any + +import pyray as rl + +from openpilot.common.constants import CV +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.text_measure import measure_text_cached + + +METER_TO_MILE = 1.0 / 1609.344 +METER_TO_KILOMETER = 0.001 + +CARD_COLOR = rl.Color(18, 20, 29, 255) +CARD_BORDER = rl.Color(67, 57, 86, 255) +TEXT_COLOR = rl.Color(250, 248, 255, 255) +MUTED_COLOR = rl.Color(190, 186, 207, 255) +TRACK_COLOR = rl.Color(49, 51, 65, 255) +PURPLE = rl.Color(139, 108, 197, 255) +TEAL = rl.Color(94, 200, 200, 255) +GREEN = rl.Color(108, 197, 110, 255) + + +@dataclass +class DriveSummary: + drives: int = 0 + distance: float = 0.0 + hours: float = 0.0 + unit: str = "miles" + + +@dataclass +class DailyDistance: + label: str + distance: float = 0.0 + is_today: bool = False + is_future: bool = False + + +@dataclass +class PersonalRecord: + title: str + value: str + detail: str + + +@dataclass +class DriveStatsData: + all_time: DriveSummary = field(default_factory=DriveSummary) + past_week: DriveSummary = field(default_factory=DriveSummary) + this_week: DriveSummary = field(default_factory=DriveSummary) + daily_distance: list[DailyDistance] = field(default_factory=list) + records: list[PersonalRecord] = field(default_factory=list) + + +@dataclass +class _RouteStat: + date: datetime + distance_meters: float + duration: int + engaged_seconds: float + attention_known: bool + clean: bool + undistracted: bool + + +def _json_object(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + if isinstance(value, str): + try: + decoded = json.loads(value) + except (TypeError, json.JSONDecodeError): + return {} + return decoded if isinstance(decoded, dict) else {} + return {} + + +def _number(value: Any, default: float = 0.0) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + return default + return parsed if parsed == parsed else default + + +def _parse_date(value: Any) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is not None: + parsed = parsed.astimezone().replace(tzinfo=None) + return parsed + + +def _distance_from_meters(distance_meters: float, is_metric: bool) -> float: + conversion = METER_TO_KILOMETER if is_metric else METER_TO_MILE + return max(0.0, distance_meters) * conversion + + +def _cloud_summary(data: dict[str, Any], is_metric: bool) -> DriveSummary: + distance = max(0.0, _number(data.get("distance", 0.0))) + if is_metric: + distance *= CV.MPH_TO_KPH + return DriveSummary( + drives=max(0, int(_number(data.get("routes", 0)))), + distance=distance, + hours=max(0.0, _number(data.get("minutes", 0.0)) / 60.0), + unit="kilometers" if is_metric else "miles", + ) + + +def _summary_has_data(summary: DriveSummary) -> bool: + return summary.drives > 0 or summary.distance > 0.0 or summary.hours > 0.0 + + +def _load_routes(galaxy_stats: dict[str, Any]) -> list[_RouteStat]: + raw_routes = galaxy_stats.get("routes", {}) + if not isinstance(raw_routes, dict): + return [] + + ignored = galaxy_stats.get("ignoredRoutes", []) + ignored_routes = {str(route) for route in ignored} if isinstance(ignored, list) else set() + routes = [] + for route_name, entry in raw_routes.items(): + if route_name in ignored_routes or not isinstance(entry, dict): + continue + route_date = _parse_date(entry.get("date")) + if route_date is None: + continue + + attention_known = bool(entry.get("attentionKnown", True)) + distracted = max(0, int(_number(entry.get("distractedMoments", 0)))) + unresponsive = max(0, int(_number(entry.get("unresponsiveMoments", 0)))) + routes.append(_RouteStat( + date=route_date, + distance_meters=max(0.0, _number(entry.get("distanceMeters", 0.0))), + duration=max(0, int(_number(entry.get("duration", 0)))), + engaged_seconds=max(0.0, _number(entry.get("engagedSeconds", 0.0))), + attention_known=attention_known, + clean=bool(entry.get("clean", unresponsive == 0)), + undistracted=bool(entry.get("undistracted", distracted == 0)), + )) + return sorted(routes, key=lambda route: route.date) + + +def _route_summary(routes: list[_RouteStat], is_metric: bool) -> DriveSummary: + return DriveSummary( + drives=len(routes), + distance=_distance_from_meters(sum(route.distance_meters for route in routes), is_metric), + hours=sum(route.duration for route in routes) / 3600.0, + unit="kilometers" if is_metric else "miles", + ) + + +def _date_label(date_value: Any) -> str: + parsed = date_value if isinstance(date_value, datetime) else _parse_date(date_value) + if parsed is None: + return tr("No drives") + date_format = "%b %d" if parsed.year == datetime.now().year else "%b %d, %Y" + return parsed.strftime(date_format).replace(" 0", " ") + + +def _distance_record(distance_meters: float, is_metric: bool) -> str: + distance = _distance_from_meters(distance_meters, is_metric) + unit = tr("km") if is_metric else tr("mi") + return f"{distance:.1f} {unit}" + + +def _duration_record(seconds: int) -> str: + seconds = max(0, int(seconds)) + hours, remaining = divmod(seconds, 3600) + minutes = remaining // 60 + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m" + + +def _count_record(value: Any, singular: str, plural: str) -> str: + count = max(0, int(_number(value, 0))) + return f"{count} {tr(singular) if count == 1 else tr(plural)}" + + +def _longest_day_streak(routes: list[_RouteStat]) -> int: + days = sorted({route.date.date() for route in routes}) + longest = current = 0 + previous = None + for day in days: + current = current + 1 if previous is not None and day == previous + timedelta(days=1) else 1 + longest = max(longest, current) + previous = day + return longest + + +def _computed_record_values(routes: list[_RouteStat]) -> dict[str, dict[str, Any]]: + if not routes: + return {} + + longest_drive = max(routes, key=lambda route: route.distance_meters) + + day_totals: dict[Any, dict[str, float]] = {} + week_totals: dict[Any, float] = {} + for route in routes: + day = route.date.date() + week = day - timedelta(days=day.weekday()) + day_totals.setdefault(day, {"duration": 0.0, "engaged": 0.0}) + day_totals[day]["duration"] += route.duration + day_totals[day]["engaged"] += min(route.engaged_seconds, route.duration) + week_totals[week] = week_totals.get(week, 0.0) + route.distance_meters + + most_engaged_day, most_engaged = max( + day_totals.items(), + key=lambda item: item[1]["engaged"] / item[1]["duration"] if item[1]["duration"] > 0 else 0.0, + ) + engaged_percent = round(100 * most_engaged["engaged"] / most_engaged["duration"]) if most_engaged["duration"] > 0 else 0 + best_week, best_week_distance = max(week_totals.items(), key=lambda item: item[1]) + + undistracted_routes = [route for route in routes if route.attention_known and route.undistracted] + longest_undistracted = max(undistracted_routes, key=lambda route: route.duration) if undistracted_routes else None + + longest_clean_count = current_clean_count = 0 + longest_clean_start = longest_clean_end = None + current_clean_start = None + for route in routes: + if route.attention_known and route.clean: + current_clean_start = current_clean_start or route.date + current_clean_count += 1 + if current_clean_count > longest_clean_count: + longest_clean_count = current_clean_count + longest_clean_start = current_clean_start + longest_clean_end = route.date + elif route.attention_known: + current_clean_count = 0 + current_clean_start = None + + return { + "longestDrive": {"distanceMeters": longest_drive.distance_meters, "date": longest_drive.date.isoformat()}, + "mostEngagedDay": {"percent": engaged_percent, "date": most_engaged_day.isoformat()}, + "bestWeek": {"distanceMeters": best_week_distance, "weekDate": best_week.isoformat()}, + "highestStreak": {"days": _longest_day_streak(routes)}, + "longestUndistractedDrive": { + "duration": longest_undistracted.duration if longest_undistracted else 0, + "date": longest_undistracted.date.isoformat() if longest_undistracted else "", + }, + "cleanDriveStreak": { + "drives": longest_clean_count, + "startDate": longest_clean_start.isoformat() if longest_clean_start else "", + "endDate": longest_clean_end.isoformat() if longest_clean_end else "", + }, + } + + +def _record_source(galaxy_stats: dict[str, Any], routes: list[_RouteStat]) -> dict[str, dict[str, Any]]: + computed = _computed_record_values(routes) + persisted = galaxy_stats.get("personalRecords", {}) + if not isinstance(persisted, dict): + return computed + + metric_keys = { + "longestDrive": "distanceMeters", + "mostEngagedDay": "percent", + "bestWeek": "distanceMeters", + "highestStreak": "days", + "longestUndistractedDrive": "duration", + "cleanDriveStreak": "drives", + } + result = dict(computed) + for key, value in persisted.items(): + metric_key = metric_keys.get(key) + current_value = result.get(key, {}) + if ( + isinstance(value, dict) + and metric_key is not None + and _number(value.get(metric_key, 0.0)) >= _number(current_value.get(metric_key, 0.0)) + ): + result[key] = value + return result + + +def _personal_records(galaxy_stats: dict[str, Any], routes: list[_RouteStat], is_metric: bool) -> list[PersonalRecord]: + raw = _record_source(galaxy_stats, routes) + + longest = raw.get("longestDrive", {}) + best_week = raw.get("bestWeek", {}) + most_engaged = raw.get("mostEngagedDay", {}) + highest_streak = raw.get("highestStreak", {}) + undistracted = raw.get("longestUndistractedDrive", {}) + clean_streak = raw.get("cleanDriveStreak", {}) + + best_week_date = _parse_date(best_week.get("weekDate")) + highest_days = max(0, int(_number(highest_streak.get("days", 0)))) + undistracted_duration = max(0, int(_number(undistracted.get("duration", 0)))) + clean_drives = max(0, int(_number(clean_streak.get("drives", 0)))) + + clean_start = _parse_date(clean_streak.get("startDate")) + clean_end = _parse_date(clean_streak.get("endDate")) + if clean_drives <= 0 or clean_start is None or clean_end is None: + clean_detail = tr("No clean drives") + else: + clean_start_label = _date_label(clean_start) + clean_end_label = _date_label(clean_end) + clean_detail = clean_start_label if clean_start_label == clean_end_label else f"{clean_start_label} - {clean_end_label}" + + return [ + PersonalRecord( + tr("Longest drive"), + _distance_record(_number(longest.get("distanceMeters", 0.0)), is_metric), + _date_label(longest.get("date")), + ), + PersonalRecord( + tr("Most engaged day"), + f"{max(0, int(_number(most_engaged.get('percent', 0))))}%", + _date_label(most_engaged.get("date")), + ), + PersonalRecord( + tr("Best week"), + _distance_record(_number(best_week.get("distanceMeters", 0.0)), is_metric), + f"{tr('Week of')} {_date_label(best_week_date)}" if best_week_date is not None else tr("No drives"), + ), + PersonalRecord( + tr("Highest streak"), + _count_record(highest_days, "day", "days"), + tr("Consecutive drive days") if highest_days > 0 else tr("No drives"), + ), + PersonalRecord( + tr("Longest undistracted drive"), + _duration_record(undistracted_duration), + _date_label(undistracted.get("date")) if undistracted_duration > 0 else tr("No clean drives"), + ), + PersonalRecord( + tr("Clean-drive streak"), + _count_record(clean_drives, "drive", "drives"), + clean_detail, + ), + ] + + +def load_drive_stats_data(params: Params, now: datetime | None = None) -> DriveStatsData: + now = now or datetime.now() + if now.tzinfo is not None: + now = now.astimezone().replace(tzinfo=None) + + is_metric = params.get_bool("IsMetric") + unit = "kilometers" if is_metric else "miles" + cloud_stats = _json_object(params.get("ApiCache_DriveStats")) + starpilot_stats = _json_object(params.get("StarPilotStats")) + galaxy_stats = _json_object(params.get("GalaxyDashboardStats")) + routes = _load_routes(galaxy_stats) + + all_time = _cloud_summary(_json_object(cloud_stats.get("all")), is_metric) + if not _summary_has_data(all_time): + all_time = DriveSummary( + drives=max(0, int(_number(starpilot_stats.get("StarPilotDrives", 0)))), + distance=_distance_from_meters(_number(starpilot_stats.get("StarPilotMeters", 0.0)), is_metric), + hours=max(0.0, _number(starpilot_stats.get("StarPilotSeconds", 0.0)) / 3600.0), + unit=unit, + ) + + past_week = _cloud_summary(_json_object(cloud_stats.get("week")), is_metric) + if not _summary_has_data(past_week): + earliest_day = now.date() - timedelta(days=6) + past_week = _route_summary([route for route in routes if earliest_day <= route.date.date() <= now.date()], is_metric) + + week_start = now.date() - timedelta(days=now.weekday()) + week_end = week_start + timedelta(days=7) + this_week_routes = [route for route in routes if week_start <= route.date.date() < week_end] + this_week = _route_summary(this_week_routes, is_metric) + + daily = [] + for day_index in range(7): + day = week_start + timedelta(days=day_index) + distance_meters = sum(route.distance_meters for route in this_week_routes if route.date.date() == day) + daily.append(DailyDistance( + label=day.strftime("%a"), + distance=_distance_from_meters(distance_meters, is_metric), + is_today=day == now.date(), + is_future=day > now.date(), + )) + + return DriveStatsData( + all_time=all_time, + past_week=past_week, + this_week=this_week, + daily_distance=daily, + records=_personal_records(galaxy_stats, routes, is_metric), + ) + + +def demo_drive_stats_data(is_metric: bool, now: datetime | None = None) -> DriveStatsData: + now = now or datetime.now() + week_start = now.date() - timedelta(days=now.weekday()) + distance_scale = CV.MPH_TO_KPH if is_metric else 1.0 + unit = "kilometers" if is_metric else "miles" + daily_miles = (19.4, 43.2, 28.6, 51.8, 12.7, 35.1, 8.9) + + daily = [ + DailyDistance( + label=(week_start + timedelta(days=index)).strftime("%a"), + distance=distance * distance_scale, + is_today=index == now.weekday(), + ) + for index, distance in enumerate(daily_miles) + ] + + longest_drive_meters = 218.7 / METER_TO_MILE + best_week_meters = 812.4 / METER_TO_MILE + return DriveStatsData( + all_time=DriveSummary(drives=584, distance=14632.8 * distance_scale, hours=419.0, unit=unit), + past_week=DriveSummary(drives=18, distance=347.6 * distance_scale, hours=11.8, unit=unit), + this_week=DriveSummary(drives=7, distance=sum(daily_miles) * distance_scale, hours=6.9, unit=unit), + daily_distance=daily, + records=[ + PersonalRecord(tr("Longest drive"), _distance_record(longest_drive_meters, is_metric), _date_label(now - timedelta(days=64))), + PersonalRecord(tr("Most engaged day"), "96%", _date_label(now - timedelta(days=22))), + PersonalRecord(tr("Best week"), _distance_record(best_week_meters, is_metric), f"{tr('Week of')} {_date_label(now - timedelta(days=105))}"), + PersonalRecord(tr("Highest streak"), f"19 {tr('days')}", tr("Consecutive drive days")), + PersonalRecord(tr("Longest undistracted drive"), "3h 34m", _date_label(now - timedelta(days=38))), + PersonalRecord( + tr("Clean-drive streak"), + f"27 {tr('drives')}", + f"{_date_label(now - timedelta(days=80))} - {_date_label(now - timedelta(days=59))}", + ), + ], + ) + + +def _format_count(value: int) -> str: + if value >= 10000: + return f"{value / 1000:.1f}k" + return f"{value:,}" + + +def _format_decimal(value: float) -> str: + if value >= 10000: + return f"{value / 1000:.1f}k" + if value >= 100: + return f"{value:,.0f}" + return f"{value:.1f}" + + +class DriveStatsDashboard: + def __init__(self, params: Params): + self._params = params + self._data = DriveStatsData() + self._font_bold = gui_app.font(FontWeight.BOLD) + self._font_semi_bold = gui_app.font(FontWeight.SEMI_BOLD) + self._font_medium = gui_app.font(FontWeight.MEDIUM) + self.refresh() + + def refresh(self) -> None: + demo_enabled = os.getenv("SP_RAYBIG_FAKE_DRIVE_STATS", "0").lower() in ("1", "true", "yes", "on") + self._data = demo_drive_stats_data(self._params.get_bool("IsMetric")) if demo_enabled else load_drive_stats_data(self._params) + + @staticmethod + def _draw_card(rect: rl.Rectangle, accent: rl.Color | None = None) -> None: + rl.draw_rectangle_rounded(rect, 0.04, 12, CARD_COLOR) + rl.draw_rectangle_rounded_lines_ex(rect, 0.04, 12, 2, CARD_BORDER) + if accent is not None: + accent_rect = rl.Rectangle(rect.x + 18, rect.y + 12, rect.width - 36, 5) + rl.draw_rectangle_rounded(accent_rect, 1.0, 8, accent) + + @staticmethod + def _draw_week_bar(rect: rl.Rectangle) -> None: + x = int(round(rect.x)) + y = int(round(rect.y)) + width = max(1, int(round(rect.width))) + height = max(1, int(round(rect.height))) + radius = max(1, min(6, width // 2, height // 2)) + + blend = radius / height + body_top = rl.Color( + round(PURPLE.r + (TEAL.r - PURPLE.r) * blend), + round(PURPLE.g + (TEAL.g - PURPLE.g) * blend), + round(PURPLE.b + (TEAL.b - PURPLE.b) * blend), + 255, + ) + + rl.draw_circle(x + radius, y + radius, radius, PURPLE) + rl.draw_circle(x + width - radius, y + radius, radius, PURPLE) + rl.draw_rectangle(x + radius, y, max(1, width - 2 * radius), radius + 1, PURPLE) + rl.draw_rectangle_gradient_v(x, y + radius, width, max(1, height - radius), body_top, TEAL) + + @staticmethod + def _draw_record_icon(index: int, rect: rl.Rectangle) -> None: + rl.draw_rectangle_rounded(rect, 0.18, 8, rl.Color(40, 33, 68, 255)) + cx = rect.x + rect.width / 2 + cy = rect.y + rect.height / 2 + color = PURPLE + thickness = 2.5 + + def line(x1: float, y1: float, x2: float, y2: float) -> None: + rl.draw_line_ex(rl.Vector2(x1, y1), rl.Vector2(x2, y2), thickness, color) + + if index == 0: + line(cx - 13, cy, cx + 12, cy) + line(cx + 12, cy, cx + 5, cy - 7) + line(cx + 12, cy, cx + 5, cy + 7) + elif index == 1: + rl.draw_circle_lines(int(cx), int(cy), 13, color) + rl.draw_circle_lines(int(cx), int(cy), 12, color) + line(cx - 7, cy, cx - 2, cy + 5) + line(cx - 2, cy + 5, cx + 8, cy - 7) + elif index == 2: + line(cx - 13, cy - 12, cx - 13, cy + 12) + line(cx - 13, cy + 12, cx + 13, cy + 12) + line(cx - 9, cy + 6, cx - 2, cy - 2) + line(cx - 2, cy - 2, cx + 4, cy + 3) + line(cx + 4, cy + 3, cx + 13, cy - 8) + elif index == 3: + points = ( + (cx + 2, cy - 15), (cx - 10, cy + 2), (cx - 2, cy + 2), + (cx - 5, cy + 15), (cx + 11, cy - 5), (cx + 3, cy - 5), + ) + for point_index, point in enumerate(points): + next_point = points[(point_index + 1) % len(points)] + line(point[0], point[1], next_point[0], next_point[1]) + elif index == 4: + points = ( + (cx, cy - 14), (cx + 12, cy - 9), (cx + 10, cy + 3), + (cx, cy + 14), (cx - 10, cy + 3), (cx - 12, cy - 9), + ) + for point_index, point in enumerate(points): + next_point = points[(point_index + 1) % len(points)] + line(point[0], point[1], next_point[0], next_point[1]) + line(cx - 5, cy, cx - 1, cy + 4) + line(cx - 1, cy + 4, cx + 6, cy - 4) + else: + def sparkle(x: float, y: float, radius: float) -> None: + inner = radius * 0.22 + points = ( + (x, y - radius), (x + inner, y - inner), + (x + radius, y), (x + inner, y + inner), + (x, y + radius), (x - inner, y + inner), + (x - radius, y), (x - inner, y - inner), + ) + for point_index, point in enumerate(points): + next_point = points[(point_index + 1) % len(points)] + line(point[0], point[1], next_point[0], next_point[1]) + + sparkle(cx + 3, cy + 2, 10) + sparkle(cx - 9, cy - 9, 5) + sparkle(cx + 12, cy - 10, 4) + + def _draw_fitted_centered(self, text: str, rect: rl.Rectangle, font_size: int, minimum_size: int, color: rl.Color) -> None: + size = font_size + while size > minimum_size and measure_text_cached(self._font_bold, text, size).x > rect.width: + size -= 2 + text_size = measure_text_cached(self._font_bold, text, size) + position = rl.Vector2( + rect.x + (rect.width - text_size.x) / 2, + rect.y + (rect.height - text_size.y) / 2, + ) + rl.draw_text_ex(self._font_bold, text, position, size, 0, color) + + def _draw_summary_card(self, rect: rl.Rectangle, title: str, summary: DriveSummary, accent: rl.Color) -> None: + self._draw_card(rect, accent) + title_pos = rl.Vector2(rect.x + 24, rect.y + 28) + rl.draw_text_ex(self._font_semi_bold, title, title_pos, 30, 0, MUTED_COLOR) + + values = ( + (_format_count(summary.drives), tr("drives")), + (_format_decimal(summary.distance), tr("km") if summary.unit == "kilometers" else tr("miles")), + (_format_decimal(summary.hours), tr("hours")), + ) + column_width = (rect.width - 32) / len(values) + for index, (value, label) in enumerate(values): + if index > 0: + divider_x = rect.x + 16 + index * column_width + rl.draw_line_ex( + rl.Vector2(divider_x, rect.y + 76), + rl.Vector2(divider_x, rect.y + rect.height - 23), + 2, + TRACK_COLOR, + ) + + column_rect = rl.Rectangle(rect.x + 16 + index * column_width, rect.y + 64, column_width, 72) + self._draw_fitted_centered(value, column_rect, 48, 30, TEXT_COLOR) + label_size = measure_text_cached(self._font_medium, label, 23) + label_pos = rl.Vector2( + column_rect.x + (column_rect.width - label_size.x) / 2, + rect.y + rect.height - 38, + ) + rl.draw_text_ex(self._font_medium, label, label_pos, 23, 0, MUTED_COLOR) + + def render_overview(self, rect: rl.Rectangle) -> None: + gap = 18 + summary_height = 184 + card_width = (rect.width - gap) / 2 + summaries = ( + (tr("ALL TIME"), self._data.all_time, PURPLE), + (tr("PAST WEEK"), self._data.past_week, TEAL), + ) + for index, (title, summary, accent) in enumerate(summaries): + card_rect = rl.Rectangle(rect.x + index * (card_width + gap), rect.y, card_width, summary_height) + self._draw_summary_card(card_rect, title, summary, accent) + + graph_rect = rl.Rectangle(rect.x, rect.y + summary_height + gap, rect.width, rect.height - summary_height - gap) + self._draw_distance_graph(graph_rect) + + def _draw_distance_graph(self, rect: rl.Rectangle) -> None: + self._draw_card(rect) + title = tr("DISTANCE THIS WEEK") + rl.draw_text_ex(self._font_semi_bold, title, rl.Vector2(rect.x + 30, rect.y + 26), 32, 0, TEXT_COLOR) + + unit = tr("km") if self._data.this_week.unit == "kilometers" else tr("miles") + total_text = f"{_format_decimal(self._data.this_week.distance)} {unit}" + total_size = measure_text_cached(self._font_bold, total_text, 34) + rl.draw_text_ex( + self._font_bold, + total_text, + rl.Vector2(rect.x + rect.width - total_size.x - 30, rect.y + 24), + 34, + 0, + TEAL, + ) + + plot = rl.Rectangle(rect.x + 42, rect.y + 92, rect.width - 84, rect.height - 142) + for line_index in range(4): + y = plot.y + line_index * plot.height / 3 + rl.draw_line(int(plot.x), int(y), int(plot.x + plot.width), int(y), TRACK_COLOR) + + max_distance = max((day.distance for day in self._data.daily_distance), default=0.0) + max_distance = max(max_distance, 1.0) + slot_width = plot.width / max(len(self._data.daily_distance), 1) + bar_width = min(78.0, slot_width * 0.52) + value_headroom = 38.0 + bar_area_height = max(1.0, plot.height - value_headroom) + for index, day in enumerate(self._data.daily_distance): + center_x = plot.x + slot_width * (index + 0.5) + bar_height = max(5.0, bar_area_height * day.distance / max_distance) if day.distance > 0.0 else 5.0 + bar_rect = rl.Rectangle(center_x - bar_width / 2, plot.y + plot.height - bar_height, bar_width, bar_height) + self._draw_week_bar(bar_rect) + + if day.distance > 0.0: + value_text = _format_decimal(day.distance) + value_size = measure_text_cached(self._font_medium, value_text, 21) + value_y = max(plot.y + 4, bar_rect.y - value_size.y - 8) + rl.draw_text_ex( + self._font_medium, + value_text, + rl.Vector2(center_x - value_size.x / 2, value_y), + 21, + 0, + MUTED_COLOR, + ) + + label_color = TEXT_COLOR if day.is_today else MUTED_COLOR + label_size = measure_text_cached(self._font_semi_bold, day.label, 24) + label_pos = rl.Vector2(center_x - label_size.x / 2, plot.y + plot.height + 16) + rl.draw_text_ex(self._font_semi_bold, day.label, label_pos, 24, 0, label_color) + + def render_records(self, rect: rl.Rectangle) -> None: + self._draw_card(rect) + rl.draw_text_ex( + self._font_semi_bold, + tr("PERSONAL RECORDS"), + rl.Vector2(rect.x + 30, rect.y + 26), + 32, + 0, + TEXT_COLOR, + ) + + header_height = 78 + row_height = (rect.height - header_height - 12) / max(len(self._data.records), 1) + for index, record in enumerate(self._data.records): + row_y = rect.y + header_height + index * row_height + if index > 0: + rl.draw_line(int(rect.x + 24), int(row_y), int(rect.x + rect.width - 24), int(row_y), TRACK_COLOR) + + icon_rect = rl.Rectangle(rect.x + 24, row_y + (row_height - 54) / 2, 54, 54) + self._draw_record_icon(index, icon_rect) + + text_x = rect.x + 96 + title_pos = rl.Vector2(text_x, row_y + 17) + rl.draw_text_ex(self._font_medium, record.title, title_pos, 23, 0, MUTED_COLOR) + + value_pos = rl.Vector2(text_x, row_y + 49) + rl.draw_text_ex(self._font_bold, record.value, value_pos, 32, 0, TEXT_COLOR) + + detail_size = measure_text_cached(self._font_medium, record.detail, 21) + detail_x = max(text_x + 190, rect.x + rect.width - detail_size.x - 28) + detail_pos = rl.Vector2(detail_x, row_y + 57) + rl.draw_text_ex(self._font_medium, record.detail, detail_pos, 21, 0, MUTED_COLOR)