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