555 lines
15 KiB
Markdown
555 lines
15 KiB
Markdown
# 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.
|