Initial commit
This commit is contained in:
554
FEATURES.md
Normal file
554
FEATURES.md
Normal file
@@ -0,0 +1,554 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user