step 5: complete first gauge catalogue

This commit is contained in:
2026-07-02 14:47:05 +02:00
parent d638c512e9
commit 1a476bc91d
11 changed files with 594 additions and 16 deletions

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import math
import time
from typing import Any, cast
@@ -9,9 +10,26 @@ import dearpygui.dearpygui as dpg
from .animation import advance_animation
from .draw_layers import DrawLayers
from .gauges import BarGaugeConfig, LevelGaugeConfig, StatusLightConfig
from .scales import fill_fraction, fill_span, format_gauge_value, normalized_span
from .sizing import effective_size, update_measured_size
from .gauges import (
AnalogGaugeConfig,
BarGaugeConfig,
BatteryGaugeConfig,
CompassGaugeConfig,
LevelGaugeConfig,
MultiValueGaugeConfig,
SegmentedGaugeConfig,
StatusLightConfig,
)
from .scales import (
active_segments,
fill_fraction,
fill_span,
format_gauge_value,
generate_ticks,
normalized_span,
value_to_angle,
)
from .sizing import effective_size, square_gauge_rect, update_measured_size
from .smoothing import advance_smoothing
from .state import DirtyFlags, GaugeState, animation_config, size_state_for_gauge, update_state_size
from .themes import GaugeStyle
@@ -102,8 +120,18 @@ class GaugeRenderer:
self._draw_frame(width, height, style)
if state.gauge_type == "digital":
self._draw_digital(width, height, style)
elif state.gauge_type in {"analog", "compass"}:
self._draw_analog(width, height, style)
elif state.gauge_type in {"bar", "level"}:
self._draw_bar_or_level(width, height, style)
elif state.gauge_type == "segmented":
self._draw_segmented(width, height, style)
elif state.gauge_type == "thermometer":
self._draw_thermometer(width, height, style)
elif state.gauge_type == "battery":
self._draw_battery(width, height, style)
elif state.gauge_type == "multi_value":
self._draw_multi_value(width, height, style)
elif state.gauge_type == "status_light":
self._draw_status_light(width, height, style)
else:
@@ -321,6 +349,280 @@ class GaugeRenderer:
size=18,
)
def _draw_analog(self, width: int, height: int, style: GaugeStyle) -> None:
state = self.state
config = state.config
if not isinstance(config, AnalogGaugeConfig | CompassGaugeConfig):
return
drawlist = _drawlist_tag(state)
rect = square_gauge_rect(width - 16, height - 16)
cx = 8 + rect.x + rect.width / 2.0
cy = 8 + rect.y + rect.height / 2.0
radius = max(min(rect.width, rect.height) * 0.43, 12.0)
if config.show_label and config.label:
self._draw_label(style, x=12, y=10)
self._draw_arc(
(cx, cy),
radius,
config.start_angle,
config.end_angle,
style.frame_color,
config.arc_width,
)
for threshold in config.thresholds:
start = value_to_angle(
threshold.start,
config.min_value,
config.max_value,
config.start_angle,
config.end_angle,
)
end = value_to_angle(
threshold.end,
config.min_value,
config.max_value,
config.start_angle,
config.end_angle,
)
self._draw_arc((cx, cy), radius, start, end, threshold.color, config.arc_width + 2)
if config.redline_start is not None:
start = value_to_angle(
config.redline_start,
config.min_value,
config.max_value,
config.start_angle,
config.end_angle,
)
self._draw_arc(
(cx, cy), radius, start, config.end_angle, style.redline_color, config.arc_width + 3
)
ticks = generate_ticks(config.min_value, config.max_value, max(config.major_ticks, 2))
for index, tick in enumerate(ticks):
angle = value_to_angle(
tick, config.min_value, config.max_value, config.start_angle, config.end_angle
)
outer = _point_on_circle(cx, cy, radius + 4, angle)
inner = _point_on_circle(cx, cy, radius - 10, angle)
tag = self.layers.static.add(f"{state.tag}__tick_{index}")
dpg.draw_line(
outer, inner, parent=drawlist, tag=tag, color=style.tick_color, thickness=2
)
if config.show_tick_labels:
label_pos = _point_on_circle(cx, cy, radius - 26, angle)
label = self.layers.text.add(f"{state.tag}__tick_label_{index}")
text = f"{tick:.0f}"
dpg.draw_text(
(label_pos[0] - 10, label_pos[1] - 6),
text,
parent=drawlist,
tag=label,
color=style.muted_text_color,
size=10,
)
for index, marker in enumerate(config.markers):
angle = value_to_angle(
marker.value,
config.min_value,
config.max_value,
config.start_angle,
config.end_angle,
)
outer = _point_on_circle(cx, cy, radius + 8, angle)
inner = _point_on_circle(cx, cy, radius - 18, angle)
tag = self.layers.markers.add(f"{state.tag}__marker_{index}")
dpg.draw_line(
outer,
inner,
parent=drawlist,
tag=tag,
color=marker.color,
thickness=marker.thickness,
)
if state.displayed_value is not None and not state.invalid_value:
angle = value_to_angle(
state.displayed_value,
config.min_value,
config.max_value,
config.start_angle,
config.end_angle,
)
end = _point_on_circle(cx, cy, radius * config.needle_length, angle)
needle = self.layers.dynamic.add(f"{state.tag}__needle")
dpg.draw_line(
(cx, cy),
end,
parent=drawlist,
tag=needle,
color=style.needle_color,
thickness=config.needle_width,
)
cap = self.layers.dynamic.add(f"{state.tag}__center_cap")
dpg.draw_circle(
(cx, cy),
config.center_cap_radius,
parent=drawlist,
tag=cap,
color=style.border_color,
fill=style.value_color,
)
if config.show_value:
value = self.layers.text.add(f"{state.tag}__value")
dpg.draw_text(
(cx - radius * 0.45, cy + radius * 0.38),
self._display_text(),
parent=drawlist,
tag=value,
color=style.text_color,
size=14,
)
def _draw_arc(
self,
center: tuple[float, float],
radius: float,
start_angle: float,
end_angle: float,
color: tuple[int, ...],
thickness: float,
) -> None:
drawlist = _drawlist_tag(self.state)
steps = max(int(abs(end_angle - start_angle) / 8), 2)
previous = _point_on_circle(center[0], center[1], radius, start_angle)
for index in range(1, steps + 1):
angle = start_angle + (end_angle - start_angle) * (index / steps)
current = _point_on_circle(center[0], center[1], radius, angle)
tag = self.layers.static.add(
f"{self.state.tag}__arc_{len(self.layers.static.item_tags)}"
)
dpg.draw_line(
previous, current, parent=drawlist, tag=tag, color=color, thickness=thickness
)
previous = current
def _draw_segmented(self, width: int, height: int, style: GaugeStyle) -> None:
state = self.state
config = state.config
if not isinstance(config, SegmentedGaugeConfig):
return
self._draw_label(style)
drawlist = _drawlist_tag(state)
count = config.segments
active = 0
if state.displayed_value is not None and not state.invalid_value:
active = active_segments(
state.displayed_value, config.min_value, config.max_value, count
)
gap = 4.0
left, top, right, bottom = 12.0, 38.0, max(width - 12.0, 13.0), max(height - 28.0, 39.0)
segment_width = max((right - left - gap * (count - 1)) / count, 1.0)
for index in range(count):
x1 = left + index * (segment_width + gap)
x2 = x1 + segment_width
color = style.value_color if index < active else style.frame_color
tag = self.layers.dynamic.add(f"{state.tag}__segment_{index}")
dpg.draw_rectangle(
(x1, top),
(x2, bottom),
parent=drawlist,
tag=tag,
color=style.border_color,
fill=color,
rounding=config.corner_radius,
)
def _draw_thermometer(self, width: int, height: int, style: GaugeStyle) -> None:
self._draw_bar_or_level(width, height, style)
drawlist = _drawlist_tag(self.state)
bulb = self.layers.dynamic.add(f"{self.state.tag}__bulb")
dpg.draw_circle(
(width / 2.0, max(height - 30.0, 18.0)),
16,
parent=drawlist,
tag=bulb,
color=style.border_color,
fill=style.value_color,
)
def _draw_battery(self, width: int, height: int, style: GaugeStyle) -> None:
state = self.state
config = state.config
if not isinstance(config, BatteryGaugeConfig):
return
self._draw_label(style)
drawlist = _drawlist_tag(state)
body = (18.0, 40.0, max(width - 34.0, 58.0), max(height - 26.0, 74.0))
nub = (
body[2],
body[1] + (body[3] - body[1]) * 0.33,
body[2] + 10,
body[1] + (body[3] - body[1]) * 0.67,
)
dpg.draw_rectangle(
(body[0], body[1]),
(body[2], body[3]),
parent=drawlist,
tag=self.layers.static.add(f"{state.tag}__battery_body"),
color=style.border_color,
thickness=2,
rounding=3,
)
dpg.draw_rectangle(
(nub[0], nub[1]),
(nub[2], nub[3]),
parent=drawlist,
tag=self.layers.static.add(f"{state.tag}__battery_nub"),
color=style.border_color,
fill=style.border_color,
rounding=2,
)
if state.displayed_value is not None and not state.invalid_value:
fraction = fill_fraction(
state.displayed_value, config.min_value, config.max_value, clamp=True
)
pad = 5.0
fill = (
body[0] + pad,
body[1] + pad,
body[0] + pad + (body[2] - body[0] - pad * 2) * fraction,
body[3] - pad,
)
if fill[2] > fill[0]:
dpg.draw_rectangle(
(fill[0], fill[1]),
(fill[2], fill[3]),
parent=drawlist,
tag=self.layers.dynamic.add(f"{state.tag}__battery_fill"),
color=style.value_color,
fill=style.value_color,
rounding=2,
)
if config.show_value:
dpg.draw_text(
(18, height - 22),
self._display_text(),
parent=drawlist,
tag=self.layers.text.add(f"{state.tag}__value"),
color=style.text_color,
size=14,
)
def _draw_multi_value(self, _width: int, height: int, style: GaugeStyle) -> None:
state = self.state
config = state.config
if not isinstance(config, MultiValueGaugeConfig):
return
self._draw_label(style)
drawlist = _drawlist_tag(state)
values = config.values or {}
y = 34.0
for index, (name, value) in enumerate(values.items()):
text = f"{name}: {value}"
tag = self.layers.text.add(f"{state.tag}__multi_{index}")
dpg.draw_text((12, y), text, parent=drawlist, tag=tag, color=style.text_color, size=16)
y += 22.0
if y > height - 18:
break
def _display_text(self) -> str:
state = self.state
if not state.config.show_value:
@@ -371,6 +673,13 @@ def _oriented_rect(rect: Rect, start: float, end: float, orientation: str) -> Re
return rect[0] + width * start, rect[1], rect[0] + width * end, rect[3]
def _point_on_circle(
cx: float, cy: float, radius: float, angle_degrees: float
) -> tuple[float, float]:
radians = math.radians(angle_degrees)
return cx + math.cos(radians) * radius, cy - math.sin(radians) * radius
def _resolve_status(config: StatusLightConfig, style: GaugeStyle) -> StatusState:
if config.states is not None and config.state in config.states:
return config.states[config.state]