mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
a82935f326
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>
287 lines
12 KiB
Python
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)
|