# dpg-map Feature Specification — Locked API Rebuild This document defines the public behaviour and API contract for the next rebuild of `dpg-map`. The first build reached overlay/layer functionality, but suffered from rendering instability, flickering, bad drag behaviour while overlays were updating, occasional unwanted recentering, cache limitations, and sizing mismatches between the child container and the drawlist. The rebuild must treat the public API as a stable contract from the beginning. ## Project summary `dpg-map` is a Python dependency providing a Dear PyGui map widget for raster XYZ map tiles and geographic overlays. It should be usable as: ```python import dpg_map as dpgm ``` The widget should feel natural in Dear PyGui code: ```python 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) dpgm.add_trajectory("lap", lats=[], lons=[]) ``` Runtime updates from telemetry or worker threads must be safe: ```python dpgm.update_marker("vehicle", lat=current_lat, lon=current_lon) dpgm.update_trajectory("lap", lats=lat_buffer, lons=lon_buffer) ``` Those calls must not directly call Dear PyGui APIs, block the caller, reset the map view, flicker the map, or interfere with dragging/zooming. ## Core requirements ### 1. Stable public API The public API is considered locked once implemented. Internals may change, but user code should not need to change. Required public areas: - global configuration - tile providers - map widget creation - map view state - overlays - layers - cache configuration - diagnostics/debugging ### 2. Thread-safe runtime updates Any public runtime function may be called from a non-GUI thread unless explicitly documented otherwise. Examples: ```python dpgm.update_marker(...) dpgm.update_polyline(...) dpgm.update_trajectory(...) dpgm.set_overlay_show(...) dpgm.delete_overlay(...) dpgm.set_center(...) dpgm.set_zoom(...) dpgm.set_view(...) dpgm.set_provider(...) dpgm.clear_layer(...) ``` Rules: - Public runtime functions must be non-blocking or near non-blocking. - They must never call Dear PyGui draw, texture, item, handler, or viewport APIs directly. - They must update a thread-safe logical state model and/or enqueue commands. - The GUI thread drains the queue and performs Dear PyGui work. - Overlay updates must not mutate map center, zoom, pan state, active drag state, or tile provider unless the function is explicitly a view/provider function. - Overlay updates must be coalesced, so a telemetry thread can update at high rate without flooding the GUI thread. ### 3. Interaction must remain stable during overlay updates While markers, polylines, or trajectories are being added/updated/deleted: - the user must still be able to drag the map - the user must still be able to zoom - the map must not recenter unexpectedly - the map must not flash through an empty state - tile draw commands should not be destroyed just because an overlay changed - overlay draw refreshes should be isolated from tile draw refreshes where possible ### 4. Persistent tile caching The cache must have two independent limits: ```python memory_cache_max_tiles: int = 512 disk_cache_max_bytes: int | None = None ``` Memory cache: - runtime only - LRU or approximate LRU - stores decoded tile data and/or active texture references - protects currently visible tiles from eviction - evicts Dear PyGui textures only on the GUI thread Disk cache: - persistent across application restarts - provider-namespaced - configurable path - configurable size limit - pruned in the background or during startup - avoids repeatedly fetching regularly used tiles Default disk cache path should use `platformdirs` unless overridden. ### 5. Sizing must be robust The Dear PyGui child container and internal drawlist must stay in sync. Requirements: - `width=0` and `height=0` preserve Dear PyGui default behaviour. - `width=-1` fills available width. - `height=-1` fills available height where Dear PyGui allows it. - fixed positive dimensions are respected. - `autosize_x` and `autosize_y` are supported where practical. - the child window stores the requested sizing intent. - the drawlist always uses measured concrete pixel dimensions. - the drawlist must resize when the child content region changes. - the map must work inside windows, groups, child windows, tabs, tables, and hidden/show-later layouts. - the renderer must not get stuck at the first measured size. ### 6. Interchangeable TileProvider system OpenStreetMap is the default provider, but users must be able to use custom XYZ providers. ```python provider = dpgm.TileProvider( name="custom", url_template="https://example.com/{z}/{x}/{y}.png", attribution="Tiles © Example", ) dpgm.register_provider(provider) ``` Provider switching should preserve overlays and the current center where possible. ### 7. uv-first package management The project must be managed with `uv`. Users should be able to install locally with: ```bash uv add -e ../dpg-map ``` The package must support normal `uv add`, `uv sync`, `uv run pytest`, and editable local development. ## Non-goals for the first rebuilt version Do not implement these in the first rebuild: - vector map rendering - Mapbox GL rendering - geocoding/search - routing - offline planet files - arbitrary projections - map rotation/pitch - complex hit testing for lines/polygons - GPU-accelerated custom renderers - tile blending/fade animations ## Public API ### Global configuration ```python dpgm.configure( *, user_agent: str | None = None, cache_dir: str | Path | None = None, default_provider: str | dpgm.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 ``` Notes: - `disk_cache_max_bytes=None` means unlimited disk cache. - `overlay_update_policy="coalesce"` means only the newest update for each overlay is rendered. - OpenStreetMap usage should require or strongly warn about a valid application-specific `user_agent`. ### TileProvider ```python @dataclass(frozen=True) class TileProvider: 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 ``` Provider helpers: ```python dpgm.register_provider(provider: dpgm.TileProvider) -> None dpgm.unregister_provider(name: str) -> None dpgm.get_provider(name: str) -> dpgm.TileProvider dpgm.list_providers() -> list[str] ``` Default provider: ```python OSM = TileProvider( name="osm", url_template="https://tile.openstreetmap.org/{z}/{x}/{y}.png", min_zoom=0, max_zoom=19, tile_size=256, attribution="© OpenStreetMap contributors", ) ``` Provider URL template rules: - `{z}`, `{x}`, and `{y}` are required. - `{s}` is optional for subdomains. - `{r}` is optional for retina suffixes. - invalid templates must raise a clear provider exception. ### Map widget ```python with dpgm.map_widget( tag: str | int | None = None, center: tuple[float, float] = (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, border: bool = True, provider: str | dpgm.TileProvider | None = None, cache_dir: str | Path | None = None, user_agent: str | None = None, no_scrollbar: bool = True, delay_tile_load_until_visible: bool = True, ) as map_tag: ... ``` The returned `map_tag` is the logical map tag, not a Dear PyGui item tag. ### View state API ```python dpgm.set_center(map_tag, lat: float, lon: float) -> None dpgm.get_center(map_tag) -> tuple[float, float] dpgm.set_zoom(map_tag, zoom: int) -> None dpgm.get_zoom(map_tag) -> int dpgm.set_view( map_tag, *, center: tuple[float, float] | None = None, zoom: int | None = None, ) -> None dpgm.fit_bounds( map_tag, min_lat: float, min_lon: float, max_lat: float, max_lon: float, padding: int = 32, ) -> None dpgm.screen_to_latlon(map_tag, x: float, y: float) -> tuple[float, float] dpgm.latlon_to_screen(map_tag, lat: float, lon: float) -> tuple[float, float] ``` View state commands may be called from other threads, but they are queued and applied on the GUI thread. `get_*` functions return the latest committed logical state. ### Overlay creation API All overlay creation functions can be used inside a map context or at runtime with `map_tag`. #### Marker ```python dpgm.add_marker( tag: str | int, lat: float, lon: float, *, label: str | None = None, color: tuple[int, int, int, int] = (255, 80, 80, 255), radius: float = 5.0, layer: str = "markers", show: bool = True, show_label: bool = False, user_data: Any = None, callback: Callable | None = None, map_tag: str | int | None = None, ) -> str | int ``` #### Polyline ```python dpgm.add_polyline( tag: str | int, points: Sequence[tuple[float, float]] | None = None, *, lats: Sequence[float] | None = None, lons: Sequence[float] | None = None, color: tuple[int, int, int, int] = (80, 180, 255, 255), thickness: float = 2.0, layer: str = "lines", show: bool = True, closed: bool = False, simplify: bool = True, user_data: Any = None, map_tag: str | int | None = None, ) -> str | int ``` #### Trajectory ```python dpgm.add_trajectory( tag: str | int, lats: Sequence[float], lons: Sequence[float], *, timestamps: Sequence[float] | None = None, color: tuple[int, int, int, int] = (255, 180, 60, 255), thickness: float = 2.0, show_points: bool = False, point_stride: int = 1, layer: str = "trajectories", show: bool = True, user_data: Any = None, map_tag: str | int | None = None, ) -> str | int ``` ### Overlay update API Overlay update APIs must be safe from background threads and must not directly touch Dear PyGui. ```python dpgm.update_marker( tag: str | int, *, lat: float | None = None, lon: float | None = None, label: str | None = None, show: bool | None = None, color: tuple[int, int, int, int] | None = None, radius: float | None = None, ) -> None ``` ```python dpgm.update_polyline( tag: str | int, points: Sequence[tuple[float, float]] | None = None, *, lats: Sequence[float] | None = None, lons: Sequence[float] | None = None, show: bool | None = None, color: tuple[int, int, int, int] | None = None, thickness: float | None = None, ) -> None ``` ```python dpgm.update_trajectory( tag: str | int, *, lats: Sequence[float] | None = None, lons: Sequence[float] | None = None, timestamps: Sequence[float] | None = None, show: bool | None = None, color: tuple[int, int, int, int] | None = None, thickness: float | None = None, ) -> None ``` Convenience aliases: ```python dpgm.set_marker_position(tag, lat, lon) -> None dpgm.set_marker_label(tag, label) -> None dpgm.set_polyline_points(tag, points=None, *, lats=None, lons=None) -> None dpgm.set_overlay_show(tag, show: bool) -> None dpgm.delete_overlay(tag) -> None ``` ### Layer API ```python dpgm.add_layer(map_tag, name: str, z_index: int) -> None dpgm.show_layer(map_tag, name: str) -> None dpgm.hide_layer(map_tag, name: str) -> None dpgm.clear_layer(map_tag, name: str) -> None ``` Layer operations are queued and applied by the GUI thread. ### Provider switching ```python dpgm.set_provider(map_tag, provider: str | dpgm.TileProvider) -> None ``` Provider switching must: - keep overlays - keep center where possible - clamp zoom to provider limits - clear or detach old tile draw layer only - update attribution - not rebuild overlays unnecessarily - not call Dear PyGui from the caller thread ### Cache control API ```python dpgm.clear_memory_cache(map_tag: str | int | None = None) -> None dpgm.clear_disk_cache(provider: str | None = None) -> None dpgm.get_cache_stats(map_tag: str | int | None = None) -> dpgm.CacheStats ``` `CacheStats` should include at least: ```python @dataclass(frozen=True) class CacheStats: memory_tiles: int memory_limit_tiles: int disk_bytes: int disk_limit_bytes: int | None queued_tiles: int loading_tiles: int failed_tiles: int ``` ### Diagnostics ```python dpgm.get_map_debug_state(map_tag) -> dict[str, Any] ``` Used to debug: - measured child size - drawlist size - center/zoom - visible tile count - overlay count - pending command count - cache stats - whether the map is currently considered visible/hovered/dragging ## Rendering stability requirements ### Dirty flags The implementation must separate dirty reasons: ```python VIEW_DIRTY TILES_DIRTY OVERLAYS_DIRTY SIZE_DIRTY PROVIDER_DIRTY FULL_DIRTY ``` Overlay updates should set `OVERLAYS_DIRTY`, not `FULL_DIRTY`. Panning/zooming should set `VIEW_DIRTY` and likely `TILES_DIRTY | OVERLAYS_DIRTY`. Resizing should set `SIZE_DIRTY | TILES_DIRTY | OVERLAYS_DIRTY`. Provider switching should set `PROVIDER_DIRTY | TILES_DIRTY`, but should not delete overlay logical state. ### Draw layers Internally, the renderer should use separate draw layers/groups where possible: ```text background layer tile layer overlay layer attribution/debug layer ``` Overlay redraws should not wipe tile draw commands unless the view, size, or provider changed. ### View ownership Only these may intentionally change map center/zoom: - user pan/zoom interaction - `set_center` - `set_zoom` - `set_view` - `fit_bounds` - provider switch only if zoom must be clamped Overlay creation/update/delete must never recenter the map. ## Acceptance tests for the rebuild The following manual and automated examples are required before treating the rebuild as usable: 1. Basic map shows OSM tiles. 2. Map fills a resizable Dear PyGui window. 3. Map inside a child window fills its container. 4. Map inside a table cell fills its cell. 5. Map inside a hidden tab starts loading when shown. 6. Dragging works while a marker is updated at 20–60 Hz from another thread. 7. Dragging works while a trajectory is updated at 10–30 Hz from another thread. 8. No unexpected center change occurs during overlay updates. 9. Returning to a recently viewed area uses memory cache. 10. Restarting the app and viewing the same area uses disk cache. 11. Disk cache prunes to the configured limit. 12. Provider switch keeps overlays. 13. `uv add -e ../dpg-map` works from another project.