Improve speed limit source bubble readability

This commit is contained in:
firestarsdog
2026-08-12 04:31:20 -04:00
parent 097c6e4eb1
commit 8c41d92e89
3 changed files with 298 additions and 36 deletions
+107 -36
View File
@@ -11,6 +11,11 @@ from openpilot.selfdrive.ui.onroad.starpilot.widget_style import (
CONTROL_BG, CONTROL_BORDER, CONTROL_BORDER_WIDTH, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, SLC_HEIGHT,
draw_control_card, roundness_for,
)
from openpilot.selfdrive.ui.onroad.starpilot.source_bubble_layout import (
enabled_source_titles, fit_source_label, source_abbreviated_value_text,
source_content_metrics, source_value_text, visible_source_rows,
)
from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state
_WHITE = rl.Color(255, 255, 255, 255)
@@ -130,7 +135,13 @@ def _get_slc_state():
show_offset = params.get_bool("ShowSLCOffset")
dashboard_sl = sm["starpilotCarState"].dashboardSpeedLimit if sm.valid.get("starpilotCarState", False) else 0.0
vision_sl = ui_state.params_memory.get_float("VisionSpeedLimit") if params.get_bool("VisionSpeedLimitDetection") else 0.0
vision_enabled = params.get_bool("VisionSpeedLimitDetection")
vision_sl = ui_state.params_memory.get_float("VisionSpeedLimit") if vision_enabled else 0.0
primary_priority = params.get("SLCPriority1", encoding="utf-8") or "Map Data"
secondary_priority = params.get("SLCPriority2", encoding="utf-8") or "None"
mapbox_enabled = params.get_bool("SLCMapboxFiller") and bool(
params.get("MapboxSecretKey", encoding="utf-8")
)
slc_overridden_speed = plan.slcOverriddenSpeed
# Driver override takes precedence over the planner's limit when active.
@@ -167,6 +178,15 @@ def _get_slc_state():
'offset_str': offset_str,
'speed_conversion': speed_conversion,
'speed_unit': " km/h" if ui_state.is_metric else " mph",
'slc_abbreviated_sources': params.get_bool("SLCAbbreviatedSources"),
'slc_active_sources_only': params.get_bool("SLCActiveSourcesOnly"),
'slc_enabled_sources': enabled_source_titles(
primary_priority,
secondary_priority,
vision_enabled=vision_enabled,
mapbox_enabled=mapbox_enabled,
dashboard_available=starpilot_state.car_state.hasDashSpeedLimits,
),
# Per-source raw values
'dashboard_sl': max(0.0, dashboard_sl * speed_conversion),
'map_sl': max(0.0, plan.slcMapSpeedLimit * speed_conversion),
@@ -398,34 +418,40 @@ def _draw_sign(state: dict, rect: rl.Rectangle, *, pending: bool = False):
# ── Sources Bubble (expandable overlay) ────────────────────────────────
# Fixed width fits the longest source label and a three-digit speed at 26px.
# Fixed outer footprint; the content scale adapts to the visible row count.
_SOURCE_PANEL_WIDTH = 248
_SOURCE_PANEL_GAP = 20
_SOURCE_PANEL_PAD_X = 9
_SOURCE_PANEL_PAD_Y = 4
_SOURCE_PANEL_BG = rl.Color(0, 0, 0, 145)
_SOURCE_PANEL_BORDER = rl.Color(196, 205, 208, 72)
_SOURCE_DIVIDER = rl.Color(196, 205, 208, 70)
_SOURCE_PANEL_PAD_Y = 2
_SOURCE_PANEL_BG = rl.Color(0, 0, 0, 175)
_SOURCE_PANEL_BORDER = rl.Color(196, 205, 208, 80)
_SOURCE_DIVIDER = rl.Color(196, 205, 208, 100)
_SOURCE_ACTIVE_BAR = rl.Color(CONTROL_BORDER.r, CONTROL_BORDER.g, CONTROL_BORDER.b, 230)
_SOURCE_ICON_MUTED = rl.Color(196, 205, 208, 190)
_SOURCE_LABEL = rl.Color(255, 255, 255, 215)
_SOURCE_FONT = 26
_SOURCE_ICON_TEXT_GAP = 8
_SOURCE_ICON_SIZE = 28
_SOURCE_ACTIVE_BAR_WIDTH = 5.0
_SOURCE_ACTIVE_BAR_HEIGHT = 32.0
_SOURCE_ICON_MUTED = rl.Color(196, 205, 208, 220)
_SOURCE_LABEL = rl.Color(255, 255, 255, 235)
_SOURCE_ACTIVE_BAR_WIDTH = 6.0
_SOURCE_ACTIVE_BAR_HEIGHT = 36.0
_SOURCE_ACTIVE_BAR_X = 2.0
_SOURCE_ACTIVE_BAR_ROW_INSET = 6.0
_SOURCE_ACTIVE_BAR_ROW_INSET = 3.0
_SOURCE_MIN_LABEL_VALUE_GAP = 6.0
_SOURCE_COMPACT_LABELS = {
"Dashboard": "Dash",
"Map Data": "OSM",
"Vision": "Vision",
"Mapbox": "Mapbox",
"Next": "Next",
}
def _draw_source_icon(icon_key: str, x: float, y: float, size: float, color: rl.Color) -> None:
"""Draw the small, intentionally simple source glyphs used by the panel."""
cx = x + size / 2
cy = y + size / 2
stroke = max(2.0, size / 12.0)
stroke = max(2.5, size / 12.0)
if icon_key == "map":
map_stroke = max(2.0, size * 0.075)
map_stroke = max(2.5, size * 0.075)
left = x + size * 0.12
fold_left = x + size * 0.37
fold_right = x + size * 0.63
@@ -462,7 +488,7 @@ def _draw_source_icon(icon_key: str, x: float, y: float, size: float, color: rl.
0.18, 8, color,
)
elif icon_key == "next":
arrow_stroke = max(2.0, size * 0.08)
arrow_stroke = max(2.5, size * 0.08)
arrow_tip = rl.Vector2(x + size * 0.88, cy)
rl.draw_line_ex(rl.Vector2(x + size * 0.10, cy), arrow_tip, arrow_stroke, color)
for endpoint in (
@@ -517,14 +543,24 @@ def _draw_source_icon(icon_key: str, x: float, y: float, size: float, color: rl.
def _draw_sources_bubble(state: dict, sign_rect: rl.Rectangle):
"""Draw the expanded source list attached to the SLC card."""
font_semi = _get_semi_bold()
font_bold = _get_bold()
active_source = state['speed_limit_source']
enabled_sources = state.get('slc_enabled_sources', ())
active_only = state.get('slc_active_sources_only', False)
abbreviated = state.get('slc_abbreviated_sources', False)
rows = []
for title, _abbrev, value_key, panel_label, icon_key in SOURCE_DEFS:
value = state[value_key]
if value == 0 and active_source != title:
continue
rows.append((panel_label, icon_key, value, active_source == title))
rows = [
(
panel_label,
_SOURCE_COMPACT_LABELS[panel_label],
icon_key,
value,
is_active,
)
for panel_label, icon_key, value, is_active in visible_source_rows(
SOURCE_DEFS, state, active_source, enabled_sources, active_only,
)
]
if not rows:
return
@@ -543,9 +579,13 @@ def _draw_sources_bubble(state: dict, sign_rect: rl.Rectangle):
row_h = (panel_rect.height - 2 * _SOURCE_PANEL_PAD_Y) / len(rows)
content_left = panel_rect.x + _SOURCE_PANEL_PAD_X
content_right = panel_rect.x + panel_rect.width - _SOURCE_PANEL_PAD_X
label_left = content_left + _SOURCE_ICON_SIZE + _SOURCE_ICON_TEXT_GAP
font_size, icon_size, icon_gap = source_content_metrics(len(rows))
label_left = (
content_left + _SOURCE_ACTIVE_BAR_WIDTH + _SOURCE_MIN_LABEL_VALUE_GAP
if abbreviated else content_left + icon_size + icon_gap
)
for index, (panel_label, icon_key, value, is_active) in enumerate(rows):
for index, (panel_label, compact_label, icon_key, value, is_active) in enumerate(rows):
row_y = panel_rect.y + _SOURCE_PANEL_PAD_Y + index * row_h
if index:
divider_y = row_y
@@ -556,14 +596,6 @@ def _draw_sources_bubble(state: dict, sign_rect: rl.Rectangle):
_SOURCE_DIVIDER,
)
text_font = font_semi
label_text = panel_label
value_text = "\u2013" if value <= 0 else str(int(round(value)))
font_size = _SOURCE_FONT
value_size = measure_text_cached(text_font, value_text, font_size)
baseline_y = row_y + (row_h - value_size.y) / 2
icon_y = row_y + (row_h - _SOURCE_ICON_SIZE) / 2
if is_active:
active_bar_height = min(
_SOURCE_ACTIVE_BAR_HEIGHT,
@@ -577,15 +609,54 @@ def _draw_sources_bubble(state: dict, sign_rect: rl.Rectangle):
)
rl.draw_rectangle_rounded(active_bar_rect, 0.5, 4, _SOURCE_ACTIVE_BAR)
value_text = source_value_text(value)
if abbreviated:
text_font = font_bold if is_active else font_semi
label_text = fit_source_label(
f"{tr(compact_label)}-{source_abbreviated_value_text(value)}",
"",
content_right - label_left,
lambda text: measure_text_cached(text_font, text, font_size).x,
)
label_size = measure_text_cached(text_font, label_text, font_size)
baseline_y = row_y + (row_h - label_size.y) / 2
rl.draw_text_ex(
text_font,
label_text,
rl.Vector2(label_left, baseline_y),
font_size,
0,
_WHITE if is_active else _SOURCE_LABEL,
)
continue
compact_label = tr(compact_label)
full_label = compact_label
value_size = measure_text_cached(font_bold, value_text, font_size)
max_label_width = max(
0.0,
content_right - label_left - _SOURCE_MIN_LABEL_VALUE_GAP - value_size.x,
)
label_text = fit_source_label(
full_label,
compact_label,
max_label_width,
lambda text: measure_text_cached(font_semi, text, font_size).x,
)
label_size = measure_text_cached(font_semi, label_text, font_size)
text_height = max(label_size.y, value_size.y)
baseline_y = row_y + (row_h - text_height) / 2
icon_y = row_y + (row_h - icon_size) / 2
icon_color = _WHITE if is_active else _SOURCE_ICON_MUTED
_draw_source_icon(icon_key, content_left, icon_y, _SOURCE_ICON_SIZE, icon_color)
_draw_source_icon(icon_key, content_left, icon_y, icon_size, icon_color)
label_pos = rl.Vector2(label_left, baseline_y)
value_pos = rl.Vector2(content_right - value_size.x, baseline_y)
label_color = _WHITE if is_active else _SOURCE_LABEL
value_color = _WHITE if is_active else _SOURCE_LABEL
rl.draw_text_ex(text_font, label_text, label_pos, font_size, 0, label_color)
rl.draw_text_ex(text_font, value_text, value_pos, font_size, 0, value_color)
rl.draw_text_ex(font_semi, label_text, label_pos, font_size, 0, label_color)
rl.draw_text_ex(font_bold, value_text, value_pos, font_size, 0, value_color)
# ── Public API ────────────────────────────────────────────────────────
@@ -0,0 +1,111 @@
"""Pure layout decisions for the on-road speed-limit source bubble."""
import math
from collections.abc import Callable, Iterable, Mapping
SOURCE_DISPLAY_ORDER = ("Dashboard", "Map Data", "Vision", "Mapbox", "Upcoming")
SOURCE_PRIORITY_NAMES = frozenset(("Dashboard", "Map Data", "Vision"))
def enabled_source_titles(
primary_priority: str,
secondary_priority: str,
*,
vision_enabled: bool,
mapbox_enabled: bool,
dashboard_available: bool = True,
) -> tuple[str, ...]:
"""Return source rows that are eligible under the current SLC settings.
``Highest`` and ``Lowest`` are aggregate priority modes. They consider the
two non-vision controller inputs directly; Vision is only a controller input
when it is explicitly selected in one of the priority slots. Mapbox is a
separate fallback toggle, and Next is derived from the selected map source.
"""
if primary_priority in ("Highest", "Lowest"):
enabled = {"Dashboard", "Map Data"}
else:
enabled = {
source
for source in (primary_priority, secondary_priority)
if source in SOURCE_PRIORITY_NAMES
}
if not dashboard_available:
enabled.discard("Dashboard")
if not vision_enabled:
enabled.discard("Vision")
if mapbox_enabled:
enabled.add("Mapbox")
if "Map Data" in enabled:
enabled.add("Upcoming")
return tuple(source for source in SOURCE_DISPLAY_ORDER if source in enabled)
def source_content_metrics(row_count: int) -> tuple[int, int, int]:
"""Return logical text size, icon size, and icon gap for the visible rows."""
if row_count <= 3:
return 30, 34, 7
if row_count == 4:
return 30, 32, 7
return 28, 30, 6
def visible_source_rows(
source_defs: Iterable[tuple[str, str, str, str, str]],
values: Mapping[str, float],
active_source: str,
enabled_sources: Iterable[str],
active_only: bool,
) -> list[tuple[str, str, float, bool]]:
"""Return enabled source rows, optionally excluding empty readings."""
enabled = set(enabled_sources)
rows = []
for title, _abbrev, value_key, panel_label, icon_key in source_defs:
if title not in enabled:
continue
value = values[value_key]
if active_only and (not math.isfinite(value) or value <= 0):
continue
rows.append((
panel_label,
icon_key,
value,
active_source == title and math.isfinite(value) and value > 0,
))
return rows
def fit_source_label(
full_label: str,
compact_label: str,
max_width: float,
measure_width: Callable[[str], float],
) -> str:
"""Choose the longest useful label that leaves room for the value column."""
for label in (full_label, compact_label):
if measure_width(label) <= max_width:
return label
ellipsis = ""
candidate = compact_label or full_label
while candidate and measure_width(candidate + ellipsis) > max_width:
candidate = candidate[:-1]
return f"{candidate}{ellipsis}" if candidate else ellipsis
def source_value_text(value: float) -> str:
"""Format a source speed, keeping missing and non-finite values explicit."""
if not math.isfinite(value) or value <= 0:
return ""
rounded = int(round(value))
return "" if rounded <= 0 else str(rounded)
def source_abbreviated_value_text(value: float) -> str:
"""Format a compact source value using the established missing-value marker."""
value_text = source_value_text(value)
return "X" if value_text == "" else value_text
@@ -0,0 +1,80 @@
from openpilot.selfdrive.ui.onroad.starpilot.source_bubble_layout import (
enabled_source_titles,
fit_source_label,
source_abbreviated_value_text,
source_content_metrics,
source_value_text,
visible_source_rows,
)
def test_source_content_metrics_scale_with_visible_row_count():
assert source_content_metrics(3) == (30, 34, 7)
assert source_content_metrics(4) == (30, 32, 7)
assert source_content_metrics(5) == (28, 30, 6)
def test_fit_source_label_preserves_a_safe_value_column_gap():
def width(text: str) -> int:
return len(text) * 10
assert fit_source_label("Dashboard", "Dash", 80, width) == "Dash"
assert fit_source_label("Vision", "Vision", 70, width) == "Vision"
assert fit_source_label("Dashboard", "Dash", 20, width) == "D…"
def test_source_value_text_keeps_missing_values_as_a_dash():
assert source_value_text(0) == ""
assert source_value_text(0.1) == ""
assert source_value_text(55) == "55"
assert source_value_text(float("nan")) == ""
assert source_value_text(float("inf")) == ""
assert source_abbreviated_value_text(0) == "X"
assert source_abbreviated_value_text(55) == "55"
def test_enabled_source_titles_follow_priority_and_fallback_settings():
assert enabled_source_titles(
"Map Data", "Vision", vision_enabled=True, mapbox_enabled=False,
) == ("Map Data", "Vision", "Upcoming")
assert enabled_source_titles(
"Dashboard", "None", vision_enabled=False, mapbox_enabled=True,
) == ("Dashboard", "Mapbox")
assert enabled_source_titles(
"Highest", "None", vision_enabled=True, mapbox_enabled=True,
) == ("Dashboard", "Map Data", "Mapbox", "Upcoming")
assert enabled_source_titles(
"Dashboard", "Map Data", vision_enabled=False, mapbox_enabled=False,
dashboard_available=False,
) == ("Map Data", "Upcoming")
def test_visible_source_rows_honor_active_only_and_source_order():
source_defs = [
("Dashboard", "Dash", "dashboard", "Dashboard", "dashboard"),
("Map Data", "MapD", "map", "Map Data", "map"),
("Vision", "Vision", "vision", "Vision", "camera"),
("Mapbox", "MapB", "mapbox", "Mapbox", "map"),
("Upcoming", "Next", "next", "Next", "next"),
]
values = {"dashboard": 45.0, "map": 0.0, "vision": 50.0, "mapbox": 30.0, "next": 20.0}
assert visible_source_rows(
source_defs, values, "Dashboard", ("Dashboard", "Map Data"), False,
) == [
("Dashboard", "dashboard", 45.0, True),
("Map Data", "map", 0.0, False),
]
assert visible_source_rows(
source_defs, values, "Dashboard", ("Dashboard", "Map Data"), True,
) == [
("Dashboard", "dashboard", 45.0, True),
]
assert visible_source_rows(
source_defs, values, "Map Data", ("Dashboard", "Map Data"), True,
) == [
("Dashboard", "dashboard", 45.0, False),
]
assert visible_source_rows(
source_defs, {key: 0.0 for key in values}, "Map Data", ("Map Data",), True,
) == []