step 6: add stable overlays and live update stress tests

This commit is contained in:
2026-05-23 10:24:34 +02:00
parent 2d6242bd3f
commit 815d8a2d88
8 changed files with 583 additions and 18 deletions

View File

@@ -1 +1,46 @@
"""Draw layer bookkeeping helpers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .types import Tag
@dataclass(frozen=True, slots=True)
class DrawLayerTags:
"""Internal Dear PyGui draw layer tags for one map."""
background: str
tiles: str
overlays: str
attribution: str
def draw_layer_tags(map_tag: Tag) -> DrawLayerTags:
"""Return stable internal draw layer tags for a map."""
return DrawLayerTags(
background=f"{map_tag}##layer-background",
tiles=f"{map_tag}##layer-tiles",
overlays=f"{map_tag}##layer-overlays",
attribution=f"{map_tag}##layer-attribution",
)
def ensure_draw_layers(dpg: Any, *, drawlist_tag: Tag, map_tag: Tag) -> DrawLayerTags:
"""Create draw layers if needed and return their tags."""
tags = draw_layer_tags(map_tag)
for layer_tag in (tags.background, tags.tiles, tags.overlays, tags.attribution):
if not dpg.does_item_exist(layer_tag):
dpg.add_draw_layer(parent=drawlist_tag, tag=layer_tag)
return tags
def clear_draw_layer(dpg: Any, layer_tag: Tag) -> None:
"""Clear one draw layer without touching sibling layers."""
if dpg.does_item_exist(layer_tag):
dpg.delete_item(layer_tag, children_only=True)

View File

@@ -3,13 +3,18 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import replace
from typing import Any
from .commands import CommandKind, MapCommand
from .draw_layers import DrawLayerTags, clear_draw_layer, ensure_draw_layers
from .interaction import HitRect, calculate_hit_rect
from .overlays import MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay
from .projection import latlon_to_world
from .sizing import SizeMeasurement, apply_size_measurement
from .state import DirtyFlags, MapState
from .tiles import Tile, VisibleTile
from .types import Color, LatLon
class MapRenderer:
@@ -18,10 +23,10 @@ class MapRenderer:
def __init__(self, state: MapState, dpg: Any) -> None:
self.state = state
self._dpg = dpg
self._background_tag = f"{state.tag}##background"
self._attribution_tag = f"{state.tag}##attribution"
self._layers: DrawLayerTags | None = None
self.last_drained_commands: tuple[MapCommand, ...] = ()
self.last_hit_rect: HitRect | None = None
self.last_overlay_count: int = 0
def schedule_next_frame(self) -> None:
"""Schedule this renderer to run on the next Dear PyGui frame."""
@@ -51,7 +56,8 @@ class MapRenderer:
with self.state.lock:
dirty = self.state.dirty
should_draw = bool(dirty & (DirtyFlags.FULL | DirtyFlags.SIZE | DirtyFlags.TILES))
draw_tiles = bool(dirty & (DirtyFlags.SIZE | DirtyFlags.TILES | DirtyFlags.PROVIDER))
draw_overlays = bool(dirty & (DirtyFlags.SIZE | DirtyFlags.OVERLAYS))
visible = self.state.is_visible
width = self.state.measured_width or self.state.last_nonzero_width
height = self.state.measured_height or self.state.last_nonzero_height
@@ -61,6 +67,12 @@ class MapRenderer:
zoom = self.state.zoom
generation = self.state.generation
cache_dir = self.state.cache_dir
overlays = tuple(
_copy_overlay_for_render(overlay) for overlay in self.state.overlays.values()
)
layers = {
name: (layer.show, layer.z_index) for name, layer in self.state.layers.items()
}
self.state.dirty = DirtyFlags.NONE
accepted_tiles = self.state.tile_manager.drain_results(
@@ -84,7 +96,7 @@ class MapRenderer:
margin=self._prefetch_margin(),
)
if visible and (should_draw or accepted_tiles):
if visible and (draw_tiles or accepted_tiles):
self._draw_tile_layer(
visible_tiles=visible_tiles,
width=width,
@@ -92,6 +104,16 @@ class MapRenderer:
attribution=provider_attribution,
tile_size=provider.tile_size,
)
if visible and draw_overlays:
self._draw_overlay_layer(
overlays=overlays,
layers=layers,
center=center,
zoom=zoom,
width=width,
height=height,
tile_size=provider.tile_size,
)
def _update_size_from_dpg(self) -> None:
width, height = self._measure_child_content()
@@ -128,12 +150,14 @@ class MapRenderer:
) -> None:
width = max(1, int(width))
height = max(1, int(height))
self._dpg.delete_item(self.state.drawlist_tag, children_only=True)
layers = self._ensure_draw_layers()
clear_draw_layer(self._dpg, layers.background)
clear_draw_layer(self._dpg, layers.tiles)
clear_draw_layer(self._dpg, layers.attribution)
self._dpg.draw_rectangle(
(0, 0),
(width, height),
parent=self.state.drawlist_tag,
tag=self._background_tag,
parent=layers.background,
color=(54, 68, 78, 255),
fill=(29, 38, 45, 255),
)
@@ -147,19 +171,193 @@ class MapRenderer:
tile.texture_tag,
(screen_x, screen_y),
(screen_x + tile_size, screen_y + tile_size),
parent=self.state.drawlist_tag,
parent=layers.tiles,
)
label = attribution or "Map tiles"
text_y = max(28, height - 24)
self._dpg.draw_text(
(12, text_y),
label,
parent=self.state.drawlist_tag,
tag=self._attribution_tag,
parent=layers.attribution,
color=(172, 184, 192, 255),
size=12,
)
def _draw_overlay_layer(
self,
*,
overlays: tuple[Overlay, ...],
layers: dict[str, tuple[bool, int]],
center: LatLon,
zoom: int,
width: int,
height: int,
tile_size: int,
) -> None:
draw_layers = self._ensure_draw_layers()
clear_draw_layer(self._dpg, draw_layers.overlays)
if width <= 0 or height <= 0:
self.last_overlay_count = 0
return
center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size)
visible_overlays = [
overlay
for overlay in overlays
if overlay.show and layers.get(overlay.layer, (True, 0))[0]
]
visible_overlays.sort(key=lambda overlay: layers.get(overlay.layer, (True, 0))[1])
drawn = 0
for overlay in visible_overlays:
if isinstance(overlay, MarkerOverlay):
self._draw_marker_overlay(
overlay, center_x, center_y, zoom, width, height, tile_size
)
drawn += 1
elif isinstance(overlay, PolylineOverlay):
self._draw_polyline_overlay(
overlay,
center_x,
center_y,
zoom,
width,
height,
tile_size,
draw_layers.overlays,
)
drawn += 1
elif isinstance(overlay, TrajectoryOverlay):
self._draw_trajectory_overlay(
overlay,
center_x,
center_y,
zoom,
width,
height,
tile_size,
draw_layers.overlays,
)
drawn += 1
self.last_overlay_count = drawn
def _draw_marker_overlay(
self,
overlay: MarkerOverlay,
center_x: float,
center_y: float,
zoom: int,
width: int,
height: int,
tile_size: int,
) -> None:
layers = self._ensure_draw_layers()
x, y = _latlon_to_screen(
overlay.lat,
overlay.lon,
center_x,
center_y,
zoom,
width,
height,
tile_size,
)
radius = max(1.0, float(overlay.radius))
self._dpg.draw_circle(
(x, y),
radius,
parent=layers.overlays,
color=(255, 255, 255, 230),
fill=_rgba(overlay.color),
thickness=1.5,
segments=20,
)
if overlay.show_label and overlay.label:
self._dpg.draw_text(
(x + radius + 4.0, y - 7.0),
overlay.label,
parent=layers.overlays,
color=(245, 248, 250, 255),
size=12,
)
def _draw_polyline_overlay(
self,
overlay: PolylineOverlay,
center_x: float,
center_y: float,
zoom: int,
width: int,
height: int,
tile_size: int,
parent: str,
) -> None:
points = _screen_points(
overlay.points,
center_x=center_x,
center_y=center_y,
zoom=zoom,
width=width,
height=height,
tile_size=tile_size,
)
if len(points) < 2:
return
self._dpg.draw_polyline(
points,
parent=parent,
closed=overlay.closed,
color=_rgba(overlay.color),
thickness=max(1.0, float(overlay.thickness)),
)
def _draw_trajectory_overlay(
self,
overlay: TrajectoryOverlay,
center_x: float,
center_y: float,
zoom: int,
width: int,
height: int,
tile_size: int,
parent: str,
) -> None:
points = _screen_points(
overlay.points,
center_x=center_x,
center_y=center_y,
zoom=zoom,
width=width,
height=height,
tile_size=tile_size,
)
if len(points) >= 2:
self._dpg.draw_polyline(
points,
parent=parent,
color=_rgba(overlay.color),
thickness=max(1.0, float(overlay.thickness)),
)
if overlay.show_points and points:
stride = max(1, int(overlay.point_stride))
for point in points[::stride]:
self._dpg.draw_circle(
point,
2.5,
parent=parent,
color=_rgba(overlay.color),
fill=_rgba(overlay.color),
segments=8,
)
def _ensure_draw_layers(self) -> DrawLayerTags:
if self._layers is None or not self._dpg.does_item_exist(self._layers.overlays):
self._layers = ensure_draw_layers(
self._dpg,
drawlist_tag=self.state.drawlist_tag,
map_tag=self.state.tag,
)
return self._layers
def _ensure_texture(self, tile: Tile) -> None:
if tile.texture_tag is not None:
return
@@ -231,3 +429,43 @@ def make_frame_pump(state: MapState, dpg: Any) -> Callable[[], None]:
state.renderer = renderer
renderer.schedule_next_frame()
return renderer.render_frame
def _rgba(color: Color) -> tuple[int, int, int, int]:
if len(color) == 3:
return (int(color[0]), int(color[1]), int(color[2]), 255)
return (int(color[0]), int(color[1]), int(color[2]), int(color[3]))
def _latlon_to_screen(
lat: float,
lon: float,
center_x: float,
center_y: float,
zoom: int,
width: int,
height: int,
tile_size: int,
) -> tuple[float, float]:
world_x, world_y = latlon_to_world(lat, lon, zoom, tile_size)
return (world_x - center_x + width / 2.0, world_y - center_y + height / 2.0)
def _screen_points(
points: tuple[LatLon, ...],
*,
center_x: float,
center_y: float,
zoom: int,
width: int,
height: int,
tile_size: int,
) -> list[tuple[float, float]]:
return [
_latlon_to_screen(lat, lon, center_x, center_y, zoom, width, height, tile_size)
for lat, lon in points
]
def _copy_overlay_for_render(overlay: Overlay) -> Overlay:
return replace(overlay)