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 WEEKDAY_LABELS = ("M", "T", "W", "T", "F", "S", "S") 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: name: str date: datetime distance_meters: float duration: int engaged_seconds: float attention_known: bool clean: bool undistracted: bool time_source: str 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 _route_counter(route_name: str) -> int | None: prefix = route_name.split("--", 1)[0] try: return int(prefix, 16) if len(prefix) == 8 else None except ValueError: return None def _repair_filesystem_route_dates(routes: list[_RouteStat]) -> None: sequenced = [ (counter, route) for route in routes if (counter := _route_counter(route.name)) is not None ] sequenced.sort(key=lambda item: item[0]) if sum(route.time_source == "log" for _, route in sequenced) < 2: return previous_anchors = [] previous_anchor = None for counter, route in sequenced: previous_anchors.append(previous_anchor) if route.time_source == "log": previous_anchor = (counter, route) following_anchors = [None] * len(sequenced) following_anchor = None for index in range(len(sequenced) - 1, -1, -1): counter, route = sequenced[index] following_anchors[index] = following_anchor if route.time_source == "log": following_anchor = (counter, route) for index, (counter, route) in enumerate(sequenced): if route.time_source != "filesystem": continue previous = previous_anchors[index] following = following_anchors[index] if previous is None or following is None: continue previous_counter, previous_route = previous following_counter, following_route = following if previous_route.date > following_route.date or previous_route.date <= route.date <= following_route.date: continue if following_counter == counter + 1: inferred = following_route.date - timedelta(seconds=route.duration + 5 * 60) elif previous_counter == counter - 1: inferred = previous_route.date + timedelta(seconds=previous_route.duration + 5 * 60) else: progress = (counter - previous_counter) / (following_counter - previous_counter) inferred = previous_route.date + (following_route.date - previous_route.date) * progress lower_bound = previous_route.date + timedelta(seconds=1) upper_bound = following_route.date - timedelta(seconds=1) route.date = min(max(inferred, lower_bound), upper_bound) 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( name=str(route_name), 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)), time_source=str(entry.get("timeSource", "") or ""), )) _repair_filesystem_route_dates(routes) 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=WEEKDAY_LABELS[day_index], 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() 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=WEEKDAY_LABELS[index], 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 scale = min(rect.width, rect.height) / 64.0 thickness = 2.5 * scale 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 * scale, cy, cx + 12 * scale, cy) line(cx + 12 * scale, cy, cx + 5 * scale, cy - 7 * scale) line(cx + 12 * scale, cy, cx + 5 * scale, cy + 7 * scale) elif index == 1: rl.draw_circle_lines(int(cx), int(cy), 13 * scale, color) rl.draw_circle_lines(int(cx), int(cy), 12 * scale, color) line(cx - 7 * scale, cy, cx - 2 * scale, cy + 5 * scale) line(cx - 2 * scale, cy + 5 * scale, cx + 8 * scale, cy - 7 * scale) elif index == 2: line(cx - 13 * scale, cy - 12 * scale, cx - 13 * scale, cy + 12 * scale) line(cx - 13 * scale, cy + 12 * scale, cx + 13 * scale, cy + 12 * scale) line(cx - 9 * scale, cy + 6 * scale, cx - 2 * scale, cy - 2 * scale) line(cx - 2 * scale, cy - 2 * scale, cx + 4 * scale, cy + 3 * scale) line(cx + 4 * scale, cy + 3 * scale, cx + 13 * scale, cy - 8 * scale) elif index == 3: points = ( (cx + 2 * scale, cy - 15 * scale), (cx - 10 * scale, cy + 2 * scale), (cx - 2 * scale, cy + 2 * scale), (cx - 5 * scale, cy + 15 * scale), (cx + 11 * scale, cy - 5 * scale), (cx + 3 * scale, cy - 5 * scale), ) 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 * scale), (cx + 12 * scale, cy - 9 * scale), (cx + 10 * scale, cy + 3 * scale), (cx, cy + 14 * scale), (cx - 10 * scale, cy + 3 * scale), (cx - 12 * scale, cy - 9 * scale), ) 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 * scale, cy, cx - scale, cy + 4 * scale) line(cx - scale, cy + 4 * scale, cx + 6 * scale, cy - 4 * scale) 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 * scale, cy + 2 * scale, 10 * scale) sparkle(cx - 9 * scale, cy - 9 * scale, 5 * scale) sparkle(cx + 12 * scale, cy - 10 * scale, 4 * scale) 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, 28) 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, 28, 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, ) displayed_records = [ (record_index, self._data.records[record_index]) for record_index in (0, 3, 5) if record_index < len(self._data.records) ] header_height = 82 row_height = (rect.height - header_height - 12) / max(len(displayed_records), 1) for row_index, (record_index, record) in enumerate(displayed_records): row_y = rect.y + header_height + row_index * row_height if row_index > 0: rl.draw_line(int(rect.x + 24), int(row_y), int(rect.x + rect.width - 24), int(row_y), TRACK_COLOR) icon_size = min(120.0, row_height - 34) icon_rect = rl.Rectangle(rect.x + 28, row_y + (row_height - icon_size) / 2, icon_size, icon_size) self._draw_record_icon(record_index, icon_rect) text_x = rect.x + 170 content_center_y = row_y + row_height / 2 title_pos = rl.Vector2(text_x, content_center_y - 66) rl.draw_text_ex(self._font_medium, record.title, title_pos, 38, 0, MUTED_COLOR) value_pos = rl.Vector2(text_x, content_center_y - 10) rl.draw_text_ex(self._font_bold, record.value, value_pos, 56, 0, TEXT_COLOR) detail_font_size = 34 detail_min_x = text_x + 250 detail_width = rect.x + rect.width - detail_min_x - 30 while detail_font_size > 29 and measure_text_cached(self._font_medium, record.detail, detail_font_size).x > detail_width: detail_font_size -= 1 detail_size = measure_text_cached(self._font_medium, record.detail, detail_font_size) detail_x = rect.x + rect.width - detail_size.x - 30 detail_pos = rl.Vector2(detail_x, content_center_y + 52) rl.draw_text_ex(self._font_medium, record.detail, detail_pos, detail_font_size, 0, MUTED_COLOR)