From cce352cccbdf5f17c8baa5a6d7b40fa9231c2505 Mon Sep 17 00:00:00 2001 From: rav4kumar Date: Sun, 15 Mar 2026 11:56:30 -0700 Subject: [PATCH] sla ui --- selfdrive/ui/mici/layouts/main.py | 16 +- selfdrive/ui/mici/layouts/mapd_panel.py | 281 +++++++++++++++++++++ selfdrive/ui/mici/onroad/speed_limit_ui.py | 210 +++++++++++++++ 3 files changed, 504 insertions(+), 3 deletions(-) create mode 100644 selfdrive/ui/mici/layouts/mapd_panel.py create mode 100644 selfdrive/ui/mici/onroad/speed_limit_ui.py diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index 2f41e1f172..1d19575d49 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -3,6 +3,7 @@ import cereal.messaging as messaging from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts +from openpilot.selfdrive.ui.mici.layouts.mapd_panel import MapdInfoPanel from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView from openpilot.selfdrive.ui.ui_state import device, ui_state from openpilot.selfdrive.ui.mici.layouts.onboarding import OnboardingWindow @@ -33,15 +34,17 @@ class MiciMainLayout(Scroller): self._alerts_layout = MiciOffroadAlerts() self._settings_layout = SettingsLayout() self._onroad_layout = AugmentedRoadView(bookmark_callback=self._on_bookmark_clicked) + self._mapd_panel = MapdInfoPanel() # Initialize widget rects - for widget in (self._home_layout, self._settings_layout, self._alerts_layout, self._onroad_layout): + for widget in (self._home_layout, self._settings_layout, self._alerts_layout, self._onroad_layout,self._mapd_panel): # TODO: set parent rect and use it if never passed rect from render (like in Scroller) widget.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) self._scroller.add_widgets([ self._alerts_layout, self._home_layout, + self._mapd_panel, self._onroad_layout, ]) self._scroller.set_reset_scroll_at_show(False) @@ -65,6 +68,11 @@ class MiciMainLayout(Scroller): self._onroad_layout.set_click_callback(lambda: self._scroll_to(self._home_layout)) device.add_interactive_timeout_callback(self._on_interactive_timeout) + def _is_on_side_panel(self) -> bool: # TODO: remove sunny not gonna like it ? + onroad_x = self._onroad_layout.rect.x + current_scroll = self._scroller.scroll_panel.get_offset() + return abs(current_scroll - onroad_x) > self._rect.width / 2 + def _scroll_to(self, layout: Widget): layout_x = int(layout.rect.x) self._scroller.scroll_to(layout_x, smooth=True) @@ -97,13 +105,15 @@ class MiciMainLayout(Scroller): # FIXME: these two pops can interrupt user interacting in the settings if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY: - gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout)) + if not self._is_on_side_panel(): + gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout)) self._onroad_time_delay = None # When car leaves standstill, pop nav stack and scroll to onroad CS = ui_state.sm["carState"] if not CS.standstill and self._prev_standstill: - gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout)) + if not self._is_on_side_panel(): + gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout)) self._prev_standstill = CS.standstill def _on_interactive_timeout(self): diff --git a/selfdrive/ui/mici/layouts/mapd_panel.py b/selfdrive/ui/mici/layouts/mapd_panel.py new file mode 100644 index 0000000000..907a23c87e --- /dev/null +++ b/selfdrive/ui/mici/layouts/mapd_panel.py @@ -0,0 +1,281 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +import pyray as rl +from dataclasses import dataclass +from openpilot.common.constants import CV +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget + +METER_TO_KM = 0.001 +METER_TO_MILE = 0.000621371 + + +@dataclass(frozen=True) +class MapdPanelColors: + white: rl.Color = rl.WHITE + black: rl.Color = rl.BLACK + red: rl.Color = rl.Color(255, 0, 0, 255) + green: rl.Color = rl.Color(0, 255, 0, 255) + grey: rl.Color = rl.Color(190, 195, 190, 255) + light_grey: rl.Color = rl.Color(200, 200, 200, 255) + dark_grey: rl.Color = rl.Color(100, 100, 100, 255) + bg_dark: rl.Color = rl.Color(0, 0, 0, 255) + card_bg: rl.Color = rl.Color(50, 50, 50, 200) + + +COLORS = MapdPanelColors() + + +class MapdInfoPanel(Widget): + def __init__(self): + super().__init__() + self.speed_limit: float = 0.0 + self.speed_limit_valid: bool = False + self.speed_limit_offset: float = 0.0 + self.next_speed_limit: float = 0.0 + self.next_speed_limit_distance: float = 0.0 + self.road_name: str = "" + self.current_speed: float = 0.0 + + self._sign_slide: float = 0.0 + + self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD) + self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD) + self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM) + + self._marquee_offset: float = 0.0 + self._marquee_direction: int = 1 + self._marquee_pause_timer: float = 0.0 + self._marquee_speed: float = 40.0 + self._marquee_pause_duration: float = 1.5 + + def _update_state(self) -> None: + sm = ui_state.sm + speed_conv = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH + + if sm.valid["longitudinalPlanSP"]: + lp_sp = sm["longitudinalPlanSP"] + resolver = lp_sp.speedLimit.resolver + self.speed_limit = resolver.speedLimit * speed_conv + self.speed_limit_valid = resolver.speedLimitValid + self.speed_limit_offset = resolver.speedLimitOffset * speed_conv + + if sm.valid["liveMapDataSP"]: + lmd = sm["liveMapDataSP"] + self.next_speed_limit = lmd.speedLimitAhead * speed_conv + self.next_speed_limit_distance = lmd.speedLimitAheadDistance + self.road_name = lmd.roadName + + if sm.updated["carState"]: + self.current_speed = sm["carState"].vEgo * speed_conv + + def _render(self, rect: rl.Rectangle) -> None: + self._update_state() + + rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height), COLORS.bg_dark) + margin = 20 + mid_y = rect.y + rect.height / 2 + + left_x = rect.x + margin + unit = tr("km/h") if ui_state.is_metric else tr("MPH") + rl.draw_text_ex(self._font_semi_bold, unit, rl.Vector2(left_x, mid_y - 95), 38, 0, COLORS.grey) + + speed_val = str(round(self.current_speed)) + if self.speed_limit_valid and self.current_speed > self.speed_limit: + speed_color = COLORS.red + else: + speed_color = COLORS.white + rl.draw_text_ex(self._font_bold, speed_val, rl.Vector2(left_x, mid_y - 60), 110, 0, speed_color) + + sign_width = 135 if ui_state.is_metric else 135 + sign_height = 135 if ui_state.is_metric else 175 + + has_next = self.next_speed_limit > 0 and self.next_speed_limit != self.speed_limit + target_slide = 1.0 if has_next else 0.0 + slide_speed = 3.0 * rl.get_frame_time() + if self._sign_slide < target_slide: + self._sign_slide = min(self._sign_slide + slide_speed, target_slide) + elif self._sign_slide > target_slide: + self._sign_slide = max(self._sign_slide - slide_speed, target_slide) + + next_w = int(sign_width * 0.7) + next_h = int(sign_height * 0.7) + next_peek = int(next_w * 0.85) + 5 + centered_x = rect.x + rect.width - sign_width - margin + shifted_x = rect.x + rect.width - sign_width - margin - next_peek + sign_x = centered_x + (shifted_x - centered_x) * self._sign_slide + sign_y = rect.y + (rect.height - sign_height) / 2 + + road_y = mid_y + 55 + road_width = sign_x - left_x - margin + self._draw_road_name(left_x, road_y, road_width) + + if has_next and self._sign_slide > 0.01: + next_val = str(round(self.next_speed_limit)) + dist_str = self._format_distance(self.next_speed_limit_distance) + next_x = sign_x + sign_width - int(next_w * 0.15) + next_y = sign_y + (sign_height - next_h) / 2 + + next_speed_color = COLORS.black + if ui_state.is_metric: + self._draw_vienna_sign(next_x, next_y, next_w, next_h, next_val, next_speed_color, is_upcoming=True) + else: + self._draw_mutcd_sign(next_x, next_y, next_w, next_h, next_val, next_speed_color, is_upcoming=True) + + dist_size = measure_text_cached(self._font_medium, dist_str, 24) + rl.draw_text_ex(self._font_medium, dist_str, rl.Vector2(next_x + next_w / 2 - dist_size.x / 2, next_y + next_h + 4), 24, 0, COLORS.grey) + + self._draw_speed_limit_sign(sign_x, sign_y, sign_width, sign_height) + + if self.speed_limit_offset != 0 and self.speed_limit_valid: + offset_val = str(abs(round(self.speed_limit_offset))) + badge_sz = 34 + badge_x = sign_x + sign_width - badge_sz * 0.85 + badge_y = sign_y - badge_sz * 0.15 + + if ui_state.is_metric: + badge_r = badge_sz / 2 + badge_cx = badge_x + badge_r + badge_cy = badge_y + badge_r + rl.draw_circle(int(badge_cx), int(badge_cy), badge_r + 2, COLORS.dark_grey) + rl.draw_circle(int(badge_cx), int(badge_cy), badge_r, rl.Color(60, 60, 60, 255)) + self._draw_text_centered(self._font_bold, offset_val, 20, rl.Vector2(badge_cx, badge_cy), COLORS.white) + else: + mutcd_badge_x = sign_x + sign_width - badge_sz * 0.65 + mutcd_badge_y = sign_y - badge_sz * 0.35 + badge_rect = rl.Rectangle(mutcd_badge_x, mutcd_badge_y, badge_sz, badge_sz) + rl.draw_rectangle_rounded(badge_rect, 0.25, 10, rl.Color(60, 60, 60, 255)) + rl.draw_rectangle_rounded_lines_ex(badge_rect, 0.25, 10, 2, COLORS.dark_grey) + self._draw_text_centered(self._font_bold, offset_val, 20, rl.Vector2(mutcd_badge_x + badge_sz / 2, mutcd_badge_y + badge_sz / 2), COLORS.white) + + # SCC + speed_size = measure_text_cached(self._font_bold, speed_val, 110) + scc_x = left_x + speed_size.x + 14 + scc_y = mid_y - 50 + self._draw_scc_icons(scc_x, scc_y) + + def _draw_scc_icons(self, x: float, y: float) -> None: + sm = ui_state.sm + if not sm.valid["longitudinalPlanSP"]: + return + scc = sm["longitudinalPlanSP"].smartCruiseControl + + box_w, box_h = 56, 36 + gap = 6 + drawn = 0 + + for label, active in [("V", scc.vision.active), ("M", scc.map.active)]: + if not active: + continue + bx = x + by = y + drawn * (box_h + gap) + rl.draw_rectangle_rounded(rl.Rectangle(bx, by, box_w, box_h), 0.3, 10, COLORS.green) + self._draw_text_centered(self._font_bold, label, 24, rl.Vector2(bx + box_w / 2, by + box_h / 2), COLORS.black) + drawn += 1 + + def _draw_speed_limit_sign(self, x: float, y: float, sign_width: float, sign_height: float) -> None: + speed_str = str(round(self.speed_limit)) if self.speed_limit_valid else "--" + speed_color = COLORS.black if not self.speed_limit_valid or self.current_speed <= self.speed_limit else COLORS.red + + if ui_state.is_metric: + self._draw_vienna_sign(x, y, sign_width, sign_height, speed_str, speed_color, is_upcoming=False) + else: + self._draw_mutcd_sign(x, y, sign_width, sign_height, speed_str, speed_color, is_upcoming=False) + + def _draw_road_name(self, x: float, y: float, width: float) -> None: + road_display = self.road_name if self.road_name else "--" + font_size = 30 + road_size = measure_text_cached(self._font_semi_bold, road_display, font_size) + text_width = road_size.x + + if text_width <= width: + self._marquee_offset = 0.0 + self._marquee_direction = 1 + self._marquee_pause_timer = 0.0 + rl.draw_text_ex(self._font_semi_bold, road_display, rl.Vector2(x, y), font_size, 0, COLORS.white) + else: + overflow = text_width - width + dt = rl.get_frame_time() + + if self._marquee_pause_timer > 0: + self._marquee_pause_timer -= dt + else: + self._marquee_offset += self._marquee_direction * self._marquee_speed * dt + + if self._marquee_offset >= overflow: + self._marquee_offset = overflow + self._marquee_direction = -1 + self._marquee_pause_timer = self._marquee_pause_duration + elif self._marquee_offset <= 0: + self._marquee_offset = 0 + self._marquee_direction = 1 + self._marquee_pause_timer = self._marquee_pause_duration + + rl.begin_scissor_mode(int(x), int(y), int(width), int(road_size.y + 4)) + text_pos = rl.Vector2(x - self._marquee_offset, y) + rl.draw_text_ex(self._font_semi_bold, road_display, text_pos, font_size, 0, COLORS.white) + rl.end_scissor_mode() + + def _draw_vienna_sign(self, x: float, y: float, width: float, height: float, speed_str: str, speed_color: rl.Color, is_upcoming: bool = False) -> None: + center = rl.Vector2(x + width / 2, y + height / 2) + outer_radius = min(width, height) / 2 + + rl.draw_circle_v(center, outer_radius, COLORS.white) + ring_width = outer_radius * 0.18 + rl.draw_ring(center, outer_radius - ring_width, outer_radius, 0, 360, 36, COLORS.red) + + font_size = outer_radius * (0.7 if len(speed_str) >= 3 else 0.9) + text_size = measure_text_cached(self._font_bold, speed_str, int(font_size)) + text_pos = rl.Vector2(center.x - text_size.x / 2, center.y - text_size.y / 2) + rl.draw_text_ex(self._font_bold, speed_str, text_pos, font_size, 0, speed_color) + + def _draw_mutcd_sign(self, x: float, y: float, width: float, height: float, speed_str: str, speed_color: rl.Color, is_upcoming: bool = False) -> None: + sign_rect = rl.Rectangle(x, y, width, height) + rl.draw_rectangle_rounded(sign_rect, 0.35, 10, COLORS.white) + + inset = max(4, width * 0.05) + inner_rect = rl.Rectangle(x + inset, y + inset, width - inset * 2, height - inset * 2) + outer_radius = 0.35 * width / 2.0 + inner_radius = outer_radius - inset + inner_roundness = inner_radius / (inner_rect.width / 2.0) + rl.draw_rectangle_rounded_lines_ex(inner_rect, inner_roundness, 10, 3, COLORS.black) + + mid_x = x + width / 2 + label_size = max(16, int(width * 0.22)) + if is_upcoming: + self._draw_text_centered(self._font_bold, tr("AHEAD"), label_size, rl.Vector2(mid_x, y + height * 0.27), COLORS.black) + else: + self._draw_text_centered(self._font_bold, tr("SPEED"), label_size, rl.Vector2(mid_x, y + height * 0.18), COLORS.black) + self._draw_text_centered(self._font_bold, tr("LIMIT"), label_size, rl.Vector2(mid_x, y + height * 0.36), COLORS.black) + + speed_font_size = int(width * 0.42) if len(speed_str) >= 3 else int(width * 0.50) + self._draw_text_centered(self._font_bold, speed_str, speed_font_size, rl.Vector2(mid_x, y + height * 0.66), speed_color) + + def _draw_text_centered(self, font, text, size, pos_center, color): + sz = measure_text_cached(font, text, size) + rl.draw_text_ex(font, text, rl.Vector2(pos_center.x - sz.x / 2, pos_center.y - sz.y / 2), size, 0, color) + + def _format_distance(self, distance: float) -> str: + if ui_state.is_metric: + if distance < 50: + return tr("Near") + if distance >= 1000: + return f"{distance * METER_TO_KM:.1f}" + tr("km") + if distance < 200: + rounded = max(10, int(distance / 10) * 10) + else: + rounded = int(distance / 100) * 100 + return str(rounded) + tr("m") + else: + distance_mi = distance * METER_TO_MILE + if distance_mi < 0.1: + return tr("Near") + return f"{distance_mi:.1f}" + tr("mi") diff --git a/selfdrive/ui/mici/onroad/speed_limit_ui.py b/selfdrive/ui/mici/onroad/speed_limit_ui.py new file mode 100644 index 0000000000..b3445aa816 --- /dev/null +++ b/selfdrive/ui/mici/onroad/speed_limit_ui.py @@ -0,0 +1,210 @@ +import pyray as rl +from dataclasses import dataclass +from openpilot.common.constants import CV +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget + +METER_TO_KM = 0.001 +METER_TO_MILE = 0.000621371 + +SPEED_LIMIT_WIDTH_METRIC = 200 +SPEED_LIMIT_WIDTH_IMPERIAL = 172 +SPEED_LIMIT_HEIGHT = 204 + + +@dataclass(frozen=True) +class SpeedLimitColors: + white: rl.Color = rl.WHITE + black: rl.Color = rl.BLACK + red: rl.Color = rl.Color(255, 0, 0, 255) + grey: rl.Color = rl.Color(145, 155, 149, 241) + green: rl.Color = rl.Color(0, 255, 0, 255) + light_grey: rl.Color = rl.Color(200, 200, 200, 255) + dark_grey: rl.Color = rl.Color(180, 180, 180, 255) + border_grey: rl.Color = rl.Color(77, 77, 77, 255) + black_translucent: rl.Color = rl.Color(0, 0, 0, 166) + border_translucent: rl.Color = rl.Color(255, 255, 255, 75) + + +COLORS = SpeedLimitColors() + + +class SpeedLimitRenderer(Widget): + def __init__(self): + super().__init__() + self.speed_limit: float = 0.0 + self.speed_limit_valid: bool = False + self.speed_limit_offset: float = 0.0 + self.next_speed_limit: float = 0.0 + self.next_speed_limit_distance: float = 0.0 + self.road_name: str = "" + self.current_speed: float = 0.0 + self._hide_for_hud: bool = False + + self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD) + self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD) + self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM) + + def set_hide_for_hud(self, hide: bool): + self._hide_for_hud = hide + + def _update_state(self) -> None: + sm = ui_state.sm + if sm.recv_frame["carState"] < ui_state.started_frame: + return + + speed_conv = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH + + if sm.updated["longitudinalPlanSP"]: + lp_sp = sm["longitudinalPlanSP"] + resolver = lp_sp.speedLimit.resolver + self.speed_limit = resolver.speedLimit * speed_conv + self.speed_limit_valid = resolver.speedLimitValid + self.speed_limit_offset = resolver.speedLimitOffset * speed_conv + + if sm.updated["liveMapDataSP"]: + lmd = sm["liveMapDataSP"] + self.next_speed_limit = lmd.speedLimitAhead * speed_conv + self.next_speed_limit_distance = lmd.speedLimitAheadDistance + self.road_name = lmd.roadName + + if sm.updated["carState"]: + self.current_speed = sm["carState"].vEgo * speed_conv + + def _render(self, rect: rl.Rectangle) -> None: + self._update_state() + + if self._hide_for_hud: + return + + if not self.speed_limit_valid: + return + + sign_width = SPEED_LIMIT_WIDTH_METRIC if ui_state.is_metric else SPEED_LIMIT_WIDTH_IMPERIAL + sign_height = SPEED_LIMIT_HEIGHT + sign_margin = 12 + sign_x = rect.x + sign_margin + sign_y = rect.y + 45 + + sign_rect = rl.Rectangle(sign_x, sign_y, sign_width, sign_height) + + speed_str = str(round(self.speed_limit)) + speed_color = COLORS.white if self.current_speed <= self.speed_limit else COLORS.red + + if ui_state.is_metric: + self._draw_vienna_sign(sign_rect, speed_str, speed_color) + else: + self._draw_mutcd_sign(sign_rect, speed_str, speed_color) + + if self.speed_limit_offset != 0: + self._draw_offset_badge(sign_rect) + + if self.next_speed_limit > 0 and self.next_speed_limit != self.speed_limit: + self._draw_upcoming_speed_limit(sign_rect) + + def _draw_vienna_sign(self, sign_rect: rl.Rectangle, speed_str: str, speed_color: rl.Color) -> None: + """Draw EU/Vienna sign.""" + center = rl.Vector2(sign_rect.x + sign_rect.width / 2, sign_rect.y + sign_rect.height / 2) + outer_radius = min(sign_rect.width, sign_rect.height) / 2 + circle_size = outer_radius * 2 + + rl.draw_circle_v(center, outer_radius, COLORS.white) + ring_width = outer_radius * 0.18 + rl.draw_ring(center, outer_radius - ring_width, outer_radius, 0, 360, 36, COLORS.red) + + font_size = circle_size * (0.35 if len(speed_str) >= 3 else 0.45) + text_size = measure_text_cached(self._font_bold, speed_str, int(font_size)) + text_pos = rl.Vector2(center.x - text_size.x / 2, center.y - text_size.y / 2) + rl.draw_text_ex(self._font_bold, speed_str, text_pos, font_size, 0, speed_color) + + def _draw_mutcd_sign(self, sign_rect: rl.Rectangle, speed_str: str, speed_color: rl.Color) -> None: + """Draw US/Canada MUTCD sign.""" + padding = 8 + inner_rect = rl.Rectangle(sign_rect.x + padding, sign_rect.y + padding, sign_rect.width - (padding * 2), sign_rect.height - (padding * 2)) + rl.draw_rectangle_rounded(inner_rect, 0.35, 10, COLORS.white) + rl.draw_rectangle_rounded_lines_ex(inner_rect, 0.35, 10, 4, COLORS.black) + + border_width = 4 + border_rect = rl.Rectangle( + inner_rect.x + border_width, inner_rect.y + border_width, inner_rect.width - (border_width * 2), inner_rect.height - (border_width * 2) + ) + speed_text = tr("SPEED") + limit_text = tr("LIMIT") + + speed_size = measure_text_cached(self._font_semi_bold, speed_text, 40) + limit_size = measure_text_cached(self._font_semi_bold, limit_text, 40) + + speed_pos = rl.Vector2(border_rect.x + (border_rect.width - speed_size.x) / 2, sign_rect.y + 27) + limit_pos = rl.Vector2(border_rect.x + (border_rect.width - limit_size.x) / 2, sign_rect.y + 65) + + rl.draw_text_ex(self._font_semi_bold, speed_text, speed_pos, 40, 0, COLORS.black) + rl.draw_text_ex(self._font_semi_bold, limit_text, limit_pos, 40, 0, COLORS.black) + + font_size = 70 if len(speed_str) >= 3 else 90 + speed_value_size = measure_text_cached(self._font_bold, speed_str, font_size) + speed_value_pos = rl.Vector2(border_rect.x + (border_rect.width - speed_value_size.x) / 2, sign_rect.y + 100) + rl.draw_text_ex(self._font_bold, speed_str, speed_value_pos, font_size, 0, speed_color) + + def _draw_offset_badge(self, sign_rect: rl.Rectangle) -> None: + """Draw speed limit offset""" + offset_str = ("+" if self.speed_limit_offset > 0 else "") + str(round(self.speed_limit_offset)) + + badge_width = 80 + badge_height = 36 + badge_x = sign_rect.x + (sign_rect.width - badge_width) / 2 + badge_y = sign_rect.y + sign_rect.height + 4 + + badge_rect = rl.Rectangle(badge_x, badge_y, badge_width, badge_height) + rl.draw_rectangle_rounded(badge_rect, 0.5, 10, COLORS.black_translucent) + + offset_font_size = 28 + offset_size = measure_text_cached(self._font_semi_bold, offset_str, offset_font_size) + offset_pos = rl.Vector2(badge_x + (badge_width - offset_size.x) / 2, badge_y + (badge_height - offset_size.y) / 2) + rl.draw_text_ex(self._font_semi_bold, offset_str, offset_pos, offset_font_size, 0, COLORS.white) + + def _draw_upcoming_speed_limit(self, sign_rect: rl.Rectangle) -> None: + """Draw upcoming speed limit.""" + speed_str = str(round(self.next_speed_limit)) + distance_str = self._format_distance(self.next_speed_limit_distance) + + ahead_width = 150 + ahead_height = 100 + ahead_x = sign_rect.x + (sign_rect.width - ahead_width) / 2 + ahead_y = sign_rect.y + sign_rect.height + 46 + ahead_rect = rl.Rectangle(ahead_x, ahead_y, ahead_width, ahead_height) + + rl.draw_rectangle_rounded(ahead_rect, 0.2, 10, rl.Color(0, 0, 0, 180)) + rl.draw_rectangle_rounded_lines_ex(ahead_rect, 0.2, 10, 3, rl.Color(255, 255, 255, 100)) + + ahead_label = tr("AHEAD") + ahead_size = measure_text_cached(self._font_semi_bold, ahead_label, 28) + ahead_pos = rl.Vector2(ahead_rect.x + (ahead_rect.width - ahead_size.x) / 2, ahead_rect.y + 4) + rl.draw_text_ex(self._font_semi_bold, ahead_label, ahead_pos, 28, 0, COLORS.light_grey) + + speed_size = measure_text_cached(self._font_bold, speed_str, 48) + speed_pos = rl.Vector2(ahead_rect.x + (ahead_rect.width - speed_size.x) / 2, ahead_rect.y + 26) + rl.draw_text_ex(self._font_bold, speed_str, speed_pos, 48, 0, COLORS.white) + + distance_size = measure_text_cached(self._font_medium, distance_str, 28) + distance_pos = rl.Vector2(ahead_rect.x + (ahead_rect.width - distance_size.x) / 2, ahead_rect.y + 70) + rl.draw_text_ex(self._font_medium, distance_str, distance_pos, 28, 0, COLORS.dark_grey) + + def _format_distance(self, distance: float) -> str: + if ui_state.is_metric: + if distance < 50: + return tr("Near") + if distance >= 1000: + return f"{distance * METER_TO_KM:.1f}" + tr("km") + if distance < 200: + rounded = max(10, int(distance / 10) * 10) + else: + rounded = int(distance / 100) * 100 + return str(rounded) + tr("m") + else: + distance_mi = distance * METER_TO_MILE + if distance_mi < 0.1: + return tr("Near") + return f"{distance_mi:.1f}" + tr("mi")