New Raybig Homescreen

This commit is contained in:
firestar5683
2026-07-20 15:34:21 -05:00
parent 43143e7a1f
commit 94e60c5d70
5 changed files with 860 additions and 6 deletions
+2
View File
@@ -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"
@@ -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,
+10 -4
View File
@@ -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()
@@ -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")
+696
View File
@@ -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)