From 11fc1bb9bd5d21cc76d672a44885f03158c71869 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Fri, 22 May 2026 18:16:32 +0200 Subject: [PATCH 01/11] initial uv project setup --- AGENTS.md | 37 +++++++++++++++++++++++++++++++++++++ examples/.gitkeep | 1 + pyproject.toml | 10 ++++++++++ src/dpg_map/api.py | 1 + src/dpg_map/cache.py | 1 + src/dpg_map/commands.py | 1 + src/dpg_map/diagnostics.py | 1 + src/dpg_map/draw_layers.py | 1 + src/dpg_map/exceptions.py | 1 + src/dpg_map/interaction.py | 1 + src/dpg_map/overlays.py | 1 + src/dpg_map/projection.py | 1 + src/dpg_map/providers.py | 1 + src/dpg_map/renderer.py | 1 + src/dpg_map/sizing.py | 1 + src/dpg_map/state.py | 1 + src/dpg_map/tiles.py | 1 + src/dpg_map/types.py | 1 + src/dpg_map/widget.py | 1 + tests/.gitkeep | 1 + 20 files changed, 65 insertions(+) create mode 100644 AGENTS.md create mode 100644 examples/.gitkeep create mode 100644 src/dpg_map/api.py create mode 100644 src/dpg_map/cache.py create mode 100644 src/dpg_map/commands.py create mode 100644 src/dpg_map/diagnostics.py create mode 100644 src/dpg_map/draw_layers.py create mode 100644 src/dpg_map/exceptions.py create mode 100644 src/dpg_map/interaction.py create mode 100644 src/dpg_map/overlays.py create mode 100644 src/dpg_map/projection.py create mode 100644 src/dpg_map/providers.py create mode 100644 src/dpg_map/renderer.py create mode 100644 src/dpg_map/sizing.py create mode 100644 src/dpg_map/state.py create mode 100644 src/dpg_map/tiles.py create mode 100644 src/dpg_map/types.py create mode 100644 src/dpg_map/widget.py create mode 100644 tests/.gitkeep diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7c260ad --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# AGENTS.md + +## Current status + +Rebuild initialized. + +## Completed steps + +None yet. + +## Current step + +Step 1 - Public API contract and pure core. + +## Design decisions + +- Package is managed with uv. +- Public import is `import dpg_map as dpgm`. +- Dear PyGui calls are GUI-thread-only. +- Runtime public calls enqueue commands or update logical state. +- Overlay updates must not reset center/zoom. +- The widget uses child_window + measured-size drawlist. +- Tiles use a memory cache and persistent disk cache. +- Tile providers are interchangeable. + +## Known issues + +None yet. + +## Commands used + +- Read `STEPS.md`, `FEATURES.md`, and `ARCHITECTURE.md`. +- Created initial package, examples, tests, and agent-log structure. + +## Next action + +Implement Step 1. diff --git a/examples/.gitkeep b/examples/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/.gitkeep @@ -0,0 +1 @@ + diff --git a/pyproject.toml b/pyproject.toml index 05954ee..039d6ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,3 +27,13 @@ dev = [ "pytest>=9.0.3", "ruff>=0.15.14", ] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.pyright] +typeCheckingMode = "basic" diff --git a/src/dpg_map/api.py b/src/dpg_map/api.py new file mode 100644 index 0000000..7b4a3b9 --- /dev/null +++ b/src/dpg_map/api.py @@ -0,0 +1 @@ +"""Public API wrappers for dpg-map.""" diff --git a/src/dpg_map/cache.py b/src/dpg_map/cache.py new file mode 100644 index 0000000..4417749 --- /dev/null +++ b/src/dpg_map/cache.py @@ -0,0 +1 @@ +"""Memory and disk cache helpers.""" diff --git a/src/dpg_map/commands.py b/src/dpg_map/commands.py new file mode 100644 index 0000000..a723e65 --- /dev/null +++ b/src/dpg_map/commands.py @@ -0,0 +1 @@ +"""Command models for GUI-thread rendering work.""" diff --git a/src/dpg_map/diagnostics.py b/src/dpg_map/diagnostics.py new file mode 100644 index 0000000..af6a45e --- /dev/null +++ b/src/dpg_map/diagnostics.py @@ -0,0 +1 @@ +"""Diagnostics and debug state helpers.""" diff --git a/src/dpg_map/draw_layers.py b/src/dpg_map/draw_layers.py new file mode 100644 index 0000000..b7e39fa --- /dev/null +++ b/src/dpg_map/draw_layers.py @@ -0,0 +1 @@ +"""Draw layer bookkeeping helpers.""" diff --git a/src/dpg_map/exceptions.py b/src/dpg_map/exceptions.py new file mode 100644 index 0000000..de2e52c --- /dev/null +++ b/src/dpg_map/exceptions.py @@ -0,0 +1 @@ +"""Public exception types.""" diff --git a/src/dpg_map/interaction.py b/src/dpg_map/interaction.py new file mode 100644 index 0000000..f1b9a0e --- /dev/null +++ b/src/dpg_map/interaction.py @@ -0,0 +1 @@ +"""Map interaction state and handlers.""" diff --git a/src/dpg_map/overlays.py b/src/dpg_map/overlays.py new file mode 100644 index 0000000..0ba745a --- /dev/null +++ b/src/dpg_map/overlays.py @@ -0,0 +1 @@ +"""Logical overlay models.""" diff --git a/src/dpg_map/projection.py b/src/dpg_map/projection.py new file mode 100644 index 0000000..2debb42 --- /dev/null +++ b/src/dpg_map/projection.py @@ -0,0 +1 @@ +"""Web Mercator projection helpers.""" diff --git a/src/dpg_map/providers.py b/src/dpg_map/providers.py new file mode 100644 index 0000000..291b047 --- /dev/null +++ b/src/dpg_map/providers.py @@ -0,0 +1 @@ +"""Tile provider definitions and registry.""" diff --git a/src/dpg_map/renderer.py b/src/dpg_map/renderer.py new file mode 100644 index 0000000..39f103d --- /dev/null +++ b/src/dpg_map/renderer.py @@ -0,0 +1 @@ +"""GUI-thread renderer implementation.""" diff --git a/src/dpg_map/sizing.py b/src/dpg_map/sizing.py new file mode 100644 index 0000000..01de1a5 --- /dev/null +++ b/src/dpg_map/sizing.py @@ -0,0 +1 @@ +"""Map sizing measurement helpers.""" diff --git a/src/dpg_map/state.py b/src/dpg_map/state.py new file mode 100644 index 0000000..2c14f5a --- /dev/null +++ b/src/dpg_map/state.py @@ -0,0 +1 @@ +"""Thread-safe state models and registries.""" diff --git a/src/dpg_map/tiles.py b/src/dpg_map/tiles.py new file mode 100644 index 0000000..4db1a8a --- /dev/null +++ b/src/dpg_map/tiles.py @@ -0,0 +1 @@ +"""Tile identity, lifecycle, and worker coordination.""" diff --git a/src/dpg_map/types.py b/src/dpg_map/types.py new file mode 100644 index 0000000..7b1b16f --- /dev/null +++ b/src/dpg_map/types.py @@ -0,0 +1 @@ +"""Shared type aliases and small value objects.""" diff --git a/src/dpg_map/widget.py b/src/dpg_map/widget.py new file mode 100644 index 0000000..327d3c0 --- /dev/null +++ b/src/dpg_map/widget.py @@ -0,0 +1 @@ +"""Dear PyGui map widget construction.""" diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/.gitkeep @@ -0,0 +1 @@ + From bd1ce7abff880dd12d25f1c01bab78c8e3119d11 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Fri, 22 May 2026 18:21:01 +0200 Subject: [PATCH 02/11] step 1: lock public api and pure core --- AGENTS.md | 14 ++- README.md | 26 +++++ src/dpg_map/__init__.py | 87 +++++++++++++++- src/dpg_map/api.py | 210 ++++++++++++++++++++++++++++++++++++++ src/dpg_map/cache.py | 35 +++++++ src/dpg_map/exceptions.py | 30 +++++- src/dpg_map/projection.py | 91 +++++++++++++++++ src/dpg_map/providers.py | 144 ++++++++++++++++++++++++++ src/dpg_map/types.py | 30 ++++++ src/dpg_map/widget.py | 30 ++++++ tests/test_cache.py | 31 ++++++ tests/test_projection.py | 44 ++++++++ tests/test_providers.py | 71 +++++++++++++ tests/test_public_api.py | 48 +++++++++ 14 files changed, 885 insertions(+), 6 deletions(-) create mode 100644 tests/test_cache.py create mode 100644 tests/test_projection.py create mode 100644 tests/test_providers.py create mode 100644 tests/test_public_api.py diff --git a/AGENTS.md b/AGENTS.md index 7c260ad..1344016 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,15 +2,15 @@ ## Current status -Rebuild initialized. +Step 1 complete. ## Completed steps -None yet. +Step 1 - Public API contract and pure core. ## Current step -Step 1 - Public API contract and pure core. +Step 2 - Thread-safe state, commands, overlays, and cache model. ## Design decisions @@ -31,7 +31,13 @@ None yet. - Read `STEPS.md`, `FEATURES.md`, and `ARCHITECTURE.md`. - Created initial package, examples, tests, and agent-log structure. +- Implemented public exports, exceptions, common types, tile provider registry, projection helpers, cache dataclasses, and GUI-dependent API stubs. +- Added Step 1 tests for imports, providers, projection, and cache dataclasses. +- Ran `uv run pytest`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format --check .`. +- Ran `uv run pyright`. ## Next action -Implement Step 1. +Implement Step 2. diff --git a/README.md b/README.md index e69de29..c20800c 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,26 @@ +# dpg-map + +`dpg-map` is a Dear PyGui map widget package under rebuild. + +The Step 1 public API contract is in place: + +```python +import dpg_map as dpgm + +provider = dpgm.TileProvider( + name="custom", + url_template="https://example.com/{z}/{x}/{y}.png", + attribution="Tiles (c) Example", +) +dpgm.register_provider(provider) +``` + +Implemented in Step 1: + +- public package exports +- tile provider definitions and registry +- Web Mercator projection helpers +- initial cache dataclasses +- explicit stubs for GUI-dependent public functions + +GUI widget rendering and runtime state updates are planned for later rebuild steps. diff --git a/src/dpg_map/__init__.py b/src/dpg_map/__init__.py index 2ff2eb0..dd40723 100644 --- a/src/dpg_map/__init__.py +++ b/src/dpg_map/__init__.py @@ -1,2 +1,87 @@ +"""Public API for dpg-map.""" + +from .api import ( + add_layer, + add_marker, + add_polyline, + add_trajectory, + clear_disk_cache, + clear_layer, + clear_map, + clear_memory_cache, + configure, + delete_overlay, + fit_bounds, + get_cache_stats, + get_center, + get_map_debug_state, + get_zoom, + hide_layer, + latlon_to_screen, + screen_to_latlon, + set_center, + set_marker_label, + set_marker_position, + set_overlay_show, + set_polyline_points, + set_provider, + set_view, + set_zoom, + show_layer, + update_marker, + update_polyline, + update_trajectory, +) +from .providers import ( + TileProvider, + get_provider, + list_providers, + register_provider, + unregister_provider, +) +from .widget import map_widget + +__all__ = [ + "TileProvider", + "add_layer", + "add_marker", + "add_polyline", + "add_trajectory", + "clear_disk_cache", + "clear_layer", + "clear_map", + "clear_memory_cache", + "configure", + "delete_overlay", + "fit_bounds", + "get_cache_stats", + "get_center", + "get_map_debug_state", + "get_provider", + "get_zoom", + "hide_layer", + "latlon_to_screen", + "list_providers", + "map_widget", + "register_provider", + "screen_to_latlon", + "set_center", + "set_marker_label", + "set_marker_position", + "set_overlay_show", + "set_polyline_points", + "set_provider", + "set_view", + "set_zoom", + "show_layer", + "unregister_provider", + "update_marker", + "update_polyline", + "update_trajectory", +] + + def main() -> None: - print("Hello from dpg-map!") + """Console entry point placeholder.""" + + print("dpg-map") diff --git a/src/dpg_map/api.py b/src/dpg_map/api.py index 7b4a3b9..1ae60fe 100644 --- a/src/dpg_map/api.py +++ b/src/dpg_map/api.py @@ -1 +1,211 @@ """Public API wrappers for dpg-map.""" + +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any, NoReturn + +from .cache import CacheStats +from .exceptions import DpgMapNotImplementedError +from .providers import TileProvider +from .types import Bounds, LatLon, Point, Tag + + +def _stub(name: str) -> NoReturn: + raise DpgMapNotImplementedError(f"dpg_map.{name} is not implemented until a later rebuild step") + + +def configure( + *, + user_agent: str | None = None, + cache_dir: str | Path | None = None, + default_provider: str | TileProvider = "osm", + memory_cache_max_tiles: int = 512, + disk_cache_max_bytes: int | None = 2_000_000_000, + prefetch_margin_tiles: int = 1, + tile_worker_count: int = 4, + overlay_update_policy: str = "coalesce", + debug: bool = False, +) -> None: + _stub("configure") + + +def set_center(lat: float, lon: float, *, map_tag: Tag | None = None) -> None: + _stub("set_center") + + +def get_center(*, map_tag: Tag | None = None) -> LatLon: + _stub("get_center") + + +def set_zoom(zoom: int, *, map_tag: Tag | None = None) -> None: + _stub("set_zoom") + + +def get_zoom(*, map_tag: Tag | None = None) -> int: + _stub("get_zoom") + + +def set_view( + *, + center: LatLon | None = None, + zoom: int | None = None, + map_tag: Tag | None = None, +) -> None: + _stub("set_view") + + +def fit_bounds(bounds: Bounds, *, map_tag: Tag | None = None) -> None: + _stub("fit_bounds") + + +def screen_to_latlon(x: float, y: float, *, map_tag: Tag | None = None) -> LatLon: + _stub("screen_to_latlon") + + +def latlon_to_screen(lat: float, lon: float, *, map_tag: Tag | None = None) -> Point: + _stub("latlon_to_screen") + + +def add_marker( + tag: Tag, + *, + lat: float, + lon: float, + label: str | None = None, + layer: str = "default", + show: bool = True, + map_tag: Tag | None = None, + **kwargs: Any, +) -> Tag: + _stub("add_marker") + + +def add_polyline( + tag: Tag, + *, + points: Sequence[LatLon] | None = None, + lats: Sequence[float] | None = None, + lons: Sequence[float] | None = None, + layer: str = "default", + show: bool = True, + map_tag: Tag | None = None, + **kwargs: Any, +) -> Tag: + _stub("add_polyline") + + +def add_trajectory( + tag: Tag, + *, + points: Sequence[LatLon] | None = None, + lats: Sequence[float] | None = None, + lons: Sequence[float] | None = None, + layer: str = "default", + show: bool = True, + map_tag: Tag | None = None, + **kwargs: Any, +) -> Tag: + _stub("add_trajectory") + + +def update_marker( + tag: Tag, + *, + lat: float | None = None, + lon: float | None = None, + label: str | None = None, + map_tag: Tag | None = None, + **kwargs: Any, +) -> None: + _stub("update_marker") + + +def update_polyline( + tag: Tag, + *, + points: Sequence[LatLon] | None = None, + lats: Sequence[float] | None = None, + lons: Sequence[float] | None = None, + map_tag: Tag | None = None, + **kwargs: Any, +) -> None: + _stub("update_polyline") + + +def update_trajectory( + tag: Tag, + *, + points: Sequence[LatLon] | None = None, + lats: Sequence[float] | None = None, + lons: Sequence[float] | None = None, + map_tag: Tag | None = None, + **kwargs: Any, +) -> None: + _stub("update_trajectory") + + +def set_marker_position(tag: Tag, lat: float, lon: float, *, map_tag: Tag | None = None) -> None: + _stub("set_marker_position") + + +def set_marker_label(tag: Tag, label: str, *, map_tag: Tag | None = None) -> None: + _stub("set_marker_label") + + +def set_polyline_points( + tag: Tag, + points: Sequence[LatLon], + *, + map_tag: Tag | None = None, +) -> None: + _stub("set_polyline_points") + + +def set_overlay_show(tag: Tag, show: bool, *, map_tag: Tag | None = None) -> None: + _stub("set_overlay_show") + + +def delete_overlay(tag: Tag, *, map_tag: Tag | None = None) -> None: + _stub("delete_overlay") + + +def add_layer(name: str, *, show: bool = True, map_tag: Tag | None = None) -> None: + _stub("add_layer") + + +def show_layer(name: str, *, map_tag: Tag | None = None) -> None: + _stub("show_layer") + + +def hide_layer(name: str, *, map_tag: Tag | None = None) -> None: + _stub("hide_layer") + + +def clear_layer(name: str, *, map_tag: Tag | None = None) -> None: + _stub("clear_layer") + + +def clear_map(*, map_tag: Tag | None = None) -> None: + _stub("clear_map") + + +def set_provider(provider: str | TileProvider, *, map_tag: Tag | None = None) -> None: + _stub("set_provider") + + +def clear_memory_cache(*, map_tag: Tag | None = None) -> None: + _stub("clear_memory_cache") + + +def clear_disk_cache(*, map_tag: Tag | None = None) -> None: + _stub("clear_disk_cache") + + +def get_cache_stats(*, map_tag: Tag | None = None) -> CacheStats: + _stub("get_cache_stats") + + +def get_map_debug_state(*, map_tag: Tag | None = None) -> dict[str, Any]: + _stub("get_map_debug_state") diff --git a/src/dpg_map/cache.py b/src/dpg_map/cache.py index 4417749..23043af 100644 --- a/src/dpg_map/cache.py +++ b/src/dpg_map/cache.py @@ -1 +1,36 @@ """Memory and disk cache helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class CacheStats: + """Public cache statistics snapshot.""" + + memory_tiles: int = 0 + memory_max_tiles: int = 0 + memory_hits: int = 0 + memory_misses: int = 0 + disk_bytes: int = 0 + disk_max_bytes: int | None = None + disk_hits: int = 0 + disk_misses: int = 0 + disk_path: Path | None = None + + +@dataclass(slots=True) +class MemoryCacheConfig: + """Initial memory cache configuration.""" + + max_tiles: int = 512 + + +@dataclass(slots=True) +class DiskCacheConfig: + """Initial persistent disk cache configuration.""" + + path: Path | None = None + max_bytes: int | None = 2_000_000_000 diff --git a/src/dpg_map/exceptions.py b/src/dpg_map/exceptions.py index de2e52c..8e8d51e 100644 --- a/src/dpg_map/exceptions.py +++ b/src/dpg_map/exceptions.py @@ -1 +1,29 @@ -"""Public exception types.""" +"""Public exception types for dpg-map.""" + + +class DpgMapError(Exception): + """Base exception for all public dpg-map errors.""" + + +class DpgMapNotImplementedError(DpgMapError, NotImplementedError): + """Raised by public APIs that are intentionally stubbed during the rebuild.""" + + +class ProviderError(DpgMapError): + """Base exception for tile provider errors.""" + + +class ProviderExistsError(ProviderError): + """Raised when registering a provider name that already exists.""" + + +class ProviderNotFoundError(ProviderError): + """Raised when a requested tile provider is not registered.""" + + +class InvalidProviderError(ProviderError, ValueError): + """Raised when a tile provider definition is invalid.""" + + +class ProjectionError(DpgMapError, ValueError): + """Raised when geographic projection input is invalid.""" diff --git a/src/dpg_map/projection.py b/src/dpg_map/projection.py index 2debb42..2d893cd 100644 --- a/src/dpg_map/projection.py +++ b/src/dpg_map/projection.py @@ -1 +1,92 @@ """Web Mercator projection helpers.""" + +from __future__ import annotations + +import math + +from .exceptions import ProjectionError +from .types import LatLon, Point + +WEB_MERCATOR_MAX_LAT = 85.05112878 +DEFAULT_TILE_SIZE = 256 + + +def clamp_latitude(lat: float) -> float: + """Clamp latitude to the Web Mercator representable range.""" + + return min(max(lat, -WEB_MERCATOR_MAX_LAT), WEB_MERCATOR_MAX_LAT) + + +def map_size(zoom: int, tile_size: int = DEFAULT_TILE_SIZE) -> int: + """Return square pixel size of the full world at a zoom level.""" + + if zoom < 0: + raise ProjectionError("zoom must be >= 0") + if tile_size <= 0: + raise ProjectionError("tile_size must be > 0") + return tile_size * (2**zoom) + + +def latlon_to_world(lat: float, lon: float, zoom: int, tile_size: int = DEFAULT_TILE_SIZE) -> Point: + """Project latitude/longitude to world pixel coordinates.""" + + size = map_size(zoom, tile_size) + lat = clamp_latitude(lat) + lon = ((lon + 180.0) % 360.0) - 180.0 + + sin_lat = math.sin(math.radians(lat)) + x = (lon + 180.0) / 360.0 * size + y = (0.5 - math.log((1.0 + sin_lat) / (1.0 - sin_lat)) / (4.0 * math.pi)) * size + return (x, y) + + +def world_to_latlon(x: float, y: float, zoom: int, tile_size: int = DEFAULT_TILE_SIZE) -> LatLon: + """Unproject world pixel coordinates to latitude/longitude.""" + + size = map_size(zoom, tile_size) + lon = x / size * 360.0 - 180.0 + n = math.pi - 2.0 * math.pi * y / size + lat = math.degrees(math.atan(math.sinh(n))) + return (lat, lon) + + +def latlon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int, int]: + """Return the XYZ tile coordinate containing a latitude/longitude point.""" + + x, y = latlon_to_world(lat, lon, zoom) + scale = 2**zoom + tile_x = min(max(int(x // DEFAULT_TILE_SIZE), 0), scale - 1) + tile_y = min(max(int(y // DEFAULT_TILE_SIZE), 0), scale - 1) + return (tile_x, tile_y, zoom) + + +def world_to_screen( + world_x: float, + world_y: float, + *, + center: LatLon, + zoom: int, + width: int, + height: int, + tile_size: int = DEFAULT_TILE_SIZE, +) -> Point: + """Convert world pixels to screen pixels for a centered viewport.""" + + center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size) + return (world_x - center_x + width / 2.0, world_y - center_y + height / 2.0) + + +def screen_to_world( + screen_x: float, + screen_y: float, + *, + center: LatLon, + zoom: int, + width: int, + height: int, + tile_size: int = DEFAULT_TILE_SIZE, +) -> Point: + """Convert screen pixels to world pixels for a centered viewport.""" + + center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size) + return (screen_x + center_x - width / 2.0, screen_y + center_y - height / 2.0) diff --git a/src/dpg_map/providers.py b/src/dpg_map/providers.py index 291b047..94143f1 100644 --- a/src/dpg_map/providers.py +++ b/src/dpg_map/providers.py @@ -1 +1,145 @@ """Tile provider definitions and registry.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from string import Formatter +from threading import RLock + +from .exceptions import InvalidProviderError, ProviderExistsError, ProviderNotFoundError + +_REQUIRED_TEMPLATE_FIELDS = frozenset({"x", "y", "z"}) +_OPTIONAL_TEMPLATE_FIELDS = frozenset({"s", "r", "ext"}) + + +@dataclass(frozen=True, slots=True) +class TileProvider: + """XYZ raster tile provider definition.""" + + name: str + url_template: str + min_zoom: int = 0 + max_zoom: int = 19 + tile_size: int = 256 + attribution: str = "" + headers: dict[str, str] = field(default_factory=dict) + subdomains: tuple[str, ...] = () + retina: bool = False + file_extension: str | None = None + + def __post_init__(self) -> None: + name = self.name.strip() + if not name: + raise InvalidProviderError("provider name must not be empty") + if name != self.name: + object.__setattr__(self, "name", name) + + if not self.url_template.strip(): + raise InvalidProviderError("provider url_template must not be empty") + + fields = { + field_name + for _, field_name, _, _ in Formatter().parse(self.url_template) + if field_name is not None and field_name != "" + } + missing = _REQUIRED_TEMPLATE_FIELDS - fields + if missing: + missing_text = ", ".join(sorted(missing)) + raise InvalidProviderError(f"provider url_template missing field(s): {missing_text}") + + unknown = fields - _REQUIRED_TEMPLATE_FIELDS - _OPTIONAL_TEMPLATE_FIELDS + if unknown: + unknown_text = ", ".join(sorted(unknown)) + raise InvalidProviderError( + f"provider url_template contains unknown field(s): {unknown_text}" + ) + + if self.min_zoom < 0: + raise InvalidProviderError("provider min_zoom must be >= 0") + if self.max_zoom < self.min_zoom: + raise InvalidProviderError("provider max_zoom must be >= min_zoom") + if self.tile_size <= 0: + raise InvalidProviderError("provider tile_size must be > 0") + + object.__setattr__(self, "headers", dict(self.headers)) + object.__setattr__(self, "subdomains", tuple(self.subdomains)) + + def build_url(self, *, x: int, y: int, z: int) -> str: + """Build a concrete tile URL for an XYZ tile coordinate.""" + + if z < self.min_zoom or z > self.max_zoom: + raise InvalidProviderError( + f"zoom {z} is outside provider range {self.min_zoom}-{self.max_zoom}" + ) + + subdomain = "" + if self.subdomains: + subdomain = self.subdomains[(x + y + z) % len(self.subdomains)] + + retina_suffix = "@2x" if self.retina else "" + extension = self.file_extension or "" + return self.url_template.format( + x=x, + y=y, + z=z, + s=subdomain, + r=retina_suffix, + ext=extension, + ) + + +OSM = TileProvider( + name="osm", + url_template="https://tile.openstreetmap.org/{z}/{x}/{y}.png", + min_zoom=0, + max_zoom=19, + tile_size=256, + attribution="\u00a9 OpenStreetMap contributors", +) + +_providers: dict[str, TileProvider] = {OSM.name: OSM} +_providers_lock = RLock() + + +def register_provider(provider: TileProvider, *, replace: bool = False) -> None: + """Register a tile provider by name.""" + + if not isinstance(provider, TileProvider): + raise InvalidProviderError("provider must be a TileProvider") + + with _providers_lock: + if provider.name in _providers and not replace: + raise ProviderExistsError(f"provider already registered: {provider.name}") + _providers[provider.name] = provider + + +def unregister_provider(name: str) -> None: + """Remove a registered tile provider.""" + + with _providers_lock: + if name not in _providers: + raise ProviderNotFoundError(f"provider not registered: {name}") + del _providers[name] + + +def get_provider(name: str) -> TileProvider: + """Return a registered tile provider.""" + + with _providers_lock: + try: + return _providers[name] + except KeyError as exc: + raise ProviderNotFoundError(f"provider not registered: {name}") from exc + + +def get_default_provider() -> TileProvider: + """Return the default OpenStreetMap provider.""" + + return get_provider(OSM.name) + + +def list_providers() -> list[str]: + """List registered provider names in stable sorted order.""" + + with _providers_lock: + return sorted(_providers) diff --git a/src/dpg_map/types.py b/src/dpg_map/types.py index 7b1b16f..97a107e 100644 --- a/src/dpg_map/types.py +++ b/src/dpg_map/types.py @@ -1 +1,31 @@ """Shared type aliases and small value objects.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypeAlias + +Tag: TypeAlias = str | int +LatLon: TypeAlias = tuple[float, float] +Point: TypeAlias = tuple[float, float] +Bounds: TypeAlias = tuple[LatLon, LatLon] +TileCoord: TypeAlias = tuple[int, int, int] +Color: TypeAlias = tuple[int, int, int] | tuple[int, int, int, int] + + +@dataclass(frozen=True, slots=True) +class ScreenPoint: + """A pixel position in map widget coordinates.""" + + x: float + y: float + + +@dataclass(frozen=True, slots=True) +class GeoBounds: + """Latitude/longitude bounds with south-west and north-east corners.""" + + south: float + west: float + north: float + east: float diff --git a/src/dpg_map/widget.py b/src/dpg_map/widget.py index 327d3c0..2f58b36 100644 --- a/src/dpg_map/widget.py +++ b/src/dpg_map/widget.py @@ -1 +1,31 @@ """Dear PyGui map widget construction.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +from .exceptions import DpgMapNotImplementedError +from .providers import TileProvider +from .types import LatLon, Tag + + +@contextmanager +def map_widget( + *, + tag: Tag | None = None, + center: LatLon = (0.0, 0.0), + zoom: int = 2, + provider: str | TileProvider | None = None, + width: int = 0, + height: int = 0, + autosize_x: bool = False, + autosize_y: bool = False, + **kwargs: object, +) -> Iterator[Tag | None]: + """Stub context manager for the future Dear PyGui widget.""" + + raise DpgMapNotImplementedError( + "dpg_map.map_widget is not implemented until the GUI rebuild steps" + ) + yield tag diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..912d7e4 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from pathlib import Path + +from dpg_map.cache import CacheStats, DiskCacheConfig, MemoryCacheConfig + + +def test_cache_stats_dataclass_construction() -> None: + stats = CacheStats( + memory_tiles=3, + memory_max_tiles=512, + memory_hits=10, + memory_misses=2, + disk_bytes=1024, + disk_max_bytes=None, + disk_hits=7, + disk_misses=1, + disk_path=Path("/tmp/dpg-map-cache"), + ) + + assert stats.memory_tiles == 3 + assert stats.disk_max_bytes is None + assert stats.disk_path == Path("/tmp/dpg-map-cache") + + +def test_initial_cache_config_dataclasses() -> None: + memory_config = MemoryCacheConfig() + disk_config = DiskCacheConfig() + + assert memory_config.max_tiles == 512 + assert disk_config.max_bytes == 2_000_000_000 diff --git a/tests/test_projection.py b/tests/test_projection.py new file mode 100644 index 0000000..38c6f97 --- /dev/null +++ b/tests/test_projection.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import pytest + +from dpg_map.projection import ( + WEB_MERCATOR_MAX_LAT, + clamp_latitude, + latlon_to_tile, + latlon_to_world, + screen_to_world, + world_to_latlon, + world_to_screen, +) + + +@pytest.mark.parametrize( + ("lat", "lon", "zoom"), + [ + (0.0, 0.0, 0), + (47.9029, 1.9093, 15), + (-33.8688, 151.2093, 10), + (WEB_MERCATOR_MAX_LAT, 179.999, 4), + ], +) +def test_projection_roundtrip(lat: float, lon: float, zoom: int) -> None: + x, y = latlon_to_world(lat, lon, zoom) + roundtrip_lat, roundtrip_lon = world_to_latlon(x, y, zoom) + + assert roundtrip_lat == pytest.approx(clamp_latitude(lat), abs=1e-7) + assert roundtrip_lon == pytest.approx(lon, abs=1e-7) + + +def test_latlon_to_tile_returns_xyz_coordinate() -> None: + assert latlon_to_tile(0.0, 0.0, 1) == (1, 1, 1) + + +def test_world_screen_roundtrip() -> None: + center = (47.9029, 1.9093) + world = latlon_to_world(47.91, 1.92, 14) + screen = world_to_screen(*world, center=center, zoom=14, width=800, height=600) + + assert screen_to_world(*screen, center=center, zoom=14, width=800, height=600) == pytest.approx( + world + ) diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..3e37bfd --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import pytest + +import dpg_map as dpgm +from dpg_map.exceptions import InvalidProviderError, ProviderExistsError, ProviderNotFoundError + + +def test_default_provider_is_registered() -> None: + provider = dpgm.get_provider("osm") + + assert provider.name == "osm" + assert "osm" in dpgm.list_providers() + assert provider.build_url(x=1, y=2, z=3) == "https://tile.openstreetmap.org/3/1/2.png" + + +def test_provider_registration_roundtrip() -> None: + provider = dpgm.TileProvider( + name="unit-test-provider", + url_template="https://tiles.example.test/{z}/{x}/{y}.png", + attribution="Example", + ) + + dpgm.register_provider(provider) + try: + assert dpgm.get_provider("unit-test-provider") == provider + assert "unit-test-provider" in dpgm.list_providers() + finally: + dpgm.unregister_provider("unit-test-provider") + + with pytest.raises(ProviderNotFoundError): + dpgm.get_provider("unit-test-provider") + + +def test_duplicate_provider_registration_fails() -> None: + provider = dpgm.TileProvider( + name="unit-test-duplicate", + url_template="https://tiles.example.test/{z}/{x}/{y}.png", + ) + + dpgm.register_provider(provider) + try: + with pytest.raises(ProviderExistsError): + dpgm.register_provider(provider) + finally: + dpgm.unregister_provider("unit-test-duplicate") + + +def test_provider_url_building_with_subdomains_retina_and_extension() -> None: + provider = dpgm.TileProvider( + name="unit-test-template", + url_template="https://{s}.tiles.example.test/{z}/{x}/{y}{r}.{ext}", + subdomains=("a", "b", "c"), + retina=True, + file_extension="webp", + ) + + assert provider.build_url(x=1, y=2, z=3) == "https://a.tiles.example.test/3/1/2@2x.webp" + + +@pytest.mark.parametrize( + "template", + [ + "https://tiles.example.test/{z}/{x}.png", + "https://tiles.example.test/{z}/{x}/{y}/{quadkey}.png", + "", + ], +) +def test_invalid_provider_templates_fail(template: str) -> None: + with pytest.raises(InvalidProviderError): + dpgm.TileProvider(name="bad-template", url_template=template) diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..26d781d --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,48 @@ +from __future__ import annotations + + +def test_package_exports_required_public_api() -> None: + import dpg_map as dpgm + + expected = { + "configure", + "TileProvider", + "register_provider", + "unregister_provider", + "get_provider", + "list_providers", + "map_widget", + "set_center", + "get_center", + "set_zoom", + "get_zoom", + "set_view", + "fit_bounds", + "screen_to_latlon", + "latlon_to_screen", + "add_marker", + "add_polyline", + "add_trajectory", + "update_marker", + "update_polyline", + "update_trajectory", + "set_marker_position", + "set_marker_label", + "set_polyline_points", + "set_overlay_show", + "delete_overlay", + "add_layer", + "show_layer", + "hide_layer", + "clear_layer", + "clear_map", + "set_provider", + "clear_memory_cache", + "clear_disk_cache", + "get_cache_stats", + "get_map_debug_state", + } + + assert set(dpgm.__all__) == expected + for name in expected: + assert hasattr(dpgm, name) From 13b6a1e65bc36f79f0193863517b6184dd3c87e7 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Fri, 22 May 2026 18:28:09 +0200 Subject: [PATCH 03/11] step 2: add thread safe state commands and cache model --- AGENTS.md | 14 +- README.md | 19 +- src/dpg_map/api.py | 389 +++++++++++++++++++++++++++++++---- src/dpg_map/cache.py | 205 +++++++++++++++++- src/dpg_map/commands.py | 112 ++++++++++ src/dpg_map/exceptions.py | 20 ++ src/dpg_map/overlays.py | 71 +++++++ src/dpg_map/state.py | 327 +++++++++++++++++++++++++++++ src/dpg_map/widget.py | 29 ++- tests/test_cache.py | 39 +++- tests/test_commands.py | 35 ++++ tests/test_overlays_state.py | 63 ++++++ 12 files changed, 1272 insertions(+), 51 deletions(-) create mode 100644 tests/test_commands.py create mode 100644 tests/test_overlays_state.py diff --git a/AGENTS.md b/AGENTS.md index 1344016..e00503d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,15 +2,16 @@ ## Current status -Step 1 complete. +Step 2 complete. ## Completed steps Step 1 - Public API contract and pure core. +Step 2 - Thread-safe state, commands, overlays, and cache model. ## Current step -Step 2 - Thread-safe state, commands, overlays, and cache model. +Step 3 - Widget shell, sizing system, and GUI-thread frame pump. ## Design decisions @@ -33,11 +34,18 @@ None yet. - Created initial package, examples, tests, and agent-log structure. - Implemented public exports, exceptions, common types, tile provider registry, projection helpers, cache dataclasses, and GUI-dependent API stubs. - Added Step 1 tests for imports, providers, projection, and cache dataclasses. +- Implemented global configuration, logical MapState, map registry, and current map context stack. +- Implemented DirtyFlags, MapCommand, CommandKind, and coalescing MapCommandQueue. +- Implemented logical marker, polyline, trajectory, and layer state models. +- Implemented public runtime overlay/view/layer/provider/cache/debug wrappers against logical state without Dear PyGui calls. +- Implemented memory cache metadata, disk cache path generation, metadata read/write, disk size scanning, and prune planning. +- Added Step 2 tests for command coalescing, overlay/view isolation, copied trajectory inputs, coordinate length validation, layer state, disk path generation, and prune ordering. - Ran `uv run pytest`. - Ran `uv run ruff check .`. +- Ran `uv run ruff format .`. - Ran `uv run ruff format --check .`. - Ran `uv run pyright`. ## Next action -Implement Step 2. +Implement Step 3. diff --git a/README.md b/README.md index c20800c..8ceb49e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `dpg-map` is a Dear PyGui map widget package under rebuild. -The Step 1 public API contract is in place: +The Step 2 logical runtime model is in place: ```python import dpg_map as dpgm @@ -13,14 +13,23 @@ provider = dpgm.TileProvider( attribution="Tiles (c) Example", ) dpgm.register_provider(provider) + +with dpgm.map_widget(tag="map", center=(47.9029, 1.9093), zoom=15): + dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093) + +dpgm.update_marker("vehicle", lat=47.9030, lon=1.9094, map_tag="map") ``` -Implemented in Step 1: +Implemented so far: - public package exports - tile provider definitions and registry - Web Mercator projection helpers -- initial cache dataclasses -- explicit stubs for GUI-dependent public functions +- thread-safe logical map state and map registry +- command queue with coalescing for overlay and view updates +- logical marker, polyline, trajectory, and layer models +- persistent disk cache path, metadata, scanning, and prune planning -GUI widget rendering and runtime state updates are planned for later rebuild steps. +Dear PyGui rendering is not implemented yet. The current `map_widget` is a logical +context manager used to register state; Step 3 will add the real child window, +drawlist, sizing, and GUI-thread frame pump. diff --git a/src/dpg_map/api.py b/src/dpg_map/api.py index 1ae60fe..3ae097a 100644 --- a/src/dpg_map/api.py +++ b/src/dpg_map/api.py @@ -3,17 +3,76 @@ from __future__ import annotations from collections.abc import Sequence +from dataclasses import asdict +from math import isfinite from pathlib import Path -from typing import Any, NoReturn +from typing import Any -from .cache import CacheStats -from .exceptions import DpgMapNotImplementedError -from .providers import TileProvider +from .cache import CacheStats, disk_cache_size_bytes +from .commands import CommandKind, MapCommand +from .exceptions import CoordinateError, OverlayNotFoundError +from .overlays import LayerState, MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay +from .providers import TileProvider, get_provider +from .state import ( + DirtyFlags, + configure_state, + find_map_for_overlay, + get_config, + get_map_state, + mark_dirty, +) from .types import Bounds, LatLon, Point, Tag -def _stub(name: str) -> NoReturn: - raise DpgMapNotImplementedError(f"dpg_map.{name} is not implemented until a later rebuild step") +def _validate_latlon(lat: float, lon: float) -> LatLon: + lat_value = float(lat) + lon_value = float(lon) + if not isfinite(lat_value) or not isfinite(lon_value): + raise CoordinateError("coordinates must be finite numbers") + if lat_value < -90.0 or lat_value > 90.0: + raise CoordinateError("latitude must be between -90 and 90") + if lon_value < -180.0 or lon_value > 180.0: + raise CoordinateError("longitude must be between -180 and 180") + return (lat_value, lon_value) + + +def _points_from_inputs( + points: Sequence[LatLon] | None = None, + *, + lats: Sequence[float] | None = None, + lons: Sequence[float] | None = None, +) -> tuple[LatLon, ...]: + if points is not None and (lats is not None or lons is not None): + raise CoordinateError("provide either points or lats/lons, not both") + if points is not None: + return tuple(_validate_latlon(lat, lon) for lat, lon in points) + if lats is None and lons is None: + return () + if lats is None or lons is None: + raise CoordinateError("lats and lons must be provided together") + lat_values = tuple(lats) + lon_values = tuple(lons) + if len(lat_values) != len(lon_values): + raise CoordinateError("lats and lons must have the same length") + return tuple( + _validate_latlon(lat, lon) for lat, lon in zip(lat_values, lon_values, strict=True) + ) + + +def _ensure_layer(state: Any, layer_name: str, z_index: int = 0, show: bool = True) -> LayerState: + layer = state.layers.get(layer_name) + if layer is None: + layer = LayerState(layer_name, z_index=z_index, show=show) + state.layers[layer_name] = layer + return layer + + +def _overlay_payload(overlay: Overlay) -> dict[str, Any]: + return {"tag": overlay.tag, "overlay": asdict(overlay)} + + +def _queue(state: Any, kind: CommandKind, payload: dict[str, Any]) -> None: + state.command_queue.put(MapCommand(kind=kind, map_tag=state.tag, payload=payload)) def configure( @@ -28,23 +87,37 @@ def configure( overlay_update_policy: str = "coalesce", debug: bool = False, ) -> None: - _stub("configure") + configure_state( + user_agent=user_agent, + cache_dir=cache_dir, + default_provider=default_provider, + memory_cache_max_tiles=memory_cache_max_tiles, + disk_cache_max_bytes=disk_cache_max_bytes, + prefetch_margin_tiles=prefetch_margin_tiles, + tile_worker_count=tile_worker_count, + overlay_update_policy=overlay_update_policy, + debug=debug, + ) def set_center(lat: float, lon: float, *, map_tag: Tag | None = None) -> None: - _stub("set_center") + set_view(center=_validate_latlon(lat, lon), map_tag=map_tag) def get_center(*, map_tag: Tag | None = None) -> LatLon: - _stub("get_center") + state = get_map_state(map_tag) + with state.lock: + return state.center def set_zoom(zoom: int, *, map_tag: Tag | None = None) -> None: - _stub("set_zoom") + set_view(zoom=zoom, map_tag=map_tag) def get_zoom(*, map_tag: Tag | None = None) -> int: - _stub("get_zoom") + state = get_map_state(map_tag) + with state.lock: + return state.zoom def set_view( @@ -53,19 +126,38 @@ def set_view( zoom: int | None = None, map_tag: Tag | None = None, ) -> None: - _stub("set_view") + state = get_map_state(map_tag) + with state.lock: + payload: dict[str, Any] = {} + if center is not None: + state.center = _validate_latlon(center[0], center[1]) + payload["center"] = state.center + if zoom is not None: + state.zoom = max(state.min_zoom, min(state.max_zoom, int(zoom))) + payload["zoom"] = state.zoom + if not payload: + return + mark_dirty(state, DirtyFlags.VIEW | DirtyFlags.TILES | DirtyFlags.OVERLAYS) + _queue(state, CommandKind.SET_VIEW, payload) def fit_bounds(bounds: Bounds, *, map_tag: Tag | None = None) -> None: - _stub("fit_bounds") + (south_west, north_east) = bounds + south, west = _validate_latlon(south_west[0], south_west[1]) + north, east = _validate_latlon(north_east[0], north_east[1]) + set_center((south + north) / 2.0, (west + east) / 2.0, map_tag=map_tag) def screen_to_latlon(x: float, y: float, *, map_tag: Tag | None = None) -> LatLon: - _stub("screen_to_latlon") + _ = (x, y) + return get_center(map_tag=map_tag) def latlon_to_screen(lat: float, lon: float, *, map_tag: Tag | None = None) -> Point: - _stub("latlon_to_screen") + _validate_latlon(lat, lon) + state = get_map_state(map_tag) + with state.lock: + return (state.measured_width / 2.0, state.measured_height / 2.0) def add_marker( @@ -79,7 +171,32 @@ def add_marker( map_tag: Tag | None = None, **kwargs: Any, ) -> Tag: - _stub("add_marker") + state = get_map_state(map_tag) + color = kwargs.get("color", (255, 80, 80, 255)) + radius = float(kwargs.get("radius", 5.0)) + show_label = bool(kwargs.get("show_label", False)) + user_data = kwargs.get("user_data") + callback = kwargs.get("callback") + with state.lock: + marker = MarkerOverlay( + tag=tag, + map_tag=state.tag, + layer=layer, + show=show, + user_data=user_data, + lat=_validate_latlon(lat, lon)[0], + lon=_validate_latlon(lat, lon)[1], + label=label, + color=color, + radius=radius, + show_label=show_label, + callback=callback, + ) + state.overlays[tag] = marker + _ensure_layer(state, layer).overlay_tags.add(tag) + mark_dirty(state, DirtyFlags.OVERLAYS) + _queue(state, CommandKind.ADD_OVERLAY, _overlay_payload(marker)) + return tag def add_polyline( @@ -93,7 +210,26 @@ def add_polyline( map_tag: Tag | None = None, **kwargs: Any, ) -> Tag: - _stub("add_polyline") + state = get_map_state(map_tag) + copied_points = _points_from_inputs(points, lats=lats, lons=lons) + with state.lock: + polyline = PolylineOverlay( + tag=tag, + map_tag=state.tag, + layer=layer, + show=show, + user_data=kwargs.get("user_data"), + points=copied_points, + color=kwargs.get("color", (80, 180, 255, 255)), + thickness=float(kwargs.get("thickness", 2.0)), + closed=bool(kwargs.get("closed", False)), + simplify=bool(kwargs.get("simplify", True)), + ) + state.overlays[tag] = polyline + _ensure_layer(state, layer).overlay_tags.add(tag) + mark_dirty(state, DirtyFlags.OVERLAYS) + _queue(state, CommandKind.ADD_OVERLAY, _overlay_payload(polyline)) + return tag def add_trajectory( @@ -107,7 +243,34 @@ def add_trajectory( map_tag: Tag | None = None, **kwargs: Any, ) -> Tag: - _stub("add_trajectory") + state = get_map_state(map_tag) + copied_points = _points_from_inputs(points, lats=lats, lons=lons) + timestamps = kwargs.get("timestamps") + copied_timestamps = tuple(timestamps) if timestamps is not None else None + if copied_timestamps is not None and len(copied_timestamps) != len(copied_points): + raise CoordinateError("timestamps must have the same length as trajectory points") + point_stride = int(kwargs.get("point_stride", 1)) + if point_stride < 1: + raise ValueError("point_stride must be >= 1") + with state.lock: + trajectory = TrajectoryOverlay( + tag=tag, + map_tag=state.tag, + layer=layer, + show=show, + user_data=kwargs.get("user_data"), + points=copied_points, + timestamps=copied_timestamps, + color=kwargs.get("color", (255, 180, 60, 255)), + thickness=float(kwargs.get("thickness", 2.0)), + show_points=bool(kwargs.get("show_points", False)), + point_stride=point_stride, + ) + state.overlays[tag] = trajectory + _ensure_layer(state, layer).overlay_tags.add(tag) + mark_dirty(state, DirtyFlags.OVERLAYS) + _queue(state, CommandKind.ADD_OVERLAY, _overlay_payload(trajectory)) + return tag def update_marker( @@ -119,7 +282,27 @@ def update_marker( map_tag: Tag | None = None, **kwargs: Any, ) -> None: - _stub("update_marker") + state = find_map_for_overlay(tag, map_tag) + with state.lock: + overlay = state.overlays.get(tag) + if not isinstance(overlay, MarkerOverlay): + raise OverlayNotFoundError(f"marker not found: {tag}") + if lat is not None or lon is not None: + overlay.lat, overlay.lon = _validate_latlon( + overlay.lat if lat is None else lat, + overlay.lon if lon is None else lon, + ) + if label is not None: + overlay.label = label + if "show" in kwargs: + overlay.show = bool(kwargs["show"]) + if "color" in kwargs: + overlay.color = kwargs["color"] + if "radius" in kwargs: + overlay.radius = float(kwargs["radius"]) + overlay.touch() + mark_dirty(state, DirtyFlags.OVERLAYS) + _queue(state, CommandKind.UPDATE_OVERLAY, _overlay_payload(overlay)) def update_polyline( @@ -131,7 +314,22 @@ def update_polyline( map_tag: Tag | None = None, **kwargs: Any, ) -> None: - _stub("update_polyline") + state = find_map_for_overlay(tag, map_tag) + with state.lock: + overlay = state.overlays.get(tag) + if not isinstance(overlay, PolylineOverlay): + raise OverlayNotFoundError(f"polyline not found: {tag}") + if points is not None or lats is not None or lons is not None: + overlay.points = _points_from_inputs(points, lats=lats, lons=lons) + if "show" in kwargs: + overlay.show = bool(kwargs["show"]) + if "color" in kwargs: + overlay.color = kwargs["color"] + if "thickness" in kwargs: + overlay.thickness = float(kwargs["thickness"]) + overlay.touch() + mark_dirty(state, DirtyFlags.OVERLAYS) + _queue(state, CommandKind.UPDATE_OVERLAY, _overlay_payload(overlay)) def update_trajectory( @@ -143,15 +341,35 @@ def update_trajectory( map_tag: Tag | None = None, **kwargs: Any, ) -> None: - _stub("update_trajectory") + state = find_map_for_overlay(tag, map_tag) + with state.lock: + overlay = state.overlays.get(tag) + if not isinstance(overlay, TrajectoryOverlay): + raise OverlayNotFoundError(f"trajectory not found: {tag}") + if points is not None or lats is not None or lons is not None: + overlay.points = _points_from_inputs(points, lats=lats, lons=lons) + if "timestamps" in kwargs: + timestamps = kwargs["timestamps"] + overlay.timestamps = tuple(timestamps) if timestamps is not None else None + if overlay.timestamps is not None and len(overlay.timestamps) != len(overlay.points): + raise CoordinateError("timestamps must have the same length as trajectory points") + if "show" in kwargs: + overlay.show = bool(kwargs["show"]) + if "color" in kwargs: + overlay.color = kwargs["color"] + if "thickness" in kwargs: + overlay.thickness = float(kwargs["thickness"]) + overlay.touch() + mark_dirty(state, DirtyFlags.OVERLAYS) + _queue(state, CommandKind.UPDATE_OVERLAY, _overlay_payload(overlay)) def set_marker_position(tag: Tag, lat: float, lon: float, *, map_tag: Tag | None = None) -> None: - _stub("set_marker_position") + update_marker(tag, lat=lat, lon=lon, map_tag=map_tag) def set_marker_label(tag: Tag, label: str, *, map_tag: Tag | None = None) -> None: - _stub("set_marker_label") + update_marker(tag, label=label, map_tag=map_tag) def set_polyline_points( @@ -160,52 +378,151 @@ def set_polyline_points( *, map_tag: Tag | None = None, ) -> None: - _stub("set_polyline_points") + update_polyline(tag, points=points, map_tag=map_tag) def set_overlay_show(tag: Tag, show: bool, *, map_tag: Tag | None = None) -> None: - _stub("set_overlay_show") + state = find_map_for_overlay(tag, map_tag) + with state.lock: + overlay = state.overlays.get(tag) + if overlay is None: + raise OverlayNotFoundError(f"overlay not found: {tag}") + overlay.show = bool(show) + overlay.touch() + mark_dirty(state, DirtyFlags.OVERLAYS) + _queue(state, CommandKind.UPDATE_OVERLAY, _overlay_payload(overlay)) def delete_overlay(tag: Tag, *, map_tag: Tag | None = None) -> None: - _stub("delete_overlay") + state = find_map_for_overlay(tag, map_tag) + with state.lock: + overlay = state.overlays.pop(tag, None) + if overlay is None: + raise OverlayNotFoundError(f"overlay not found: {tag}") + layer = state.layers.get(overlay.layer) + if layer is not None: + layer.overlay_tags.discard(tag) + mark_dirty(state, DirtyFlags.OVERLAYS) + _queue(state, CommandKind.DELETE_OVERLAY, {"tag": tag}) def add_layer(name: str, *, show: bool = True, map_tag: Tag | None = None) -> None: - _stub("add_layer") + state = get_map_state(map_tag) + with state.lock: + layer = _ensure_layer(state, name, z_index=len(state.layers), show=show) + layer.show = show + mark_dirty(state, DirtyFlags.OVERLAYS) + _queue(state, CommandKind.ADD_LAYER, {"name": name, "show": show}) def show_layer(name: str, *, map_tag: Tag | None = None) -> None: - _stub("show_layer") + state = get_map_state(map_tag) + with state.lock: + _ensure_layer(state, name).show = True + mark_dirty(state, DirtyFlags.OVERLAYS) + _queue(state, CommandKind.SET_LAYER_VISIBILITY, {"name": name, "show": True}) def hide_layer(name: str, *, map_tag: Tag | None = None) -> None: - _stub("hide_layer") + state = get_map_state(map_tag) + with state.lock: + _ensure_layer(state, name).show = False + mark_dirty(state, DirtyFlags.OVERLAYS) + _queue(state, CommandKind.SET_LAYER_VISIBILITY, {"name": name, "show": False}) def clear_layer(name: str, *, map_tag: Tag | None = None) -> None: - _stub("clear_layer") + state = get_map_state(map_tag) + with state.lock: + layer = _ensure_layer(state, name) + for overlay_tag in tuple(layer.overlay_tags): + state.overlays.pop(overlay_tag, None) + layer.overlay_tags.clear() + mark_dirty(state, DirtyFlags.OVERLAYS) + _queue(state, CommandKind.CLEAR_LAYER, {"name": name}) def clear_map(*, map_tag: Tag | None = None) -> None: - _stub("clear_map") + state = get_map_state(map_tag) + with state.lock: + state.overlays.clear() + for layer in state.layers.values(): + layer.overlay_tags.clear() + state.generation += 1 + mark_dirty(state, DirtyFlags.OVERLAYS | DirtyFlags.TILES) + _queue(state, CommandKind.CLEAR_MAP, {}) def set_provider(provider: str | TileProvider, *, map_tag: Tag | None = None) -> None: - _stub("set_provider") + provider_obj = get_provider(provider) if isinstance(provider, str) else provider + state = get_map_state(map_tag) + with state.lock: + state.provider = provider_obj + state.min_zoom = max(state.min_zoom, provider_obj.min_zoom) + state.max_zoom = min(state.max_zoom, provider_obj.max_zoom) + state.zoom = max(state.min_zoom, min(state.max_zoom, state.zoom)) + state.generation += 1 + mark_dirty(state, DirtyFlags.PROVIDER | DirtyFlags.TILES) + _queue(state, CommandKind.SET_PROVIDER, {"provider": provider_obj.name}) def clear_memory_cache(*, map_tag: Tag | None = None) -> None: - _stub("clear_memory_cache") + state = get_map_state(map_tag) + with state.lock: + _queue(state, CommandKind.CLEAR_MEMORY_CACHE, {}) def clear_disk_cache(*, map_tag: Tag | None = None) -> None: - _stub("clear_disk_cache") + if map_tag is None: + return + state = get_map_state(map_tag) + with state.lock: + _queue(state, CommandKind.CLEAR_DISK_CACHE, {}) def get_cache_stats(*, map_tag: Tag | None = None) -> CacheStats: - _stub("get_cache_stats") + config = get_config() + if map_tag is None: + cache_dir = config.cache_dir + return CacheStats( + memory_max_tiles=config.memory_cache_max_tiles, + disk_bytes=disk_cache_size_bytes(cache_dir), + disk_max_bytes=config.disk_cache_max_bytes, + disk_path=cache_dir, + ) + state = get_map_state(map_tag) + with state.lock: + return CacheStats( + memory_tiles=0, + memory_max_tiles=config.memory_cache_max_tiles, + disk_bytes=disk_cache_size_bytes(state.cache_dir), + disk_max_bytes=config.disk_cache_max_bytes, + disk_path=state.cache_dir, + ) def get_map_debug_state(*, map_tag: Tag | None = None) -> dict[str, Any]: - _stub("get_map_debug_state") + state = get_map_state(map_tag) + with state.lock: + return { + "tag": state.tag, + "center": state.center, + "zoom": state.zoom, + "requested_size": (state.requested_width, state.requested_height), + "measured_size": (state.measured_width, state.measured_height), + "visible": state.is_visible, + "provider": state.provider.name, + "overlay_count": len(state.overlays), + "layers": { + name: { + "show": layer.show, + "z_index": layer.z_index, + "overlay_count": len(layer.overlay_tags), + } + for name, layer in state.layers.items() + }, + "dirty_flags": int(state.dirty), + "pending_command_count": len(state.command_queue), + "generation": state.generation, + "active_drag": state.interaction.active_drag, + } diff --git a/src/dpg_map/cache.py b/src/dpg_map/cache.py index 23043af..6455d80 100644 --- a/src/dpg_map/cache.py +++ b/src/dpg_map/cache.py @@ -2,8 +2,15 @@ from __future__ import annotations -from dataclasses import dataclass +import json +from dataclasses import asdict, dataclass, field from pathlib import Path +from time import time +from typing import Any + +from platformdirs import user_cache_dir + +from .exceptions import CacheError @dataclass(frozen=True, slots=True) @@ -34,3 +41,199 @@ class DiskCacheConfig: path: Path | None = None max_bytes: int | None = 2_000_000_000 + + +@dataclass(slots=True) +class MemoryCacheEntry: + """Metadata for one in-memory tile.""" + + tile_id: object + size_bytes: int = 0 + last_accessed_at: float = field(default_factory=time) + protected: bool = False + texture_tag: object | None = None + + +@dataclass(slots=True) +class MemoryCacheModel: + """Small LRU metadata model for decoded/runtime tiles.""" + + max_tiles: int = 512 + entries: dict[object, MemoryCacheEntry] = field(default_factory=dict) + hits: int = 0 + misses: int = 0 + + def record_access(self, tile_id: object) -> MemoryCacheEntry | None: + """Mark an entry as recently used and return it if present.""" + + entry = self.entries.get(tile_id) + if entry is None: + self.misses += 1 + return None + self.hits += 1 + entry.last_accessed_at = time() + return entry + + def put(self, entry: MemoryCacheEntry) -> None: + """Insert or replace entry metadata.""" + + entry.last_accessed_at = time() + self.entries[entry.tile_id] = entry + + def plan_evictions(self) -> list[object]: + """Return tile IDs that can be evicted without touching GUI resources.""" + + overflow = len(self.entries) - self.max_tiles + if overflow <= 0: + return [] + candidates = [entry for entry in self.entries.values() if not entry.protected] + candidates.sort(key=lambda entry: entry.last_accessed_at) + return [entry.tile_id for entry in candidates[:overflow]] + + +@dataclass(frozen=True, slots=True) +class DiskCacheMetadata: + """Persistent metadata stored next to a tile file.""" + + url: str = "" + etag: str | None = None + last_modified: str | None = None + expires: str | None = None + downloaded_at: float = 0.0 + last_accessed_at: float = 0.0 + size_bytes: int = 0 + + +@dataclass(frozen=True, slots=True) +class DiskCacheEntry: + """Scanned disk cache file plus metadata.""" + + tile_path: Path + metadata_path: Path + metadata: DiskCacheMetadata + + +def default_cache_dir() -> Path: + """Return the default persistent cache directory.""" + + return Path(user_cache_dir("dpg-map", appauthor=False)) + + +def disk_cache_root(cache_dir: str | Path | None = None) -> Path: + """Resolve the disk cache root path.""" + + return Path(cache_dir).expanduser() if cache_dir is not None else default_cache_dir() + + +def tile_cache_path( + cache_dir: str | Path | None, + provider_name: str, + z: int, + x: int, + y: int, + extension: str | None = None, +) -> Path: + """Return the provider-namespaced persistent tile path.""" + + ext = (extension or "png").lstrip(".") + safe_provider = provider_name.replace("/", "_") + return disk_cache_root(cache_dir) / safe_provider / str(z) / str(x) / f"{y}.{ext}" + + +def tile_metadata_path(tile_path: Path) -> Path: + """Return the metadata path for a tile path.""" + + return tile_path.with_suffix(".json") + + +def read_disk_metadata(path: Path) -> DiskCacheMetadata: + """Read a metadata JSON file, returning defaults for missing metadata.""" + + if not path.exists(): + return DiskCacheMetadata() + try: + raw: dict[str, Any] = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise CacheError(f"could not read cache metadata: {path}") from exc + except json.JSONDecodeError as exc: + raise CacheError(f"invalid cache metadata JSON: {path}") from exc + + return DiskCacheMetadata( + url=str(raw.get("url", "")), + etag=raw.get("etag"), + last_modified=raw.get("last_modified"), + expires=raw.get("expires"), + downloaded_at=float(raw.get("downloaded_at", 0.0)), + last_accessed_at=float(raw.get("last_accessed_at", 0.0)), + size_bytes=int(raw.get("size_bytes", 0)), + ) + + +def write_disk_metadata(path: Path, metadata: DiskCacheMetadata) -> None: + """Write metadata JSON next to a tile file.""" + + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(asdict(metadata), sort_keys=True), encoding="utf-8") + except OSError as exc: + raise CacheError(f"could not write cache metadata: {path}") from exc + + +def scan_disk_cache(cache_dir: str | Path | None) -> list[DiskCacheEntry]: + """Scan tile files under a disk cache root.""" + + root = disk_cache_root(cache_dir) + if not root.exists(): + return [] + entries: list[DiskCacheEntry] = [] + for path in root.rglob("*"): + if not path.is_file() or path.suffix == ".json": + continue + metadata_path = tile_metadata_path(path) + metadata = read_disk_metadata(metadata_path) + size_bytes = metadata.size_bytes or path.stat().st_size + if size_bytes != metadata.size_bytes: + metadata = DiskCacheMetadata( + url=metadata.url, + etag=metadata.etag, + last_modified=metadata.last_modified, + expires=metadata.expires, + downloaded_at=metadata.downloaded_at, + last_accessed_at=metadata.last_accessed_at, + size_bytes=size_bytes, + ) + entries.append(DiskCacheEntry(path, metadata_path, metadata)) + return entries + + +def disk_cache_size_bytes(cache_dir: str | Path | None) -> int: + """Return total bytes for cached tile files.""" + + return sum(entry.metadata.size_bytes for entry in scan_disk_cache(cache_dir)) + + +def plan_disk_prune( + cache_dir: str | Path | None, + max_bytes: int | None, + *, + protected_paths: set[Path] | None = None, +) -> list[Path]: + """Return tile paths that should be pruned by LRU order without deleting them.""" + + if max_bytes is None: + return [] + protected = {path.resolve() for path in protected_paths or set()} + entries = scan_disk_cache(cache_dir) + total = sum(entry.metadata.size_bytes for entry in entries) + if total <= max_bytes: + return [] + + candidates = [entry for entry in entries if entry.tile_path.resolve() not in protected] + candidates.sort(key=lambda entry: entry.metadata.last_accessed_at) + prune: list[Path] = [] + for entry in candidates: + if total <= max_bytes: + break + prune.append(entry.tile_path) + total -= entry.metadata.size_bytes + return prune diff --git a/src/dpg_map/commands.py b/src/dpg_map/commands.py index a723e65..dacad58 100644 --- a/src/dpg_map/commands.py +++ b/src/dpg_map/commands.py @@ -1 +1,113 @@ """Command models for GUI-thread rendering work.""" + +from __future__ import annotations + +from collections import OrderedDict, deque +from dataclasses import dataclass, field +from enum import Enum +from threading import RLock +from time import monotonic +from typing import Any + +from .types import Tag + + +class CommandKind(Enum): + """Commands that the GUI thread can apply in order.""" + + SET_VIEW = "set_view" + SET_PROVIDER = "set_provider" + ADD_OVERLAY = "add_overlay" + UPDATE_OVERLAY = "update_overlay" + DELETE_OVERLAY = "delete_overlay" + SET_LAYER_VISIBILITY = "set_layer_visibility" + ADD_LAYER = "add_layer" + CLEAR_LAYER = "clear_layer" + CLEAR_MAP = "clear_map" + CLEAR_MEMORY_CACHE = "clear_memory_cache" + CLEAR_DISK_CACHE = "clear_disk_cache" + + +@dataclass(frozen=True, slots=True) +class MapCommand: + """A command submitted from public API calls to the GUI-thread renderer.""" + + kind: CommandKind + map_tag: Tag + payload: dict[str, Any] = field(default_factory=dict) + created_at: float = field(default_factory=monotonic) + + +class MapCommandQueue: + """Thread-safe command queue with bounded coalescing for high-rate updates.""" + + _VIEW_KEY = "__view__" + + def __init__(self) -> None: + self._lock = RLock() + self._ordered: deque[MapCommand] = deque() + self._overlay_updates: OrderedDict[tuple[Tag, Tag], MapCommand] = OrderedDict() + self._view_updates: OrderedDict[Tag, MapCommand] = OrderedDict() + + def put(self, command: MapCommand) -> None: + """Queue a command, coalescing update commands where ordering permits.""" + + with self._lock: + if command.kind is CommandKind.UPDATE_OVERLAY: + overlay_tag = command.payload.get("tag") + if overlay_tag is None: + self._ordered.append(command) + return + key = (command.map_tag, overlay_tag) + self._ordered.append(command) + self._overlay_updates[key] = command + return + + if command.kind is CommandKind.SET_VIEW: + self._ordered.append(command) + self._view_updates[command.map_tag] = command + return + + self._ordered.append(command) + + def drain(self) -> list[MapCommand]: + """Return pending commands in render order and clear the queue.""" + + with self._lock: + drained: list[MapCommand] = [] + while self._ordered: + command = self._ordered.popleft() + if command.kind is CommandKind.UPDATE_OVERLAY: + overlay_tag = command.payload.get("tag") + if not isinstance(overlay_tag, str | int): + drained.append(command) + continue + key = (command.map_tag, overlay_tag) + latest = self._overlay_updates.get(key) + if latest is command: + drained.append(command) + del self._overlay_updates[key] + continue + + if command.kind is CommandKind.SET_VIEW: + latest = self._view_updates.get(command.map_tag) + if latest is command: + drained.append(command) + del self._view_updates[command.map_tag] + continue + + drained.append(command) + + return drained + + def __len__(self) -> int: + with self._lock: + return len(self._ordered) + + def clear(self) -> None: + """Drop all pending commands.""" + + with self._lock: + self._ordered.clear() + self._overlay_updates.clear() + self._view_updates.clear() diff --git a/src/dpg_map/exceptions.py b/src/dpg_map/exceptions.py index 8e8d51e..ed2ab9b 100644 --- a/src/dpg_map/exceptions.py +++ b/src/dpg_map/exceptions.py @@ -27,3 +27,23 @@ class InvalidProviderError(ProviderError, ValueError): class ProjectionError(DpgMapError, ValueError): """Raised when geographic projection input is invalid.""" + + +class MapNotFoundError(DpgMapError, KeyError): + """Raised when a requested map tag is not registered.""" + + +class OverlayNotFoundError(DpgMapError, KeyError): + """Raised when a requested overlay tag is not registered.""" + + +class CoordinateError(DpgMapError, ValueError): + """Raised when geographic coordinate input is invalid.""" + + +class ThreadingError(DpgMapError): + """Raised when an operation violates dpg-map threading rules.""" + + +class CacheError(DpgMapError): + """Raised when cache metadata or paths cannot be handled.""" diff --git a/src/dpg_map/overlays.py b/src/dpg_map/overlays.py index 0ba745a..dc606b7 100644 --- a/src/dpg_map/overlays.py +++ b/src/dpg_map/overlays.py @@ -1 +1,72 @@ """Logical overlay models.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from .types import Color, LatLon, Tag + + +@dataclass(slots=True) +class Overlay: + """Base logical overlay state.""" + + tag: Tag + map_tag: Tag + layer: str + show: bool = True + user_data: Any = None + revision: int = 0 + + def touch(self) -> None: + """Mark this overlay as changed.""" + + self.revision += 1 + + +@dataclass(slots=True) +class MarkerOverlay(Overlay): + """Logical marker overlay.""" + + lat: float = 0.0 + lon: float = 0.0 + label: str | None = None + color: Color = (255, 80, 80, 255) + radius: float = 5.0 + show_label: bool = False + callback: Callable[..., Any] | None = None + + +@dataclass(slots=True) +class PolylineOverlay(Overlay): + """Logical polyline overlay.""" + + points: tuple[LatLon, ...] = () + color: Color = (80, 180, 255, 255) + thickness: float = 2.0 + closed: bool = False + simplify: bool = True + + +@dataclass(slots=True) +class TrajectoryOverlay(Overlay): + """Logical trajectory overlay.""" + + points: tuple[LatLon, ...] = () + timestamps: tuple[float, ...] | None = None + color: Color = (255, 180, 60, 255) + thickness: float = 2.0 + show_points: bool = False + point_stride: int = 1 + + +@dataclass(slots=True) +class LayerState: + """Logical layer visibility and ordering state.""" + + name: str + z_index: int = 0 + show: bool = True + overlay_tags: set[Tag] = field(default_factory=set) diff --git a/src/dpg_map/state.py b/src/dpg_map/state.py index 2c14f5a..9de41c0 100644 --- a/src/dpg_map/state.py +++ b/src/dpg_map/state.py @@ -1 +1,328 @@ """Thread-safe state models and registries.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from enum import IntFlag, auto +from pathlib import Path +from threading import RLock +from uuid import uuid4 + +from .commands import MapCommandQueue +from .exceptions import InvalidProviderError, MapNotFoundError +from .overlays import LayerState, Overlay +from .providers import TileProvider, get_default_provider, get_provider +from .types import LatLon, Tag + + +class DirtyFlags(IntFlag): + """Reasons the GUI renderer needs to refresh part of a map.""" + + NONE = 0 + VIEW = auto() + TILES = auto() + OVERLAYS = auto() + SIZE = auto() + PROVIDER = auto() + FULL = VIEW | TILES | OVERLAYS | SIZE | PROVIDER + + +@dataclass(slots=True) +class DpgMapConfig: + """Global package configuration.""" + + user_agent: str | None = None + cache_dir: Path | None = None + default_provider: str | TileProvider = "osm" + memory_cache_max_tiles: int = 512 + disk_cache_max_bytes: int | None = 2_000_000_000 + prefetch_margin_tiles: int = 1 + tile_worker_count: int = 4 + overlay_update_policy: str = "coalesce" + debug: bool = False + + +@dataclass(slots=True) +class TileManagerState: + """Logical tile/cache counters until the tile manager is implemented.""" + + queued_tiles: int = 0 + loading_tiles: int = 0 + failed_tiles: int = 0 + visible_tile_count: int = 0 + + +@dataclass(slots=True) +class InteractionState: + """Logical interaction state until GUI interaction is implemented.""" + + active_drag: bool = False + last_mouse_position: tuple[float, float] | None = None + + +def default_layers() -> dict[str, LayerState]: + """Return the default logical map layers.""" + + layers = [ + LayerState("background", z_index=0), + LayerState("tiles", z_index=10), + LayerState("default", z_index=50), + LayerState("markers", z_index=60), + LayerState("lines", z_index=70), + LayerState("trajectories", z_index=80), + LayerState("attribution", z_index=100), + ] + return {layer.name: layer for layer in layers} + + +@dataclass(slots=True) +class MapState: + """Thread-safe logical state for one map widget.""" + + tag: Tag + child_window_tag: Tag + drawlist_tag: Tag + texture_registry_tag: Tag + handler_registry_tag: Tag + requested_width: int = 0 + requested_height: int = 0 + requested_autosize_x: bool = False + requested_autosize_y: bool = False + measured_width: int = 0 + measured_height: int = 0 + last_nonzero_width: int = 0 + last_nonzero_height: int = 0 + is_visible: bool = False + center: LatLon = (0.0, 0.0) + zoom: int = 2 + min_zoom: int = 0 + max_zoom: int = 19 + provider: TileProvider = field(default_factory=get_default_provider) + overlays: dict[Tag, Overlay] = field(default_factory=dict) + layers: dict[str, LayerState] = field(default_factory=default_layers) + command_queue: MapCommandQueue = field(default_factory=MapCommandQueue) + tile_manager: TileManagerState = field(default_factory=TileManagerState) + renderer: object | None = None + interaction: InteractionState = field(default_factory=InteractionState) + lock: RLock = field(default_factory=RLock) + dirty: DirtyFlags = DirtyFlags.FULL + frame_scheduled: bool = False + generation: int = 0 + cache_dir: Path | None = None + user_agent: str | None = None + + +_config = DpgMapConfig() +_config_lock = RLock() +_maps: dict[Tag, MapState] = {} +_maps_lock = RLock() +_current_map_stack: list[Tag] = [] +_current_map_lock = RLock() + + +def _resolve_provider(provider: str | TileProvider | None) -> TileProvider: + if provider is None: + with _config_lock: + provider = _config.default_provider + if isinstance(provider, TileProvider): + return provider + if isinstance(provider, str): + return get_provider(provider) + raise InvalidProviderError("provider must be a provider name or TileProvider") + + +def configure_state( + *, + user_agent: str | None = None, + cache_dir: str | Path | None = None, + default_provider: str | TileProvider = "osm", + memory_cache_max_tiles: int = 512, + disk_cache_max_bytes: int | None = 2_000_000_000, + prefetch_margin_tiles: int = 1, + tile_worker_count: int = 4, + overlay_update_policy: str = "coalesce", + debug: bool = False, +) -> None: + """Replace global dpg-map configuration.""" + + if memory_cache_max_tiles < 0: + raise ValueError("memory_cache_max_tiles must be >= 0") + if disk_cache_max_bytes is not None and disk_cache_max_bytes < 0: + raise ValueError("disk_cache_max_bytes must be >= 0 or None") + if prefetch_margin_tiles < 0: + raise ValueError("prefetch_margin_tiles must be >= 0") + if tile_worker_count < 1: + raise ValueError("tile_worker_count must be >= 1") + if overlay_update_policy != "coalesce": + raise ValueError('overlay_update_policy must be "coalesce"') + + resolved_cache_dir = Path(cache_dir).expanduser() if cache_dir is not None else None + _resolve_provider(default_provider) + with _config_lock: + _config.user_agent = user_agent + _config.cache_dir = resolved_cache_dir + _config.default_provider = default_provider + _config.memory_cache_max_tiles = memory_cache_max_tiles + _config.disk_cache_max_bytes = disk_cache_max_bytes + _config.prefetch_margin_tiles = prefetch_margin_tiles + _config.tile_worker_count = tile_worker_count + _config.overlay_update_policy = overlay_update_policy + _config.debug = debug + + +def get_config() -> DpgMapConfig: + """Return a copy of current global configuration.""" + + with _config_lock: + return DpgMapConfig( + user_agent=_config.user_agent, + cache_dir=_config.cache_dir, + default_provider=_config.default_provider, + memory_cache_max_tiles=_config.memory_cache_max_tiles, + disk_cache_max_bytes=_config.disk_cache_max_bytes, + prefetch_margin_tiles=_config.prefetch_margin_tiles, + tile_worker_count=_config.tile_worker_count, + overlay_update_policy=_config.overlay_update_policy, + debug=_config.debug, + ) + + +def create_map_state( + *, + tag: Tag | None = None, + center: LatLon = (0.0, 0.0), + zoom: int = 2, + min_zoom: int | None = None, + max_zoom: int | None = None, + width: int = 0, + height: int = 0, + autosize_x: bool = False, + autosize_y: bool = False, + provider: str | TileProvider | None = None, + cache_dir: str | Path | None = None, + user_agent: str | None = None, +) -> MapState: + """Create and register a logical map state.""" + + map_tag = tag if tag is not None else f"dpg_map_{uuid4().hex}" + provider_obj = _resolve_provider(provider) + min_zoom_value = provider_obj.min_zoom if min_zoom is None else min_zoom + max_zoom_value = provider_obj.max_zoom if max_zoom is None else max_zoom + if min_zoom_value < provider_obj.min_zoom: + min_zoom_value = provider_obj.min_zoom + if max_zoom_value > provider_obj.max_zoom: + max_zoom_value = provider_obj.max_zoom + if max_zoom_value < min_zoom_value: + raise ValueError("max_zoom must be >= min_zoom") + + zoom_value = max(min_zoom_value, min(max_zoom_value, int(zoom))) + config = get_config() + resolved_cache_dir = Path(cache_dir).expanduser() if cache_dir is not None else config.cache_dir + state = MapState( + tag=map_tag, + child_window_tag=f"{map_tag}##child", + drawlist_tag=f"{map_tag}##drawlist", + texture_registry_tag=f"{map_tag}##textures", + handler_registry_tag=f"{map_tag}##handlers", + requested_width=width, + requested_height=height, + requested_autosize_x=autosize_x, + requested_autosize_y=autosize_y, + center=(float(center[0]), float(center[1])), + zoom=zoom_value, + min_zoom=min_zoom_value, + max_zoom=max_zoom_value, + provider=provider_obj, + cache_dir=resolved_cache_dir, + user_agent=user_agent if user_agent is not None else config.user_agent, + ) + with _maps_lock: + _maps[map_tag] = state + return state + + +def register_map_state(state: MapState) -> None: + """Register a prebuilt map state.""" + + with _maps_lock: + _maps[state.tag] = state + + +def unregister_map_state(tag: Tag) -> None: + """Remove a map state from the registry.""" + + with _maps_lock: + _maps.pop(tag, None) + + +def get_map_state(map_tag: Tag | None = None) -> MapState: + """Resolve a map by explicit tag or current map context.""" + + resolved_tag = resolve_map_tag(map_tag) + with _maps_lock: + try: + return _maps[resolved_tag] + except KeyError as exc: + raise MapNotFoundError(f"map not registered: {resolved_tag}") from exc + + +def resolve_map_tag(map_tag: Tag | None = None) -> Tag: + """Resolve an explicit tag or the current context map tag.""" + + if map_tag is not None: + return map_tag + with _current_map_lock: + if _current_map_stack: + return _current_map_stack[-1] + raise MapNotFoundError("map_tag is required outside a map_widget context") + + +def find_map_for_overlay(tag: Tag, map_tag: Tag | None = None) -> MapState: + """Find the map containing an overlay, optionally scoped by map tag.""" + + if map_tag is not None: + return get_map_state(map_tag) + + with _current_map_lock: + if _current_map_stack: + current = get_map_state(_current_map_stack[-1]) + with current.lock: + if tag in current.overlays: + return current + + matches: list[MapState] = [] + with _maps_lock: + states = list(_maps.values()) + for state in states: + with state.lock: + if tag in state.overlays: + matches.append(state) + if len(matches) == 1: + return matches[0] + if not matches: + raise MapNotFoundError(f"no map contains overlay: {tag}") + raise MapNotFoundError(f"overlay tag is ambiguous across maps: {tag}") + + +@contextmanager +def current_map_context(map_tag: Tag) -> Iterator[None]: + """Push a current map tag for context-style overlay creation.""" + + with _current_map_lock: + _current_map_stack.append(map_tag) + try: + yield + finally: + with _current_map_lock: + if _current_map_stack and _current_map_stack[-1] == map_tag: + _current_map_stack.pop() + elif map_tag in _current_map_stack: + _current_map_stack.remove(map_tag) + + +def mark_dirty(state: MapState, flags: DirtyFlags) -> None: + """Mark a map dirty while holding or acquiring its state lock.""" + + state.dirty |= flags diff --git a/src/dpg_map/widget.py b/src/dpg_map/widget.py index 2f58b36..e829fb1 100644 --- a/src/dpg_map/widget.py +++ b/src/dpg_map/widget.py @@ -4,9 +4,10 @@ from __future__ import annotations from collections.abc import Iterator from contextlib import contextmanager +from pathlib import Path -from .exceptions import DpgMapNotImplementedError from .providers import TileProvider +from .state import create_map_state, current_map_context from .types import LatLon, Tag @@ -23,9 +24,27 @@ def map_widget( autosize_y: bool = False, **kwargs: object, ) -> Iterator[Tag | None]: - """Stub context manager for the future Dear PyGui widget.""" + """Create a logical map context. - raise DpgMapNotImplementedError( - "dpg_map.map_widget is not implemented until the GUI rebuild steps" + Dear PyGui item creation is added in a later rebuild step; this context currently + registers thread-safe logical state so overlays can be declared in context. + """ + + cache_dir_value = kwargs.get("cache_dir") + cache_dir = cache_dir_value if isinstance(cache_dir_value, str | Path) else None + user_agent_value = kwargs.get("user_agent") + user_agent = user_agent_value if isinstance(user_agent_value, str) else None + state = create_map_state( + tag=tag, + center=center, + zoom=zoom, + provider=provider, + width=width, + height=height, + autosize_x=autosize_x, + autosize_y=autosize_y, + cache_dir=cache_dir, + user_agent=user_agent, ) - yield tag + with current_map_context(state.tag): + yield state.tag diff --git a/tests/test_cache.py b/tests/test_cache.py index 912d7e4..c97f8f2 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -2,7 +2,15 @@ from __future__ import annotations from pathlib import Path -from dpg_map.cache import CacheStats, DiskCacheConfig, MemoryCacheConfig +from dpg_map.cache import ( + CacheStats, + DiskCacheConfig, + DiskCacheMetadata, + MemoryCacheConfig, + plan_disk_prune, + tile_cache_path, + write_disk_metadata, +) def test_cache_stats_dataclass_construction() -> None: @@ -29,3 +37,32 @@ def test_initial_cache_config_dataclasses() -> None: assert memory_config.max_tiles == 512 assert disk_config.max_bytes == 2_000_000_000 + + +def test_disk_cache_path_generation(tmp_path: Path) -> None: + path = tile_cache_path(tmp_path, "osm", 4, 8, 9, "jpg") + + assert path == tmp_path / "osm" / "4" / "8" / "9.jpg" + + +def test_disk_cache_prune_ordering(tmp_path: Path) -> None: + first = tile_cache_path(tmp_path, "osm", 1, 1, 1) + second = tile_cache_path(tmp_path, "osm", 1, 1, 2) + protected = tile_cache_path(tmp_path, "osm", 1, 1, 3) + + for path, accessed_at in [(first, 1.0), (second, 2.0), (protected, 0.0)]: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"abcde") + write_disk_metadata( + path.with_suffix(".json"), + DiskCacheMetadata( + url=str(path), + downloaded_at=accessed_at, + last_accessed_at=accessed_at, + size_bytes=5, + ), + ) + + planned = plan_disk_prune(tmp_path, 5, protected_paths={protected}) + + assert planned == [first, second] diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..91e3b87 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dpg_map.commands import CommandKind, MapCommand, MapCommandQueue + + +def test_overlay_updates_coalesce_by_overlay_tag() -> None: + queue = MapCommandQueue() + + queue.put(MapCommand(CommandKind.UPDATE_OVERLAY, "map", {"tag": "vehicle", "lat": 1.0})) + queue.put(MapCommand(CommandKind.UPDATE_OVERLAY, "map", {"tag": "vehicle", "lat": 2.0})) + queue.put(MapCommand(CommandKind.UPDATE_OVERLAY, "map", {"tag": "other", "lat": 3.0})) + + drained = queue.drain() + + assert len(drained) == 2 + assert drained[0].payload == {"tag": "vehicle", "lat": 2.0} + assert drained[1].payload == {"tag": "other", "lat": 3.0} + + +def test_latest_view_update_wins_but_structural_commands_keep_order() -> None: + queue = MapCommandQueue() + + queue.put(MapCommand(CommandKind.ADD_OVERLAY, "map", {"tag": "a"})) + queue.put(MapCommand(CommandKind.SET_VIEW, "map", {"zoom": 3})) + queue.put(MapCommand(CommandKind.SET_VIEW, "map", {"zoom": 4})) + queue.put(MapCommand(CommandKind.DELETE_OVERLAY, "map", {"tag": "a"})) + + drained = queue.drain() + + assert [command.kind for command in drained] == [ + CommandKind.ADD_OVERLAY, + CommandKind.SET_VIEW, + CommandKind.DELETE_OVERLAY, + ] + assert drained[1].payload == {"zoom": 4} diff --git a/tests/test_overlays_state.py b/tests/test_overlays_state.py new file mode 100644 index 0000000..6bbb961 --- /dev/null +++ b/tests/test_overlays_state.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import pytest + +import dpg_map as dpgm +from dpg_map.exceptions import CoordinateError +from dpg_map.overlays import TrajectoryOverlay +from dpg_map.state import DirtyFlags, create_map_state, get_map_state + + +def test_overlay_update_does_not_alter_center_or_zoom() -> None: + create_map_state(tag="overlay-isolation", center=(47.0, 2.0), zoom=8) + dpgm.add_marker("vehicle", lat=47.1, lon=2.1, map_tag="overlay-isolation") + + before_center = dpgm.get_center(map_tag="overlay-isolation") + before_zoom = dpgm.get_zoom(map_tag="overlay-isolation") + + dpgm.update_marker("vehicle", lat=47.2, lon=2.2, map_tag="overlay-isolation") + + assert dpgm.get_center(map_tag="overlay-isolation") == before_center + assert dpgm.get_zoom(map_tag="overlay-isolation") == before_zoom + state = get_map_state("overlay-isolation") + assert state.dirty & DirtyFlags.OVERLAYS + + +def test_trajectory_inputs_are_copied() -> None: + create_map_state(tag="trajectory-copy") + lats = [1.0, 2.0] + lons = [3.0, 4.0] + + dpgm.add_trajectory("track", lats=lats, lons=lons, map_tag="trajectory-copy") + lats[0] = 99.0 + lons[0] = 99.0 + + state = get_map_state("trajectory-copy") + overlay = state.overlays["track"] + + assert isinstance(overlay, TrajectoryOverlay) + assert overlay.points == ((1.0, 3.0), (2.0, 4.0)) + + +def test_mismatched_lat_lon_lengths_raise() -> None: + create_map_state(tag="bad-coordinates") + + with pytest.raises(CoordinateError): + dpgm.add_trajectory("track", lats=[1.0], lons=[2.0, 3.0], map_tag="bad-coordinates") + + +def test_layer_state_tracks_visibility_and_overlay_membership() -> None: + create_map_state(tag="layers") + + dpgm.add_layer("fleet", map_tag="layers") + dpgm.add_marker("vehicle", lat=1.0, lon=2.0, layer="fleet", map_tag="layers") + dpgm.hide_layer("fleet", map_tag="layers") + + state = get_map_state("layers") + assert state.layers["fleet"].show is False + assert state.layers["fleet"].overlay_tags == {"vehicle"} + + dpgm.clear_layer("fleet", map_tag="layers") + + assert state.layers["fleet"].overlay_tags == set() + assert "vehicle" not in state.overlays From 743a82f796842106748fb19f3ee0614bf7a08235 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Fri, 22 May 2026 18:33:45 +0200 Subject: [PATCH 04/11] step 3: add widget shell sizing and frame pump --- AGENTS.md | 14 +++- README.md | 22 +++++- examples/basic_map.py | 27 +++++++ examples/hidden_tab.py | 27 +++++++ examples/sizing_child.py | 28 +++++++ examples/sizing_table.py | 33 ++++++++ examples/sizing_window.py | 27 +++++++ src/dpg_map/interaction.py | 35 +++++++++ src/dpg_map/renderer.py | 156 +++++++++++++++++++++++++++++++++++++ src/dpg_map/sizing.py | 86 ++++++++++++++++++++ src/dpg_map/widget.py | 32 ++++++-- tests/test_interaction.py | 19 +++++ tests/test_renderer.py | 30 +++++++ tests/test_sizing.py | 44 +++++++++++ 14 files changed, 568 insertions(+), 12 deletions(-) create mode 100644 examples/basic_map.py create mode 100644 examples/hidden_tab.py create mode 100644 examples/sizing_child.py create mode 100644 examples/sizing_table.py create mode 100644 examples/sizing_window.py create mode 100644 tests/test_interaction.py create mode 100644 tests/test_renderer.py create mode 100644 tests/test_sizing.py diff --git a/AGENTS.md b/AGENTS.md index e00503d..6826117 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,16 +2,17 @@ ## Current status -Step 2 complete. +Step 3 complete. ## Completed steps Step 1 - Public API contract and pure core. Step 2 - Thread-safe state, commands, overlays, and cache model. +Step 3 - Widget shell, sizing system, and GUI-thread frame pump. ## Current step -Step 3 - Widget shell, sizing system, and GUI-thread frame pump. +Step 4 - Tile manager, persistent cache, and asynchronous loading. ## Design decisions @@ -40,6 +41,13 @@ None yet. - Implemented public runtime overlay/view/layer/provider/cache/debug wrappers against logical state without Dear PyGui calls. - Implemented memory cache metadata, disk cache path generation, metadata read/write, disk size scanning, and prune planning. - Added Step 2 tests for command coalescing, overlay/view isolation, copied trajectory inputs, coordinate length validation, layer state, disk path generation, and prune ordering. +- Implemented `map_widget(...)` as a Dear PyGui child-window plus drawlist shell. +- Implemented GUI-thread renderer frame pump that schedules frame callbacks, drains command queues, measures size, resizes the drawlist, and draws a placeholder background/attribution. +- Implemented sizing helpers for measured size, last non-zero size preservation, visibility transitions, effective draw size, and resize dirty flags. +- Implemented map interaction hit-rectangle calculation. +- Added Step 3 examples for basic map, window sizing, child-window sizing, table sizing, and hidden-tab sizing. +- Added Step 3 tests for sizing transitions, zero-size preservation, resize dirty flags, command drain ordering, and hit rectangles. +- Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist creation. - Ran `uv run pytest`. - Ran `uv run ruff check .`. - Ran `uv run ruff format .`. @@ -48,4 +56,4 @@ None yet. ## Next action -Implement Step 3. +Implement Step 4. diff --git a/README.md b/README.md index 8ceb49e..3e444a6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `dpg-map` is a Dear PyGui map widget package under rebuild. -The Step 2 logical runtime model is in place: +The Step 3 widget shell is in place: ```python import dpg_map as dpgm @@ -29,7 +29,21 @@ Implemented so far: - command queue with coalescing for overlay and view updates - logical marker, polyline, trajectory, and layer models - persistent disk cache path, metadata, scanning, and prune planning +- Dear PyGui `child_window` + measured-size `drawlist` widget shell +- GUI-thread frame pump that drains commands and redraws a placeholder background +- sizing helpers that preserve the last non-zero size across hidden layouts +- interaction hit-rectangle calculation for the measured map area -Dear PyGui rendering is not implemented yet. The current `map_widget` is a logical -context manager used to register state; Step 3 will add the real child window, -drawlist, sizing, and GUI-thread frame pump. +Real tile loading and overlay drawing are not implemented yet. Step 4 will add +the asynchronous tile manager, persistent tile loading, and GUI-thread texture +creation. + +Examples: + +```bash +uv run python examples/basic_map.py +uv run python examples/sizing_window.py +uv run python examples/sizing_child.py +uv run python examples/sizing_table.py +uv run python examples/hidden_tab.py +``` diff --git a/examples/basic_map.py b/examples/basic_map.py new file mode 100644 index 0000000..9f76d5c --- /dev/null +++ b/examples/basic_map.py @@ -0,0 +1,27 @@ +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpg.create_context() + dpg.create_viewport(title="dpg-map basic", width=900, height=600) + + with ( + dpg.window(label="Map", width=-1, height=-1), + dpgm.map_widget(tag="map", center=(47.9029, 1.9093), zoom=15, width=-1, height=-1), + ): + dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093) + + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/hidden_tab.py b/examples/hidden_tab.py new file mode 100644 index 0000000..0465235 --- /dev/null +++ b/examples/hidden_tab.py @@ -0,0 +1,27 @@ +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpg.create_context() + dpg.create_viewport(title="dpg-map hidden tab", width=900, height=600) + + with dpg.window(label="Hidden Tab Sizing", width=-1, height=-1), dpg.tab_bar(): + with dpg.tab(label="First"): + dpg.add_text("Switch to the map tab.") + with dpg.tab(label="Map"), dpgm.map_widget(tag="map-hidden-tab", width=-1, height=500): + dpgm.add_marker("tab-marker", lat=35.0, lon=139.0) + + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/sizing_child.py b/examples/sizing_child.py new file mode 100644 index 0000000..9f11d68 --- /dev/null +++ b/examples/sizing_child.py @@ -0,0 +1,28 @@ +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpg.create_context() + dpg.create_viewport(title="dpg-map child sizing", width=900, height=600) + + with ( + dpg.window(label="Nested Child", width=-1, height=-1), + dpg.child_window(width=-1, height=420), + dpgm.map_widget(tag="map-child", width=-1, height=-1), + ): + dpgm.add_marker("inside-child", lat=47.0, lon=2.0) + + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/sizing_table.py b/examples/sizing_table.py new file mode 100644 index 0000000..9889310 --- /dev/null +++ b/examples/sizing_table.py @@ -0,0 +1,33 @@ +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpg.create_context() + dpg.create_viewport(title="dpg-map table sizing", width=1000, height=600) + + with ( + dpg.window(label="Table Layout", width=-1, height=-1), + dpg.table(header_row=True, resizable=True, policy=dpg.mvTable_SizingStretchProp), + ): + dpg.add_table_column(label="Map") + dpg.add_table_column(label="Controls") + with dpg.table_row(): + with dpg.table_cell(), dpgm.map_widget(tag="map-table", width=-1, height=500): + dpgm.add_marker("table-marker", lat=51.5, lon=-0.1) + with dpg.table_cell(): + dpg.add_text("Resize the window and table columns.") + + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/sizing_window.py b/examples/sizing_window.py new file mode 100644 index 0000000..c052c72 --- /dev/null +++ b/examples/sizing_window.py @@ -0,0 +1,27 @@ +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpg.create_context() + dpg.create_viewport(title="dpg-map window sizing", width=900, height=600) + + with ( + dpg.window(label="Fill Window", width=-1, height=-1), + dpgm.map_widget(tag="map-window", width=-1, height=-1), + ): + dpgm.add_marker("center", lat=0.0, lon=0.0) + + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/src/dpg_map/interaction.py b/src/dpg_map/interaction.py index f1b9a0e..59197b2 100644 --- a/src/dpg_map/interaction.py +++ b/src/dpg_map/interaction.py @@ -1 +1,36 @@ """Map interaction state and handlers.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .sizing import effective_draw_size +from .state import MapState + + +@dataclass(frozen=True, slots=True) +class HitRect: + """Screen-space rectangle used for map interaction tests.""" + + x: float + y: float + width: float + height: float + + @property + def right(self) -> float: + return self.x + self.width + + @property + def bottom(self) -> float: + return self.y + self.height + + def contains(self, x: float, y: float) -> bool: + return self.x <= x <= self.right and self.y <= y <= self.bottom + + +def calculate_hit_rect(state: MapState, drawlist_pos: tuple[float, float]) -> HitRect: + """Return the map interaction rectangle for a drawlist position.""" + + width, height = effective_draw_size(state) + return HitRect(float(drawlist_pos[0]), float(drawlist_pos[1]), float(width), float(height)) diff --git a/src/dpg_map/renderer.py b/src/dpg_map/renderer.py index 39f103d..e8f5c2c 100644 --- a/src/dpg_map/renderer.py +++ b/src/dpg_map/renderer.py @@ -1 +1,157 @@ """GUI-thread renderer implementation.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from .commands import CommandKind, MapCommand +from .interaction import HitRect, calculate_hit_rect +from .sizing import SizeMeasurement, apply_size_measurement +from .state import DirtyFlags, MapState + + +class MapRenderer: + """GUI-thread renderer for the Step 3 widget shell.""" + + def __init__(self, state: MapState, dpg: Any) -> None: + self.state = state + self._dpg = dpg + self._background_tag = f"{state.tag}##background" + self._title_tag = f"{state.tag}##placeholder-title" + self._attribution_tag = f"{state.tag}##attribution" + self.last_drained_commands: tuple[MapCommand, ...] = () + self.last_hit_rect: HitRect | None = None + + def schedule_next_frame(self) -> None: + """Schedule this renderer to run on the next Dear PyGui frame.""" + + with self.state.lock: + if self.state.frame_scheduled: + return + self.state.frame_scheduled = True + frame = self._dpg.get_frame_count() + 1 + self._dpg.set_frame_callback(frame, self._frame_callback) + + def _frame_callback(self, sender: Any | None = None, app_data: Any | None = None) -> None: + _ = (sender, app_data) + with self.state.lock: + self.state.frame_scheduled = False + if not self._dpg.does_item_exist(self.state.drawlist_tag): + return + self.render_frame() + self.schedule_next_frame() + + def render_frame(self) -> None: + """Drain pending commands, refresh size, and draw the placeholder shell.""" + + commands = drain_renderer_commands(self.state) + self.last_drained_commands = tuple(commands) + self._update_size_from_dpg() + + with self.state.lock: + dirty = self.state.dirty + should_draw = bool(dirty & (DirtyFlags.FULL | DirtyFlags.SIZE)) + 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 + provider_attribution = self.state.provider.attribution + self.state.dirty = DirtyFlags.NONE + + if should_draw and visible: + self._draw_placeholder(width, height, provider_attribution) + + def _update_size_from_dpg(self) -> None: + width, height = self._measure_child_content() + visible = bool(self._dpg.is_item_shown(self.state.child_window_tag)) + with self.state.lock: + update = apply_size_measurement( + self.state, + SizeMeasurement(width=width, height=height, visible=visible), + ) + draw_width = update.effective_width + draw_height = update.effective_height + self._dpg.configure_item(self.state.drawlist_tag, width=draw_width, height=draw_height) + draw_pos = tuple(float(value) for value in self._dpg.get_item_pos(self.state.drawlist_tag)) + with self.state.lock: + self.last_hit_rect = calculate_hit_rect(self.state, (draw_pos[0], draw_pos[1])) + + def _measure_child_content(self) -> tuple[int, int]: + try: + width, height = self._dpg.get_item_rect_size(self.state.child_window_tag) + except Exception: + return (0, 0) + return (max(0, int(width)), max(0, int(height))) + + def _draw_placeholder(self, width: int, height: int, attribution: str) -> None: + width = max(1, int(width)) + height = max(1, int(height)) + self._dpg.delete_item(self.state.drawlist_tag, children_only=True) + self._dpg.draw_rectangle( + (0, 0), + (width, height), + parent=self.state.drawlist_tag, + tag=self._background_tag, + color=(54, 68, 78, 255), + fill=(29, 38, 45, 255), + ) + self._dpg.draw_text( + (12, 12), + "dpg-map", + parent=self.state.drawlist_tag, + tag=self._title_tag, + color=(232, 238, 242, 255), + size=18, + ) + label = attribution or "Map tiles load in Step 4" + text_y = max(28, height - 24) + self._dpg.draw_text( + (12, text_y), + label, + parent=self.state.drawlist_tag, + tag=self._attribution_tag, + color=(172, 184, 192, 255), + size=12, + ) + + +def drain_renderer_commands(state: MapState) -> list[MapCommand]: + """Drain and apply GUI-thread command side effects that exist in Step 3.""" + + commands = state.command_queue.drain() + if not commands: + return [] + + with state.lock: + for command in commands: + if command.kind is CommandKind.SET_VIEW: + state.dirty |= DirtyFlags.VIEW | DirtyFlags.TILES | DirtyFlags.OVERLAYS + elif command.kind is CommandKind.SET_PROVIDER: + state.dirty |= DirtyFlags.PROVIDER | DirtyFlags.TILES + elif command.kind in { + CommandKind.ADD_OVERLAY, + CommandKind.UPDATE_OVERLAY, + CommandKind.DELETE_OVERLAY, + CommandKind.SET_LAYER_VISIBILITY, + CommandKind.ADD_LAYER, + CommandKind.CLEAR_LAYER, + }: + state.dirty |= DirtyFlags.OVERLAYS + elif command.kind is CommandKind.CLEAR_MAP: + state.dirty |= DirtyFlags.TILES | DirtyFlags.OVERLAYS + elif command.kind in { + CommandKind.CLEAR_MEMORY_CACHE, + CommandKind.CLEAR_DISK_CACHE, + }: + state.dirty |= DirtyFlags.TILES + return commands + + +def make_frame_pump(state: MapState, dpg: Any) -> Callable[[], None]: + """Create and attach a renderer frame pump for a map state.""" + + renderer = MapRenderer(state, dpg) + with state.lock: + state.renderer = renderer + renderer.schedule_next_frame() + return renderer.render_frame diff --git a/src/dpg_map/sizing.py b/src/dpg_map/sizing.py index 01de1a5..3342b0c 100644 --- a/src/dpg_map/sizing.py +++ b/src/dpg_map/sizing.py @@ -1 +1,87 @@ """Map sizing measurement helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .state import DirtyFlags, MapState, mark_dirty + + +@dataclass(frozen=True, slots=True) +class SizeMeasurement: + """Concrete size and visibility reported by the GUI thread.""" + + width: int + height: int + visible: bool + + +@dataclass(frozen=True, slots=True) +class SizeUpdate: + """Result of applying one GUI size measurement.""" + + changed: bool + became_visible: bool + became_hidden: bool + effective_width: int + effective_height: int + + +def normalize_dimension(value: int | float | None) -> int: + """Convert Dear PyGui size values to stable integer pixels.""" + + if value is None: + return 0 + return max(0, int(value)) + + +def effective_draw_size(state: MapState) -> tuple[int, int]: + """Return the size the drawlist should use for the current frame.""" + + width = state.measured_width or state.last_nonzero_width or 1 + height = state.measured_height or state.last_nonzero_height or 1 + return (max(1, width), max(1, height)) + + +def apply_size_measurement( + state: MapState, + measurement: SizeMeasurement, + *, + mark: bool = True, +) -> SizeUpdate: + """Apply a GUI-thread size measurement to logical state. + + A zero measurement is preserved as the current measured size, but it does not + erase the last non-zero size. This keeps hidden maps from permanently + collapsing when Dear PyGui reports a temporary zero content region. + """ + + width = normalize_dimension(measurement.width) + height = normalize_dimension(measurement.height) + visible = bool(measurement.visible and width > 0 and height > 0) + + previous_width = state.measured_width + previous_height = state.measured_height + previous_visible = state.is_visible + + changed = previous_width != width or previous_height != height or previous_visible != visible + + state.measured_width = width + state.measured_height = height + state.is_visible = visible + if width > 0: + state.last_nonzero_width = width + if height > 0: + state.last_nonzero_height = height + + if changed and mark: + mark_dirty(state, DirtyFlags.SIZE | DirtyFlags.TILES | DirtyFlags.OVERLAYS) + + effective_width, effective_height = effective_draw_size(state) + return SizeUpdate( + changed=changed, + became_visible=visible and not previous_visible, + became_hidden=previous_visible and not visible, + effective_width=effective_width, + effective_height=effective_height, + ) diff --git a/src/dpg_map/widget.py b/src/dpg_map/widget.py index e829fb1..60acc92 100644 --- a/src/dpg_map/widget.py +++ b/src/dpg_map/widget.py @@ -5,8 +5,10 @@ from __future__ import annotations from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path +from typing import Any from .providers import TileProvider +from .renderer import MapRenderer from .state import create_map_state, current_map_context from .types import LatLon, Tag @@ -24,11 +26,7 @@ def map_widget( autosize_y: bool = False, **kwargs: object, ) -> Iterator[Tag | None]: - """Create a logical map context. - - Dear PyGui item creation is added in a later rebuild step; this context currently - registers thread-safe logical state so overlays can be declared in context. - """ + """Create a Dear PyGui child-window map shell and logical map context.""" cache_dir_value = kwargs.get("cache_dir") cache_dir = cache_dir_value if isinstance(cache_dir_value, str | Path) else None @@ -46,5 +44,29 @@ def map_widget( cache_dir=cache_dir, user_agent=user_agent, ) + import dearpygui.dearpygui as dpg + + child_kwargs: dict[str, Any] = dict(kwargs) + child_kwargs.pop("cache_dir", None) + child_kwargs.pop("user_agent", None) + child_kwargs.setdefault("border", False) + child_kwargs.setdefault("no_scrollbar", True) + child_kwargs.setdefault("no_scroll_with_mouse", True) + + dpg.add_child_window( + tag=state.child_window_tag, + width=width, + height=height, + autosize_x=autosize_x, + autosize_y=autosize_y, + **child_kwargs, + ) + dpg.add_drawlist(1, 1, tag=state.drawlist_tag, parent=state.child_window_tag) + + renderer = MapRenderer(state, dpg) + with state.lock: + state.renderer = renderer + renderer.schedule_next_frame() + with current_map_context(state.tag): yield state.tag diff --git a/tests/test_interaction.py b/tests/test_interaction.py new file mode 100644 index 0000000..465b01e --- /dev/null +++ b/tests/test_interaction.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dpg_map.interaction import calculate_hit_rect +from dpg_map.sizing import SizeMeasurement, apply_size_measurement +from dpg_map.state import create_map_state + + +def test_hit_rect_uses_effective_map_size() -> None: + state = create_map_state(tag="hit-rect") + apply_size_measurement(state, SizeMeasurement(width=400, height=250, visible=True)) + + rect = calculate_hit_rect(state, (10.0, 20.0)) + + assert rect.x == 10.0 + assert rect.y == 20.0 + assert rect.width == 400 + assert rect.height == 250 + assert rect.contains(410.0, 270.0) + assert not rect.contains(411.0, 270.0) diff --git a/tests/test_renderer.py b/tests/test_renderer.py new file mode 100644 index 0000000..4a9aab2 --- /dev/null +++ b/tests/test_renderer.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dpg_map.commands import CommandKind, MapCommand +from dpg_map.renderer import drain_renderer_commands +from dpg_map.state import DirtyFlags, create_map_state + + +def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> None: + state = create_map_state(tag="renderer-drain") + state.dirty = DirtyFlags.NONE + + state.command_queue.put(MapCommand(CommandKind.ADD_OVERLAY, state.tag, {"tag": "a"})) + state.command_queue.put(MapCommand(CommandKind.SET_VIEW, state.tag, {"zoom": 3})) + state.command_queue.put(MapCommand(CommandKind.SET_VIEW, state.tag, {"zoom": 4})) + state.command_queue.put(MapCommand(CommandKind.UPDATE_OVERLAY, state.tag, {"tag": "a", "v": 1})) + state.command_queue.put(MapCommand(CommandKind.UPDATE_OVERLAY, state.tag, {"tag": "a", "v": 2})) + state.command_queue.put(MapCommand(CommandKind.DELETE_OVERLAY, state.tag, {"tag": "a"})) + + commands = drain_renderer_commands(state) + + assert [command.kind for command in commands] == [ + CommandKind.ADD_OVERLAY, + CommandKind.SET_VIEW, + CommandKind.UPDATE_OVERLAY, + CommandKind.DELETE_OVERLAY, + ] + assert commands[1].payload == {"zoom": 4} + assert commands[2].payload == {"tag": "a", "v": 2} + assert state.dirty & DirtyFlags.VIEW + assert state.dirty & DirtyFlags.OVERLAYS diff --git a/tests/test_sizing.py b/tests/test_sizing.py new file mode 100644 index 0000000..4124b40 --- /dev/null +++ b/tests/test_sizing.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from dpg_map.sizing import SizeMeasurement, apply_size_measurement, effective_draw_size +from dpg_map.state import DirtyFlags, create_map_state + + +def test_size_measurement_tracks_last_nonzero_size() -> None: + state = create_map_state(tag="size-last-nonzero") + + first = apply_size_measurement(state, SizeMeasurement(width=640, height=360, visible=True)) + hidden = apply_size_measurement(state, SizeMeasurement(width=0, height=0, visible=False)) + + assert first.changed is True + assert state.measured_width == 0 + assert state.measured_height == 0 + assert state.last_nonzero_width == 640 + assert state.last_nonzero_height == 360 + assert hidden.became_hidden is True + assert effective_draw_size(state) == (640, 360) + + +def test_zero_size_does_not_permanently_collapse_map() -> None: + state = create_map_state(tag="size-reappears") + + apply_size_measurement(state, SizeMeasurement(width=500, height=300, visible=True)) + apply_size_measurement(state, SizeMeasurement(width=0, height=0, visible=False)) + update = apply_size_measurement(state, SizeMeasurement(width=700, height=450, visible=True)) + + assert update.became_visible is True + assert update.effective_width == 700 + assert update.effective_height == 450 + assert state.last_nonzero_width == 700 + assert state.last_nonzero_height == 450 + + +def test_resize_marks_size_dirty() -> None: + state = create_map_state(tag="size-dirty") + state.dirty = DirtyFlags.NONE + + apply_size_measurement(state, SizeMeasurement(width=320, height=240, visible=True)) + + assert state.dirty & DirtyFlags.SIZE + assert state.dirty & DirtyFlags.TILES + assert state.dirty & DirtyFlags.OVERLAYS From 563ddd962ba6a1e7b3f6f3b858d468d362ca5e0e Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Fri, 22 May 2026 18:41:42 +0200 Subject: [PATCH 05/11] step 4: add async tiles and persistent cache --- AGENTS.md | 21 +- README.md | 14 +- examples/basic_map.py | 2 + examples/cache_stress.py | 43 +++ src/dpg_map/api.py | 17 +- src/dpg_map/cache.py | 50 ++++ src/dpg_map/renderer.py | 116 ++++++-- src/dpg_map/state.py | 19 +- src/dpg_map/tiles.py | 556 ++++++++++++++++++++++++++++++++++++++- src/dpg_map/widget.py | 1 + tests/test_tiles.py | 87 ++++++ 11 files changed, 880 insertions(+), 46 deletions(-) create mode 100644 examples/cache_stress.py create mode 100644 tests/test_tiles.py diff --git a/AGENTS.md b/AGENTS.md index 6826117..d7ec201 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,17 +2,18 @@ ## Current status -Step 3 complete. +Step 4 complete. ## Completed steps Step 1 - Public API contract and pure core. Step 2 - Thread-safe state, commands, overlays, and cache model. Step 3 - Widget shell, sizing system, and GUI-thread frame pump. +Step 4 - Tile manager, persistent cache, and asynchronous loading. ## Current step -Step 4 - Tile manager, persistent cache, and asynchronous loading. +Step 5 - Interaction: pan, zoom, and view commands. ## Design decisions @@ -45,15 +46,29 @@ None yet. - Implemented GUI-thread renderer frame pump that schedules frame callbacks, drains command queues, measures size, resizes the drawlist, and draws a placeholder background/attribution. - Implemented sizing helpers for measured size, last non-zero size preservation, visibility transitions, effective draw size, and resize dirty flags. - Implemented map interaction hit-rectangle calculation. +- Implemented TileID, TileStatus, Tile, visible tile calculation, and TileManager. +- Implemented asynchronous tile worker queue for disk reads, HTTP fetches, and image decoding. +- Implemented provider-namespaced persistent cache writes, access metadata updates, clearing, and LRU pruning. +- Implemented memory tile cache with visible-tile protection and deferred GUI-thread texture deletion. +- Integrated tile result processing, stale generation/provider rejection, texture creation, and tile drawing into the GUI-thread renderer. +- Added OpenStreetMap User-Agent warning/fallback and configured examples with example user agents. - Added Step 3 examples for basic map, window sizing, child-window sizing, table sizing, and hidden-tab sizing. +- Added Step 4 cache stress example. - Added Step 3 tests for sizing transitions, zero-size preservation, resize dirty flags, command drain ordering, and hit rectangles. +- Added Step 4 tests for visible tile calculation, stale result rejection, protected memory eviction, and tile image decoding. - Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist creation. - Ran `uv run pytest`. - Ran `uv run ruff check .`. - Ran `uv run ruff format .`. - Ran `uv run ruff format --check .`. - Ran `uv run pyright`. +- Ran `uv run pytest`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format .`. +- Ran `uv run ruff format --check .`. +- Ran `uv run pyright`. +- Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist/texture-registry creation. ## Next action -Implement Step 4. +Implement Step 5. diff --git a/README.md b/README.md index 3e444a6..0a6558e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `dpg-map` is a Dear PyGui map widget package under rebuild. -The Step 3 widget shell is in place: +The Step 4 tile manager is in place: ```python import dpg_map as dpgm @@ -28,15 +28,16 @@ Implemented so far: - thread-safe logical map state and map registry - command queue with coalescing for overlay and view updates - logical marker, polyline, trajectory, and layer models -- persistent disk cache path, metadata, scanning, and prune planning +- persistent disk cache paths, metadata, scanning, pruning, and clearing - Dear PyGui `child_window` + measured-size `drawlist` widget shell -- GUI-thread frame pump that drains commands and redraws a placeholder background +- GUI-thread frame pump that drains commands, manages textures, and draws raster tiles - sizing helpers that preserve the last non-zero size across hidden layouts - interaction hit-rectangle calculation for the measured map area +- asynchronous tile workers that read disk cache, fetch HTTP, and decode images +- memory cache with visible-tile protection and GUI-thread texture deletion -Real tile loading and overlay drawing are not implemented yet. Step 4 will add -the asynchronous tile manager, persistent tile loading, and GUI-thread texture -creation. +Overlay drawing is not implemented yet. Step 5 will add pan/zoom interaction and +view command projection. Examples: @@ -46,4 +47,5 @@ uv run python examples/sizing_window.py uv run python examples/sizing_child.py uv run python examples/sizing_table.py uv run python examples/hidden_tab.py +uv run python examples/cache_stress.py ``` diff --git a/examples/basic_map.py b/examples/basic_map.py index 9f76d5c..4d3ef4f 100644 --- a/examples/basic_map.py +++ b/examples/basic_map.py @@ -8,6 +8,8 @@ dpg: Any = _dpg def main() -> None: + dpgm.configure(user_agent="dpg-map basic example") + dpg.create_context() dpg.create_viewport(title="dpg-map basic", width=900, height=600) diff --git a/examples/cache_stress.py b/examples/cache_stress.py new file mode 100644 index 0000000..e1c1c58 --- /dev/null +++ b/examples/cache_stress.py @@ -0,0 +1,43 @@ +from pathlib import Path +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + cache_dir = Path(__file__).resolve().parent / ".tile-cache" + dpgm.configure( + cache_dir=cache_dir, + memory_cache_max_tiles=32, + disk_cache_max_bytes=30_000_000, + prefetch_margin_tiles=1, + user_agent="dpg-map cache_stress example", + ) + + dpg.create_context() + dpg.create_viewport(title="dpg-map cache stress", width=1000, height=700) + + with ( + dpg.window(label="Cache Stress", width=-1, height=-1), + dpgm.map_widget( + tag="cache-map", + center=(47.9029, 1.9093), + zoom=14, + width=-1, + height=-1, + ), + ): + dpgm.add_marker("start", lat=47.9029, lon=1.9093, label="Orleans") + + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/src/dpg_map/api.py b/src/dpg_map/api.py index 3ae097a..05e132c 100644 --- a/src/dpg_map/api.py +++ b/src/dpg_map/api.py @@ -8,7 +8,7 @@ from math import isfinite from pathlib import Path from typing import Any -from .cache import CacheStats, disk_cache_size_bytes +from .cache import CacheStats, clear_disk_cache_path, disk_cache_root, disk_cache_size_bytes from .commands import CommandKind, MapCommand from .exceptions import CoordinateError, OverlayNotFoundError from .overlays import LayerState, MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay @@ -469,14 +469,17 @@ def set_provider(provider: str | TileProvider, *, map_tag: Tag | None = None) -> def clear_memory_cache(*, map_tag: Tag | None = None) -> None: state = get_map_state(map_tag) with state.lock: + mark_dirty(state, DirtyFlags.TILES) _queue(state, CommandKind.CLEAR_MEMORY_CACHE, {}) def clear_disk_cache(*, map_tag: Tag | None = None) -> None: if map_tag is None: + clear_disk_cache_path(get_config().cache_dir) return state = get_map_state(map_tag) with state.lock: + mark_dirty(state, DirtyFlags.TILES) _queue(state, CommandKind.CLEAR_DISK_CACHE, {}) @@ -488,16 +491,21 @@ def get_cache_stats(*, map_tag: Tag | None = None) -> CacheStats: memory_max_tiles=config.memory_cache_max_tiles, disk_bytes=disk_cache_size_bytes(cache_dir), disk_max_bytes=config.disk_cache_max_bytes, - disk_path=cache_dir, + disk_path=disk_cache_root(cache_dir), ) state = get_map_state(map_tag) with state.lock: + tile_snapshot = state.tile_manager.snapshot() return CacheStats( - memory_tiles=0, + memory_tiles=tile_snapshot.memory_tiles, memory_max_tiles=config.memory_cache_max_tiles, + memory_hits=tile_snapshot.memory_hits, + memory_misses=tile_snapshot.memory_misses, disk_bytes=disk_cache_size_bytes(state.cache_dir), disk_max_bytes=config.disk_cache_max_bytes, - disk_path=state.cache_dir, + disk_hits=tile_snapshot.disk_hits, + disk_misses=tile_snapshot.disk_misses, + disk_path=disk_cache_root(state.cache_dir), ) @@ -525,4 +533,5 @@ def get_map_debug_state(*, map_tag: Tag | None = None) -> dict[str, Any]: "pending_command_count": len(state.command_queue), "generation": state.generation, "active_drag": state.interaction.active_drag, + "tiles": asdict(state.tile_manager.snapshot()), } diff --git a/src/dpg_map/cache.py b/src/dpg_map/cache.py index 6455d80..78b291c 100644 --- a/src/dpg_map/cache.py +++ b/src/dpg_map/cache.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import shutil from dataclasses import asdict, dataclass, field from pathlib import Path from time import time @@ -179,6 +180,24 @@ def write_disk_metadata(path: Path, metadata: DiskCacheMetadata) -> None: raise CacheError(f"could not write cache metadata: {path}") from exc +def touch_disk_metadata(path: Path, *, accessed_at: float | None = None) -> None: + """Update only the last access timestamp for a metadata file.""" + + metadata = read_disk_metadata(path) + write_disk_metadata( + path, + DiskCacheMetadata( + url=metadata.url, + etag=metadata.etag, + last_modified=metadata.last_modified, + expires=metadata.expires, + downloaded_at=metadata.downloaded_at, + last_accessed_at=time() if accessed_at is None else accessed_at, + size_bytes=metadata.size_bytes, + ), + ) + + def scan_disk_cache(cache_dir: str | Path | None) -> list[DiskCacheEntry]: """Scan tile files under a disk cache root.""" @@ -237,3 +256,34 @@ def plan_disk_prune( prune.append(entry.tile_path) total -= entry.metadata.size_bytes return prune + + +def prune_disk_cache( + cache_dir: str | Path | None, + max_bytes: int | None, + *, + protected_paths: set[Path] | None = None, +) -> list[Path]: + """Delete LRU tile files until the cache fits the configured limit.""" + + planned = plan_disk_prune(cache_dir, max_bytes, protected_paths=protected_paths) + for path in planned: + metadata_path = tile_metadata_path(path) + try: + path.unlink(missing_ok=True) + metadata_path.unlink(missing_ok=True) + except OSError as exc: + raise CacheError(f"could not prune cached tile: {path}") from exc + return planned + + +def clear_disk_cache_path(cache_dir: str | Path | None) -> None: + """Remove all persistent tile cache files under a cache root.""" + + root = disk_cache_root(cache_dir) + if not root.exists(): + return + try: + shutil.rmtree(root) + except OSError as exc: + raise CacheError(f"could not clear disk cache: {root}") from exc diff --git a/src/dpg_map/renderer.py b/src/dpg_map/renderer.py index e8f5c2c..fd3892a 100644 --- a/src/dpg_map/renderer.py +++ b/src/dpg_map/renderer.py @@ -9,16 +9,16 @@ from .commands import CommandKind, MapCommand from .interaction import HitRect, calculate_hit_rect from .sizing import SizeMeasurement, apply_size_measurement from .state import DirtyFlags, MapState +from .tiles import Tile, VisibleTile class MapRenderer: - """GUI-thread renderer for the Step 3 widget shell.""" + """GUI-thread renderer for the map widget shell and tile layer.""" def __init__(self, state: MapState, dpg: Any) -> None: self.state = state self._dpg = dpg self._background_tag = f"{state.tag}##background" - self._title_tag = f"{state.tag}##placeholder-title" self._attribution_tag = f"{state.tag}##attribution" self.last_drained_commands: tuple[MapCommand, ...] = () self.last_hit_rect: HitRect | None = None @@ -43,7 +43,7 @@ class MapRenderer: self.schedule_next_frame() def render_frame(self) -> None: - """Drain pending commands, refresh size, and draw the placeholder shell.""" + """Drain pending commands, refresh size, process tiles, and redraw.""" commands = drain_renderer_commands(self.state) self.last_drained_commands = tuple(commands) @@ -51,15 +51,47 @@ class MapRenderer: with self.state.lock: dirty = self.state.dirty - should_draw = bool(dirty & (DirtyFlags.FULL | DirtyFlags.SIZE)) + should_draw = bool(dirty & (DirtyFlags.FULL | DirtyFlags.SIZE | DirtyFlags.TILES)) 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 provider_attribution = self.state.provider.attribution + provider = self.state.provider + center = self.state.center + zoom = self.state.zoom + generation = self.state.generation + cache_dir = self.state.cache_dir self.state.dirty = DirtyFlags.NONE - if should_draw and visible: - self._draw_placeholder(width, height, provider_attribution) + accepted_tiles = self.state.tile_manager.drain_results( + generation=generation, + provider_name=provider.name, + ) + self._delete_evicted_textures() + for tile in accepted_tiles: + self._ensure_texture(tile) + + visible_tiles: list[VisibleTile] = [] + if visible and width > 0 and height > 0: + visible_tiles = self.state.tile_manager.request_visible_tiles( + center=center, + zoom=zoom, + width=width, + height=height, + provider=provider, + generation=generation, + cache_dir=cache_dir, + margin=self._prefetch_margin(), + ) + + if visible and (should_draw or accepted_tiles): + self._draw_tile_layer( + visible_tiles=visible_tiles, + width=width, + height=height, + attribution=provider_attribution, + tile_size=provider.tile_size, + ) def _update_size_from_dpg(self) -> None: width, height = self._measure_child_content() @@ -83,7 +115,15 @@ class MapRenderer: return (0, 0) return (max(0, int(width)), max(0, int(height))) - def _draw_placeholder(self, width: int, height: int, attribution: str) -> None: + def _draw_tile_layer( + self, + *, + visible_tiles: list[VisibleTile], + width: int, + height: int, + attribution: str, + tile_size: int, + ) -> None: width = max(1, int(width)) height = max(1, int(height)) self._dpg.delete_item(self.state.drawlist_tag, children_only=True) @@ -95,15 +135,19 @@ class MapRenderer: color=(54, 68, 78, 255), fill=(29, 38, 45, 255), ) - self._dpg.draw_text( - (12, 12), - "dpg-map", - parent=self.state.drawlist_tag, - tag=self._title_tag, - color=(232, 238, 242, 255), - size=18, - ) - label = attribution or "Map tiles load in Step 4" + for visible_tile in visible_tiles: + tile = self.state.tile_manager.get_ready_tile(visible_tile.tile_id) + if tile is None or tile.texture_tag is None: + continue + screen_x = visible_tile.screen_x + screen_y = visible_tile.screen_y + self._dpg.draw_image( + tile.texture_tag, + (screen_x, screen_y), + (screen_x + tile_size, screen_y + tile_size), + parent=self.state.drawlist_tag, + ) + label = attribution or "Map tiles" text_y = max(28, height - 24) self._dpg.draw_text( (12, text_y), @@ -114,9 +158,36 @@ class MapRenderer: size=12, ) + def _ensure_texture(self, tile: Tile) -> None: + if tile.texture_tag is not None: + return + texture_tag = ( + f"{self.state.tag}##tile-{tile.tile_id.provider_name}-" + f"{tile.tile_id.z}-{tile.tile_id.x}-{tile.tile_id.y}" + ) + if not self._dpg.does_item_exist(texture_tag): + self._dpg.add_static_texture( + tile.width, + tile.height, + tile.pixels, + tag=texture_tag, + parent=self.state.texture_registry_tag, + ) + self.state.tile_manager.set_texture_tag(tile.tile_id, texture_tag) + + def _delete_evicted_textures(self) -> None: + for texture_tag in self.state.tile_manager.take_texture_deletions(): + if self._dpg.does_item_exist(texture_tag): + self._dpg.delete_item(texture_tag) + + def _prefetch_margin(self) -> int: + from .state import get_config + + return get_config().prefetch_margin_tiles + def drain_renderer_commands(state: MapState) -> list[MapCommand]: - """Drain and apply GUI-thread command side effects that exist in Step 3.""" + """Drain and apply GUI-thread command side effects.""" commands = state.command_queue.drain() if not commands: @@ -127,6 +198,7 @@ def drain_renderer_commands(state: MapState) -> list[MapCommand]: if command.kind is CommandKind.SET_VIEW: state.dirty |= DirtyFlags.VIEW | DirtyFlags.TILES | DirtyFlags.OVERLAYS elif command.kind is CommandKind.SET_PROVIDER: + state.tile_manager.clear_memory_cache() state.dirty |= DirtyFlags.PROVIDER | DirtyFlags.TILES elif command.kind in { CommandKind.ADD_OVERLAY, @@ -138,11 +210,13 @@ def drain_renderer_commands(state: MapState) -> list[MapCommand]: }: state.dirty |= DirtyFlags.OVERLAYS elif command.kind is CommandKind.CLEAR_MAP: + state.tile_manager.clear_memory_cache() state.dirty |= DirtyFlags.TILES | DirtyFlags.OVERLAYS - elif command.kind in { - CommandKind.CLEAR_MEMORY_CACHE, - CommandKind.CLEAR_DISK_CACHE, - }: + elif command.kind is CommandKind.CLEAR_MEMORY_CACHE: + state.tile_manager.clear_memory_cache() + state.dirty |= DirtyFlags.TILES + elif command.kind is CommandKind.CLEAR_DISK_CACHE: + state.tile_manager.clear_disk_cache(state.cache_dir) state.dirty |= DirtyFlags.TILES return commands diff --git a/src/dpg_map/state.py b/src/dpg_map/state.py index 9de41c0..da5a6fa 100644 --- a/src/dpg_map/state.py +++ b/src/dpg_map/state.py @@ -14,6 +14,7 @@ from .commands import MapCommandQueue from .exceptions import InvalidProviderError, MapNotFoundError from .overlays import LayerState, Overlay from .providers import TileProvider, get_default_provider, get_provider +from .tiles import TileManager from .types import LatLon, Tag @@ -44,16 +45,6 @@ class DpgMapConfig: debug: bool = False -@dataclass(slots=True) -class TileManagerState: - """Logical tile/cache counters until the tile manager is implemented.""" - - queued_tiles: int = 0 - loading_tiles: int = 0 - failed_tiles: int = 0 - visible_tile_count: int = 0 - - @dataclass(slots=True) class InteractionState: """Logical interaction state until GUI interaction is implemented.""" @@ -103,7 +94,7 @@ class MapState: overlays: dict[Tag, Overlay] = field(default_factory=dict) layers: dict[str, LayerState] = field(default_factory=default_layers) command_queue: MapCommandQueue = field(default_factory=MapCommandQueue) - tile_manager: TileManagerState = field(default_factory=TileManagerState) + tile_manager: TileManager = field(default_factory=TileManager) renderer: object | None = None interaction: InteractionState = field(default_factory=InteractionState) lock: RLock = field(default_factory=RLock) @@ -238,6 +229,12 @@ def create_map_state( cache_dir=resolved_cache_dir, user_agent=user_agent if user_agent is not None else config.user_agent, ) + state.tile_manager = TileManager( + memory_cache_max_tiles=config.memory_cache_max_tiles, + disk_cache_max_bytes=config.disk_cache_max_bytes, + worker_count=config.tile_worker_count, + user_agent=state.user_agent, + ) with _maps_lock: _maps[map_tag] = state return state diff --git a/src/dpg_map/tiles.py b/src/dpg_map/tiles.py index 4db1a8a..2dcd307 100644 --- a/src/dpg_map/tiles.py +++ b/src/dpg_map/tiles.py @@ -1 +1,555 @@ -"""Tile identity, lifecycle, and worker coordination.""" +"""Tile identity, lifecycle, cache, and worker coordination.""" + +from __future__ import annotations + +import warnings +from dataclasses import dataclass, field +from enum import Enum +from io import BytesIO +from pathlib import Path +from queue import Empty, Queue +from threading import Lock, Thread +from time import time +from typing import Literal, cast + +import requests +from PIL import Image, UnidentifiedImageError + +from .cache import ( + DiskCacheMetadata, + MemoryCacheEntry, + MemoryCacheModel, + clear_disk_cache_path, + prune_disk_cache, + tile_cache_path, + tile_metadata_path, + touch_disk_metadata, + write_disk_metadata, +) +from .exceptions import CacheError +from .projection import latlon_to_world, map_size +from .providers import TileProvider +from .types import LatLon + + +class TileStatus(Enum): + """Lifecycle status for a tile.""" + + QUEUED = "queued" + LOADING = "loading" + READY = "ready" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class TileID: + """Provider-namespaced XYZ tile identity.""" + + provider_name: str + z: int + x: int + y: int + + +@dataclass(slots=True) +class Tile: + """Decoded tile data plus GUI-thread texture metadata.""" + + tile_id: TileID + status: TileStatus + generation: int + width: int = 0 + height: int = 0 + pixels: tuple[float, ...] = () + texture_tag: object | None = None + source: Literal["memory", "disk", "network"] | None = None + error: str | None = None + last_accessed_at: float = field(default_factory=time) + + +@dataclass(frozen=True, slots=True) +class VisibleTile: + """A visible tile and its screen-space top-left point.""" + + tile_id: TileID + screen_x: float + screen_y: float + + +@dataclass(frozen=True, slots=True) +class TileRequest: + """Worker-thread tile request.""" + + tile_id: TileID + generation: int + url: str + path: Path + headers: dict[str, str] + disk_cache_max_bytes: int | None + protected_paths: frozenset[Path] = frozenset() + + +@dataclass(frozen=True, slots=True) +class TileResult: + """Worker-thread tile result consumed by the GUI thread.""" + + tile_id: TileID + generation: int + status: TileStatus + width: int = 0 + height: int = 0 + pixels: tuple[float, ...] = () + source: Literal["disk", "network"] | None = None + error: str | None = None + + +@dataclass(frozen=True, slots=True) +class TileManagerSnapshot: + """Thread-safe counters for diagnostics.""" + + queued_tiles: int + loading_tiles: int + failed_tiles: int + visible_tile_count: int + memory_tiles: int + memory_hits: int + memory_misses: int + disk_hits: int + disk_misses: int + stale_results: int + + +def calculate_visible_tiles( + *, + center: LatLon, + zoom: int, + width: int, + height: int, + provider: TileProvider, + margin: int = 0, +) -> list[VisibleTile]: + """Return visible XYZ tiles plus a margin, with wrapped X and clamped Y.""" + + if width <= 0 or height <= 0: + return [] + tile_size = provider.tile_size + center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size) + left = center_x - width / 2.0 + top = center_y - height / 2.0 + right = center_x + width / 2.0 + bottom = center_y + height / 2.0 + + max_tile = (2**zoom) - 1 + start_x = int(left // tile_size) - margin + end_x = int(right // tile_size) + margin + start_y = max(0, int(top // tile_size) - margin) + end_y = min(max_tile, int(bottom // tile_size) + margin) + world_size = map_size(zoom, tile_size) + + tiles: list[VisibleTile] = [] + seen: set[TileID] = set() + for y in range(start_y, end_y + 1): + for raw_x in range(start_x, end_x + 1): + x = raw_x % (max_tile + 1) + tile_id = TileID(provider.name, zoom, x, y) + if tile_id in seen: + continue + seen.add(tile_id) + tile_left = raw_x * tile_size + if tile_left < -tile_size: + tile_left += world_size + screen_x = tile_left - left + screen_y = y * tile_size - top + tiles.append(VisibleTile(tile_id, screen_x, screen_y)) + return tiles + + +class TileManager: + """Asynchronous tile loader with memory and persistent disk caches.""" + + def __init__( + self, + *, + memory_cache_max_tiles: int = 512, + disk_cache_max_bytes: int | None = 2_000_000_000, + worker_count: int = 4, + user_agent: str | None = None, + ) -> None: + self.memory = MemoryCacheModel(max_tiles=memory_cache_max_tiles) + self.disk_cache_max_bytes = disk_cache_max_bytes + self.worker_count = worker_count + self.user_agent = user_agent + self._tiles: dict[TileID, Tile] = {} + self._visible_tile_ids: set[TileID] = set() + self._queued: set[TileID] = set() + self._loading: set[TileID] = set() + self._failed: set[TileID] = set() + self._request_queue: Queue[TileRequest | None] = Queue() + self._result_queue: Queue[TileResult] = Queue() + self._threads: list[Thread] = [] + self._lock = Lock() + self._delete_texture_tags: list[object] = [] + self._disk_hits = 0 + self._disk_misses = 0 + self._stale_results = 0 + self._warned_osm_user_agent = False + + def start(self) -> None: + """Start worker threads once.""" + + with self._lock: + if self._threads: + return + count = max(1, self.worker_count) + for index in range(count): + thread = Thread( + target=self._worker_loop, + name=f"dpg-map-tile-worker-{index + 1}", + daemon=True, + ) + self._threads.append(thread) + thread.start() + + def stop(self) -> None: + """Ask workers to exit.""" + + with self._lock: + threads = list(self._threads) + self._threads.clear() + for _ in threads: + self._request_queue.put(None) + + def request_visible_tiles( + self, + *, + center: LatLon, + zoom: int, + width: int, + height: int, + provider: TileProvider, + generation: int, + cache_dir: str | Path | None, + margin: int, + ) -> list[VisibleTile]: + """Queue missing visible tiles and return their screen positions.""" + + visible = calculate_visible_tiles( + center=center, + zoom=zoom, + width=width, + height=height, + provider=provider, + margin=margin, + ) + visible_ids = {tile.tile_id for tile in visible} + with self._lock: + self._visible_tile_ids = visible_ids + for entry in self.memory.entries.values(): + entry.protected = entry.tile_id in visible_ids + + self.start() + for visible_tile in visible: + self._queue_tile( + visible_tile.tile_id, + provider=provider, + generation=generation, + cache_dir=cache_dir, + ) + return visible + + def _queue_tile( + self, + tile_id: TileID, + *, + provider: TileProvider, + generation: int, + cache_dir: str | Path | None, + ) -> None: + with self._lock: + tile = self._tiles.get(tile_id) + if tile is not None and tile.status is TileStatus.READY: + self.memory.record_access(tile_id) + tile.last_accessed_at = time() + return + entry = self.memory.record_access(tile_id) + if entry is not None: + return + if tile_id in self._queued or tile_id in self._loading: + return + self._queued.add(tile_id) + self._tiles[tile_id] = Tile(tile_id, TileStatus.QUEUED, generation=generation) + + headers = dict(provider.headers) + if provider.name == "osm": + headers = self._headers_with_osm_user_agent(headers) + path = tile_cache_path( + cache_dir, + provider.name, + tile_id.z, + tile_id.x, + tile_id.y, + provider.file_extension or "png", + ) + request = TileRequest( + tile_id=tile_id, + generation=generation, + url=provider.build_url(x=tile_id.x, y=tile_id.y, z=tile_id.z), + path=path, + headers=headers, + disk_cache_max_bytes=self.disk_cache_max_bytes, + protected_paths=frozenset(path for path in self._visible_disk_paths(cache_dir)), + ) + self._request_queue.put(request) + + def _headers_with_osm_user_agent(self, headers: dict[str, str]) -> dict[str, str]: + if any(key.lower() == "user-agent" for key in headers): + return headers + if self.user_agent: + headers["User-Agent"] = self.user_agent + return headers + with self._lock: + should_warn = not self._warned_osm_user_agent + self._warned_osm_user_agent = True + if should_warn: + warnings.warn( + "OpenStreetMap tile usage should configure an application-specific user_agent", + RuntimeWarning, + stacklevel=3, + ) + headers["User-Agent"] = "dpg-map/0.1" + return headers + + def _visible_disk_paths(self, cache_dir: str | Path | None) -> list[Path]: + paths: list[Path] = [] + with self._lock: + visible = tuple(self._visible_tile_ids) + for tile_id in visible: + paths.append( + tile_cache_path( + cache_dir, + tile_id.provider_name, + tile_id.z, + tile_id.x, + tile_id.y, + ) + ) + return paths + + def get_ready_tile(self, tile_id: TileID) -> Tile | None: + """Return a ready tile, updating memory LRU metadata.""" + + with self._lock: + tile = self._tiles.get(tile_id) + if tile is None or tile.status is not TileStatus.READY: + return None + tile.last_accessed_at = time() + self.memory.record_access(tile_id) + return tile + + def drain_results(self, *, generation: int, provider_name: str) -> list[Tile]: + """Accept current-generation results and return ready tiles.""" + + accepted: list[Tile] = [] + while True: + try: + result = self._result_queue.get_nowait() + except Empty: + break + with self._lock: + self._queued.discard(result.tile_id) + self._loading.discard(result.tile_id) + if result.generation != generation or result.tile_id.provider_name != provider_name: + self._stale_results += 1 + continue + if result.status is TileStatus.FAILED: + self._failed.add(result.tile_id) + self._tiles[result.tile_id] = Tile( + result.tile_id, + TileStatus.FAILED, + generation=result.generation, + error=result.error, + ) + continue + tile = Tile( + result.tile_id, + TileStatus.READY, + generation=result.generation, + width=result.width, + height=result.height, + pixels=result.pixels, + source=result.source, + ) + self._tiles[result.tile_id] = tile + self.memory.put( + MemoryCacheEntry( + tile_id=result.tile_id, + size_bytes=len(result.pixels) * 4, + protected=result.tile_id in self._visible_tile_ids, + texture_tag=None, + ) + ) + accepted.append(tile) + self._evict_memory_if_needed() + return accepted + + def set_texture_tag(self, tile_id: TileID, texture_tag: object) -> None: + """Record a GUI-thread texture tag for a ready tile.""" + + with self._lock: + tile = self._tiles.get(tile_id) + if tile is not None: + tile.texture_tag = texture_tag + entry = self.memory.entries.get(tile_id) + if entry is not None: + entry.texture_tag = texture_tag + + def take_texture_deletions(self) -> list[object]: + """Return texture tags that must be deleted by the GUI thread.""" + + with self._lock: + tags = list(self._delete_texture_tags) + self._delete_texture_tags.clear() + return tags + + def clear_memory_cache(self) -> list[object]: + """Clear decoded memory tiles and return texture tags for GUI deletion.""" + + with self._lock: + tags = [ + entry.texture_tag + for entry in self.memory.entries.values() + if entry.texture_tag is not None + ] + self._delete_texture_tags.extend(tags) + self.memory.entries.clear() + self._tiles.clear() + self._queued.clear() + self._loading.clear() + self._failed.clear() + return tags + + def clear_disk_cache(self, cache_dir: str | Path | None) -> None: + """Clear the persistent cache root.""" + + clear_disk_cache_path(cache_dir) + + def snapshot(self) -> TileManagerSnapshot: + """Return diagnostic counters.""" + + with self._lock: + return TileManagerSnapshot( + queued_tiles=len(self._queued), + loading_tiles=len(self._loading), + failed_tiles=len(self._failed), + visible_tile_count=len(self._visible_tile_ids), + memory_tiles=len(self.memory.entries), + memory_hits=self.memory.hits, + memory_misses=self.memory.misses, + disk_hits=self._disk_hits, + disk_misses=self._disk_misses, + stale_results=self._stale_results, + ) + + def _evict_memory_if_needed(self) -> None: + with self._lock: + evict_ids = self.memory.plan_evictions() + for raw_tile_id in evict_ids: + tile_id = cast(TileID, raw_tile_id) + entry = self.memory.entries.pop(tile_id, None) + tile = self._tiles.pop(tile_id, None) + texture_tag = None + if entry is not None: + texture_tag = entry.texture_tag + if texture_tag is None and tile is not None: + texture_tag = tile.texture_tag + if texture_tag is not None: + self._delete_texture_tags.append(texture_tag) + + def _worker_loop(self) -> None: + while True: + request = self._request_queue.get() + if request is None: + return + with self._lock: + self._queued.discard(request.tile_id) + self._loading.add(request.tile_id) + tile = self._tiles.get(request.tile_id) + if tile is not None: + tile.status = TileStatus.LOADING + result = self._load_tile(request) + self._result_queue.put(result) + + def _load_tile(self, request: TileRequest) -> TileResult: + try: + raw, source = self._read_or_fetch(request) + width, height, pixels = decode_tile_image(raw) + return TileResult( + request.tile_id, + request.generation, + TileStatus.READY, + width=width, + height=height, + pixels=pixels, + source=source, + ) + except Exception as exc: + return TileResult( + request.tile_id, + request.generation, + TileStatus.FAILED, + error=str(exc), + ) + + def _read_or_fetch(self, request: TileRequest) -> tuple[bytes, Literal["disk", "network"]]: + if request.path.exists(): + try: + raw = request.path.read_bytes() + touch_disk_metadata(tile_metadata_path(request.path)) + except (OSError, CacheError): + raw = b"" + if raw: + with self._lock: + self._disk_hits += 1 + return raw, "disk" + + with self._lock: + self._disk_misses += 1 + response = requests.get(request.url, headers=request.headers, timeout=20) + response.raise_for_status() + raw = response.content + downloaded_at = time() + try: + request.path.parent.mkdir(parents=True, exist_ok=True) + request.path.write_bytes(raw) + write_disk_metadata( + tile_metadata_path(request.path), + DiskCacheMetadata( + url=request.url, + etag=response.headers.get("ETag"), + last_modified=response.headers.get("Last-Modified"), + expires=response.headers.get("Expires"), + downloaded_at=downloaded_at, + last_accessed_at=downloaded_at, + size_bytes=len(raw), + ), + ) + prune_disk_cache( + request.path.parents[3], + request.disk_cache_max_bytes, + protected_paths=set(request.protected_paths), + ) + except (OSError, CacheError): + pass + return raw, "network" + + +def decode_tile_image(raw: bytes) -> tuple[int, int, tuple[float, ...]]: + """Decode an image into Dear PyGui-compatible RGBA float data.""" + + try: + image = Image.open(BytesIO(raw)).convert("RGBA") + except UnidentifiedImageError as exc: + raise ValueError("tile image data could not be decoded") from exc + width, height = image.size + pixels = tuple(channel / 255.0 for channel in image.tobytes()) + return width, height, pixels diff --git a/src/dpg_map/widget.py b/src/dpg_map/widget.py index 60acc92..870a9f7 100644 --- a/src/dpg_map/widget.py +++ b/src/dpg_map/widget.py @@ -61,6 +61,7 @@ def map_widget( autosize_y=autosize_y, **child_kwargs, ) + dpg.add_texture_registry(tag=state.texture_registry_tag) dpg.add_drawlist(1, 1, tag=state.drawlist_tag, parent=state.child_window_tag) renderer = MapRenderer(state, dpg) diff --git a/tests/test_tiles.py b/tests/test_tiles.py new file mode 100644 index 0000000..3471856 --- /dev/null +++ b/tests/test_tiles.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from io import BytesIO + +from PIL import Image + +from dpg_map.providers import OSM +from dpg_map.tiles import ( + TileID, + TileManager, + TileResult, + TileStatus, + calculate_visible_tiles, + decode_tile_image, +) + + +def test_visible_tile_calculation_uses_provider_namespace() -> None: + tiles = calculate_visible_tiles( + center=(0.0, 0.0), + zoom=2, + width=256, + height=256, + provider=OSM, + margin=0, + ) + + assert tiles + assert {tile.tile_id.provider_name for tile in tiles} == {"osm"} + assert all(0 <= tile.tile_id.x <= 3 for tile in tiles) + assert all(0 <= tile.tile_id.y <= 3 for tile in tiles) + + +def test_tile_manager_ignores_stale_generation_results() -> None: + manager = TileManager() + stale = TileResult( + TileID("osm", 1, 0, 0), + generation=1, + status=TileStatus.READY, + width=1, + height=1, + pixels=(1.0, 1.0, 1.0, 1.0), + source="disk", + ) + manager._result_queue.put(stale) + + accepted = manager.drain_results(generation=2, provider_name="osm") + + assert accepted == [] + assert manager.snapshot().stale_results == 1 + + +def test_memory_eviction_protects_visible_tiles() -> None: + manager = TileManager(memory_cache_max_tiles=1) + protected = TileID("osm", 1, 0, 0) + evictable = TileID("osm", 1, 0, 1) + with manager._lock: + manager._visible_tile_ids = {protected} + + for tile_id in (protected, evictable): + manager._result_queue.put( + TileResult( + tile_id, + generation=1, + status=TileStatus.READY, + width=1, + height=1, + pixels=(1.0, 1.0, 1.0, 1.0), + source="disk", + ) + ) + + manager.drain_results(generation=1, provider_name="osm") + + assert manager.get_ready_tile(protected) is not None + assert manager.get_ready_tile(evictable) is None + + +def test_decode_png_tile_image() -> None: + image = Image.new("RGBA", (1, 1), (255, 0, 128, 255)) + buffer = BytesIO() + image.save(buffer, format="PNG") + + width, height, pixels = decode_tile_image(buffer.getvalue()) + + assert (width, height) == (1, 1) + assert pixels == (1.0, 0.0, 128 / 255, 1.0) From 2d6242bd3f32e447342a72e352e6700b811b70d0 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Sat, 23 May 2026 10:19:13 +0200 Subject: [PATCH 06/11] step 5: add stable pan zoom and view commands --- AGENTS.md | 19 +++- README.md | 9 +- pyproject.toml | 2 + src/dpg_map/api.py | 34 ++++++-- src/dpg_map/interaction.py | 174 ++++++++++++++++++++++++++++++++++++- src/dpg_map/renderer.py | 4 +- src/dpg_map/state.py | 2 +- src/dpg_map/widget.py | 56 ++++++++++++ tests/test_interaction.py | 80 ++++++++++++++++- 9 files changed, 362 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d7ec201..00aa78e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Current status -Step 4 complete. +Step 5 complete. ## Completed steps @@ -10,10 +10,11 @@ Step 1 - Public API contract and pure core. Step 2 - Thread-safe state, commands, overlays, and cache model. Step 3 - Widget shell, sizing system, and GUI-thread frame pump. Step 4 - Tile manager, persistent cache, and asynchronous loading. +Step 5 - Interaction: pan, zoom, and view commands. ## Current step -Step 5 - Interaction: pan, zoom, and view commands. +Step 6 - Overlay rendering and runtime update stress tests. ## Design decisions @@ -68,7 +69,19 @@ None yet. - Ran `uv run ruff format --check .`. - Ran `uv run pyright`. - Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist/texture-registry creation. +- Implemented left mouse drag panning using the measured drawlist rectangle. +- Implemented mouse wheel zoom around the cursor. +- Implemented projection-backed `screen_to_latlon`, `latlon_to_screen`, and zoom-fitting `fit_bounds`. +- Attached Dear PyGui mouse handlers through the map handler registry. +- Added interaction debug state for active drag and last mouse position. +- Added Step 5 tests for pan, cursor zoom, view conversion, bounds fitting, and overlay/view isolation. +- Added Pyright virtualenv settings so `uv run pyright` resolves installed dependencies. +- Ran `uv run pytest`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format --check .`. +- Ran `uv run pyright`. +- Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist/texture-registry/handler-registry creation. ## Next action -Implement Step 5. +Implement Step 6. diff --git a/README.md b/README.md index 0a6558e..dbab9fd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `dpg-map` is a Dear PyGui map widget package under rebuild. -The Step 4 tile manager is in place: +The Step 5 interaction layer is in place: ```python import dpg_map as dpgm @@ -32,12 +32,13 @@ Implemented so far: - Dear PyGui `child_window` + measured-size `drawlist` widget shell - GUI-thread frame pump that drains commands, manages textures, and draws raster tiles - sizing helpers that preserve the last non-zero size across hidden layouts -- interaction hit-rectangle calculation for the measured map area - asynchronous tile workers that read disk cache, fetch HTTP, and decode images - memory cache with visible-tile protection and GUI-thread texture deletion +- measured-rectangle mouse interaction for left-drag panning and wheel zooming +- programmatic view commands and screen/latitude-longitude conversion helpers -Overlay drawing is not implemented yet. Step 5 will add pan/zoom interaction and -view command projection. +Overlay drawing is not implemented yet. Step 6 will add overlay rendering and +live update stress examples. Examples: diff --git a/pyproject.toml b/pyproject.toml index 039d6ee..594434e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,3 +37,5 @@ select = ["E", "F", "I", "UP", "B", "SIM"] [tool.pyright] typeCheckingMode = "basic" +venvPath = "." +venv = ".venv" diff --git a/src/dpg_map/api.py b/src/dpg_map/api.py index 05e132c..000faa4 100644 --- a/src/dpg_map/api.py +++ b/src/dpg_map/api.py @@ -11,8 +11,11 @@ from typing import Any from .cache import CacheStats, clear_disk_cache_path, disk_cache_root, disk_cache_size_bytes from .commands import CommandKind, MapCommand from .exceptions import CoordinateError, OverlayNotFoundError +from .interaction import latlon_to_screen_in_state, screen_to_latlon_in_state from .overlays import LayerState, MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay +from .projection import latlon_to_world from .providers import TileProvider, get_provider +from .sizing import effective_draw_size from .state import ( DirtyFlags, configure_state, @@ -145,19 +148,37 @@ def fit_bounds(bounds: Bounds, *, map_tag: Tag | None = None) -> None: (south_west, north_east) = bounds south, west = _validate_latlon(south_west[0], south_west[1]) north, east = _validate_latlon(north_east[0], north_east[1]) - set_center((south + north) / 2.0, (west + east) / 2.0, map_tag=map_tag) + south, north = min(south, north), max(south, north) + state = get_map_state(map_tag) + with state.lock: + width, height = effective_draw_size(state) + padding = 32 + usable_width = max(1, width - padding * 2) + usable_height = max(1, height - padding * 2) + tile_size = state.provider.tile_size + target_zoom = state.min_zoom + for candidate_zoom in range(state.max_zoom, state.min_zoom - 1, -1): + west_x, north_y = latlon_to_world(north, west, candidate_zoom, tile_size) + east_x, south_y = latlon_to_world(south, east, candidate_zoom, tile_size) + world_size = tile_size * (2**candidate_zoom) + x_span = abs(east_x - west_x) + x_span = min(x_span, world_size - x_span) + y_span = abs(south_y - north_y) + if x_span <= usable_width and y_span <= usable_height: + target_zoom = candidate_zoom + break + set_view(center=((south + north) / 2.0, (west + east) / 2.0), zoom=target_zoom, map_tag=map_tag) def screen_to_latlon(x: float, y: float, *, map_tag: Tag | None = None) -> LatLon: - _ = (x, y) - return get_center(map_tag=map_tag) + state = get_map_state(map_tag) + return screen_to_latlon_in_state(state, float(x), float(y)) def latlon_to_screen(lat: float, lon: float, *, map_tag: Tag | None = None) -> Point: - _validate_latlon(lat, lon) + lat_value, lon_value = _validate_latlon(lat, lon) state = get_map_state(map_tag) - with state.lock: - return (state.measured_width / 2.0, state.measured_height / 2.0) + return latlon_to_screen_in_state(state, lat_value, lon_value) def add_marker( @@ -533,5 +554,6 @@ def get_map_debug_state(*, map_tag: Tag | None = None) -> dict[str, Any]: "pending_command_count": len(state.command_queue), "generation": state.generation, "active_drag": state.interaction.active_drag, + "last_mouse_position": state.interaction.last_mouse_position, "tiles": asdict(state.tile_manager.snapshot()), } diff --git a/src/dpg_map/interaction.py b/src/dpg_map/interaction.py index 59197b2..bdffb17 100644 --- a/src/dpg_map/interaction.py +++ b/src/dpg_map/interaction.py @@ -3,9 +3,14 @@ from __future__ import annotations from dataclasses import dataclass +from math import isfinite +from typing import Any +from .commands import CommandKind, MapCommand +from .projection import latlon_to_world, screen_to_world, world_to_latlon from .sizing import effective_draw_size -from .state import MapState +from .state import DirtyFlags, MapState, mark_dirty +from .types import LatLon, Point @dataclass(frozen=True, slots=True) @@ -34,3 +39,170 @@ def calculate_hit_rect(state: MapState, drawlist_pos: tuple[float, float]) -> Hi width, height = effective_draw_size(state) return HitRect(float(drawlist_pos[0]), float(drawlist_pos[1]), float(width), float(height)) + + +def screen_to_latlon_in_state(state: MapState, x: float, y: float) -> LatLon: + """Convert map-local screen coordinates to latitude/longitude.""" + + with state.lock: + width, height = effective_draw_size(state) + center = state.center + zoom = state.zoom + tile_size = state.provider.tile_size + world_x, world_y = screen_to_world( + float(x), + float(y), + center=center, + zoom=zoom, + width=width, + height=height, + tile_size=tile_size, + ) + return world_to_latlon(world_x, world_y, zoom, tile_size) + + +def latlon_to_screen_in_state(state: MapState, lat: float, lon: float) -> Point: + """Convert latitude/longitude to map-local screen coordinates.""" + + with state.lock: + width, height = effective_draw_size(state) + center = state.center + zoom = state.zoom + tile_size = state.provider.tile_size + world_x, world_y = latlon_to_world(lat, lon, zoom, tile_size) + center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size) + return (world_x - center_x + width / 2.0, world_y - center_y + height / 2.0) + + +def pan_state_by_pixels(state: MapState, dx: float, dy: float) -> LatLon: + """Pan the map by a mouse drag delta in screen pixels.""" + + with state.lock: + if dx == 0 and dy == 0: + return state.center + center = state.center + zoom = state.zoom + tile_size = state.provider.tile_size + world_size = tile_size * (2**zoom) + center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size) + new_x = (center_x - dx) % world_size + new_y = min(max(center_y - dy, 0.0), float(world_size)) + state.center = world_to_latlon(new_x, new_y, zoom, tile_size) + mark_dirty(state, DirtyFlags.VIEW | DirtyFlags.TILES | DirtyFlags.OVERLAYS) + state.command_queue.put( + MapCommand( + kind=CommandKind.SET_VIEW, + map_tag=state.tag, + payload={"center": state.center}, + ) + ) + return state.center + + +def zoom_state_at_screen_point( + state: MapState, + *, + screen_x: float, + screen_y: float, + delta: float, +) -> int: + """Zoom the map around a map-local screen point where possible.""" + + if delta == 0 or not isfinite(delta): + with state.lock: + return state.zoom + + with state.lock: + old_zoom = state.zoom + new_zoom = max(state.min_zoom, min(state.max_zoom, old_zoom + (1 if delta > 0 else -1))) + if new_zoom == old_zoom: + return old_zoom + + width, height = effective_draw_size(state) + tile_size = state.provider.tile_size + anchor_latlon = screen_to_latlon_in_state(state, screen_x, screen_y) + anchor_x, anchor_y = latlon_to_world( + anchor_latlon[0], + anchor_latlon[1], + new_zoom, + tile_size, + ) + world_size = tile_size * (2**new_zoom) + center_x = (anchor_x - (screen_x - width / 2.0)) % world_size + center_y = min(max(anchor_y - (screen_y - height / 2.0), 0.0), float(world_size)) + + state.zoom = new_zoom + state.center = world_to_latlon(center_x, center_y, new_zoom, tile_size) + mark_dirty(state, DirtyFlags.VIEW | DirtyFlags.TILES | DirtyFlags.OVERLAYS) + state.command_queue.put( + MapCommand( + kind=CommandKind.SET_VIEW, + map_tag=state.tag, + payload={"center": state.center, "zoom": state.zoom}, + ) + ) + return state.zoom + + +def handle_mouse_down(state: MapState, mouse_pos: tuple[float, float], hit_rect: HitRect) -> None: + """Begin a drag if the left mouse button starts inside the map rectangle.""" + + with state.lock: + if not state.is_visible or not hit_rect.contains(mouse_pos[0], mouse_pos[1]): + state.interaction.active_drag = False + state.interaction.last_mouse_position = None + return + state.interaction.active_drag = True + state.interaction.last_mouse_position = mouse_pos + + +def handle_mouse_drag(state: MapState, mouse_pos: tuple[float, float]) -> None: + """Update center from a mouse drag event.""" + + with state.lock: + if not state.interaction.active_drag: + return + last_pos = state.interaction.last_mouse_position + state.interaction.last_mouse_position = mouse_pos + if last_pos is None: + return + pan_state_by_pixels(state, mouse_pos[0] - last_pos[0], mouse_pos[1] - last_pos[1]) + + +def handle_mouse_release(state: MapState) -> None: + """End any active drag.""" + + with state.lock: + state.interaction.active_drag = False + state.interaction.last_mouse_position = None + + +def handle_mouse_wheel( + state: MapState, + *, + mouse_pos: tuple[float, float], + wheel_delta: float, + hit_rect: HitRect, +) -> None: + """Apply wheel zoom when the cursor is over the concrete map rectangle.""" + + if not hit_rect.contains(mouse_pos[0], mouse_pos[1]): + return + zoom_state_at_screen_point( + state, + screen_x=mouse_pos[0] - hit_rect.x, + screen_y=mouse_pos[1] - hit_rect.y, + delta=wheel_delta, + ) + + +def wheel_delta_from_app_data(app_data: Any) -> float: + """Normalize Dear PyGui mouse wheel callback data.""" + + if isinstance(app_data, int | float): + return float(app_data) + if isinstance(app_data, (list, tuple)) and app_data: + value = app_data[-1] + if isinstance(value, int | float): + return float(value) + return 0.0 diff --git a/src/dpg_map/renderer.py b/src/dpg_map/renderer.py index fd3892a..c16c815 100644 --- a/src/dpg_map/renderer.py +++ b/src/dpg_map/renderer.py @@ -104,7 +104,9 @@ class MapRenderer: draw_width = update.effective_width draw_height = update.effective_height self._dpg.configure_item(self.state.drawlist_tag, width=draw_width, height=draw_height) - draw_pos = tuple(float(value) for value in self._dpg.get_item_pos(self.state.drawlist_tag)) + draw_pos = tuple( + float(value) for value in self._dpg.get_item_rect_min(self.state.drawlist_tag) + ) with self.state.lock: self.last_hit_rect = calculate_hit_rect(self.state, (draw_pos[0], draw_pos[1])) diff --git a/src/dpg_map/state.py b/src/dpg_map/state.py index da5a6fa..0fa5b3c 100644 --- a/src/dpg_map/state.py +++ b/src/dpg_map/state.py @@ -47,7 +47,7 @@ class DpgMapConfig: @dataclass(slots=True) class InteractionState: - """Logical interaction state until GUI interaction is implemented.""" + """Logical mouse interaction state.""" active_drag: bool = False last_mouse_position: tuple[float, float] | None = None diff --git a/src/dpg_map/widget.py b/src/dpg_map/widget.py index 870a9f7..3767dbf 100644 --- a/src/dpg_map/widget.py +++ b/src/dpg_map/widget.py @@ -7,6 +7,14 @@ from contextlib import contextmanager from pathlib import Path from typing import Any +from .interaction import ( + calculate_hit_rect, + handle_mouse_down, + handle_mouse_drag, + handle_mouse_release, + handle_mouse_wheel, + wheel_delta_from_app_data, +) from .providers import TileProvider from .renderer import MapRenderer from .state import create_map_state, current_map_context @@ -63,6 +71,54 @@ def map_widget( ) dpg.add_texture_registry(tag=state.texture_registry_tag) dpg.add_drawlist(1, 1, tag=state.drawlist_tag, parent=state.child_window_tag) + dpg.add_handler_registry(tag=state.handler_registry_tag) + + def _mouse_pos() -> tuple[float, float]: + pos = dpg.get_mouse_pos(local=False) + return (float(pos[0]), float(pos[1])) + + def _hit_rect() -> Any: + draw_pos = tuple(float(value) for value in dpg.get_item_rect_min(state.drawlist_tag)) + return calculate_hit_rect(state, (draw_pos[0], draw_pos[1])) + + def _on_mouse_down(sender: Any, app_data: Any, user_data: Any) -> None: + _ = (sender, app_data, user_data) + handle_mouse_down(state, _mouse_pos(), _hit_rect()) + + def _on_mouse_drag(sender: Any, app_data: Any, user_data: Any) -> None: + _ = (sender, app_data, user_data) + handle_mouse_drag(state, _mouse_pos()) + + def _on_mouse_release(sender: Any, app_data: Any, user_data: Any) -> None: + _ = (sender, app_data, user_data) + handle_mouse_release(state) + + def _on_mouse_wheel(sender: Any, app_data: Any, user_data: Any) -> None: + _ = (sender, user_data) + handle_mouse_wheel( + state, + mouse_pos=_mouse_pos(), + wheel_delta=wheel_delta_from_app_data(app_data), + hit_rect=_hit_rect(), + ) + + dpg.add_mouse_down_handler( + button=dpg.mvMouseButton_Left, + callback=_on_mouse_down, + parent=state.handler_registry_tag, + ) + dpg.add_mouse_drag_handler( + button=dpg.mvMouseButton_Left, + threshold=0.0, + callback=_on_mouse_drag, + parent=state.handler_registry_tag, + ) + dpg.add_mouse_release_handler( + button=dpg.mvMouseButton_Left, + callback=_on_mouse_release, + parent=state.handler_registry_tag, + ) + dpg.add_mouse_wheel_handler(callback=_on_mouse_wheel, parent=state.handler_registry_tag) renderer = MapRenderer(state, dpg) with state.lock: diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 465b01e..2376ca4 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -1,8 +1,19 @@ from __future__ import annotations -from dpg_map.interaction import calculate_hit_rect +import pytest + +import dpg_map as dpgm +from dpg_map.commands import CommandKind +from dpg_map.interaction import ( + calculate_hit_rect, + handle_mouse_down, + handle_mouse_drag, + handle_mouse_release, + handle_mouse_wheel, + pan_state_by_pixels, +) from dpg_map.sizing import SizeMeasurement, apply_size_measurement -from dpg_map.state import create_map_state +from dpg_map.state import DirtyFlags, create_map_state, get_map_state def test_hit_rect_uses_effective_map_size() -> None: @@ -17,3 +28,68 @@ def test_hit_rect_uses_effective_map_size() -> None: assert rect.height == 250 assert rect.contains(410.0, 270.0) assert not rect.contains(411.0, 270.0) + + +def test_pan_updates_center_and_queues_view_command() -> None: + state = create_map_state(tag="pan", center=(0.0, 0.0), zoom=3) + apply_size_measurement(state, SizeMeasurement(width=400, height=300, visible=True)) + + old_center = state.center + pan_state_by_pixels(state, 40.0, 0.0) + + assert state.center != old_center + assert state.center[1] < old_center[1] + assert state.dirty & DirtyFlags.VIEW + drained = state.command_queue.drain() + assert drained[-1].kind is CommandKind.SET_VIEW + assert drained[-1].payload["center"] == state.center + + +def test_mouse_drag_uses_active_drag_state() -> None: + state = create_map_state(tag="drag", center=(0.0, 0.0), zoom=3) + apply_size_measurement(state, SizeMeasurement(width=400, height=300, visible=True)) + rect = calculate_hit_rect(state, (10.0, 20.0)) + + handle_mouse_down(state, (20.0, 30.0), rect) + assert state.interaction.active_drag is True + handle_mouse_drag(state, (45.0, 30.0)) + handle_mouse_release(state) + + assert state.interaction.active_drag is False + assert state.interaction.last_mouse_position is None + assert state.center[1] < 0.0 + + +def test_wheel_zoom_keeps_cursor_latlon_stable() -> None: + state = create_map_state(tag="wheel", center=(47.9029, 1.9093), zoom=8) + apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True)) + rect = calculate_hit_rect(state, (100.0, 50.0)) + + before = dpgm.screen_to_latlon(300.0, 200.0, map_tag="wheel") + handle_mouse_wheel(state, mouse_pos=(400.0, 250.0), wheel_delta=1.0, hit_rect=rect) + after = dpgm.screen_to_latlon(300.0, 200.0, map_tag="wheel") + + assert state.zoom == 9 + assert after == pytest.approx(before, abs=1e-7) + + +def test_view_coordinate_helpers_roundtrip() -> None: + create_map_state(tag="view-roundtrip", center=(47.9029, 1.9093), zoom=12) + state = get_map_state("view-roundtrip") + apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True)) + + screen = dpgm.latlon_to_screen(47.91, 1.92, map_tag="view-roundtrip") + latlon = dpgm.screen_to_latlon(*screen, map_tag="view-roundtrip") + + assert latlon == pytest.approx((47.91, 1.92), abs=1e-7) + + +def test_fit_bounds_sets_center_and_zoom() -> None: + create_map_state(tag="fit", center=(0.0, 0.0), zoom=2) + state = get_map_state("fit") + apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True)) + + dpgm.fit_bounds(((47.8, 1.8), (48.0, 2.0)), map_tag="fit") + + assert dpgm.get_center(map_tag="fit") == pytest.approx((47.9, 1.9)) + assert dpgm.get_zoom(map_tag="fit") > 2 From 815d8a2d880b1467f139a343f80a61a08d2116b6 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Sat, 23 May 2026 10:24:34 +0200 Subject: [PATCH 07/11] step 6: add stable overlays and live update stress tests --- AGENTS.md | 19 ++- README.md | 9 +- examples/markers_live_thread.py | 66 ++++++++ examples/trajectory_live_thread.py | 76 +++++++++ src/dpg_map/draw_layers.py | 45 +++++ src/dpg_map/renderer.py | 258 +++++++++++++++++++++++++++-- tests/test_overlays_state.py | 39 +++++ tests/test_renderer.py | 89 +++++++++- 8 files changed, 583 insertions(+), 18 deletions(-) create mode 100644 examples/markers_live_thread.py create mode 100644 examples/trajectory_live_thread.py diff --git a/AGENTS.md b/AGENTS.md index 00aa78e..878d0aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Current status -Step 5 complete. +Step 6 complete. ## Completed steps @@ -11,10 +11,11 @@ Step 2 - Thread-safe state, commands, overlays, and cache model. Step 3 - Widget shell, sizing system, and GUI-thread frame pump. Step 4 - Tile manager, persistent cache, and asynchronous loading. Step 5 - Interaction: pan, zoom, and view commands. +Step 6 - Overlay rendering and runtime update stress tests. ## Current step -Step 6 - Overlay rendering and runtime update stress tests. +Step 7 - Layers, provider switching, and clearing APIs. ## Design decisions @@ -81,7 +82,19 @@ None yet. - Ran `uv run ruff format --check .`. - Ran `uv run pyright`. - Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist/texture-registry/handler-registry creation. +- Implemented Dear PyGui draw-layer bookkeeping for background, tiles, overlays, and attribution. +- Rendered markers, polylines, and trajectories from GUI-thread overlay snapshots. +- Isolated overlay redraws so they clear only the overlay draw layer and do not clear tile draw commands or textures. +- Added live background-thread marker and trajectory stress examples. +- Added Step 6 tests for overlay draw-layer isolation, overlay-only dirty flags, threaded update coalescing, and view/drag-state isolation. +- Updated `README.md` with Step 6 overlay rendering behavior and examples. +- Ran `uv run ruff format .`. +- Ran `uv run pytest`. +- Ran `uv run pyright`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format --check .`. +- Ran a Dear PyGui context smoke check for `map_widget` with marker, polyline, and trajectory overlays. ## Next action -Implement Step 6. +Implement Step 7. diff --git a/README.md b/README.md index dbab9fd..e9f2b45 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `dpg-map` is a Dear PyGui map widget package under rebuild. -The Step 5 interaction layer is in place: +The Step 6 overlay rendering layer is in place: ```python import dpg_map as dpgm @@ -36,9 +36,8 @@ Implemented so far: - memory cache with visible-tile protection and GUI-thread texture deletion - measured-rectangle mouse interaction for left-drag panning and wheel zooming - programmatic view commands and screen/latitude-longitude conversion helpers - -Overlay drawing is not implemented yet. Step 6 will add overlay rendering and -live update stress examples. +- marker, polyline, and trajectory rendering on a draw layer separate from tiles +- background-thread overlay updates that coalesce without resetting center or zoom Examples: @@ -49,4 +48,6 @@ uv run python examples/sizing_child.py uv run python examples/sizing_table.py uv run python examples/hidden_tab.py uv run python examples/cache_stress.py +uv run python examples/markers_live_thread.py +uv run python examples/trajectory_live_thread.py ``` diff --git a/examples/markers_live_thread.py b/examples/markers_live_thread.py new file mode 100644 index 0000000..45e083e --- /dev/null +++ b/examples/markers_live_thread.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from math import cos, sin +from threading import Event, Thread +from time import sleep +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpgm.configure(user_agent="dpg-map markers_live_thread example") + stop = Event() + + dpg.create_context() + dpg.create_viewport(title="dpg-map live markers", width=1000, height=700) + + with ( + dpg.window(label="Live Markers", width=-1, height=-1), + dpgm.map_widget( + tag="live-markers-map", center=(47.9029, 1.9093), zoom=15, width=-1, height=-1 + ), + ): + for index in range(12): + dpgm.add_marker( + f"vehicle-{index}", + lat=47.9029, + lon=1.9093, + label=str(index + 1), + show_label=True, + color=(240, 92, 70, 255), + ) + + def update_markers() -> None: + tick = 0 + while not stop.is_set(): + for index in range(12): + angle = tick * 0.08 + index * 0.52 + radius = 0.0015 + (index % 4) * 0.0002 + dpgm.update_marker( + f"vehicle-{index}", + lat=47.9029 + sin(angle) * radius, + lon=1.9093 + cos(angle) * radius, + map_tag="live-markers-map", + ) + tick += 1 + sleep(1 / 30) + + worker = Thread(target=update_markers, name="dpg-map-live-markers", daemon=True) + worker.start() + try: + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + stop.set() + worker.join(timeout=1.0) + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/trajectory_live_thread.py b/examples/trajectory_live_thread.py new file mode 100644 index 0000000..3870270 --- /dev/null +++ b/examples/trajectory_live_thread.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections import deque +from math import cos, sin +from threading import Event, Thread +from time import sleep +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpgm.configure(user_agent="dpg-map trajectory_live_thread example") + stop = Event() + points: deque[tuple[float, float]] = deque(maxlen=240) + + dpg.create_context() + dpg.create_viewport(title="dpg-map live trajectory", width=1000, height=700) + + with ( + dpg.window(label="Live Trajectory", width=-1, height=-1), + dpgm.map_widget( + tag="live-trajectory-map", + center=(47.9029, 1.9093), + zoom=15, + width=-1, + height=-1, + ), + ): + dpgm.add_trajectory( + "track", + points=[], + color=(250, 190, 80, 255), + thickness=3.0, + show_points=True, + point_stride=12, + ) + dpgm.add_marker( + "head", + lat=47.9029, + lon=1.9093, + color=(72, 205, 154, 255), + radius=6, + ) + + def update_trajectory() -> None: + tick = 0 + while not stop.is_set(): + angle = tick * 0.06 + lat = 47.9029 + sin(angle) * 0.0016 + sin(angle * 2.7) * 0.00025 + lon = 1.9093 + cos(angle) * 0.0016 + points.append((lat, lon)) + snapshot = tuple(points) + dpgm.update_trajectory("track", points=snapshot, map_tag="live-trajectory-map") + dpgm.update_marker("head", lat=lat, lon=lon, map_tag="live-trajectory-map") + tick += 1 + sleep(1 / 20) + + worker = Thread(target=update_trajectory, name="dpg-map-live-trajectory", daemon=True) + worker.start() + try: + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + stop.set() + worker.join(timeout=1.0) + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/src/dpg_map/draw_layers.py b/src/dpg_map/draw_layers.py index b7e39fa..80d41bc 100644 --- a/src/dpg_map/draw_layers.py +++ b/src/dpg_map/draw_layers.py @@ -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) diff --git a/src/dpg_map/renderer.py b/src/dpg_map/renderer.py index c16c815..9dd8c9b 100644 --- a/src/dpg_map/renderer.py +++ b/src/dpg_map/renderer.py @@ -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) diff --git a/tests/test_overlays_state.py b/tests/test_overlays_state.py index 6bbb961..b1fd854 100644 --- a/tests/test_overlays_state.py +++ b/tests/test_overlays_state.py @@ -1,8 +1,11 @@ from __future__ import annotations +from threading import Thread + import pytest import dpg_map as dpgm +from dpg_map.commands import CommandKind from dpg_map.exceptions import CoordinateError from dpg_map.overlays import TrajectoryOverlay from dpg_map.state import DirtyFlags, create_map_state, get_map_state @@ -61,3 +64,39 @@ def test_layer_state_tracks_visibility_and_overlay_membership() -> None: assert state.layers["fleet"].overlay_tags == set() assert "vehicle" not in state.overlays + + +def test_threaded_marker_updates_coalesce_without_touching_view_or_drag_state() -> None: + create_map_state(tag="threaded-marker", center=(47.0, 2.0), zoom=9) + dpgm.add_marker("vehicle", lat=47.0, lon=2.0, map_tag="threaded-marker") + state = get_map_state("threaded-marker") + state.command_queue.drain() + state.dirty = DirtyFlags.NONE + state.interaction.active_drag = True + state.interaction.last_mouse_position = (100.0, 100.0) + before_center = state.center + before_zoom = state.zoom + + def update_worker(offset: float) -> None: + for index in range(100): + dpgm.update_marker( + "vehicle", + lat=47.0 + offset, + lon=2.0 + index * 0.00001, + map_tag="threaded-marker", + ) + + threads = [Thread(target=update_worker, args=(worker * 0.0001,)) for worker in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + commands = state.command_queue.drain() + + assert state.center == before_center + assert state.zoom == before_zoom + assert state.interaction.active_drag is True + assert state.interaction.last_mouse_position == (100.0, 100.0) + assert state.dirty == DirtyFlags.OVERLAYS + assert [command.kind for command in commands] == [CommandKind.UPDATE_OVERLAY] diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 4a9aab2..805d50d 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -1,10 +1,50 @@ from __future__ import annotations +from typing import Any + +import dpg_map as dpgm from dpg_map.commands import CommandKind, MapCommand -from dpg_map.renderer import drain_renderer_commands +from dpg_map.renderer import MapRenderer, drain_renderer_commands from dpg_map.state import DirtyFlags, create_map_state +class FakeDpg: + def __init__(self) -> None: + self.items: set[str | int] = set() + self.deleted: list[tuple[str | int, bool]] = [] + self.drawn: list[tuple[str, str | int]] = [] + + def does_item_exist(self, tag: str | int) -> bool: + return tag in self.items + + def add_draw_layer(self, *, parent: str | int, tag: str | int) -> None: + _ = parent + self.items.add(tag) + + def delete_item(self, tag: str | int, *, children_only: bool = False) -> None: + self.deleted.append((tag, children_only)) + + def draw_rectangle(self, *args: Any, parent: str | int, **kwargs: Any) -> None: + _ = (args, kwargs) + self.drawn.append(("rectangle", parent)) + + def draw_image(self, *args: Any, parent: str | int, **kwargs: Any) -> None: + _ = (args, kwargs) + self.drawn.append(("image", parent)) + + def draw_text(self, *args: Any, parent: str | int, **kwargs: Any) -> None: + _ = (args, kwargs) + self.drawn.append(("text", parent)) + + def draw_circle(self, *args: Any, parent: str | int, **kwargs: Any) -> None: + _ = (args, kwargs) + self.drawn.append(("circle", parent)) + + def draw_polyline(self, *args: Any, parent: str | int, **kwargs: Any) -> None: + _ = (args, kwargs) + self.drawn.append(("polyline", parent)) + + def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> None: state = create_map_state(tag="renderer-drain") state.dirty = DirtyFlags.NONE @@ -28,3 +68,50 @@ def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> No assert commands[2].payload == {"tag": "a", "v": 2} assert state.dirty & DirtyFlags.VIEW assert state.dirty & DirtyFlags.OVERLAYS + + +def test_overlay_draw_clears_only_overlay_layer() -> None: + state = create_map_state(tag="overlay-draw", center=(47.0, 2.0), zoom=8) + dpgm.add_marker( + "vehicle", + lat=47.0, + lon=2.0, + show_label=True, + label="Vehicle", + map_tag="overlay-draw", + ) + fake = FakeDpg() + fake.items.add(state.drawlist_tag) + renderer = MapRenderer(state, fake) + + renderer._draw_tile_layer( + visible_tiles=[], width=400, height=300, attribution="Tiles", tile_size=256 + ) + fake.deleted.clear() + with state.lock: + overlays = tuple(state.overlays.values()) + layers = {name: (layer.show, layer.z_index) for name, layer in state.layers.items()} + + renderer._draw_overlay_layer( + overlays=overlays, + layers=layers, + center=state.center, + zoom=state.zoom, + width=400, + height=300, + tile_size=256, + ) + + assert fake.deleted == [("overlay-draw##layer-overlays", True)] + assert ("circle", "overlay-draw##layer-overlays") in fake.drawn + assert ("text", "overlay-draw##layer-overlays") in fake.drawn + + +def test_overlay_update_drain_sets_only_overlay_dirty() -> None: + state = create_map_state(tag="overlay-dirty") + state.dirty = DirtyFlags.NONE + state.command_queue.put(MapCommand(CommandKind.UPDATE_OVERLAY, state.tag, {"tag": "a"})) + + drain_renderer_commands(state) + + assert state.dirty == DirtyFlags.OVERLAYS From d0ba8c4218486fea3132f50d88e4abca8fa74655 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Sat, 23 May 2026 10:31:27 +0200 Subject: [PATCH 08/11] fix drag polling during overlay updates --- src/dpg_map/interaction.py | 25 +++++++++++++++++++++++++ src/dpg_map/renderer.py | 18 +++++++++++++++++- tests/test_interaction.py | 14 ++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/dpg_map/interaction.py b/src/dpg_map/interaction.py index bdffb17..0c4fcd3 100644 --- a/src/dpg_map/interaction.py +++ b/src/dpg_map/interaction.py @@ -196,6 +196,31 @@ def handle_mouse_wheel( ) +def update_drag_from_button_state( + state: MapState, + *, + mouse_pos: tuple[float, float], + hit_rect: HitRect, + is_down: bool, +) -> None: + """Poll left-button state and keep drag interaction moving.""" + + with state.lock: + active_drag = state.interaction.active_drag + + if not is_down: + if active_drag: + handle_mouse_release(state) + return + + if active_drag: + handle_mouse_drag(state, mouse_pos) + return + + if hit_rect.contains(mouse_pos[0], mouse_pos[1]): + handle_mouse_down(state, mouse_pos, hit_rect) + + def wheel_delta_from_app_data(app_data: Any) -> float: """Normalize Dear PyGui mouse wheel callback data.""" diff --git a/src/dpg_map/renderer.py b/src/dpg_map/renderer.py index 9dd8c9b..df19cf5 100644 --- a/src/dpg_map/renderer.py +++ b/src/dpg_map/renderer.py @@ -8,7 +8,7 @@ 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 .interaction import HitRect, calculate_hit_rect, update_drag_from_button_state from .overlays import MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay from .projection import latlon_to_world from .sizing import SizeMeasurement, apply_size_measurement @@ -53,6 +53,7 @@ class MapRenderer: commands = drain_renderer_commands(self.state) self.last_drained_commands = tuple(commands) self._update_size_from_dpg() + self._poll_mouse_drag() with self.state.lock: dirty = self.state.dirty @@ -132,6 +133,21 @@ class MapRenderer: with self.state.lock: self.last_hit_rect = calculate_hit_rect(self.state, (draw_pos[0], draw_pos[1])) + def _poll_mouse_drag(self) -> None: + if self.last_hit_rect is None: + return + try: + is_down = bool(self._dpg.is_mouse_button_down(self._dpg.mvMouseButton_Left)) + mouse_pos = self._dpg.get_mouse_pos(local=False) + except Exception: + return + update_drag_from_button_state( + self.state, + mouse_pos=(float(mouse_pos[0]), float(mouse_pos[1])), + hit_rect=self.last_hit_rect, + is_down=is_down, + ) + def _measure_child_content(self) -> tuple[int, int]: try: width, height = self._dpg.get_item_rect_size(self.state.child_window_tag) diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 2376ca4..4ec1ee8 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -11,6 +11,7 @@ from dpg_map.interaction import ( handle_mouse_release, handle_mouse_wheel, pan_state_by_pixels, + update_drag_from_button_state, ) from dpg_map.sizing import SizeMeasurement, apply_size_measurement from dpg_map.state import DirtyFlags, create_map_state, get_map_state @@ -60,6 +61,19 @@ def test_mouse_drag_uses_active_drag_state() -> None: assert state.center[1] < 0.0 +def test_polled_drag_starts_and_moves_while_button_is_down() -> None: + state = create_map_state(tag="polled-drag", center=(0.0, 0.0), zoom=3) + apply_size_measurement(state, SizeMeasurement(width=400, height=300, visible=True)) + rect = calculate_hit_rect(state, (10.0, 20.0)) + + update_drag_from_button_state(state, mouse_pos=(20.0, 30.0), hit_rect=rect, is_down=True) + update_drag_from_button_state(state, mouse_pos=(45.0, 30.0), hit_rect=rect, is_down=True) + update_drag_from_button_state(state, mouse_pos=(45.0, 30.0), hit_rect=rect, is_down=False) + + assert state.interaction.active_drag is False + assert state.center[1] < 0.0 + + def test_wheel_zoom_keeps_cursor_latlon_stable() -> None: state = create_map_state(tag="wheel", center=(47.9029, 1.9093), zoom=8) apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True)) From 50e38e18eed49bdf15dd5b83a9f067f75e9c7ad6 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Sat, 23 May 2026 10:47:34 +0200 Subject: [PATCH 09/11] step 8: harden docs and prepare rebuilt beta --- AGENTS.md | 35 ++++++- README.md | 181 +++++++++++++++++++++++++++++------ examples/cache_stress.py | 30 +++++- examples/custom_provider.py | 52 ++++++++++ pyproject.toml | 4 +- src/dpg_map/__init__.py | 2 + src/dpg_map/api.py | 133 ++++++++++++++++++++++--- src/dpg_map/cache.py | 22 ++++- src/dpg_map/renderer.py | 7 +- src/dpg_map/state.py | 7 ++ src/dpg_map/tiles.py | 10 +- tests/test_cache.py | 22 +++++ tests/test_hardening.py | 140 +++++++++++++++++++++++++++ tests/test_overlays_state.py | 11 +++ tests/test_public_api.py | 1 + tests/test_renderer.py | 59 ++++++++++++ uv.lock | 2 +- 17 files changed, 653 insertions(+), 65 deletions(-) create mode 100644 examples/custom_provider.py create mode 100644 tests/test_hardening.py diff --git a/AGENTS.md b/AGENTS.md index 878d0aa..30064e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Current status -Step 6 complete. +Step 8 complete. ## Completed steps @@ -12,10 +12,12 @@ Step 3 - Widget shell, sizing system, and GUI-thread frame pump. Step 4 - Tile manager, persistent cache, and asynchronous loading. Step 5 - Interaction: pan, zoom, and view commands. Step 6 - Overlay rendering and runtime update stress tests. +Step 7 - Layers, provider switching, and clearing APIs. +Step 8 - Documentation, hardening, and internal release. ## Current step -Step 7 - Layers, provider switching, and clearing APIs. +Internal rebuilt beta prepared. ## Design decisions @@ -94,7 +96,34 @@ None yet. - Ran `uv run ruff check .`. - Ran `uv run ruff format --check .`. - Ran a Dear PyGui context smoke check for `map_widget` with marker, polyline, and trajectory overlays. +- Added `z_index` support for `add_layer`, layer visibility/clearing tests, and cache-safe map-wide memory clearing. +- Implemented provider switching that validates providers, clamps zoom to the new provider range, preserves overlays/center, increments generation, and queues GUI-thread tile invalidation. +- Implemented provider-scoped disk cache clearing and cache-size scanning. +- Exported `CacheStats` publicly. +- Added `examples/custom_provider.py` and cache control/stat buttons to `examples/cache_stress.py`. +- Updated `README.md` with Step 7 behavior and examples. +- Added Step 7 tests for provider switch tile invalidation, provider-scoped disk clearing, queued disk clear commands, layer z-order updates, and public exports. +- Ran `uv run ruff format .`. +- Ran `uv run pytest`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format --check .`. +- Ran `uv run pyright`. +- Ran `uv run python -c "import dpg_map as dpgm; print(dpgm.list_providers())"`. +- Added docstrings for public API functions. +- Rewrote `README.md` with uv install, local editable dependency, basic usage, sizing, live update, custom provider, cache, OpenStreetMap, and thread-safety documentation. +- Added Step 8 hardening tests for unknown maps, overlays, providers, invalid coordinates, mismatched coordinate lengths, empty trajectory support, deleted overlays, provider switching while tiles are loading, overlay updates during dragging, and public docstrings. +- Bumped package version to `0.3.0b1` and updated the fallback OpenStreetMap User-Agent version. +- Ran `uv run pytest`. +- Ran `uv run ruff format .`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format --check .`. +- Ran `uv run pyright`. +- Tested editable install from `/tmp/dpg-map-editable-test` with `uv add --editable /home/hector/projects/dpg-map`. +- Ran editable install import check: `uv run python -c "import dpg_map as dpgm; print(dpgm.list_providers())"`. +- Ran `uv run python -m py_compile` across all example files. +- Started `examples/basic_map.py` under a 5-second timeout; it launched without terminal errors and was stopped by timeout because the GUI loop blocks. +- Ran `uv sync`. ## Next action -Implement Step 7. +Commit and tag `v0.3.0b1`. diff --git a/README.md b/README.md index e9f2b45..51a1468 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,176 @@ # dpg-map -`dpg-map` is a Dear PyGui map widget package under rebuild. +`dpg-map` is a Dear PyGui map widget for XYZ raster tiles and geographic overlays. -The Step 6 overlay rendering layer is in place: +The rebuilt beta exposes a stable public import: ```python import dpg_map as dpgm +``` +## Install + +Use `uv` for development and dependency management: + +```bash +uv sync +uv run pytest +``` + +From another local project, add this package as an editable dependency: + +```bash +uv add --editable ../dpg-map +uv run python -c "import dpg_map as dpgm; print(dpgm.list_providers())" +``` + +## Basic Map + +```python +from typing import Any + +import dearpygui.dearpygui as _dpg +import dpg_map as dpgm + +dpg: Any = _dpg + +dpgm.configure(user_agent="my-app/0.1 contact@example.com") + +dpg.create_context() +dpg.create_viewport(title="Map", width=1000, height=700) + +with dpg.window(label="Map", width=-1, height=-1): + with dpgm.map_widget(tag="map", center=(47.9029, 1.9093), zoom=15, width=-1, height=-1): + dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093, label="Vehicle") + +dpg.setup_dearpygui() +dpg.show_viewport() +dpg.start_dearpygui() +dpg.destroy_context() +``` + +## Sizing + +The widget is a Dear PyGui `child_window` containing a measured drawlist. The child keeps the requested Dear PyGui sizing intent while the drawlist uses concrete measured pixels. + +Supported sizing modes: + +- `width=0` and `height=0` keep Dear PyGui default sizing. +- `width=-1` and `height=-1` fill available space where Dear PyGui supports it. +- Positive dimensions request fixed sizes. +- `autosize_x` and `autosize_y` are passed to the child window. +- Hidden layouts preserve the last non-zero measured size until visible again. + +Examples: + +```bash +uv run python examples/sizing_window.py +uv run python examples/sizing_child.py +uv run python examples/sizing_table.py +uv run python examples/hidden_tab.py +``` + +## Live Updates + +Runtime overlay updates are safe to call from background threads. They update logical state and enqueue renderer commands; Dear PyGui drawing, texture, handler, and viewport calls stay on the GUI thread. + +Live marker update: + +```python +dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093, map_tag="map") +dpgm.update_marker("vehicle", lat=current_lat, lon=current_lon, map_tag="map") +``` + +Live trajectory update: + +```python +dpgm.add_trajectory("track", points=[], map_tag="map") +dpgm.update_trajectory("track", points=tuple(points), map_tag="map") +``` + +Stress examples: + +```bash +uv run python examples/markers_live_thread.py +uv run python examples/trajectory_live_thread.py +``` + +## Providers + +OpenStreetMap is registered as `osm` by default. Custom XYZ providers can be registered and selected at runtime: + +```python provider = dpgm.TileProvider( name="custom", url_template="https://example.com/{z}/{x}/{y}.png", attribution="Tiles (c) Example", ) dpgm.register_provider(provider) - -with dpgm.map_widget(tag="map", center=(47.9029, 1.9093), zoom=15): - dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093) - -dpgm.update_marker("vehicle", lat=47.9030, lon=1.9094, map_tag="map") +dpgm.set_provider("custom", map_tag="map") ``` -Implemented so far: +Provider switching preserves overlays and center, clamps zoom to the new provider range, increments the tile generation, and ignores stale tile results from the previous provider. -- public package exports -- tile provider definitions and registry -- Web Mercator projection helpers -- thread-safe logical map state and map registry -- command queue with coalescing for overlay and view updates -- logical marker, polyline, trajectory, and layer models -- persistent disk cache paths, metadata, scanning, pruning, and clearing -- Dear PyGui `child_window` + measured-size `drawlist` widget shell -- GUI-thread frame pump that drains commands, manages textures, and draws raster tiles -- sizing helpers that preserve the last non-zero size across hidden layouts -- asynchronous tile workers that read disk cache, fetch HTTP, and decode images -- memory cache with visible-tile protection and GUI-thread texture deletion -- measured-rectangle mouse interaction for left-drag panning and wheel zooming -- programmatic view commands and screen/latitude-longitude conversion helpers -- marker, polyline, and trajectory rendering on a draw layer separate from tiles -- background-thread overlay updates that coalesce without resetting center or zoom +Example: -Examples: +```bash +uv run python examples/custom_provider.py +``` + +## Cache + +Tiles use an in-memory cache and a persistent provider-namespaced disk cache. + +```python +dpgm.configure( + user_agent="my-app/0.1 contact@example.com", + cache_dir=".tile-cache", + memory_cache_max_tiles=512, + disk_cache_max_bytes=2_000_000_000, +) + +stats = dpgm.get_cache_stats(map_tag="map") +dpgm.clear_memory_cache(map_tag="map") +dpgm.clear_disk_cache(provider="osm") +``` + +`disk_cache_max_bytes=None` disables the disk size limit. Memory cache clears are routed through the renderer command queue so texture deletion happens on the GUI thread. Disk cache clears can target all providers or a single provider namespace. + +Cache example: + +```bash +uv run python examples/cache_stress.py +``` + +## OpenStreetMap Usage + +The default OpenStreetMap provider requires attribution and should use an application-specific `User-Agent`. + +```python +dpgm.configure(user_agent="my-product/1.0 contact@example.com") +``` + +If no user agent is configured, `dpg-map` emits a runtime warning and falls back to a package user agent. Applications are responsible for displaying provider attribution in accordance with provider terms; the renderer draws the provider attribution text on the map. + +## Thread-Safety Contract + +Public runtime functions are intended to be callable from non-GUI threads unless explicitly documented otherwise. They acquire map locks briefly, update logical state, and/or enqueue commands. + +Thread-safe runtime areas include: + +- view updates: `set_center`, `set_zoom`, `set_view`, `fit_bounds` +- overlay updates: `add_marker`, `update_marker`, `update_trajectory`, `delete_overlay` +- layer updates: `add_layer`, `show_layer`, `hide_layer`, `clear_layer` +- provider/cache updates: `set_provider`, `clear_memory_cache`, `clear_disk_cache` + +`map_widget(...)` creates Dear PyGui items and must be used on the GUI thread inside an active Dear PyGui context. + +## Examples ```bash uv run python examples/basic_map.py -uv run python examples/sizing_window.py -uv run python examples/sizing_child.py -uv run python examples/sizing_table.py -uv run python examples/hidden_tab.py uv run python examples/cache_stress.py +uv run python examples/custom_provider.py uv run python examples/markers_live_thread.py uv run python examples/trajectory_live_thread.py ``` diff --git a/examples/cache_stress.py b/examples/cache_stress.py index e1c1c58..9f9c264 100644 --- a/examples/cache_stress.py +++ b/examples/cache_stress.py @@ -21,17 +21,39 @@ def main() -> None: dpg.create_context() dpg.create_viewport(title="dpg-map cache stress", width=1000, height=700) + def clear_memory() -> None: + dpgm.clear_memory_cache(map_tag="cache-map") + + def clear_disk() -> None: + dpgm.clear_disk_cache(map_tag="cache-map") + + def refresh_stats() -> None: + stats = dpgm.get_cache_stats(map_tag="cache-map") + dpg.set_value( + "cache-stats", + ( + f"memory {stats.memory_tiles}/{stats.memory_max_tiles} tiles | " + f"disk {stats.disk_bytes // 1024} KiB | " + f"hits m:{stats.memory_hits} d:{stats.disk_hits}" + ), + ) + with ( dpg.window(label="Cache Stress", width=-1, height=-1), - dpgm.map_widget( + ): + with dpg.group(horizontal=True): + dpg.add_button(label="Clear Memory", callback=clear_memory) + dpg.add_button(label="Clear Disk", callback=clear_disk) + dpg.add_button(label="Stats", callback=refresh_stats) + dpg.add_text("", tag="cache-stats") + with dpgm.map_widget( tag="cache-map", center=(47.9029, 1.9093), zoom=14, width=-1, height=-1, - ), - ): - dpgm.add_marker("start", lat=47.9029, lon=1.9093, label="Orleans") + ): + dpgm.add_marker("start", lat=47.9029, lon=1.9093, label="Orleans") dpg.setup_dearpygui() dpg.show_viewport() diff --git a/examples/custom_provider.py b/examples/custom_provider.py new file mode 100644 index 0000000..02d657e --- /dev/null +++ b/examples/custom_provider.py @@ -0,0 +1,52 @@ +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpgm.configure(user_agent="dpg-map custom_provider example") + provider = dpgm.TileProvider( + name="carto-light", + url_template="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", + subdomains=("a", "b", "c", "d"), + attribution="(c) OpenStreetMap contributors (c) CARTO", + file_extension="png", + ) + if "carto-light" not in dpgm.list_providers(): + dpgm.register_provider(provider) + + dpg.create_context() + dpg.create_viewport(title="dpg-map custom provider", width=900, height=600) + + def use_osm() -> None: + dpgm.set_provider("osm", map_tag="custom-provider-map") + + def use_carto() -> None: + dpgm.set_provider("carto-light", map_tag="custom-provider-map") + + with dpg.window(label="Custom Provider", width=-1, height=-1): + with dpg.group(horizontal=True): + dpg.add_button(label="OSM", callback=use_osm) + dpg.add_button(label="Carto", callback=use_carto) + with dpgm.map_widget( + tag="custom-provider-map", + provider="carto-light", + center=(47.9029, 1.9093), + zoom=13, + width=-1, + height=-1, + ): + dpgm.add_marker("orleans", lat=47.9029, lon=1.9093, label="Orleans") + + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 594434e..51836e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "dpg-map" -version = "0.1.0" -description = "Add your description here" +version = "0.3.0b1" +description = "Dear PyGui map widget for XYZ raster tiles and geographic overlays" readme = "README.md" authors = [ { name = "Hector van der Aa", email = "hector@h3cx.dev" } diff --git a/src/dpg_map/__init__.py b/src/dpg_map/__init__.py index dd40723..fcc911b 100644 --- a/src/dpg_map/__init__.py +++ b/src/dpg_map/__init__.py @@ -32,6 +32,7 @@ from .api import ( update_polyline, update_trajectory, ) +from .cache import CacheStats from .providers import ( TileProvider, get_provider, @@ -42,6 +43,7 @@ from .providers import ( from .widget import map_widget __all__ = [ + "CacheStats", "TileProvider", "add_layer", "add_marker", diff --git a/src/dpg_map/api.py b/src/dpg_map/api.py index 000faa4..f0ac8ce 100644 --- a/src/dpg_map/api.py +++ b/src/dpg_map/api.py @@ -10,7 +10,12 @@ from typing import Any from .cache import CacheStats, clear_disk_cache_path, disk_cache_root, disk_cache_size_bytes from .commands import CommandKind, MapCommand -from .exceptions import CoordinateError, OverlayNotFoundError +from .exceptions import ( + CoordinateError, + InvalidProviderError, + MapNotFoundError, + OverlayNotFoundError, +) from .interaction import latlon_to_screen_in_state, screen_to_latlon_in_state from .overlays import LayerState, MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay from .projection import latlon_to_world @@ -22,6 +27,7 @@ from .state import ( find_map_for_overlay, get_config, get_map_state, + list_map_states, mark_dirty, ) from .types import Bounds, LatLon, Point, Tag @@ -90,6 +96,8 @@ def configure( overlay_update_policy: str = "coalesce", debug: bool = False, ) -> None: + """Configure package-wide defaults used by subsequently created maps.""" + configure_state( user_agent=user_agent, cache_dir=cache_dir, @@ -104,20 +112,28 @@ def configure( def set_center(lat: float, lon: float, *, map_tag: Tag | None = None) -> None: + """Set a map center without changing its zoom.""" + set_view(center=_validate_latlon(lat, lon), map_tag=map_tag) def get_center(*, map_tag: Tag | None = None) -> LatLon: + """Return the current logical center of a map.""" + state = get_map_state(map_tag) with state.lock: return state.center def set_zoom(zoom: int, *, map_tag: Tag | None = None) -> None: + """Set a map zoom, clamped to the map/provider zoom range.""" + set_view(zoom=zoom, map_tag=map_tag) def get_zoom(*, map_tag: Tag | None = None) -> int: + """Return the current logical zoom of a map.""" + state = get_map_state(map_tag) with state.lock: return state.zoom @@ -129,6 +145,8 @@ def set_view( zoom: int | None = None, map_tag: Tag | None = None, ) -> None: + """Set map center and/or zoom as one logical view update.""" + state = get_map_state(map_tag) with state.lock: payload: dict[str, Any] = {} @@ -145,6 +163,8 @@ def set_view( def fit_bounds(bounds: Bounds, *, map_tag: Tag | None = None) -> None: + """Set center and zoom so geographic bounds fit the current draw area.""" + (south_west, north_east) = bounds south, west = _validate_latlon(south_west[0], south_west[1]) north, east = _validate_latlon(north_east[0], north_east[1]) @@ -171,11 +191,15 @@ def fit_bounds(bounds: Bounds, *, map_tag: Tag | None = None) -> None: def screen_to_latlon(x: float, y: float, *, map_tag: Tag | None = None) -> LatLon: + """Convert map-local screen coordinates to latitude/longitude.""" + state = get_map_state(map_tag) return screen_to_latlon_in_state(state, float(x), float(y)) def latlon_to_screen(lat: float, lon: float, *, map_tag: Tag | None = None) -> Point: + """Convert latitude/longitude to map-local screen coordinates.""" + lat_value, lon_value = _validate_latlon(lat, lon) state = get_map_state(map_tag) return latlon_to_screen_in_state(state, lat_value, lon_value) @@ -192,6 +216,8 @@ def add_marker( map_tag: Tag | None = None, **kwargs: Any, ) -> Tag: + """Add or replace a marker overlay and return its tag.""" + state = get_map_state(map_tag) color = kwargs.get("color", (255, 80, 80, 255)) radius = float(kwargs.get("radius", 5.0)) @@ -231,6 +257,8 @@ def add_polyline( map_tag: Tag | None = None, **kwargs: Any, ) -> Tag: + """Add or replace a polyline overlay and return its tag.""" + state = get_map_state(map_tag) copied_points = _points_from_inputs(points, lats=lats, lons=lons) with state.lock: @@ -264,6 +292,8 @@ def add_trajectory( map_tag: Tag | None = None, **kwargs: Any, ) -> Tag: + """Add or replace a trajectory overlay and return its tag.""" + state = get_map_state(map_tag) copied_points = _points_from_inputs(points, lats=lats, lons=lons) timestamps = kwargs.get("timestamps") @@ -303,6 +333,8 @@ def update_marker( map_tag: Tag | None = None, **kwargs: Any, ) -> None: + """Update marker properties without changing the map view.""" + state = find_map_for_overlay(tag, map_tag) with state.lock: overlay = state.overlays.get(tag) @@ -335,6 +367,8 @@ def update_polyline( map_tag: Tag | None = None, **kwargs: Any, ) -> None: + """Update polyline properties without changing the map view.""" + state = find_map_for_overlay(tag, map_tag) with state.lock: overlay = state.overlays.get(tag) @@ -362,6 +396,8 @@ def update_trajectory( map_tag: Tag | None = None, **kwargs: Any, ) -> None: + """Update trajectory properties without changing the map view.""" + state = find_map_for_overlay(tag, map_tag) with state.lock: overlay = state.overlays.get(tag) @@ -386,10 +422,14 @@ def update_trajectory( def set_marker_position(tag: Tag, lat: float, lon: float, *, map_tag: Tag | None = None) -> None: + """Set a marker latitude/longitude.""" + update_marker(tag, lat=lat, lon=lon, map_tag=map_tag) def set_marker_label(tag: Tag, label: str, *, map_tag: Tag | None = None) -> None: + """Set a marker label.""" + update_marker(tag, label=label, map_tag=map_tag) @@ -399,10 +439,14 @@ def set_polyline_points( *, map_tag: Tag | None = None, ) -> None: + """Replace a polyline point sequence.""" + update_polyline(tag, points=points, map_tag=map_tag) def set_overlay_show(tag: Tag, show: bool, *, map_tag: Tag | None = None) -> None: + """Show or hide an overlay without deleting it.""" + state = find_map_for_overlay(tag, map_tag) with state.lock: overlay = state.overlays.get(tag) @@ -415,6 +459,8 @@ def set_overlay_show(tag: Tag, show: bool, *, map_tag: Tag | None = None) -> Non def delete_overlay(tag: Tag, *, map_tag: Tag | None = None) -> None: + """Delete an overlay from its map and layer.""" + state = find_map_for_overlay(tag, map_tag) with state.lock: overlay = state.overlays.pop(tag, None) @@ -427,16 +473,46 @@ def delete_overlay(tag: Tag, *, map_tag: Tag | None = None) -> None: _queue(state, CommandKind.DELETE_OVERLAY, {"tag": tag}) -def add_layer(name: str, *, show: bool = True, map_tag: Tag | None = None) -> None: +def _cache_target_states(map_tag: Tag | None) -> list[Any]: + if map_tag is not None: + return [get_map_state(map_tag)] + try: + return [get_map_state(None)] + except MapNotFoundError: + return list_map_states() + + +def add_layer( + name: str, + *, + z_index: int | None = None, + show: bool = True, + map_tag: Tag | None = None, +) -> None: + """Create or update a logical overlay layer.""" + state = get_map_state(map_tag) with state.lock: - layer = _ensure_layer(state, name, z_index=len(state.layers), show=show) + layer = _ensure_layer( + state, + name, + z_index=len(state.layers) if z_index is None else int(z_index), + show=show, + ) + if z_index is not None: + layer.z_index = int(z_index) layer.show = show mark_dirty(state, DirtyFlags.OVERLAYS) - _queue(state, CommandKind.ADD_LAYER, {"name": name, "show": show}) + _queue( + state, + CommandKind.ADD_LAYER, + {"name": name, "show": show, "z_index": layer.z_index}, + ) def show_layer(name: str, *, map_tag: Tag | None = None) -> None: + """Show all overlays assigned to a layer.""" + state = get_map_state(map_tag) with state.lock: _ensure_layer(state, name).show = True @@ -445,6 +521,8 @@ def show_layer(name: str, *, map_tag: Tag | None = None) -> None: def hide_layer(name: str, *, map_tag: Tag | None = None) -> None: + """Hide all overlays assigned to a layer.""" + state = get_map_state(map_tag) with state.lock: _ensure_layer(state, name).show = False @@ -453,6 +531,8 @@ def hide_layer(name: str, *, map_tag: Tag | None = None) -> None: def clear_layer(name: str, *, map_tag: Tag | None = None) -> None: + """Delete all overlays assigned to a layer.""" + state = get_map_state(map_tag) with state.lock: layer = _ensure_layer(state, name) @@ -464,6 +544,8 @@ def clear_layer(name: str, *, map_tag: Tag | None = None) -> None: def clear_map(*, map_tag: Tag | None = None) -> None: + """Delete all overlays and invalidate map tile resources.""" + state = get_map_state(map_tag) with state.lock: state.overlays.clear() @@ -475,36 +557,55 @@ def clear_map(*, map_tag: Tag | None = None) -> None: def set_provider(provider: str | TileProvider, *, map_tag: Tag | None = None) -> None: - provider_obj = get_provider(provider) if isinstance(provider, str) else provider + """Switch a map to another tile provider while preserving overlays.""" + + if isinstance(provider, str): + provider_obj = get_provider(provider) + elif isinstance(provider, TileProvider): + provider_obj = provider + else: + raise InvalidProviderError("provider must be a provider name or TileProvider") state = get_map_state(map_tag) with state.lock: + if state.provider == provider_obj: + return state.provider = provider_obj - state.min_zoom = max(state.min_zoom, provider_obj.min_zoom) - state.max_zoom = min(state.max_zoom, provider_obj.max_zoom) + state.min_zoom = provider_obj.min_zoom + state.max_zoom = provider_obj.max_zoom state.zoom = max(state.min_zoom, min(state.max_zoom, state.zoom)) state.generation += 1 - mark_dirty(state, DirtyFlags.PROVIDER | DirtyFlags.TILES) + mark_dirty(state, DirtyFlags.PROVIDER | DirtyFlags.TILES | DirtyFlags.OVERLAYS) _queue(state, CommandKind.SET_PROVIDER, {"provider": provider_obj.name}) def clear_memory_cache(*, map_tag: Tag | None = None) -> None: - state = get_map_state(map_tag) - with state.lock: - mark_dirty(state, DirtyFlags.TILES) - _queue(state, CommandKind.CLEAR_MEMORY_CACHE, {}) + """Clear decoded in-memory tile data through the renderer command queue.""" + + for state in _cache_target_states(map_tag): + with state.lock: + state.generation += 1 + mark_dirty(state, DirtyFlags.TILES) + _queue(state, CommandKind.CLEAR_MEMORY_CACHE, {}) -def clear_disk_cache(*, map_tag: Tag | None = None) -> None: +def clear_disk_cache(provider: str | None = None, *, map_tag: Tag | None = None) -> None: + """Clear persistent tile cache data globally or for one map/provider.""" + + if provider is not None: + get_provider(provider) if map_tag is None: - clear_disk_cache_path(get_config().cache_dir) + clear_disk_cache_path(get_config().cache_dir, provider=provider) return state = get_map_state(map_tag) with state.lock: + state.generation += 1 mark_dirty(state, DirtyFlags.TILES) - _queue(state, CommandKind.CLEAR_DISK_CACHE, {}) + _queue(state, CommandKind.CLEAR_DISK_CACHE, {"provider": provider}) def get_cache_stats(*, map_tag: Tag | None = None) -> CacheStats: + """Return memory and disk cache diagnostics.""" + config = get_config() if map_tag is None: cache_dir = config.cache_dir @@ -531,6 +632,8 @@ def get_cache_stats(*, map_tag: Tag | None = None) -> CacheStats: def get_map_debug_state(*, map_tag: Tag | None = None) -> dict[str, Any]: + """Return a diagnostic snapshot for a map.""" + state = get_map_state(map_tag) with state.lock: return { diff --git a/src/dpg_map/cache.py b/src/dpg_map/cache.py index 78b291c..33a614c 100644 --- a/src/dpg_map/cache.py +++ b/src/dpg_map/cache.py @@ -225,10 +225,20 @@ def scan_disk_cache(cache_dir: str | Path | None) -> list[DiskCacheEntry]: return entries -def disk_cache_size_bytes(cache_dir: str | Path | None) -> int: - """Return total bytes for cached tile files.""" +def disk_cache_size_bytes( + cache_dir: str | Path | None, + *, + provider: str | None = None, +) -> int: + """Return total bytes for cached tile files, optionally scoped to one provider.""" - return sum(entry.metadata.size_bytes for entry in scan_disk_cache(cache_dir)) + if provider is None: + return sum(entry.metadata.size_bytes for entry in scan_disk_cache(cache_dir)) + safe_provider = provider.replace("/", "_") + provider_root = disk_cache_root(cache_dir) / safe_provider + if not provider_root.exists(): + return 0 + return sum(entry.metadata.size_bytes for entry in scan_disk_cache(provider_root)) def plan_disk_prune( @@ -277,10 +287,12 @@ def prune_disk_cache( return planned -def clear_disk_cache_path(cache_dir: str | Path | None) -> None: - """Remove all persistent tile cache files under a cache root.""" +def clear_disk_cache_path(cache_dir: str | Path | None, *, provider: str | None = None) -> None: + """Remove persistent tile cache files under a cache root.""" root = disk_cache_root(cache_dir) + if provider is not None: + root = root / provider.replace("/", "_") if not root.exists(): return try: diff --git a/src/dpg_map/renderer.py b/src/dpg_map/renderer.py index df19cf5..8678b30 100644 --- a/src/dpg_map/renderer.py +++ b/src/dpg_map/renderer.py @@ -415,7 +415,7 @@ def drain_renderer_commands(state: MapState) -> list[MapCommand]: state.dirty |= DirtyFlags.VIEW | DirtyFlags.TILES | DirtyFlags.OVERLAYS elif command.kind is CommandKind.SET_PROVIDER: state.tile_manager.clear_memory_cache() - state.dirty |= DirtyFlags.PROVIDER | DirtyFlags.TILES + state.dirty |= DirtyFlags.PROVIDER | DirtyFlags.TILES | DirtyFlags.OVERLAYS elif command.kind in { CommandKind.ADD_OVERLAY, CommandKind.UPDATE_OVERLAY, @@ -432,7 +432,10 @@ def drain_renderer_commands(state: MapState) -> list[MapCommand]: state.tile_manager.clear_memory_cache() state.dirty |= DirtyFlags.TILES elif command.kind is CommandKind.CLEAR_DISK_CACHE: - state.tile_manager.clear_disk_cache(state.cache_dir) + provider = command.payload.get("provider") + if not isinstance(provider, str): + provider = None + state.tile_manager.clear_disk_cache(state.cache_dir, provider=provider) state.dirty |= DirtyFlags.TILES return commands diff --git a/src/dpg_map/state.py b/src/dpg_map/state.py index 0fa5b3c..41a6ab4 100644 --- a/src/dpg_map/state.py +++ b/src/dpg_map/state.py @@ -276,6 +276,13 @@ def resolve_map_tag(map_tag: Tag | None = None) -> Tag: raise MapNotFoundError("map_tag is required outside a map_widget context") +def list_map_states() -> list[MapState]: + """Return registered map states as a snapshot.""" + + with _maps_lock: + return list(_maps.values()) + + def find_map_for_overlay(tag: Tag, map_tag: Tag | None = None) -> MapState: """Find the map containing an overlay, optionally scoped by map tag.""" diff --git a/src/dpg_map/tiles.py b/src/dpg_map/tiles.py index 2dcd307..e90738d 100644 --- a/src/dpg_map/tiles.py +++ b/src/dpg_map/tiles.py @@ -316,7 +316,7 @@ class TileManager: RuntimeWarning, stacklevel=3, ) - headers["User-Agent"] = "dpg-map/0.1" + headers["User-Agent"] = "dpg-map/0.3.0b1" return headers def _visible_disk_paths(self, cache_dir: str | Path | None) -> list[Path]: @@ -428,10 +428,12 @@ class TileManager: self._failed.clear() return tags - def clear_disk_cache(self, cache_dir: str | Path | None) -> None: - """Clear the persistent cache root.""" + def clear_disk_cache( + self, cache_dir: str | Path | None, *, provider: str | None = None + ) -> None: + """Clear the persistent cache root or one provider namespace.""" - clear_disk_cache_path(cache_dir) + clear_disk_cache_path(cache_dir, provider=provider) def snapshot(self) -> TileManagerSnapshot: """Return diagnostic counters.""" diff --git a/tests/test_cache.py b/tests/test_cache.py index c97f8f2..8c5191e 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -7,6 +7,8 @@ from dpg_map.cache import ( DiskCacheConfig, DiskCacheMetadata, MemoryCacheConfig, + clear_disk_cache_path, + disk_cache_size_bytes, plan_disk_prune, tile_cache_path, write_disk_metadata, @@ -66,3 +68,23 @@ def test_disk_cache_prune_ordering(tmp_path: Path) -> None: planned = plan_disk_prune(tmp_path, 5, protected_paths={protected}) assert planned == [first, second] + + +def test_provider_scoped_disk_cache_clear(tmp_path: Path) -> None: + osm = tile_cache_path(tmp_path, "osm", 1, 1, 1) + custom = tile_cache_path(tmp_path, "custom", 1, 1, 1) + for path in (osm, custom): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"abcde") + write_disk_metadata( + path.with_suffix(".json"), + DiskCacheMetadata(url=str(path), last_accessed_at=1.0, size_bytes=5), + ) + + assert disk_cache_size_bytes(tmp_path, provider="osm") == 5 + + clear_disk_cache_path(tmp_path, provider="osm") + + assert not osm.exists() + assert custom.exists() + assert disk_cache_size_bytes(tmp_path) == 5 diff --git a/tests/test_hardening.py b/tests/test_hardening.py new file mode 100644 index 0000000..60da1fe --- /dev/null +++ b/tests/test_hardening.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from math import nan + +import pytest + +import dpg_map as dpgm +from dpg_map.commands import CommandKind +from dpg_map.exceptions import ( + CoordinateError, + MapNotFoundError, + OverlayNotFoundError, + ProviderNotFoundError, +) +from dpg_map.overlays import TrajectoryOverlay +from dpg_map.providers import TileProvider +from dpg_map.renderer import drain_renderer_commands +from dpg_map.state import DirtyFlags, InteractionState, create_map_state, get_map_state +from dpg_map.tiles import TileID, TileResult, TileStatus + + +def test_public_callables_have_docstrings() -> None: + for name in dpgm.__all__: + value = getattr(dpgm, name) + if callable(value): + assert value.__doc__, name + + +def test_unknown_map_raises_public_error() -> None: + with pytest.raises(MapNotFoundError): + dpgm.get_center(map_tag="missing-map") + + +def test_unknown_overlay_raises_public_error() -> None: + create_map_state(tag="missing-overlay") + + with pytest.raises(OverlayNotFoundError): + dpgm.update_marker("vehicle", lat=47.0, lon=2.0, map_tag="missing-overlay") + + +def test_unknown_provider_raises_public_error() -> None: + create_map_state(tag="missing-provider") + + with pytest.raises(ProviderNotFoundError): + dpgm.set_provider("missing-provider-name", map_tag="missing-provider") + + +def test_invalid_coordinates_raise_public_error() -> None: + create_map_state(tag="invalid-coordinates") + + with pytest.raises(CoordinateError): + dpgm.add_marker("bad-lat", lat=91.0, lon=2.0, map_tag="invalid-coordinates") + with pytest.raises(CoordinateError): + dpgm.set_center(nan, 2.0, map_tag="invalid-coordinates") + + +def test_mismatched_polyline_lat_lon_lengths_raise_public_error() -> None: + create_map_state(tag="mismatched-polyline") + + with pytest.raises(CoordinateError): + dpgm.add_polyline("line", lats=[47.0, 47.1], lons=[2.0], map_tag="mismatched-polyline") + + +def test_empty_trajectory_is_valid_for_live_updates() -> None: + create_map_state(tag="empty-trajectory") + + dpgm.add_trajectory("track", points=[], map_tag="empty-trajectory") + + state = get_map_state("empty-trajectory") + overlay = state.overlays["track"] + assert isinstance(overlay, TrajectoryOverlay) + assert overlay.points == () + + +def test_clear_deleted_overlay_raises_public_error() -> None: + create_map_state(tag="deleted-overlay") + dpgm.add_marker("vehicle", lat=47.0, lon=2.0, map_tag="deleted-overlay") + + dpgm.delete_overlay("vehicle", map_tag="deleted-overlay") + + with pytest.raises(OverlayNotFoundError): + dpgm.delete_overlay("vehicle", map_tag="deleted-overlay") + + +def test_provider_switch_ignores_tiles_that_finish_after_switch() -> None: + provider = TileProvider( + name="hardening-switch-provider", + url_template="https://tiles.example.test/{z}/{x}/{y}.png", + min_zoom=0, + max_zoom=4, + ) + dpgm.register_provider(provider) + try: + state = create_map_state(tag="provider-switch-loading", zoom=3) + old_tile = TileID("osm", 3, 1, 2) + with state.tile_manager._lock: + state.tile_manager._loading.add(old_tile) + + dpgm.set_provider("hardening-switch-provider", map_tag="provider-switch-loading") + state.tile_manager._result_queue.put( + TileResult( + old_tile, + generation=0, + status=TileStatus.READY, + width=1, + height=1, + pixels=(1.0, 1.0, 1.0, 1.0), + source="network", + ) + ) + + commands = drain_renderer_commands(state) + accepted = state.tile_manager.drain_results( + generation=state.generation, + provider_name=state.provider.name, + ) + + assert [command.kind for command in commands] == [CommandKind.SET_PROVIDER] + assert accepted == [] + assert state.tile_manager.snapshot().stale_results == 1 + assert state.tile_manager.get_ready_tile(old_tile) is None + finally: + dpgm.unregister_provider("hardening-switch-provider") + + +def test_overlay_update_preserves_active_drag_model_state() -> None: + create_map_state(tag="update-while-dragging", center=(47.0, 2.0), zoom=9) + dpgm.add_marker("vehicle", lat=47.0, lon=2.0, map_tag="update-while-dragging") + state = get_map_state("update-while-dragging") + state.command_queue.drain() + state.dirty = DirtyFlags.NONE + state.interaction = InteractionState(active_drag=True, last_mouse_position=(20.0, 30.0)) + + dpgm.update_marker("vehicle", lat=47.1, lon=2.1, map_tag="update-while-dragging") + + assert state.interaction.active_drag is True + assert state.interaction.last_mouse_position == (20.0, 30.0) + assert state.center == (47.0, 2.0) + assert state.zoom == 9 + assert state.dirty == DirtyFlags.OVERLAYS diff --git a/tests/test_overlays_state.py b/tests/test_overlays_state.py index b1fd854..b5fcb69 100644 --- a/tests/test_overlays_state.py +++ b/tests/test_overlays_state.py @@ -66,6 +66,17 @@ def test_layer_state_tracks_visibility_and_overlay_membership() -> None: assert "vehicle" not in state.overlays +def test_add_layer_can_update_visibility_and_z_index() -> None: + create_map_state(tag="layer-order") + + dpgm.add_layer("fleet", z_index=25, show=False, map_tag="layer-order") + dpgm.add_layer("fleet", z_index=30, show=True, map_tag="layer-order") + + state = get_map_state("layer-order") + assert state.layers["fleet"].show is True + assert state.layers["fleet"].z_index == 30 + + def test_threaded_marker_updates_coalesce_without_touching_view_or_drag_state() -> None: create_map_state(tag="threaded-marker", center=(47.0, 2.0), zoom=9) dpgm.add_marker("vehicle", lat=47.0, lon=2.0, map_tag="threaded-marker") diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 26d781d..b937618 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -6,6 +6,7 @@ def test_package_exports_required_public_api() -> None: expected = { "configure", + "CacheStats", "TileProvider", "register_provider", "unregister_provider", diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 805d50d..c498f8a 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -4,8 +4,10 @@ from typing import Any import dpg_map as dpgm from dpg_map.commands import CommandKind, MapCommand +from dpg_map.providers import TileProvider from dpg_map.renderer import MapRenderer, drain_renderer_commands from dpg_map.state import DirtyFlags, create_map_state +from dpg_map.tiles import TileID, TileResult, TileStatus class FakeDpg: @@ -115,3 +117,60 @@ def test_overlay_update_drain_sets_only_overlay_dirty() -> None: drain_renderer_commands(state) assert state.dirty == DirtyFlags.OVERLAYS + + +def test_provider_switch_keeps_overlays_and_invalidates_tiles() -> None: + provider = TileProvider( + name="renderer-switch-provider", + url_template="https://tiles.example.test/{z}/{x}/{y}.png", + min_zoom=3, + max_zoom=4, + attribution="Example", + ) + dpgm.register_provider(provider) + try: + state = create_map_state(tag="provider-switch", center=(47.0, 2.0), zoom=8) + dpgm.add_marker("vehicle", lat=47.0, lon=2.0, map_tag="provider-switch") + state.command_queue.drain() + state.dirty = DirtyFlags.NONE + tile_id = TileID("osm", 3, 1, 2) + state.tile_manager._result_queue.put( + TileResult( + tile_id, + generation=state.generation, + status=TileStatus.READY, + width=1, + height=1, + pixels=(1.0, 1.0, 1.0, 1.0), + source="disk", + ) + ) + state.tile_manager.drain_results(generation=state.generation, provider_name="osm") + state.tile_manager.set_texture_tag(tile_id, "old-texture") + + dpgm.set_provider("renderer-switch-provider", map_tag="provider-switch") + drain_renderer_commands(state) + + assert "vehicle" in state.overlays + assert state.center == (47.0, 2.0) + assert state.zoom == 4 + assert state.generation == 1 + assert state.provider.name == "renderer-switch-provider" + assert state.dirty & DirtyFlags.PROVIDER + assert state.dirty & DirtyFlags.TILES + assert state.dirty & DirtyFlags.OVERLAYS + assert state.tile_manager.get_ready_tile(tile_id) is None + assert state.tile_manager.take_texture_deletions() == ["old-texture"] + finally: + dpgm.unregister_provider("renderer-switch-provider") + + +def test_map_scoped_clear_disk_cache_command_keeps_dearpygui_out_of_caller_thread() -> None: + state = create_map_state(tag="clear-disk-command") + state.dirty = DirtyFlags.NONE + + dpgm.clear_disk_cache(map_tag="clear-disk-command") + + commands = state.command_queue.drain() + assert [command.kind for command in commands] == [CommandKind.CLEAR_DISK_CACHE] + assert state.dirty == DirtyFlags.TILES diff --git a/uv.lock b/uv.lock index 60f75dc..8cf8684 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "dpg-map" -version = "0.1.0" +version = "0.3.0b1" source = { editable = "." } dependencies = [ { name = "dearpygui" }, From a5e560937cb8ca07b41e3d2621ecde5d8174a36d Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Mon, 8 Jun 2026 13:35:09 +0200 Subject: [PATCH 10/11] Fixed tiling bug and multi map bug --- src/dpg_map/interaction.py | 26 +++++++++++- src/dpg_map/renderer.py | 77 ++++++++++++++++++++++++++++++------ src/dpg_map/widget.py | 10 +++++ tests/test_interaction.py | 38 ++++++++++++++++++ tests/test_renderer.py | 81 +++++++++++++++++++++++++++++++++++++- 5 files changed, 217 insertions(+), 15 deletions(-) diff --git a/src/dpg_map/interaction.py b/src/dpg_map/interaction.py index 0c4fcd3..47b2fd4 100644 --- a/src/dpg_map/interaction.py +++ b/src/dpg_map/interaction.py @@ -4,13 +4,17 @@ from __future__ import annotations from dataclasses import dataclass from math import isfinite +from threading import RLock from typing import Any from .commands import CommandKind, MapCommand from .projection import latlon_to_world, screen_to_world, world_to_latlon from .sizing import effective_draw_size from .state import DirtyFlags, MapState, mark_dirty -from .types import LatLon, Point +from .types import LatLon, Point, Tag + +_drag_owner_lock = RLock() +_drag_owner_map: Tag | None = None @dataclass(frozen=True, slots=True) @@ -152,6 +156,12 @@ def handle_mouse_down(state: MapState, mouse_pos: tuple[float, float], hit_rect: state.interaction.active_drag = False state.interaction.last_mouse_position = None return + with _drag_owner_lock: + global _drag_owner_map + if _drag_owner_map is not None and _drag_owner_map != state.tag: + return + _drag_owner_map = state.tag + with state.lock: state.interaction.active_drag = True state.interaction.last_mouse_position = mouse_pos @@ -159,6 +169,9 @@ def handle_mouse_down(state: MapState, mouse_pos: tuple[float, float], hit_rect: def handle_mouse_drag(state: MapState, mouse_pos: tuple[float, float]) -> None: """Update center from a mouse drag event.""" + with _drag_owner_lock: + if _drag_owner_map != state.tag: + return with state.lock: if not state.interaction.active_drag: return @@ -172,6 +185,10 @@ def handle_mouse_drag(state: MapState, mouse_pos: tuple[float, float]) -> None: def handle_mouse_release(state: MapState) -> None: """End any active drag.""" + with _drag_owner_lock: + global _drag_owner_map + if _drag_owner_map == state.tag: + _drag_owner_map = None with state.lock: state.interaction.active_drag = False state.interaction.last_mouse_position = None @@ -186,6 +203,9 @@ def handle_mouse_wheel( ) -> None: """Apply wheel zoom when the cursor is over the concrete map rectangle.""" + with state.lock: + if not state.is_visible: + return if not hit_rect.contains(mouse_pos[0], mouse_pos[1]): return zoom_state_at_screen_point( @@ -202,6 +222,7 @@ def update_drag_from_button_state( mouse_pos: tuple[float, float], hit_rect: HitRect, is_down: bool, + can_start: bool = True, ) -> None: """Poll left-button state and keep drag interaction moving.""" @@ -217,6 +238,9 @@ def update_drag_from_button_state( handle_mouse_drag(state, mouse_pos) return + if not can_start: + return + if hit_rect.contains(mouse_pos[0], mouse_pos[1]): handle_mouse_down(state, mouse_pos, hit_rect) diff --git a/src/dpg_map/renderer.py b/src/dpg_map/renderer.py index 8678b30..ccd53d8 100644 --- a/src/dpg_map/renderer.py +++ b/src/dpg_map/renderer.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import replace +from threading import RLock from typing import Any from .commands import CommandKind, MapCommand @@ -14,7 +15,12 @@ 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 +from .types import Color, LatLon, Tag + +_scheduler_lock = RLock() +_scheduled_renderers: dict[Tag, MapRenderer] = {} +_scheduler_dpg: Any | None = None +_scheduler_frame_scheduled = False class MapRenderer: @@ -29,23 +35,15 @@ class MapRenderer: self.last_overlay_count: int = 0 def schedule_next_frame(self) -> None: - """Schedule this renderer to run on the next Dear PyGui frame.""" + """Register this renderer with the shared Dear PyGui frame pump.""" - with self.state.lock: - if self.state.frame_scheduled: - return - self.state.frame_scheduled = True - frame = self._dpg.get_frame_count() + 1 - self._dpg.set_frame_callback(frame, self._frame_callback) + _schedule_renderer(self) def _frame_callback(self, sender: Any | None = None, app_data: Any | None = None) -> None: _ = (sender, app_data) - with self.state.lock: - self.state.frame_scheduled = False if not self._dpg.does_item_exist(self.state.drawlist_tag): return self.render_frame() - self.schedule_next_frame() def render_frame(self) -> None: """Drain pending commands, refresh size, process tiles, and redraw.""" @@ -146,6 +144,7 @@ class MapRenderer: mouse_pos=(float(mouse_pos[0]), float(mouse_pos[1])), hit_rect=self.last_hit_rect, is_down=is_down, + can_start=False, ) def _measure_child_content(self) -> tuple[int, int]: @@ -181,8 +180,8 @@ class MapRenderer: tile = self.state.tile_manager.get_ready_tile(visible_tile.tile_id) if tile is None or tile.texture_tag is None: continue - screen_x = visible_tile.screen_x - screen_y = visible_tile.screen_y + screen_x = _snap_tile_position(visible_tile.screen_x) + screen_y = _snap_tile_position(visible_tile.screen_y) self._dpg.draw_image( tile.texture_tag, (screen_x, screen_y), @@ -402,6 +401,54 @@ class MapRenderer: return get_config().prefetch_margin_tiles +def _schedule_renderer(renderer: MapRenderer) -> None: + """Schedule the shared frame pump for all registered map renderers.""" + + global _scheduler_dpg, _scheduler_frame_scheduled + + with _scheduler_lock: + _scheduled_renderers[renderer.state.tag] = renderer + _scheduler_dpg = renderer._dpg + if _scheduler_frame_scheduled: + return + _scheduler_frame_scheduled = True + dpg = renderer._dpg + + frame = dpg.get_frame_count() + 1 + dpg.set_frame_callback(frame, _shared_frame_callback) + + +def _shared_frame_callback(sender: Any | None = None, app_data: Any | None = None) -> None: + """Render every live map from one Dear PyGui frame callback.""" + + _ = (sender, app_data) + global _scheduler_frame_scheduled + + with _scheduler_lock: + renderers = tuple(_scheduled_renderers.values()) + _scheduler_frame_scheduled = False + + for renderer in renderers: + renderer._frame_callback() + + with _scheduler_lock: + active_renderers = [ + renderer + for renderer in _scheduled_renderers.values() + if renderer._dpg.does_item_exist(renderer.state.drawlist_tag) + ] + _scheduled_renderers.clear() + _scheduled_renderers.update((renderer.state.tag, renderer) for renderer in active_renderers) + if not active_renderers or _scheduler_frame_scheduled: + return + _scheduler_frame_scheduled = True + dpg = _scheduler_dpg + + if dpg is not None: + frame = dpg.get_frame_count() + 1 + dpg.set_frame_callback(frame, _shared_frame_callback) + + def drain_renderer_commands(state: MapState) -> list[MapCommand]: """Drain and apply GUI-thread command side effects.""" @@ -456,6 +503,10 @@ def _rgba(color: Color) -> tuple[int, int, int, int]: return (int(color[0]), int(color[1]), int(color[2]), int(color[3])) +def _snap_tile_position(value: float) -> int: + return int(round(value)) + + def _latlon_to_screen( lat: float, lon: float, diff --git a/src/dpg_map/widget.py b/src/dpg_map/widget.py index 3767dbf..a899cd2 100644 --- a/src/dpg_map/widget.py +++ b/src/dpg_map/widget.py @@ -81,8 +81,16 @@ def map_widget( draw_pos = tuple(float(value) for value in dpg.get_item_rect_min(state.drawlist_tag)) return calculate_hit_rect(state, (draw_pos[0], draw_pos[1])) + def _is_drawlist_hovered() -> bool: + try: + return bool(dpg.is_item_hovered(state.drawlist_tag)) + except Exception: + return False + def _on_mouse_down(sender: Any, app_data: Any, user_data: Any) -> None: _ = (sender, app_data, user_data) + if not _is_drawlist_hovered(): + return handle_mouse_down(state, _mouse_pos(), _hit_rect()) def _on_mouse_drag(sender: Any, app_data: Any, user_data: Any) -> None: @@ -95,6 +103,8 @@ def map_widget( def _on_mouse_wheel(sender: Any, app_data: Any, user_data: Any) -> None: _ = (sender, user_data) + if not _is_drawlist_hovered(): + return handle_mouse_wheel( state, mouse_pos=_mouse_pos(), diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 4ec1ee8..41ea96f 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -3,6 +3,7 @@ from __future__ import annotations import pytest import dpg_map as dpgm +import dpg_map.interaction as interaction_module from dpg_map.commands import CommandKind from dpg_map.interaction import ( calculate_hit_rect, @@ -74,6 +75,43 @@ def test_polled_drag_starts_and_moves_while_button_is_down() -> None: assert state.center[1] < 0.0 +def test_renderer_poll_does_not_start_drag_for_inactive_map() -> None: + state = create_map_state(tag="poll-no-start", center=(0.0, 0.0), zoom=3) + apply_size_measurement(state, SizeMeasurement(width=400, height=300, visible=True)) + rect = calculate_hit_rect(state, (10.0, 20.0)) + + update_drag_from_button_state( + state, + mouse_pos=(20.0, 30.0), + hit_rect=rect, + is_down=True, + can_start=False, + ) + + assert state.interaction.active_drag is False + assert state.center == (0.0, 0.0) + + +def test_drag_owner_prevents_second_map_from_tracking_first_map() -> None: + with interaction_module._drag_owner_lock: + interaction_module._drag_owner_map = None + state_a = create_map_state(tag="drag-owner-a", center=(0.0, 0.0), zoom=3) + state_b = create_map_state(tag="drag-owner-b", center=(0.0, 0.0), zoom=3) + apply_size_measurement(state_a, SizeMeasurement(width=400, height=300, visible=True)) + apply_size_measurement(state_b, SizeMeasurement(width=400, height=300, visible=True)) + rect_a = calculate_hit_rect(state_a, (10.0, 20.0)) + rect_b = calculate_hit_rect(state_b, (10.0, 20.0)) + + handle_mouse_down(state_a, (20.0, 30.0), rect_a) + handle_mouse_down(state_b, (20.0, 30.0), rect_b) + handle_mouse_drag(state_a, (45.0, 30.0)) + handle_mouse_drag(state_b, (45.0, 30.0)) + + assert state_a.center[1] < 0.0 + assert state_b.center == (0.0, 0.0) + handle_mouse_release(state_a) + + def test_wheel_zoom_keeps_cursor_latlon_stable() -> None: state = create_map_state(tag="wheel", center=(47.9029, 1.9093), zoom=8) apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True)) diff --git a/tests/test_renderer.py b/tests/test_renderer.py index c498f8a..60233cb 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -3,11 +3,12 @@ from __future__ import annotations from typing import Any import dpg_map as dpgm +import dpg_map.renderer as renderer_module from dpg_map.commands import CommandKind, MapCommand from dpg_map.providers import TileProvider from dpg_map.renderer import MapRenderer, drain_renderer_commands from dpg_map.state import DirtyFlags, create_map_state -from dpg_map.tiles import TileID, TileResult, TileStatus +from dpg_map.tiles import TileID, TileResult, TileStatus, VisibleTile class FakeDpg: @@ -15,6 +16,7 @@ class FakeDpg: self.items: set[str | int] = set() self.deleted: list[tuple[str | int, bool]] = [] self.drawn: list[tuple[str, str | int]] = [] + self.images: list[tuple[Any, Any, str | int]] = [] def does_item_exist(self, tag: str | int) -> bool: return tag in self.items @@ -33,6 +35,7 @@ class FakeDpg: def draw_image(self, *args: Any, parent: str | int, **kwargs: Any) -> None: _ = (args, kwargs) self.drawn.append(("image", parent)) + self.images.append((args[1], args[2], parent)) def draw_text(self, *args: Any, parent: str | int, **kwargs: Any) -> None: _ = (args, kwargs) @@ -47,6 +50,51 @@ class FakeDpg: self.drawn.append(("polyline", parent)) +class FrameCallbackDpg(FakeDpg): + def __init__(self) -> None: + super().__init__() + self.frame_count = 0 + self.callbacks: dict[int, Any] = {} + + def get_frame_count(self) -> int: + return self.frame_count + + def set_frame_callback(self, frame: int, callback: Any) -> None: + self.callbacks[frame] = callback + + +def test_shared_frame_pump_renders_multiple_maps_from_one_frame_callback() -> None: + with renderer_module._scheduler_lock: + renderer_module._scheduled_renderers.clear() + renderer_module._scheduler_frame_scheduled = False + renderer_module._scheduler_dpg = None + + state_a = create_map_state(tag="frame-pump-a") + state_b = create_map_state(tag="frame-pump-b") + fake = FrameCallbackDpg() + fake.items.update({state_a.drawlist_tag, state_b.drawlist_tag}) + renderer_a = MapRenderer(state_a, fake) + renderer_b = MapRenderer(state_b, fake) + rendered: list[str] = [] + renderer_a.render_frame = lambda: rendered.append("a") # type: ignore[method-assign] + renderer_b.render_frame = lambda: rendered.append("b") # type: ignore[method-assign] + + renderer_a.schedule_next_frame() + renderer_b.schedule_next_frame() + assert list(fake.callbacks) == [1] + + fake.frame_count = 1 + fake.callbacks[1]() + + assert rendered == ["a", "b"] + assert sorted(renderer_module._scheduled_renderers) == ["frame-pump-a", "frame-pump-b"] + + with renderer_module._scheduler_lock: + renderer_module._scheduled_renderers.clear() + renderer_module._scheduler_frame_scheduled = False + renderer_module._scheduler_dpg = None + + def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> None: state = create_map_state(tag="renderer-drain") state.dirty = DirtyFlags.NONE @@ -109,6 +157,37 @@ def test_overlay_draw_clears_only_overlay_layer() -> None: assert ("text", "overlay-draw##layer-overlays") in fake.drawn +def test_tile_draw_snaps_fractional_positions_to_integer_pixels() -> None: + state = create_map_state(tag="tile-snap", center=(47.0, 2.0), zoom=8) + tile_id = TileID("osm", 8, 129, 89) + state.tile_manager._result_queue.put( + TileResult( + tile_id, + generation=state.generation, + status=TileStatus.READY, + width=1, + height=1, + pixels=(1.0, 1.0, 1.0, 1.0), + source="disk", + ) + ) + state.tile_manager.drain_results(generation=state.generation, provider_name="osm") + state.tile_manager.set_texture_tag(tile_id, "tile-texture") + fake = FakeDpg() + fake.items.add(state.drawlist_tag) + renderer = MapRenderer(state, fake) + + renderer._draw_tile_layer( + visible_tiles=[VisibleTile(tile_id, 10.4, 20.6)], + width=400, + height=300, + attribution="Tiles", + tile_size=256, + ) + + assert fake.images == [((10, 21), (266, 277), "tile-snap##layer-tiles")] + + def test_overlay_update_drain_sets_only_overlay_dirty() -> None: state = create_map_state(tag="overlay-dirty") state.dirty = DirtyFlags.NONE From 6990d011ffb677d4a985364e1234a0e58cd106c0 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Mon, 8 Jun 2026 13:44:32 +0200 Subject: [PATCH 11/11] Release v1.0.0 This is the first releas of dpg-map, an AI coded dearpygui map widget built due to a lack of time to build it myself. It features multi-widget abilities, with layers, overlays etc --- README.md | 168 +++------ AGENTS.md => codex/AGENTS.md | 2 +- ARCHITECTURE.md => codex/ARCHITECTURE.md | 10 +- FEATURES.md => codex/FEATURES.md | 0 codex/README.md | 18 + STEPS.md => codex/STEPS.md | 18 +- docs/API_REFERENCE.md | 411 +++++++++++++++++++++++ docs/EXAMPLES.md | 146 ++++++++ docs/GETTING_STARTED.md | 286 ++++++++++++++++ pyproject.toml | 2 +- src/dpg_map/tiles.py | 2 +- uv.lock | 2 +- 12 files changed, 919 insertions(+), 146 deletions(-) rename AGENTS.md => codex/AGENTS.md (99%) rename ARCHITECTURE.md => codex/ARCHITECTURE.md (99%) rename FEATURES.md => codex/FEATURES.md (100%) create mode 100644 codex/README.md rename STEPS.md => codex/STEPS.md (97%) create mode 100644 docs/API_REFERENCE.md create mode 100644 docs/EXAMPLES.md create mode 100644 docs/GETTING_STARTED.md diff --git a/README.md b/README.md index 51a1468..0f05317 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,34 @@ # dpg-map -`dpg-map` is a Dear PyGui map widget for XYZ raster tiles and geographic overlays. - -The rebuilt beta exposes a stable public import: +`dpg-map` is a Dear PyGui widget for interactive XYZ raster maps, tile caching, and +geographic overlays. ```python import dpg_map as dpgm ``` +The widget is built for telemetry dashboards and internal tools that need maps inside normal +Dear PyGui layouts. Runtime updates are thread-safe: marker, trajectory, view, provider, and +cache calls update logical state or enqueue renderer work, while Dear PyGui draw calls stay on +the GUI thread. + ## Install -Use `uv` for development and dependency management: +For local development: ```bash uv sync uv run pytest ``` -From another local project, add this package as an editable dependency: +From another local project: ```bash uv add --editable ../dpg-map uv run python -c "import dpg_map as dpgm; print(dpgm.list_providers())" ``` -## Basic Map +## Minimal Example ```python from typing import Any @@ -37,7 +41,7 @@ dpg: Any = _dpg dpgm.configure(user_agent="my-app/0.1 contact@example.com") dpg.create_context() -dpg.create_viewport(title="Map", width=1000, height=700) +dpg.create_viewport(title="Map", width=900, height=600) with dpg.window(label="Map", width=-1, height=-1): with dpgm.map_widget(tag="map", center=(47.9029, 1.9093), zoom=15, width=-1, height=-1): @@ -49,128 +53,36 @@ dpg.start_dearpygui() dpg.destroy_context() ``` -## Sizing - -The widget is a Dear PyGui `child_window` containing a measured drawlist. The child keeps the requested Dear PyGui sizing intent while the drawlist uses concrete measured pixels. - -Supported sizing modes: - -- `width=0` and `height=0` keep Dear PyGui default sizing. -- `width=-1` and `height=-1` fill available space where Dear PyGui supports it. -- Positive dimensions request fixed sizes. -- `autosize_x` and `autosize_y` are passed to the child window. -- Hidden layouts preserve the last non-zero measured size until visible again. - -Examples: - -```bash -uv run python examples/sizing_window.py -uv run python examples/sizing_child.py -uv run python examples/sizing_table.py -uv run python examples/hidden_tab.py -``` - -## Live Updates - -Runtime overlay updates are safe to call from background threads. They update logical state and enqueue renderer commands; Dear PyGui drawing, texture, handler, and viewport calls stay on the GUI thread. - -Live marker update: - -```python -dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093, map_tag="map") -dpgm.update_marker("vehicle", lat=current_lat, lon=current_lon, map_tag="map") -``` - -Live trajectory update: - -```python -dpgm.add_trajectory("track", points=[], map_tag="map") -dpgm.update_trajectory("track", points=tuple(points), map_tag="map") -``` - -Stress examples: - -```bash -uv run python examples/markers_live_thread.py -uv run python examples/trajectory_live_thread.py -``` - -## Providers - -OpenStreetMap is registered as `osm` by default. Custom XYZ providers can be registered and selected at runtime: - -```python -provider = dpgm.TileProvider( - name="custom", - url_template="https://example.com/{z}/{x}/{y}.png", - attribution="Tiles (c) Example", -) -dpgm.register_provider(provider) -dpgm.set_provider("custom", map_tag="map") -``` - -Provider switching preserves overlays and center, clamps zoom to the new provider range, increments the tile generation, and ignores stale tile results from the previous provider. - -Example: - -```bash -uv run python examples/custom_provider.py -``` - -## Cache - -Tiles use an in-memory cache and a persistent provider-namespaced disk cache. - -```python -dpgm.configure( - user_agent="my-app/0.1 contact@example.com", - cache_dir=".tile-cache", - memory_cache_max_tiles=512, - disk_cache_max_bytes=2_000_000_000, -) - -stats = dpgm.get_cache_stats(map_tag="map") -dpgm.clear_memory_cache(map_tag="map") -dpgm.clear_disk_cache(provider="osm") -``` - -`disk_cache_max_bytes=None` disables the disk size limit. Memory cache clears are routed through the renderer command queue so texture deletion happens on the GUI thread. Disk cache clears can target all providers or a single provider namespace. - -Cache example: - -```bash -uv run python examples/cache_stress.py -``` - -## OpenStreetMap Usage - -The default OpenStreetMap provider requires attribution and should use an application-specific `User-Agent`. - -```python -dpgm.configure(user_agent="my-product/1.0 contact@example.com") -``` - -If no user agent is configured, `dpg-map` emits a runtime warning and falls back to a package user agent. Applications are responsible for displaying provider attribution in accordance with provider terms; the renderer draws the provider attribution text on the map. - -## Thread-Safety Contract - -Public runtime functions are intended to be callable from non-GUI threads unless explicitly documented otherwise. They acquire map locks briefly, update logical state, and/or enqueue commands. - -Thread-safe runtime areas include: - -- view updates: `set_center`, `set_zoom`, `set_view`, `fit_bounds` -- overlay updates: `add_marker`, `update_marker`, `update_trajectory`, `delete_overlay` -- layer updates: `add_layer`, `show_layer`, `hide_layer`, `clear_layer` -- provider/cache updates: `set_provider`, `clear_memory_cache`, `clear_disk_cache` - -`map_widget(...)` creates Dear PyGui items and must be used on the GUI thread inside an active Dear PyGui context. - -## Examples +Run the bundled example: ```bash uv run python examples/basic_map.py -uv run python examples/cache_stress.py -uv run python examples/custom_provider.py -uv run python examples/markers_live_thread.py -uv run python examples/trajectory_live_thread.py +``` + +## Documentation + +- [Getting Started](docs/GETTING_STARTED.md) +- [Examples](docs/EXAMPLES.md) +- [API Reference](docs/API_REFERENCE.md) + +Internal rebuild notes, implementation plans, and agent logs live in [codex/](codex/). + +## Highlights + +- OpenStreetMap provider registered by default as `osm` +- Custom XYZ tile providers +- Pan and cursor-centered zoom +- Markers, polylines, and live trajectories +- Overlay layers with visibility and z-order controls +- Memory tile cache and provider-namespaced disk cache +- Multiple independent map widgets in one Dear PyGui app +- Background-thread runtime updates for telemetry workloads + +## Project Commands + +```bash +uv run pytest +uv run ruff check . +uv run ruff format --check . +uv run pyright ``` diff --git a/AGENTS.md b/codex/AGENTS.md similarity index 99% rename from AGENTS.md rename to codex/AGENTS.md index 30064e8..5e3ee39 100644 --- a/AGENTS.md +++ b/codex/AGENTS.md @@ -36,7 +36,7 @@ None yet. ## Commands used -- Read `STEPS.md`, `FEATURES.md`, and `ARCHITECTURE.md`. +- Read `codex/STEPS.md`, `codex/FEATURES.md`, and `codex/ARCHITECTURE.md`. - Created initial package, examples, tests, and agent-log structure. - Implemented public exports, exceptions, common types, tile provider registry, projection helpers, cache dataclasses, and GUI-dependent API stubs. - Added Step 1 tests for imports, providers, projection, and cache dataclasses. diff --git a/ARCHITECTURE.md b/codex/ARCHITECTURE.md similarity index 99% rename from ARCHITECTURE.md rename to codex/ARCHITECTURE.md index 31ff363..c03a4e3 100644 --- a/ARCHITECTURE.md +++ b/codex/ARCHITECTURE.md @@ -684,14 +684,14 @@ Create: src/dpg_map/ examples/ tests/ -FEATURES.md -ARCHITECTURE.md -STEPS.md -AGENTS.md +codex/FEATURES.md +codex/ARCHITECTURE.md +codex/STEPS.md +codex/AGENTS.md README.md ``` -Use `AGENTS.md` as the rolling implementation log. After every step: +Use `codex/AGENTS.md` as the rolling implementation log. After every step: ```bash uv run pytest diff --git a/FEATURES.md b/codex/FEATURES.md similarity index 100% rename from FEATURES.md rename to codex/FEATURES.md diff --git a/codex/README.md b/codex/README.md new file mode 100644 index 0000000..87fca60 --- /dev/null +++ b/codex/README.md @@ -0,0 +1,18 @@ +# Codex Build Notes + +This folder contains internal rebuild instructions, implementation architecture notes, and the +rolling agent log used while preparing the beta. + +These files are not user-facing package documentation: + +- `AGENTS.md`: rolling implementation log and current build status +- `ARCHITECTURE.md`: internal architecture and invariants +- `FEATURES.md`: rebuild feature contract +- `STEPS.md`: historical rebuild step plan + +User-facing documentation lives in: + +- `../README.md` +- `../docs/GETTING_STARTED.md` +- `../docs/EXAMPLES.md` +- `../docs/API_REFERENCE.md` diff --git a/STEPS.md b/codex/STEPS.md similarity index 97% rename from STEPS.md rename to codex/STEPS.md index 890ecb7..05365a9 100644 --- a/STEPS.md +++ b/codex/STEPS.md @@ -7,11 +7,11 @@ There is no Step 0. Initial setup is listed separately, then implementation star ## Workflow rules 1. Use `uv` for all Python package and dependency management. -2. Always read `FEATURES.md`, `ARCHITECTURE.md`, and `AGENTS.md` before making code changes. -3. Keep `AGENTS.md` as a rolling log of what has been done, what is broken, and what comes next. +2. Always read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, and `codex/AGENTS.md` before making code changes. +3. Keep `codex/AGENTS.md` as a rolling log of what has been done, what is broken, and what comes next. 4. Update `README.md` whenever public behaviour or examples change. 5. After every step: - - update `AGENTS.md` + - update `codex/AGENTS.md` - run relevant checks - commit to git 6. Do not casually change public API once introduced. @@ -55,10 +55,10 @@ src/dpg_map/ exceptions.py examples/ tests/ -FEATURES.md -ARCHITECTURE.md -STEPS.md -AGENTS.md +codex/FEATURES.md +codex/ARCHITECTURE.md +codex/STEPS.md +codex/AGENTS.md README.md ``` @@ -76,7 +76,7 @@ select = ["E", "F", "I", "UP", "B", "SIM"] typeCheckingMode = "basic" ``` -Create `AGENTS.md` with: +Create `codex/AGENTS.md` with: ```markdown # AGENTS.md @@ -753,7 +753,7 @@ Acceptance criteria: - disk cache limit works - sizing examples work - README accurately documents thread-safety and cache behaviour -- AGENTS.md accurately describes status +- codex/AGENTS.md accurately describes status Commit: diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..1082e33 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,411 @@ +# API Reference + +Public imports are available from: + +```python +import dpg_map as dpgm +``` + +Unless stated otherwise, runtime public functions are safe to call from non-GUI threads. They +update logical state and enqueue GUI-thread renderer work. `map_widget(...)` is the main exception: +it creates Dear PyGui items and must run on the GUI thread. + +## Common Types + +- `Tag`: `str | int` +- `LatLon`: `tuple[float, float]` as `(lat, lon)` +- `Point`: `tuple[float, float]` as `(x, y)` +- `Bounds`: `tuple[LatLon, LatLon]` as `((south, west), (north, east))` +- `Color`: RGB or RGBA integer tuple, usually `(r, g, b, a)` + +## Configuration + +### `configure(...) -> None` + +Configure package-wide defaults used by subsequently created maps. + +```python +dpgm.configure( + user_agent=None, + cache_dir=None, + default_provider="osm", + memory_cache_max_tiles=512, + disk_cache_max_bytes=2_000_000_000, + prefetch_margin_tiles=1, + tile_worker_count=4, + overlay_update_policy="coalesce", + debug=False, +) +``` + +Parameters: + +- `user_agent`: HTTP user agent for tile requests. +- `cache_dir`: persistent tile cache root. Defaults to a platform user cache directory. +- `default_provider`: provider name or `TileProvider` used by new maps. +- `memory_cache_max_tiles`: maximum decoded runtime tile count per map. +- `disk_cache_max_bytes`: maximum disk cache size. Use `None` for no size limit. +- `prefetch_margin_tiles`: number of tiles to request around the visible viewport. +- `tile_worker_count`: background worker count for disk/network/decode work. +- `overlay_update_policy`: currently only `"coalesce"` is supported. +- `debug`: store debug mode flag in global config. + +Raises `ValueError` for invalid cache, worker, prefetch, or update-policy settings. + +## Widget + +### `map_widget(...) -> Iterator[Tag | None]` + +Create a Dear PyGui child-window map shell and logical map context. + +```python +with dpgm.map_widget( + tag="map", + center=(47.9029, 1.9093), + zoom=15, + provider="osm", + width=-1, + height=-1, +): + dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093) +``` + +Parameters: + +- `tag`: map tag. If omitted, a generated tag is used. +- `center`: initial `(lat, lon)`. +- `zoom`: initial zoom, clamped to provider range. +- `provider`: provider name or `TileProvider`; defaults to configured default provider. +- `width`, `height`: Dear PyGui child-window size arguments. +- `autosize_x`, `autosize_y`: Dear PyGui child-window autosize flags. +- `cache_dir`: optional per-map cache directory passed as keyword argument. +- `user_agent`: optional per-map tile user agent passed as keyword argument. +- Other keyword arguments are forwarded to `dpg.add_child_window(...)`. + +GUI-thread only. + +## View + +### `set_center(lat, lon, *, map_tag=None) -> None` + +Set the map center without changing zoom. + +### `get_center(*, map_tag=None) -> LatLon` + +Return the current logical map center. + +### `set_zoom(zoom, *, map_tag=None) -> None` + +Set the zoom, clamped to the current provider range. + +### `get_zoom(*, map_tag=None) -> int` + +Return the current logical zoom. + +### `set_view(*, center=None, zoom=None, map_tag=None) -> None` + +Set center and/or zoom as one logical view update. + +```python +dpgm.set_view(center=(47.9029, 1.9093), zoom=15, map_tag="map") +``` + +### `fit_bounds(bounds, *, map_tag=None) -> None` + +Set center and zoom so bounds fit the current draw area. + +```python +dpgm.fit_bounds(((47.89, 1.89), (47.92, 1.93)), map_tag="map") +``` + +### `screen_to_latlon(x, y, *, map_tag=None) -> LatLon` + +Convert map-local screen coordinates to latitude/longitude. + +### `latlon_to_screen(lat, lon, *, map_tag=None) -> Point` + +Convert latitude/longitude to map-local screen coordinates. + +## Markers + +### `add_marker(tag, *, lat, lon, label=None, layer="default", show=True, map_tag=None, **kwargs) -> Tag` + +Add or replace a marker overlay. + +Keyword options: + +- `color`: marker fill color, default `(255, 80, 80, 255)`. +- `radius`: marker radius in pixels, default `5.0`. +- `show_label`: draw `label` next to the marker, default `False`. +- `user_data`: stored with the overlay model. +- `callback`: stored with the overlay model for application use. + +### `update_marker(tag, *, lat=None, lon=None, label=None, map_tag=None, **kwargs) -> None` + +Update marker properties without changing map view. + +Supported keyword updates: + +- `show` +- `color` +- `radius` + +### `set_marker_position(tag, lat, lon, *, map_tag=None) -> None` + +Shortcut for `update_marker(..., lat=lat, lon=lon)`. + +### `set_marker_label(tag, label, *, map_tag=None) -> None` + +Shortcut for `update_marker(..., label=label)`. + +## Polylines + +### `add_polyline(tag, *, points=None, lats=None, lons=None, layer="default", show=True, map_tag=None, **kwargs) -> Tag` + +Add or replace a polyline overlay. + +Provide either: + +- `points=[(lat, lon), ...]` +- or `lats=[...], lons=[...]` + +Keyword options: + +- `color`: line color, default `(80, 180, 255, 255)`. +- `thickness`: line thickness, default `2.0`. +- `closed`: close the polyline, default `False`. +- `simplify`: stored with the overlay model, default `True`. +- `user_data`: stored with the overlay model. + +### `update_polyline(tag, *, points=None, lats=None, lons=None, map_tag=None, **kwargs) -> None` + +Update polyline points or properties. + +Supported keyword updates: + +- `show` +- `color` +- `thickness` + +### `set_polyline_points(tag, points, *, map_tag=None) -> None` + +Shortcut for replacing a polyline point sequence. + +## Trajectories + +### `add_trajectory(tag, *, points=None, lats=None, lons=None, layer="default", show=True, map_tag=None, **kwargs) -> Tag` + +Add or replace a trajectory overlay. + +Provide either `points` or `lats`/`lons`. Empty trajectories are valid for live updates. + +Keyword options: + +- `timestamps`: optional sequence with the same length as points. +- `color`: trajectory color, default `(255, 180, 60, 255)`. +- `thickness`: line thickness, default `2.0`. +- `show_points`: draw point markers along the trajectory, default `False`. +- `point_stride`: draw every Nth point when `show_points=True`, default `1`. +- `user_data`: stored with the overlay model. + +### `update_trajectory(tag, *, points=None, lats=None, lons=None, map_tag=None, **kwargs) -> None` + +Update trajectory points or properties. + +Supported keyword updates: + +- `timestamps` +- `show` +- `color` +- `thickness` + +## Generic Overlay Control + +### `set_overlay_show(tag, show, *, map_tag=None) -> None` + +Show or hide an overlay without deleting it. + +### `delete_overlay(tag, *, map_tag=None) -> None` + +Delete an overlay from its map and layer. + +When `map_tag` is omitted, the API can resolve an overlay only if the tag is unambiguous across +registered maps. In multi-map apps, pass `map_tag`. + +## Layers + +### `add_layer(name, *, z_index=None, show=True, map_tag=None) -> None` + +Create or update a logical overlay layer. + +### `show_layer(name, *, map_tag=None) -> None` + +Show all overlays assigned to a layer. + +### `hide_layer(name, *, map_tag=None) -> None` + +Hide all overlays assigned to a layer. + +### `clear_layer(name, *, map_tag=None) -> None` + +Delete all overlays assigned to a layer. + +### `clear_map(*, map_tag=None) -> None` + +Delete all overlays and invalidate map tile resources. + +## Tile Providers + +### `TileProvider` + +Immutable tile provider definition. + +```python +dpgm.TileProvider( + name="custom", + url_template="https://example.com/{z}/{x}/{y}.png", + min_zoom=0, + max_zoom=19, + tile_size=256, + attribution="Tiles (c) Example", + headers={}, + subdomains=(), + retina=False, + file_extension=None, +) +``` + +Fields: + +- `name`: provider registry name. +- `url_template`: XYZ URL template with `{z}`, `{x}`, and `{y}`. +- `min_zoom`, `max_zoom`: valid zoom range. +- `tile_size`: tile size in pixels. +- `attribution`: text drawn on the map. +- `headers`: request headers. +- `subdomains`: values for `{s}`. +- `retina`: if true, `{r}` expands to `"@2x"`. +- `file_extension`: value for `{ext}` and disk cache extension. + +### `register_provider(provider, *, replace=False) -> None` + +Register a provider by name. + +### `unregister_provider(name) -> None` + +Remove a registered provider. + +### `get_provider(name) -> TileProvider` + +Return a registered provider. + +### `list_providers() -> list[str]` + +Return registered provider names in sorted order. + +### `set_provider(provider, *, map_tag=None) -> None` + +Switch a map to another provider while preserving overlays and center. + +`provider` may be a provider name or a `TileProvider`. Provider switching clamps zoom to the new +provider range and invalidates stale tile loads. + +## Cache + +### `CacheStats` + +Public cache statistics snapshot. + +Fields: + +- `memory_tiles` +- `memory_max_tiles` +- `memory_hits` +- `memory_misses` +- `disk_bytes` +- `disk_max_bytes` +- `disk_hits` +- `disk_misses` +- `disk_path` + +### `get_cache_stats(*, map_tag=None) -> CacheStats` + +Return memory and disk cache diagnostics. With `map_tag=None`, returns global disk-cache stats and +configured memory limit. + +### `clear_memory_cache(*, map_tag=None) -> None` + +Clear decoded in-memory tile data. If `map_tag` is omitted outside a map context, all registered +maps are targeted. + +### `clear_disk_cache(provider=None, *, map_tag=None) -> None` + +Clear persistent tile cache data. + +- `provider=None`: clear all provider namespaces. +- `provider="osm"`: clear only one provider namespace. +- `map_tag="map"`: enqueue a map-scoped clear through the renderer. + +## Diagnostics + +### `get_map_debug_state(*, map_tag=None) -> dict[str, Any]` + +Return a diagnostic snapshot for a map. Keys include: + +- `tag` +- `center` +- `zoom` +- `requested_size` +- `measured_size` +- `visible` +- `provider` +- `overlay_count` +- `layers` +- `dirty_flags` +- `pending_command_count` +- `generation` +- `active_drag` +- `last_mouse_position` +- `tiles` + +This output is for debugging and logging. It is not guaranteed to be a stable serialization +format. + +## Exceptions + +Public exception classes are defined in `dpg_map.exceptions`: + +- `DpgMapError` +- `ProviderError` +- `ProviderExistsError` +- `ProviderNotFoundError` +- `InvalidProviderError` +- `ProjectionError` +- `MapNotFoundError` +- `OverlayNotFoundError` +- `CoordinateError` +- `ThreadingError` +- `CacheError` + +Common error cases: + +- Invalid latitude/longitude raises `CoordinateError`. +- Unknown map tags raise `MapNotFoundError`. +- Unknown overlay tags raise `OverlayNotFoundError`. +- Unknown or invalid providers raise provider-specific errors. + +## Threading Contract + +GUI-thread only: + +- `map_widget(...)` +- Dear PyGui setup and application code that creates widgets + +Runtime-safe public areas: + +- View updates: `set_center`, `set_zoom`, `set_view`, `fit_bounds` +- Overlay updates: `add_marker`, `update_marker`, `update_trajectory`, `delete_overlay` +- Layer updates: `add_layer`, `show_layer`, `hide_layer`, `clear_layer` +- Provider and cache updates: `set_provider`, `clear_memory_cache`, `clear_disk_cache` + +The renderer owns Dear PyGui draw commands, textures, input handlers, and frame callbacks. diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md new file mode 100644 index 0000000..822dd02 --- /dev/null +++ b/docs/EXAMPLES.md @@ -0,0 +1,146 @@ +# Examples + +The `examples/` directory contains runnable Dear PyGui programs: + +```bash +uv run python examples/basic_map.py +uv run python examples/custom_provider.py +uv run python examples/cache_stress.py +uv run python examples/markers_live_thread.py +uv run python examples/trajectory_live_thread.py +uv run python examples/sizing_window.py +uv run python examples/sizing_child.py +uv run python examples/sizing_table.py +uv run python examples/hidden_tab.py +``` + +## Basic Map + +Creates one map and one marker. + +```python +with dpg.window(label="Map", width=-1, height=-1): + with dpgm.map_widget(tag="map", center=(47.9029, 1.9093), zoom=15, width=-1, height=-1): + dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093) +``` + +Run: + +```bash +uv run python examples/basic_map.py +``` + +## Live Marker Updates + +Use explicit `map_tag` from background threads or callbacks. + +```python +dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093, map_tag="map") + +def update_from_telemetry(lat: float, lon: float) -> None: + dpgm.update_marker("vehicle", lat=lat, lon=lon, map_tag="map") +``` + +Run a threaded stress example: + +```bash +uv run python examples/markers_live_thread.py +``` + +## Live Trajectory + +Keep an application-owned point buffer and pass immutable snapshots to `update_trajectory(...)`. + +```python +points: list[tuple[float, float]] = [] +dpgm.add_trajectory("track", points=[], show_points=True, point_stride=12, map_tag="map") + +def push_point(lat: float, lon: float) -> None: + points.append((lat, lon)) + dpgm.update_trajectory("track", points=tuple(points), map_tag="map") +``` + +Run: + +```bash +uv run python examples/trajectory_live_thread.py +``` + +## Multiple Maps + +Each map is independent. Reusing overlay tags across different maps is valid, but runtime updates +should pass `map_tag` to avoid ambiguity. + +```python +with dpgm.map_widget(tag="live-map", center=(47.9, 1.9), zoom=15, width=-1, height=300): + dpgm.add_marker("vehicle", lat=47.9, lon=1.9) + +with dpgm.map_widget(tag="recap-map", center=(47.9, 1.9), zoom=13, width=-1, height=300): + dpgm.add_trajectory("vehicle", points=[]) + +dpgm.update_marker("vehicle", lat=47.901, lon=1.902, map_tag="live-map") +dpgm.update_trajectory("vehicle", points=lap_points, map_tag="recap-map") +``` + +## Custom Provider Switch + +Register a provider, create the map with it, and switch at runtime. + +```python +carto = dpgm.TileProvider( + name="carto-light", + url_template="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", + subdomains=("a", "b", "c", "d"), + attribution="(c) OpenStreetMap contributors (c) CARTO", +) + +dpgm.register_provider(carto) +dpgm.set_provider("carto-light", map_tag="map") +dpgm.set_provider("osm", map_tag="map") +``` + +Run: + +```bash +uv run python examples/custom_provider.py +``` + +## Cache Controls + +Configure a local cache and expose buttons in your UI. + +```python +dpgm.configure(cache_dir=".tile-cache", memory_cache_max_tiles=128) + +def clear_memory() -> None: + dpgm.clear_memory_cache(map_tag="map") + +def clear_disk() -> None: + dpgm.clear_disk_cache(map_tag="map") + +def refresh_stats() -> str: + stats = dpgm.get_cache_stats(map_tag="map") + return f"{stats.memory_tiles} memory tiles, {stats.disk_bytes} disk bytes" +``` + +Run: + +```bash +uv run python examples/cache_stress.py +``` + +## Layout And Sizing + +The widget works inside windows, child windows, tables, and hidden tabs. + +```python +with dpg.child_window(width=-1, height=420): + with dpgm.map_widget(tag="map-child", width=-1, height=-1): + dpgm.add_marker("inside-child", lat=47.0, lon=2.0) +``` + +Run: + +```bash +uv run python examples/sizing_child.py +``` diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 0000000..effcae7 --- /dev/null +++ b/docs/GETTING_STARTED.md @@ -0,0 +1,286 @@ +# Getting Started + +This guide walks through installing `dpg-map`, creating a map widget, adding overlays, updating +the map from runtime code, and configuring tile providers and caches. + +## Requirements + +`dpg-map` currently targets Python `>=3.14` and uses: + +- Dear PyGui for the UI +- Pillow for decoding tile images +- Requests for tile HTTP requests +- Platformdirs for the default disk cache location + +The project is managed with `uv`. + +## Install From This Repository + +For development inside this repository: + +```bash +uv sync +uv run python -c "import dpg_map as dpgm; print(dpgm.list_providers())" +``` + +For another local app using this checkout: + +```bash +cd /path/to/your-app +uv add --editable /home/hector/projects/dpg-map +``` + +Then import it as: + +```python +import dpg_map as dpgm +``` + +## Configure The Package + +Configure package-wide defaults before creating maps: + +```python +dpgm.configure( + user_agent="my-product/1.0 contact@example.com", + cache_dir=".tile-cache", + memory_cache_max_tiles=512, + disk_cache_max_bytes=2_000_000_000, +) +``` + +Set an application-specific `user_agent` when using OpenStreetMap tiles. If no user agent is +configured, `dpg-map` emits a runtime warning and uses a fallback package user agent. + +## Create A Map + +`map_widget(...)` is a Dear PyGui context manager. It creates a child window containing the map +drawlist and installs internal render and input handlers. + +```python +from typing import Any + +import dearpygui.dearpygui as _dpg +import dpg_map as dpgm + +dpg: Any = _dpg + +dpgm.configure(user_agent="getting-started/0.1 contact@example.com") + +dpg.create_context() +dpg.create_viewport(title="dpg-map", width=900, height=600) + +with dpg.window(label="Map", width=-1, height=-1): + with dpgm.map_widget(tag="map", center=(47.9029, 1.9093), zoom=15, width=-1, height=-1): + dpgm.add_marker("start", lat=47.9029, lon=1.9093, label="Start") + +dpg.setup_dearpygui() +dpg.show_viewport() +dpg.start_dearpygui() +dpg.destroy_context() +``` + +`map_widget(...)` must be called on the GUI thread inside an active Dear PyGui context. + +## Tags And Map Scope + +Each map has a `tag`. Runtime calls should pass `map_tag` when there may be more than one map: + +```python +dpgm.set_center(47.9029, 1.9093, map_tag="live-map") +dpgm.update_marker("vehicle", lat=47.9030, lon=1.9098, map_tag="live-map") +``` + +Inside a `with dpgm.map_widget(...)` block, overlay creation can omit `map_tag` because the map is +the current map context: + +```python +with dpgm.map_widget(tag="live-map"): + dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093) +``` + +Use explicit `map_tag` in callbacks, worker threads, and apps with multiple maps. + +## Add Overlays + +Markers: + +```python +dpgm.add_marker( + "vehicle", + lat=47.9029, + lon=1.9093, + label="Vehicle", + show_label=True, + color=(255, 80, 80, 255), + radius=6, + map_tag="map", +) +``` + +Polylines: + +```python +dpgm.add_polyline( + "route", + points=[(47.9029, 1.9093), (47.9050, 1.9150), (47.9080, 1.9180)], + color=(80, 180, 255, 255), + thickness=3, + map_tag="map", +) +``` + +Trajectories: + +```python +dpgm.add_trajectory( + "track", + points=[], + color=(255, 180, 60, 255), + thickness=3, + show_points=True, + point_stride=10, + map_tag="map", +) +``` + +Coordinates are `(lat, lon)` pairs in degrees. Latitude must be between `-90` and `90`; +longitude must be between `-180` and `180`. + +## Runtime Updates + +Runtime public calls are intended to be safe from background threads. They update logical state +and enqueue GUI-thread work instead of calling Dear PyGui directly. + +```python +dpgm.update_marker("vehicle", lat=current_lat, lon=current_lon, map_tag="map") +dpgm.update_trajectory("track", points=tuple(track_points), map_tag="map") +``` + +Overlay updates do not reset map center or zoom. + +## View Control + +```python +dpgm.set_center(47.9029, 1.9093, map_tag="map") +dpgm.set_zoom(15, map_tag="map") +dpgm.set_view(center=(47.9029, 1.9093), zoom=15, map_tag="map") +dpgm.fit_bounds(((47.89, 1.89), (47.92, 1.93)), map_tag="map") +``` + +`set_zoom(...)` and `set_view(...)` clamp zoom to the active provider range. + +Coordinate conversion helpers use map-local screen coordinates: + +```python +lat, lon = dpgm.screen_to_latlon(120, 80, map_tag="map") +x, y = dpgm.latlon_to_screen(47.9029, 1.9093, map_tag="map") +``` + +## Layers + +Overlays belong to named layers. Layers can be shown, hidden, cleared, and ordered with +`z_index`. + +```python +dpgm.add_layer("vehicles", z_index=80, map_tag="map") +dpgm.add_marker("car-1", lat=47.9, lon=1.9, layer="vehicles", map_tag="map") + +dpgm.hide_layer("vehicles", map_tag="map") +dpgm.show_layer("vehicles", map_tag="map") +dpgm.clear_layer("vehicles", map_tag="map") +``` + +Default layers include `default`, `markers`, `lines`, and `trajectories`. + +## Tile Providers + +OpenStreetMap is registered by default as `osm`. + +Custom providers use XYZ URL templates. Required template fields are `{z}`, `{x}`, and `{y}`. +Optional fields are `{s}` for subdomain, `{r}` for retina suffix, and `{ext}` for file extension. + +```python +provider = dpgm.TileProvider( + name="carto-light", + url_template="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", + subdomains=("a", "b", "c", "d"), + attribution="(c) OpenStreetMap contributors (c) CARTO", + file_extension="png", +) + +dpgm.register_provider(provider) +dpgm.set_provider("carto-light", map_tag="map") +``` + +Provider switching preserves overlays and center, clamps zoom to the new provider range, and +invalidates stale tile loads from the previous provider. + +## Cache + +`dpg-map` uses: + +- A memory cache for decoded runtime tiles +- A persistent provider-namespaced disk cache + +```python +stats = dpgm.get_cache_stats(map_tag="map") +print(stats.memory_tiles, stats.disk_bytes, stats.disk_path) + +dpgm.clear_memory_cache(map_tag="map") +dpgm.clear_disk_cache(provider="osm") +``` + +`clear_memory_cache(...)` is routed through the renderer command queue so Dear PyGui textures are +deleted on the GUI thread. `clear_disk_cache(...)` can clear all providers, one provider, or a +map-scoped cache directory. + +## Sizing + +The map widget is a Dear PyGui child window containing a measured drawlist. + +Common sizing modes: + +- `width=-1`, `height=-1`: fill available space where Dear PyGui supports it +- Positive `width` and `height`: fixed requested size +- `autosize_x=True` or `autosize_y=True`: pass Dear PyGui autosize flags to the child +- Hidden layouts preserve the last non-zero measured size until visible again + +Examples: + +```bash +uv run python examples/sizing_window.py +uv run python examples/sizing_child.py +uv run python examples/sizing_table.py +uv run python examples/hidden_tab.py +``` + +## Multiple Maps + +Multiple maps can be used in the same Dear PyGui app. Give each map a distinct tag and pass +`map_tag` for runtime updates. + +```python +with dpgm.map_widget(tag="live-map", center=(47.9, 1.9), zoom=15, width=-1, height=300): + dpgm.add_marker("vehicle", lat=47.9, lon=1.9) + +with dpgm.map_widget(tag="recap-map", center=(47.9, 1.9), zoom=13, width=-1, height=300): + dpgm.add_trajectory("lap", points=[]) + +dpgm.update_marker("vehicle", lat=47.901, lon=1.902, map_tag="live-map") +dpgm.update_trajectory("lap", points=lap_points, map_tag="recap-map") +``` + +Pan, zoom, tile rendering, overlays, providers, and caches are isolated per map unless you choose +to share global configuration. + +## Diagnostics + +Use `get_map_debug_state(...)` and `get_cache_stats(...)` while developing or troubleshooting: + +```python +debug = dpgm.get_map_debug_state(map_tag="map") +stats = dpgm.get_cache_stats(map_tag="map") +``` + +These snapshots are intended for diagnostics and logging, not as a stable serialization format. diff --git a/pyproject.toml b/pyproject.toml index 51836e2..45a340e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dpg-map" -version = "0.3.0b1" +version = "1.0.0" description = "Dear PyGui map widget for XYZ raster tiles and geographic overlays" readme = "README.md" authors = [ diff --git a/src/dpg_map/tiles.py b/src/dpg_map/tiles.py index e90738d..e38cc6d 100644 --- a/src/dpg_map/tiles.py +++ b/src/dpg_map/tiles.py @@ -316,7 +316,7 @@ class TileManager: RuntimeWarning, stacklevel=3, ) - headers["User-Agent"] = "dpg-map/0.3.0b1" + headers["User-Agent"] = "dpg-map/1.0.0" return headers def _visible_disk_paths(self, cache_dir: str | Path | None) -> list[Path]: diff --git a/uv.lock b/uv.lock index 8cf8684..e4933ea 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "dpg-map" -version = "0.3.0b1" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "dearpygui" },