Files
dpg-map/FEATURES.md
2026-05-22 18:14:35 +02:00

555 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 2060 Hz from another thread.
7. Dragging works while a trajectory is updated at 1030 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.