720 lines
17 KiB
Markdown
720 lines
17 KiB
Markdown
# dpg-map Architecture — Locked Rebuild
|
|
|
|
This document defines the internal architecture for the rebuild.
|
|
|
|
The central rule is:
|
|
|
|
> Public API calls update logical state or enqueue commands. The GUI thread alone owns Dear PyGui items, draw commands, textures, handlers, and frame rendering.
|
|
|
|
This prevents flicker, dragging failures, random recentering, and Dear PyGui thread-safety issues when overlays are updated from telemetry threads.
|
|
|
|
## High-level architecture
|
|
|
|
```text
|
|
User API
|
|
↓
|
|
Thread-safe command/logical-state layer
|
|
↓
|
|
Map registry and MapState
|
|
↓
|
|
Renderer frame pump on GUI thread
|
|
↓
|
|
Dear PyGui child_window + drawlist + textures
|
|
```
|
|
|
|
Detailed flow:
|
|
|
|
```text
|
|
Telemetry thread / app thread / GUI callback
|
|
↓
|
|
public dpg_map function
|
|
↓
|
|
validate lightweight inputs
|
|
↓
|
|
acquire MapState.lock briefly
|
|
↓
|
|
update logical model and/or enqueue command
|
|
↓
|
|
mark specific dirty flags
|
|
↓
|
|
return immediately
|
|
↓
|
|
GUI frame callback drains commands
|
|
↓
|
|
renderer updates DPG draw layers/textures
|
|
```
|
|
|
|
## Critical invariants
|
|
|
|
### Threading invariants
|
|
|
|
1. Dear PyGui APIs are called only on the GUI thread.
|
|
2. Public API functions do not call Dear PyGui directly unless explicitly documented as GUI-thread-only.
|
|
3. Tile worker threads do network and disk work only.
|
|
4. Tile workers do not create or delete Dear PyGui textures.
|
|
5. Texture creation and deletion are queued to the renderer and executed on the GUI thread.
|
|
6. Runtime overlay updates are coalesced by overlay tag.
|
|
7. Locks are held briefly and never during network, disk, image decoding, or Dear PyGui calls.
|
|
|
|
### View invariants
|
|
|
|
1. `MapState.center` and `MapState.zoom` are changed only by view commands or interaction.
|
|
2. Overlay commands must never change center or zoom.
|
|
3. Tile loading must never change center or zoom.
|
|
4. Renderer redraw must render the current state; it must not infer a new center from overlays.
|
|
5. Provider switching may clamp zoom, but must not recenter unless the current center is invalid for the projection.
|
|
|
|
### Rendering invariants
|
|
|
|
1. Overlay redraw does not clear tile draw commands.
|
|
2. Tile redraw does not delete overlay logical state.
|
|
3. Provider switch clears only provider-specific tile resources.
|
|
4. Full redraw is reserved for initial render, size change, provider change, or recovery.
|
|
5. The renderer always reads a consistent snapshot of state for each frame.
|
|
6. DPG draw item tags are internal and not exposed as public overlay tags.
|
|
|
|
### Sizing invariants
|
|
|
|
1. The child window receives the requested Dear PyGui sizing intent.
|
|
2. The drawlist receives concrete measured dimensions.
|
|
3. Requested size and measured size are stored separately.
|
|
4. A measured size of zero while hidden must not permanently collapse the map.
|
|
5. The map should delay tile loading until a non-zero visible size is measured, unless explicitly configured otherwise.
|
|
6. Resize triggers tile and overlay redraw.
|
|
|
|
## Package layout
|
|
|
|
```text
|
|
src/dpg_map/
|
|
__init__.py
|
|
api.py
|
|
widget.py
|
|
state.py
|
|
commands.py
|
|
renderer.py
|
|
draw_layers.py
|
|
interaction.py
|
|
overlays.py
|
|
providers.py
|
|
projection.py
|
|
tiles.py
|
|
cache.py
|
|
sizing.py
|
|
diagnostics.py
|
|
types.py
|
|
exceptions.py
|
|
examples/
|
|
basic_map.py
|
|
sizing_window.py
|
|
sizing_child.py
|
|
sizing_table.py
|
|
hidden_tab.py
|
|
markers_live_thread.py
|
|
trajectory_live_thread.py
|
|
custom_provider.py
|
|
cache_stress.py
|
|
tests/
|
|
```
|
|
|
|
## Module responsibilities
|
|
|
|
### `__init__.py`
|
|
|
|
Exports the public API only.
|
|
|
|
No heavy initialization should happen here except registering the default provider.
|
|
|
|
### `api.py`
|
|
|
|
Contains thin public API wrappers, especially for runtime calls.
|
|
|
|
Responsibilities:
|
|
|
|
- resolve map/overlay tags
|
|
- validate simple arguments
|
|
- enqueue commands
|
|
- expose stable public functions
|
|
|
|
The public API should call into `state.py`, `commands.py`, and `overlays.py`, but should not know renderer internals.
|
|
|
|
### `commands.py`
|
|
|
|
Defines commands that can be submitted from any thread.
|
|
|
|
Suggested command model:
|
|
|
|
```python
|
|
class CommandKind(Enum):
|
|
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"
|
|
CLEAR_LAYER = "clear_layer"
|
|
CLEAR_MAP = "clear_map"
|
|
CLEAR_MEMORY_CACHE = "clear_memory_cache"
|
|
CLEAR_DISK_CACHE = "clear_disk_cache"
|
|
```
|
|
|
|
Command dataclass:
|
|
|
|
```python
|
|
@dataclass
|
|
class MapCommand:
|
|
kind: CommandKind
|
|
map_tag: str | int
|
|
payload: dict[str, Any]
|
|
created_at: float
|
|
```
|
|
|
|
Coalescing rules:
|
|
|
|
- `UPDATE_OVERLAY` commands coalesce by `(map_tag, overlay_tag)`.
|
|
- repeated `SET_VIEW` commands may coalesce, keeping the newest.
|
|
- `ADD_OVERLAY`, `DELETE_OVERLAY`, `CLEAR_LAYER`, `CLEAR_MAP`, and `SET_PROVIDER` must preserve order.
|
|
- provider switches form a barrier; older tile results for the previous provider must be ignored.
|
|
|
|
### `state.py`
|
|
|
|
Owns configuration, registries, and `MapState`.
|
|
|
|
Global config:
|
|
|
|
```python
|
|
@dataclass
|
|
class DpgMapConfig:
|
|
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
|
|
```
|
|
|
|
MapState:
|
|
|
|
```python
|
|
@dataclass
|
|
class MapState:
|
|
tag: str | int
|
|
|
|
# DPG implementation tags, internal only
|
|
child_window_tag: str | int
|
|
drawlist_tag: str | int
|
|
texture_registry_tag: str | int
|
|
handler_registry_tag: str | int
|
|
|
|
# sizing
|
|
requested_width: int
|
|
requested_height: int
|
|
requested_autosize_x: bool
|
|
requested_autosize_y: bool
|
|
measured_width: int = 0
|
|
measured_height: int = 0
|
|
last_nonzero_width: int = 0
|
|
last_nonzero_height: int = 0
|
|
is_visible: bool = False
|
|
|
|
# view
|
|
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)
|
|
|
|
# models
|
|
overlays: dict[str | int, Overlay] = field(default_factory=dict)
|
|
layers: dict[str, LayerState] = field(default_factory=default_layers)
|
|
|
|
# subsystems
|
|
command_queue: MapCommandQueue = field(default_factory=MapCommandQueue)
|
|
tile_manager: TileManager = field(default_factory=TileManager)
|
|
renderer: MapRenderer | None = None
|
|
interaction: InteractionState = field(default_factory=InteractionState)
|
|
|
|
# concurrency
|
|
lock: RLock = field(default_factory=RLock)
|
|
|
|
# redraw tracking
|
|
dirty: DirtyFlags = DirtyFlags.FULL
|
|
frame_scheduled: bool = False
|
|
generation: int = 0
|
|
```
|
|
|
|
`generation` increments on provider switch, full clear, or other operations that invalidate pending tile results.
|
|
|
|
### `types.py`
|
|
|
|
Contains aliases and small dataclasses:
|
|
|
|
```python
|
|
LatLon = tuple[float, float]
|
|
RGBA = tuple[int, int, int, int]
|
|
ScreenPoint = tuple[float, float]
|
|
```
|
|
|
|
### `exceptions.py`
|
|
|
|
Contains public exceptions:
|
|
|
|
```python
|
|
class DpgMapError(Exception): ...
|
|
class ProviderError(DpgMapError): ...
|
|
class UnknownProviderError(ProviderError): ...
|
|
class MapNotFoundError(DpgMapError): ...
|
|
class OverlayNotFoundError(DpgMapError): ...
|
|
class CoordinateError(DpgMapError): ...
|
|
class ThreadingError(DpgMapError): ...
|
|
class CacheError(DpgMapError): ...
|
|
```
|
|
|
|
### `providers.py`
|
|
|
|
Defines the provider system.
|
|
|
|
The default OpenStreetMap provider is registered at import time.
|
|
|
|
Provider identity is part of `TileID`, so caches are provider-namespaced.
|
|
|
|
### `projection.py`
|
|
|
|
Pure Web Mercator math only.
|
|
|
|
No Dear PyGui imports.
|
|
|
|
No global state.
|
|
|
|
Must include tests.
|
|
|
|
### `overlays.py`
|
|
|
|
Defines logical overlay dataclasses only.
|
|
|
|
Overlay objects are not Dear PyGui draw items.
|
|
|
|
```python
|
|
@dataclass
|
|
class Overlay:
|
|
tag: str | int
|
|
map_tag: str | int
|
|
layer: str
|
|
show: bool = True
|
|
user_data: Any = None
|
|
revision: int = 0
|
|
```
|
|
|
|
`revision` increments when an overlay changes. The renderer can use this to decide whether overlay draw data needs rebuilding.
|
|
|
|
Marker, Polyline, and Trajectory inherit from `Overlay`.
|
|
|
|
Path overlays should copy incoming coordinate sequences into internal tuples/lists during command processing, not store references to mutable user lists. This prevents background buffers changing while the renderer reads them.
|
|
|
|
### `widget.py`
|
|
|
|
Creates the Dear PyGui structure.
|
|
|
|
Internal structure:
|
|
|
|
```text
|
|
child_window
|
|
drawlist
|
|
```
|
|
|
|
Rules:
|
|
|
|
- child window receives the user-requested size.
|
|
- drawlist uses measured concrete content size.
|
|
- map is registered before entering the context body.
|
|
- current map stack is pushed for context-style overlay creation.
|
|
- frame callback is scheduled after creation to measure and render.
|
|
- resize/visibility handlers mark `SIZE_DIRTY`.
|
|
|
|
The widget must not expose `child_window_tag` or `drawlist_tag`.
|
|
|
|
### `sizing.py`
|
|
|
|
Dedicated sizing helper module.
|
|
|
|
Responsibilities:
|
|
|
|
- measure child content region
|
|
- maintain last non-zero measured size
|
|
- decide whether tile loading can begin
|
|
- resize drawlist safely
|
|
- detect hidden-to-visible transitions
|
|
- avoid permanent collapse from zero-size measurements
|
|
|
|
Suggested function:
|
|
|
|
```python
|
|
def update_measured_size(state: MapState) -> bool:
|
|
"""Return True if concrete drawlist size changed."""
|
|
```
|
|
|
|
### `interaction.py`
|
|
|
|
Owns panning and zooming.
|
|
|
|
Responsibilities:
|
|
|
|
- hit-test against concrete drawlist rectangle
|
|
- track active drag state
|
|
- update center during drag
|
|
- update zoom during wheel
|
|
- zoom around cursor where practical
|
|
- never allow overlay updates to reset interaction state
|
|
|
|
Interaction should use the current measured drawlist rect, not only `dpg.is_item_hovered`.
|
|
|
|
### `tiles.py`
|
|
|
|
Owns tile identity, tile lifecycle, and visible tile calculation.
|
|
|
|
TileID:
|
|
|
|
```python
|
|
@dataclass(frozen=True)
|
|
class TileID:
|
|
provider_name: str
|
|
z: int
|
|
x: int
|
|
y: int
|
|
```
|
|
|
|
Tile states:
|
|
|
|
```python
|
|
class TileStatus(Enum):
|
|
MISSING = "missing"
|
|
QUEUED = "queued"
|
|
LOADING = "loading"
|
|
READY = "ready"
|
|
FAILED = "failed"
|
|
```
|
|
|
|
Tile manager responsibilities:
|
|
|
|
- compute visible tile IDs
|
|
- queue missing tiles
|
|
- avoid duplicate requests
|
|
- ignore stale tile results from old generations/providers
|
|
- manage memory cache metadata
|
|
- request texture creation on GUI thread
|
|
- request texture deletion on GUI thread
|
|
- protect currently visible tiles from eviction
|
|
|
|
Tile workers:
|
|
|
|
- check disk cache
|
|
- fetch network if needed
|
|
- decode image bytes to RGBA-compatible data if practical
|
|
- return loaded data to GUI queue
|
|
- never call DPG APIs
|
|
|
|
### `cache.py`
|
|
|
|
Owns persistent disk cache and memory cache policy.
|
|
|
|
Disk path:
|
|
|
|
```text
|
|
{cache_dir}/{provider_name}/{z}/{x}/{y}.{ext}
|
|
```
|
|
|
|
Metadata path:
|
|
|
|
```text
|
|
{cache_dir}/{provider_name}/{z}/{x}/{y}.json
|
|
```
|
|
|
|
Metadata should support:
|
|
|
|
```json
|
|
{
|
|
"url": "...",
|
|
"etag": "...",
|
|
"last_modified": "...",
|
|
"expires": "...",
|
|
"downloaded_at": 0,
|
|
"last_accessed_at": 0,
|
|
"size_bytes": 0
|
|
}
|
|
```
|
|
|
|
MVP disk cache behaviour:
|
|
|
|
- file exists and is readable: use it
|
|
- file missing: fetch it
|
|
- update `last_accessed_at`
|
|
- prune by LRU when above `disk_cache_max_bytes`
|
|
- pruning must not delete files currently loading or currently visible
|
|
|
|
HTTP-aware cache headers can be added after the stable MVP.
|
|
|
|
### `renderer.py`
|
|
|
|
The renderer is GUI-thread-only.
|
|
|
|
Responsibilities:
|
|
|
|
- drain command queue
|
|
- apply commands to logical state
|
|
- process tile worker results
|
|
- create/delete textures
|
|
- measure size
|
|
- compute visible tiles
|
|
- redraw dirty layers
|
|
- draw attribution/debug information
|
|
|
|
The renderer must support separate draw layers. Dear PyGui drawlist does not provide retained sublayers in the same way as a scene graph, so implementation may use internal groups/tags or controlled delete/rebuild by tag prefix. The important rule is behavioural:
|
|
|
|
- overlay update must not clear tile textures or tile logical state
|
|
- tile update must not modify overlay logical state
|
|
- full redraw must not reset view state
|
|
|
|
### `draw_layers.py`
|
|
|
|
Optional helper module for draw item bookkeeping.
|
|
|
|
Suggested structure:
|
|
|
|
```python
|
|
@dataclass
|
|
class DrawLayer:
|
|
name: str
|
|
item_tags: set[str | int] = field(default_factory=set)
|
|
```
|
|
|
|
Renderer can delete and rebuild only a selected layer.
|
|
|
|
### `diagnostics.py`
|
|
|
|
Provides debug state for examples and troubleshooting.
|
|
|
|
```python
|
|
def get_map_debug_state(map_tag) -> dict[str, Any]:
|
|
...
|
|
```
|
|
|
|
Should expose:
|
|
|
|
- center
|
|
- zoom
|
|
- measured size
|
|
- requested size
|
|
- visible tile count
|
|
- queued/loading/failed tile counts
|
|
- memory cache count
|
|
- disk cache bytes
|
|
- overlay count
|
|
- dirty flags
|
|
- active drag state
|
|
- pending command count
|
|
- generation
|
|
|
|
## Rendering frame lifecycle
|
|
|
|
Each frame callback should roughly do:
|
|
|
|
```text
|
|
for each map:
|
|
1. update measured size
|
|
2. drain pending public commands
|
|
3. apply interaction input
|
|
4. process tile results
|
|
5. compute visible tiles if view/size/provider changed
|
|
6. queue missing visible tiles
|
|
7. create/delete textures requested by tile manager
|
|
8. redraw required layers based on dirty flags
|
|
9. clear dirty flags
|
|
10. schedule next frame only if work remains or map needs continuous interaction polling
|
|
```
|
|
|
|
## Dirty flag model
|
|
|
|
Use bit flags:
|
|
|
|
```python
|
|
class DirtyFlags(IntFlag):
|
|
NONE = 0
|
|
VIEW = auto()
|
|
TILES = auto()
|
|
OVERLAYS = auto()
|
|
SIZE = auto()
|
|
PROVIDER = auto()
|
|
ATTRIBUTION = auto()
|
|
DEBUG = auto()
|
|
FULL = VIEW | TILES | OVERLAYS | SIZE | PROVIDER | ATTRIBUTION | DEBUG
|
|
```
|
|
|
|
Expected usage:
|
|
|
|
| Operation | Dirty flags |
|
|
|---|---|
|
|
| marker update | `OVERLAYS` |
|
|
| trajectory update | `OVERLAYS` |
|
|
| layer visibility | `OVERLAYS` |
|
|
| pan | `VIEW | TILES | OVERLAYS` |
|
|
| zoom | `VIEW | TILES | OVERLAYS` |
|
|
| resize | `SIZE | TILES | OVERLAYS` |
|
|
| provider switch | `PROVIDER | TILES | ATTRIBUTION` |
|
|
| cache tile ready | `TILES` |
|
|
| debug setting changed | `DEBUG` |
|
|
|
|
## Command queue and coalescing
|
|
|
|
The command queue must prevent flooding during telemetry.
|
|
|
|
Recommended approach:
|
|
|
|
```python
|
|
class MapCommandQueue:
|
|
ordered: deque[MapCommand]
|
|
overlay_updates: dict[tuple[map_tag, overlay_tag], MapCommand]
|
|
view_update: MapCommand | None
|
|
lock: Lock
|
|
```
|
|
|
|
When draining:
|
|
|
|
1. preserve ordered structural commands
|
|
2. apply newest coalesced view command
|
|
3. apply newest overlay updates
|
|
4. mark dirty flags once
|
|
|
|
Structural commands:
|
|
|
|
- add overlay
|
|
- delete overlay
|
|
- clear layer
|
|
- clear map
|
|
- provider switch
|
|
|
|
Coalesced commands:
|
|
|
|
- update marker
|
|
- update polyline
|
|
- update trajectory
|
|
- set overlay show
|
|
- set center/zoom/view
|
|
|
|
## Tile result generation safety
|
|
|
|
Every tile request should include:
|
|
|
|
```python
|
|
provider_name
|
|
generation
|
|
tile_id
|
|
```
|
|
|
|
When result returns:
|
|
|
|
- if generation no longer matches map generation: discard result
|
|
- if provider no longer matches: discard result
|
|
- if tile no longer requested/visible/cacheable: optionally keep disk cache but do not create texture immediately
|
|
|
|
This prevents stale workers from drawing old-provider tiles after a provider switch.
|
|
|
|
## Persistent disk cache pruning
|
|
|
|
Disk cache pruning should run:
|
|
|
|
- at startup/open map
|
|
- after downloads push cache above the limit
|
|
- optionally manually via `clear_disk_cache`
|
|
|
|
Pruning rules:
|
|
|
|
1. calculate provider or global cache size
|
|
2. sort cached tile metadata by `last_accessed_at`
|
|
3. delete oldest until under target
|
|
4. never delete files currently visible/loading
|
|
5. tolerate missing/corrupt metadata
|
|
|
|
## Sizing strategy
|
|
|
|
The map widget uses two sizes:
|
|
|
|
```python
|
|
requested_width / requested_height
|
|
measured_width / measured_height
|
|
```
|
|
|
|
The child window is created with requested values:
|
|
|
|
```python
|
|
dpg.add_child_window(width=requested_width, height=requested_height, ...)
|
|
```
|
|
|
|
The drawlist is resized to measured content size:
|
|
|
|
```python
|
|
content_width = dpg.get_item_width(child_window_tag)
|
|
content_height = dpg.get_item_height(child_window_tag)
|
|
dpg.configure_item(drawlist_tag, width=content_width, height=content_height)
|
|
```
|
|
|
|
If measured size is zero:
|
|
|
|
- keep last non-zero size
|
|
- do not permanently configure drawlist to zero
|
|
- if no last non-zero size exists, render placeholder only and delay tile loading
|
|
|
|
## Initial setup instructions
|
|
|
|
These replace the old Step 0.
|
|
|
|
```bash
|
|
mkdir dpg-map
|
|
cd dpg-map
|
|
git init
|
|
uv init --package dpg-map
|
|
uv add dearpygui pillow platformdirs requests
|
|
uv add --dev pytest ruff pyright
|
|
```
|
|
|
|
Create:
|
|
|
|
```text
|
|
src/dpg_map/
|
|
examples/
|
|
tests/
|
|
FEATURES.md
|
|
ARCHITECTURE.md
|
|
STEPS.md
|
|
AGENTS.md
|
|
README.md
|
|
```
|
|
|
|
Use `AGENTS.md` as the rolling implementation log. After every step:
|
|
|
|
```bash
|
|
uv run pytest
|
|
uv run ruff check .
|
|
uv run ruff format --check .
|
|
git status
|
|
git add .
|
|
git commit -m "step N: short description"
|
|
```
|
|
|
|
## Development examples required
|
|
|
|
The rebuild must include examples specifically designed to reproduce the first build's failure modes:
|
|
|
|
```text
|
|
examples/basic_map.py
|
|
examples/sizing_window.py
|
|
examples/sizing_child.py
|
|
examples/sizing_table.py
|
|
examples/hidden_tab.py
|
|
examples/markers_live_thread.py
|
|
examples/trajectory_live_thread.py
|
|
examples/custom_provider.py
|
|
examples/cache_stress.py
|
|
```
|