Files
StarPilot/openpilot/starpilot/ui/home_stats.py
T
firestar5683 a82935f326 User interfaces and Android Auto
Integrate onroad views, configurable widgets, local settings, steering-profile contracts, Bluetooth and Android Auto with their device and runtime services.

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
Co-authored-by: Daniel Koepping <elkoled@gmail.com>
Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: DimitriChrysafis <Dimitri.chrysafis@gmail.com>
Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com>
Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: Shane Smiskol <shane@smiskol.com>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
Co-authored-by: stef <19478336+stefpi@users.noreply.github.com>
Co-authored-by: whoisdomi <dcantu83@gmail.com>

Co-authored-by: Intelli <contact@intelli.software>
Co-authored-by: pharmacomaniac <blittle65@gmail.com>

Co-authored-by: prabhaavp <prabhaav@msu.edu>
2026-10-14 20:02:39 -05:00

287 lines
12 KiB
Python

"""Home statistics drawing with supplied data and native presentation resources."""
from __future__ import annotations
import pyray as rl
from openpilot.starpilot.ui.home_state import DriveStatsData, DriveSummary
from openpilot.starpilot.ui.home_geometry import outside_rounded_border
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole
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)
def _format_count(value: int) -> str:
return f"{value / 1000:.1f}k" if value >= 10000 else f"{value:,}"
def _format_decimal(value: float) -> str:
if value >= 10000:
return f"{value / 1000:.1f}k"
return f"{value:,.0f}" if value >= 100 else f"{value:.1f}"
class DriveStatsDashboard:
def __init__(self, fonts: BitmapFonts, data: DriveStatsData):
self._fonts = fonts
self._data = data
self._font_bold = fonts.font(FontRole.BOLD)
self._font_semi_bold = fonts.font(FontRole.SEMI_BOLD)
self._font_medium = fonts.font(FontRole.MEDIUM)
def _measure(self, font, text, size):
return rl.measure_text_ex(font, text, size * self._fonts.profile.font_scale, 0) # noqa: TID251
def _draw_text(self, font, text, position, size, spacing, color):
draw = getattr(rl, "_orig_draw_text_ex", rl.draw_text_ex)
draw(font, text, position, size * self._fonts.profile.font_scale, spacing, color)
@staticmethod
def _draw_card(rect: rl.Rectangle, accent: rl.Color | None = None) -> None:
rl.draw_rectangle_rounded(rect, 0.04, 12, CARD_COLOR)
outside_rounded_border(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 self._measure(self._font_bold, text, size).x > rect.width:
size -= 2
text_size = self._measure(self._font_bold, text, size)
position = rl.Vector2(
rect.x + (rect.width - text_size.x) / 2,
rect.y + (rect.height - text_size.y) / 2,
)
self._draw_text(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)
self._draw_text(self._font_semi_bold, title, title_pos, 30, 0, MUTED_COLOR)
values = (
(_format_count(summary.drives), ("drives")),
(_format_decimal(summary.distance), ("km") if summary.unit == "kilometers" else ("miles")),
(_format_decimal(summary.hours), ("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 = self._measure(self._font_medium, label, 23)
label_pos = rl.Vector2(
column_rect.x + (column_rect.width - label_size.x) / 2,
rect.y + rect.height - 38,
)
self._draw_text(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 = (
(("ALL TIME"), self._data.all_time, PURPLE),
(("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 = ("DISTANCE THIS WEEK")
self._draw_text(self._font_semi_bold, title, rl.Vector2(rect.x + 30, rect.y + 26), 32, 0, TEXT_COLOR)
unit = ("km") if self._data.this_week.unit == "kilometers" else ("miles")
total_text = f"{_format_decimal(self._data.this_week.distance)} {unit}"
total_size = self._measure(self._font_bold, total_text, 34)
self._draw_text(
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 = self._measure(self._font_medium, value_text, 21)
value_y = max(plot.y + 4, bar_rect.y - value_size.y - 8)
self._draw_text(
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 = self._measure(self._font_semi_bold, day.label, 28)
label_pos = rl.Vector2(center_x - label_size.x / 2, plot.y + plot.height + 16)
self._draw_text(self._font_semi_bold, day.label, label_pos, 28, 0, label_color)
def render_records(self, rect: rl.Rectangle) -> None:
self._draw_card(rect)
self._draw_text(
self._font_semi_bold,
("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)
self._draw_text(self._font_medium, record.title, title_pos, 38, 0, MUTED_COLOR)
value_pos = rl.Vector2(text_x, content_center_y - 10)
self._draw_text(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 self._measure(self._font_medium, record.detail, detail_font_size).x > detail_width:
detail_font_size -= 1
detail_size = self._measure(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)
self._draw_text(self._font_medium, record.detail, detail_pos, detail_font_size, 0, MUTED_COLOR)