Compare commits

...

7 Commits

Author SHA1 Message Date
192ce33d64 Release v1.0.0 2026-06-08 13:46:19 +02:00
6990d011ff Release v1.0.0
This is the first releas of dpg-map, an AI coded dearpygui map widget
built due to a lack of time to build it myself.

It features multi-widget abilities, with layers, overlays etc
2026-06-08 13:44:32 +02:00
a5e560937c Fixed tiling bug and multi map bug 2026-06-08 13:35:09 +02:00
50e38e18ee step 8: harden docs and prepare rebuilt beta 2026-05-23 10:47:34 +02:00
d0ba8c4218 fix drag polling during overlay updates 2026-05-23 10:31:27 +02:00
815d8a2d88 step 6: add stable overlays and live update stress tests 2026-05-23 10:24:34 +02:00
2d6242bd3f step 5: add stable pan zoom and view commands 2026-05-23 10:19:13 +02:00
31 changed files with 2720 additions and 193 deletions

View File

@@ -1,74 +0,0 @@
# AGENTS.md
## Current status
Step 4 complete.
## Completed steps
Step 1 - Public API contract and pure core.
Step 2 - Thread-safe state, commands, overlays, and cache model.
Step 3 - Widget shell, sizing system, and GUI-thread frame pump.
Step 4 - Tile manager, persistent cache, and asynchronous loading.
## Current step
Step 5 - Interaction: pan, zoom, and view commands.
## Design decisions
- Package is managed with uv.
- Public import is `import dpg_map as dpgm`.
- Dear PyGui calls are GUI-thread-only.
- Runtime public calls enqueue commands or update logical state.
- Overlay updates must not reset center/zoom.
- The widget uses child_window + measured-size drawlist.
- Tiles use a memory cache and persistent disk cache.
- Tile providers are interchangeable.
## Known issues
None yet.
## Commands used
- Read `STEPS.md`, `FEATURES.md`, and `ARCHITECTURE.md`.
- Created initial package, examples, tests, and agent-log structure.
- Implemented public exports, exceptions, common types, tile provider registry, projection helpers, cache dataclasses, and GUI-dependent API stubs.
- Added Step 1 tests for imports, providers, projection, and cache dataclasses.
- Implemented global configuration, logical MapState, map registry, and current map context stack.
- Implemented DirtyFlags, MapCommand, CommandKind, and coalescing MapCommandQueue.
- Implemented logical marker, polyline, trajectory, and layer state models.
- Implemented public runtime overlay/view/layer/provider/cache/debug wrappers against logical state without Dear PyGui calls.
- Implemented memory cache metadata, disk cache path generation, metadata read/write, disk size scanning, and prune planning.
- Added Step 2 tests for command coalescing, overlay/view isolation, copied trajectory inputs, coordinate length validation, layer state, disk path generation, and prune ordering.
- Implemented `map_widget(...)` as a Dear PyGui child-window plus drawlist shell.
- Implemented GUI-thread renderer frame pump that schedules frame callbacks, drains command queues, measures size, resizes the drawlist, and draws a placeholder background/attribution.
- Implemented sizing helpers for measured size, last non-zero size preservation, visibility transitions, effective draw size, and resize dirty flags.
- Implemented map interaction hit-rectangle calculation.
- Implemented TileID, TileStatus, Tile, visible tile calculation, and TileManager.
- Implemented asynchronous tile worker queue for disk reads, HTTP fetches, and image decoding.
- Implemented provider-namespaced persistent cache writes, access metadata updates, clearing, and LRU pruning.
- Implemented memory tile cache with visible-tile protection and deferred GUI-thread texture deletion.
- Integrated tile result processing, stale generation/provider rejection, texture creation, and tile drawing into the GUI-thread renderer.
- Added OpenStreetMap User-Agent warning/fallback and configured examples with example user agents.
- Added Step 3 examples for basic map, window sizing, child-window sizing, table sizing, and hidden-tab sizing.
- Added Step 4 cache stress example.
- Added Step 3 tests for sizing transitions, zero-size preservation, resize dirty flags, command drain ordering, and hit rectangles.
- Added Step 4 tests for visible tile calculation, stale result rejection, protected memory eviction, and tile image decoding.
- Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist creation.
- Ran `uv run pytest`.
- Ran `uv run ruff check .`.
- Ran `uv run ruff format .`.
- Ran `uv run ruff format --check .`.
- Ran `uv run pyright`.
- Ran `uv run pytest`.
- Ran `uv run ruff check .`.
- Ran `uv run ruff format .`.
- Ran `uv run ruff format --check .`.
- Ran `uv run pyright`.
- Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist/texture-registry creation.
## Next action
Implement Step 5.

111
README.md
View File

@@ -1,51 +1,88 @@
# dpg-map
`dpg-map` is a Dear PyGui map widget package under rebuild.
The Step 4 tile manager is in place:
`dpg-map` is a Dear PyGui widget for interactive XYZ raster maps, tile caching, and
geographic overlays.
```python
import dpg_map as dpgm
provider = dpgm.TileProvider(
name="custom",
url_template="https://example.com/{z}/{x}/{y}.png",
attribution="Tiles (c) Example",
)
dpgm.register_provider(provider)
with dpgm.map_widget(tag="map", center=(47.9029, 1.9093), zoom=15):
dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093)
dpgm.update_marker("vehicle", lat=47.9030, lon=1.9094, map_tag="map")
```
Implemented so far:
The widget is built for telemetry dashboards and internal tools that need maps inside normal
Dear PyGui layouts. Runtime updates are thread-safe: marker, trajectory, view, provider, and
cache calls update logical state or enqueue renderer work, while Dear PyGui draw calls stay on
the GUI thread.
- public package exports
- tile provider definitions and registry
- Web Mercator projection helpers
- thread-safe logical map state and map registry
- command queue with coalescing for overlay and view updates
- logical marker, polyline, trajectory, and layer models
- persistent disk cache paths, metadata, scanning, pruning, and clearing
- Dear PyGui `child_window` + measured-size `drawlist` widget shell
- GUI-thread frame pump that drains commands, manages textures, and draws raster tiles
- sizing helpers that preserve the last non-zero size across hidden layouts
- interaction hit-rectangle calculation for the measured map area
- asynchronous tile workers that read disk cache, fetch HTTP, and decode images
- memory cache with visible-tile protection and GUI-thread texture deletion
## Install
Overlay drawing is not implemented yet. Step 5 will add pan/zoom interaction and
view command projection.
For local development:
Examples:
```bash
uv sync
uv run pytest
```
From another local project:
```bash
uv add --editable ../dpg-map
uv run python -c "import dpg_map as dpgm; print(dpgm.list_providers())"
```
## Minimal Example
```python
from typing import Any
import dearpygui.dearpygui as _dpg
import dpg_map as dpgm
dpg: Any = _dpg
dpgm.configure(user_agent="my-app/0.1 contact@example.com")
dpg.create_context()
dpg.create_viewport(title="Map", width=900, height=600)
with dpg.window(label="Map", width=-1, height=-1):
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, label="Vehicle")
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.start_dearpygui()
dpg.destroy_context()
```
Run the bundled example:
```bash
uv run python examples/basic_map.py
uv run python examples/sizing_window.py
uv run python examples/sizing_child.py
uv run python examples/sizing_table.py
uv run python examples/hidden_tab.py
uv run python examples/cache_stress.py
```
## Documentation
- [Getting Started](docs/GETTING_STARTED.md)
- [Examples](docs/EXAMPLES.md)
- [API Reference](docs/API_REFERENCE.md)
Internal rebuild notes, implementation plans, and agent logs live in [codex/](codex/).
## Highlights
- OpenStreetMap provider registered by default as `osm`
- Custom XYZ tile providers
- Pan and cursor-centered zoom
- Markers, polylines, and live trajectories
- Overlay layers with visibility and z-order controls
- Memory tile cache and provider-namespaced disk cache
- Multiple independent map widgets in one Dear PyGui app
- Background-thread runtime updates for telemetry workloads
## Project Commands
```bash
uv run pytest
uv run ruff check .
uv run ruff format --check .
uv run pyright
```

129
codex/AGENTS.md Normal file
View File

@@ -0,0 +1,129 @@
# AGENTS.md
## Current status
Step 8 complete.
## Completed steps
Step 1 - Public API contract and pure core.
Step 2 - Thread-safe state, commands, overlays, and cache model.
Step 3 - Widget shell, sizing system, and GUI-thread frame pump.
Step 4 - Tile manager, persistent cache, and asynchronous loading.
Step 5 - Interaction: pan, zoom, and view commands.
Step 6 - Overlay rendering and runtime update stress tests.
Step 7 - Layers, provider switching, and clearing APIs.
Step 8 - Documentation, hardening, and internal release.
## Current step
Internal rebuilt beta prepared.
## Design decisions
- Package is managed with uv.
- Public import is `import dpg_map as dpgm`.
- Dear PyGui calls are GUI-thread-only.
- Runtime public calls enqueue commands or update logical state.
- Overlay updates must not reset center/zoom.
- The widget uses child_window + measured-size drawlist.
- Tiles use a memory cache and persistent disk cache.
- Tile providers are interchangeable.
## Known issues
None yet.
## Commands used
- Read `codex/STEPS.md`, `codex/FEATURES.md`, and `codex/ARCHITECTURE.md`.
- Created initial package, examples, tests, and agent-log structure.
- Implemented public exports, exceptions, common types, tile provider registry, projection helpers, cache dataclasses, and GUI-dependent API stubs.
- Added Step 1 tests for imports, providers, projection, and cache dataclasses.
- Implemented global configuration, logical MapState, map registry, and current map context stack.
- Implemented DirtyFlags, MapCommand, CommandKind, and coalescing MapCommandQueue.
- Implemented logical marker, polyline, trajectory, and layer state models.
- Implemented public runtime overlay/view/layer/provider/cache/debug wrappers against logical state without Dear PyGui calls.
- Implemented memory cache metadata, disk cache path generation, metadata read/write, disk size scanning, and prune planning.
- Added Step 2 tests for command coalescing, overlay/view isolation, copied trajectory inputs, coordinate length validation, layer state, disk path generation, and prune ordering.
- Implemented `map_widget(...)` as a Dear PyGui child-window plus drawlist shell.
- Implemented GUI-thread renderer frame pump that schedules frame callbacks, drains command queues, measures size, resizes the drawlist, and draws a placeholder background/attribution.
- Implemented sizing helpers for measured size, last non-zero size preservation, visibility transitions, effective draw size, and resize dirty flags.
- Implemented map interaction hit-rectangle calculation.
- Implemented TileID, TileStatus, Tile, visible tile calculation, and TileManager.
- Implemented asynchronous tile worker queue for disk reads, HTTP fetches, and image decoding.
- Implemented provider-namespaced persistent cache writes, access metadata updates, clearing, and LRU pruning.
- Implemented memory tile cache with visible-tile protection and deferred GUI-thread texture deletion.
- Integrated tile result processing, stale generation/provider rejection, texture creation, and tile drawing into the GUI-thread renderer.
- Added OpenStreetMap User-Agent warning/fallback and configured examples with example user agents.
- Added Step 3 examples for basic map, window sizing, child-window sizing, table sizing, and hidden-tab sizing.
- Added Step 4 cache stress example.
- Added Step 3 tests for sizing transitions, zero-size preservation, resize dirty flags, command drain ordering, and hit rectangles.
- Added Step 4 tests for visible tile calculation, stale result rejection, protected memory eviction, and tile image decoding.
- Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist creation.
- Ran `uv run pytest`.
- Ran `uv run ruff check .`.
- Ran `uv run ruff format .`.
- Ran `uv run ruff format --check .`.
- Ran `uv run pyright`.
- Ran `uv run pytest`.
- Ran `uv run ruff check .`.
- Ran `uv run ruff format .`.
- Ran `uv run ruff format --check .`.
- Ran `uv run pyright`.
- Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist/texture-registry creation.
- Implemented left mouse drag panning using the measured drawlist rectangle.
- Implemented mouse wheel zoom around the cursor.
- Implemented projection-backed `screen_to_latlon`, `latlon_to_screen`, and zoom-fitting `fit_bounds`.
- Attached Dear PyGui mouse handlers through the map handler registry.
- Added interaction debug state for active drag and last mouse position.
- Added Step 5 tests for pan, cursor zoom, view conversion, bounds fitting, and overlay/view isolation.
- Added Pyright virtualenv settings so `uv run pyright` resolves installed dependencies.
- Ran `uv run pytest`.
- Ran `uv run ruff check .`.
- Ran `uv run ruff format --check .`.
- Ran `uv run pyright`.
- Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist/texture-registry/handler-registry creation.
- Implemented Dear PyGui draw-layer bookkeeping for background, tiles, overlays, and attribution.
- Rendered markers, polylines, and trajectories from GUI-thread overlay snapshots.
- Isolated overlay redraws so they clear only the overlay draw layer and do not clear tile draw commands or textures.
- Added live background-thread marker and trajectory stress examples.
- Added Step 6 tests for overlay draw-layer isolation, overlay-only dirty flags, threaded update coalescing, and view/drag-state isolation.
- Updated `README.md` with Step 6 overlay rendering behavior and examples.
- Ran `uv run ruff format .`.
- Ran `uv run pytest`.
- Ran `uv run pyright`.
- Ran `uv run ruff check .`.
- Ran `uv run ruff format --check .`.
- Ran a Dear PyGui context smoke check for `map_widget` with marker, polyline, and trajectory overlays.
- Added `z_index` support for `add_layer`, layer visibility/clearing tests, and cache-safe map-wide memory clearing.
- Implemented provider switching that validates providers, clamps zoom to the new provider range, preserves overlays/center, increments generation, and queues GUI-thread tile invalidation.
- Implemented provider-scoped disk cache clearing and cache-size scanning.
- Exported `CacheStats` publicly.
- Added `examples/custom_provider.py` and cache control/stat buttons to `examples/cache_stress.py`.
- Updated `README.md` with Step 7 behavior and examples.
- Added Step 7 tests for provider switch tile invalidation, provider-scoped disk clearing, queued disk clear commands, layer z-order updates, and public exports.
- Ran `uv run ruff format .`.
- Ran `uv run pytest`.
- Ran `uv run ruff check .`.
- Ran `uv run ruff format --check .`.
- Ran `uv run pyright`.
- Ran `uv run python -c "import dpg_map as dpgm; print(dpgm.list_providers())"`.
- Added docstrings for public API functions.
- Rewrote `README.md` with uv install, local editable dependency, basic usage, sizing, live update, custom provider, cache, OpenStreetMap, and thread-safety documentation.
- Added Step 8 hardening tests for unknown maps, overlays, providers, invalid coordinates, mismatched coordinate lengths, empty trajectory support, deleted overlays, provider switching while tiles are loading, overlay updates during dragging, and public docstrings.
- Bumped package version to `0.3.0b1` and updated the fallback OpenStreetMap User-Agent version.
- Ran `uv run pytest`.
- Ran `uv run ruff format .`.
- Ran `uv run ruff check .`.
- Ran `uv run ruff format --check .`.
- Ran `uv run pyright`.
- Tested editable install from `/tmp/dpg-map-editable-test` with `uv add --editable /home/hector/projects/dpg-map`.
- Ran editable install import check: `uv run python -c "import dpg_map as dpgm; print(dpgm.list_providers())"`.
- Ran `uv run python -m py_compile` across all example files.
- Started `examples/basic_map.py` under a 5-second timeout; it launched without terminal errors and was stopped by timeout because the GUI loop blocks.
- Ran `uv sync`.
## Next action
Commit and tag `v0.3.0b1`.

View File

@@ -684,14 +684,14 @@ Create:
src/dpg_map/
examples/
tests/
FEATURES.md
ARCHITECTURE.md
STEPS.md
AGENTS.md
codex/FEATURES.md
codex/ARCHITECTURE.md
codex/STEPS.md
codex/AGENTS.md
README.md
```
Use `AGENTS.md` as the rolling implementation log. After every step:
Use `codex/AGENTS.md` as the rolling implementation log. After every step:
```bash
uv run pytest

18
codex/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Codex Build Notes
This folder contains internal rebuild instructions, implementation architecture notes, and the
rolling agent log used while preparing the beta.
These files are not user-facing package documentation:
- `AGENTS.md`: rolling implementation log and current build status
- `ARCHITECTURE.md`: internal architecture and invariants
- `FEATURES.md`: rebuild feature contract
- `STEPS.md`: historical rebuild step plan
User-facing documentation lives in:
- `../README.md`
- `../docs/GETTING_STARTED.md`
- `../docs/EXAMPLES.md`
- `../docs/API_REFERENCE.md`

View File

@@ -7,11 +7,11 @@ There is no Step 0. Initial setup is listed separately, then implementation star
## Workflow rules
1. Use `uv` for all Python package and dependency management.
2. Always read `FEATURES.md`, `ARCHITECTURE.md`, and `AGENTS.md` before making code changes.
3. Keep `AGENTS.md` as a rolling log of what has been done, what is broken, and what comes next.
2. Always read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, and `codex/AGENTS.md` before making code changes.
3. Keep `codex/AGENTS.md` as a rolling log of what has been done, what is broken, and what comes next.
4. Update `README.md` whenever public behaviour or examples change.
5. After every step:
- update `AGENTS.md`
- update `codex/AGENTS.md`
- run relevant checks
- commit to git
6. Do not casually change public API once introduced.
@@ -55,10 +55,10 @@ src/dpg_map/
exceptions.py
examples/
tests/
FEATURES.md
ARCHITECTURE.md
STEPS.md
AGENTS.md
codex/FEATURES.md
codex/ARCHITECTURE.md
codex/STEPS.md
codex/AGENTS.md
README.md
```
@@ -76,7 +76,7 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
typeCheckingMode = "basic"
```
Create `AGENTS.md` with:
Create `codex/AGENTS.md` with:
```markdown
# AGENTS.md
@@ -753,7 +753,7 @@ Acceptance criteria:
- disk cache limit works
- sizing examples work
- README accurately documents thread-safety and cache behaviour
- AGENTS.md accurately describes status
- codex/AGENTS.md accurately describes status
Commit:

411
docs/API_REFERENCE.md Normal file
View File

@@ -0,0 +1,411 @@
# API Reference
Public imports are available from:
```python
import dpg_map as dpgm
```
Unless stated otherwise, runtime public functions are safe to call from non-GUI threads. They
update logical state and enqueue GUI-thread renderer work. `map_widget(...)` is the main exception:
it creates Dear PyGui items and must run on the GUI thread.
## Common Types
- `Tag`: `str | int`
- `LatLon`: `tuple[float, float]` as `(lat, lon)`
- `Point`: `tuple[float, float]` as `(x, y)`
- `Bounds`: `tuple[LatLon, LatLon]` as `((south, west), (north, east))`
- `Color`: RGB or RGBA integer tuple, usually `(r, g, b, a)`
## Configuration
### `configure(...) -> None`
Configure package-wide defaults used by subsequently created maps.
```python
dpgm.configure(
user_agent=None,
cache_dir=None,
default_provider="osm",
memory_cache_max_tiles=512,
disk_cache_max_bytes=2_000_000_000,
prefetch_margin_tiles=1,
tile_worker_count=4,
overlay_update_policy="coalesce",
debug=False,
)
```
Parameters:
- `user_agent`: HTTP user agent for tile requests.
- `cache_dir`: persistent tile cache root. Defaults to a platform user cache directory.
- `default_provider`: provider name or `TileProvider` used by new maps.
- `memory_cache_max_tiles`: maximum decoded runtime tile count per map.
- `disk_cache_max_bytes`: maximum disk cache size. Use `None` for no size limit.
- `prefetch_margin_tiles`: number of tiles to request around the visible viewport.
- `tile_worker_count`: background worker count for disk/network/decode work.
- `overlay_update_policy`: currently only `"coalesce"` is supported.
- `debug`: store debug mode flag in global config.
Raises `ValueError` for invalid cache, worker, prefetch, or update-policy settings.
## Widget
### `map_widget(...) -> Iterator[Tag | None]`
Create a Dear PyGui child-window map shell and logical map context.
```python
with dpgm.map_widget(
tag="map",
center=(47.9029, 1.9093),
zoom=15,
provider="osm",
width=-1,
height=-1,
):
dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093)
```
Parameters:
- `tag`: map tag. If omitted, a generated tag is used.
- `center`: initial `(lat, lon)`.
- `zoom`: initial zoom, clamped to provider range.
- `provider`: provider name or `TileProvider`; defaults to configured default provider.
- `width`, `height`: Dear PyGui child-window size arguments.
- `autosize_x`, `autosize_y`: Dear PyGui child-window autosize flags.
- `cache_dir`: optional per-map cache directory passed as keyword argument.
- `user_agent`: optional per-map tile user agent passed as keyword argument.
- Other keyword arguments are forwarded to `dpg.add_child_window(...)`.
GUI-thread only.
## View
### `set_center(lat, lon, *, map_tag=None) -> None`
Set the map center without changing zoom.
### `get_center(*, map_tag=None) -> LatLon`
Return the current logical map center.
### `set_zoom(zoom, *, map_tag=None) -> None`
Set the zoom, clamped to the current provider range.
### `get_zoom(*, map_tag=None) -> int`
Return the current logical zoom.
### `set_view(*, center=None, zoom=None, map_tag=None) -> None`
Set center and/or zoom as one logical view update.
```python
dpgm.set_view(center=(47.9029, 1.9093), zoom=15, map_tag="map")
```
### `fit_bounds(bounds, *, map_tag=None) -> None`
Set center and zoom so bounds fit the current draw area.
```python
dpgm.fit_bounds(((47.89, 1.89), (47.92, 1.93)), map_tag="map")
```
### `screen_to_latlon(x, y, *, map_tag=None) -> LatLon`
Convert map-local screen coordinates to latitude/longitude.
### `latlon_to_screen(lat, lon, *, map_tag=None) -> Point`
Convert latitude/longitude to map-local screen coordinates.
## Markers
### `add_marker(tag, *, lat, lon, label=None, layer="default", show=True, map_tag=None, **kwargs) -> Tag`
Add or replace a marker overlay.
Keyword options:
- `color`: marker fill color, default `(255, 80, 80, 255)`.
- `radius`: marker radius in pixels, default `5.0`.
- `show_label`: draw `label` next to the marker, default `False`.
- `user_data`: stored with the overlay model.
- `callback`: stored with the overlay model for application use.
### `update_marker(tag, *, lat=None, lon=None, label=None, map_tag=None, **kwargs) -> None`
Update marker properties without changing map view.
Supported keyword updates:
- `show`
- `color`
- `radius`
### `set_marker_position(tag, lat, lon, *, map_tag=None) -> None`
Shortcut for `update_marker(..., lat=lat, lon=lon)`.
### `set_marker_label(tag, label, *, map_tag=None) -> None`
Shortcut for `update_marker(..., label=label)`.
## Polylines
### `add_polyline(tag, *, points=None, lats=None, lons=None, layer="default", show=True, map_tag=None, **kwargs) -> Tag`
Add or replace a polyline overlay.
Provide either:
- `points=[(lat, lon), ...]`
- or `lats=[...], lons=[...]`
Keyword options:
- `color`: line color, default `(80, 180, 255, 255)`.
- `thickness`: line thickness, default `2.0`.
- `closed`: close the polyline, default `False`.
- `simplify`: stored with the overlay model, default `True`.
- `user_data`: stored with the overlay model.
### `update_polyline(tag, *, points=None, lats=None, lons=None, map_tag=None, **kwargs) -> None`
Update polyline points or properties.
Supported keyword updates:
- `show`
- `color`
- `thickness`
### `set_polyline_points(tag, points, *, map_tag=None) -> None`
Shortcut for replacing a polyline point sequence.
## Trajectories
### `add_trajectory(tag, *, points=None, lats=None, lons=None, layer="default", show=True, map_tag=None, **kwargs) -> Tag`
Add or replace a trajectory overlay.
Provide either `points` or `lats`/`lons`. Empty trajectories are valid for live updates.
Keyword options:
- `timestamps`: optional sequence with the same length as points.
- `color`: trajectory color, default `(255, 180, 60, 255)`.
- `thickness`: line thickness, default `2.0`.
- `show_points`: draw point markers along the trajectory, default `False`.
- `point_stride`: draw every Nth point when `show_points=True`, default `1`.
- `user_data`: stored with the overlay model.
### `update_trajectory(tag, *, points=None, lats=None, lons=None, map_tag=None, **kwargs) -> None`
Update trajectory points or properties.
Supported keyword updates:
- `timestamps`
- `show`
- `color`
- `thickness`
## Generic Overlay Control
### `set_overlay_show(tag, show, *, map_tag=None) -> None`
Show or hide an overlay without deleting it.
### `delete_overlay(tag, *, map_tag=None) -> None`
Delete an overlay from its map and layer.
When `map_tag` is omitted, the API can resolve an overlay only if the tag is unambiguous across
registered maps. In multi-map apps, pass `map_tag`.
## Layers
### `add_layer(name, *, z_index=None, show=True, map_tag=None) -> None`
Create or update a logical overlay layer.
### `show_layer(name, *, map_tag=None) -> None`
Show all overlays assigned to a layer.
### `hide_layer(name, *, map_tag=None) -> None`
Hide all overlays assigned to a layer.
### `clear_layer(name, *, map_tag=None) -> None`
Delete all overlays assigned to a layer.
### `clear_map(*, map_tag=None) -> None`
Delete all overlays and invalidate map tile resources.
## Tile Providers
### `TileProvider`
Immutable tile provider definition.
```python
dpgm.TileProvider(
name="custom",
url_template="https://example.com/{z}/{x}/{y}.png",
min_zoom=0,
max_zoom=19,
tile_size=256,
attribution="Tiles (c) Example",
headers={},
subdomains=(),
retina=False,
file_extension=None,
)
```
Fields:
- `name`: provider registry name.
- `url_template`: XYZ URL template with `{z}`, `{x}`, and `{y}`.
- `min_zoom`, `max_zoom`: valid zoom range.
- `tile_size`: tile size in pixels.
- `attribution`: text drawn on the map.
- `headers`: request headers.
- `subdomains`: values for `{s}`.
- `retina`: if true, `{r}` expands to `"@2x"`.
- `file_extension`: value for `{ext}` and disk cache extension.
### `register_provider(provider, *, replace=False) -> None`
Register a provider by name.
### `unregister_provider(name) -> None`
Remove a registered provider.
### `get_provider(name) -> TileProvider`
Return a registered provider.
### `list_providers() -> list[str]`
Return registered provider names in sorted order.
### `set_provider(provider, *, map_tag=None) -> None`
Switch a map to another provider while preserving overlays and center.
`provider` may be a provider name or a `TileProvider`. Provider switching clamps zoom to the new
provider range and invalidates stale tile loads.
## Cache
### `CacheStats`
Public cache statistics snapshot.
Fields:
- `memory_tiles`
- `memory_max_tiles`
- `memory_hits`
- `memory_misses`
- `disk_bytes`
- `disk_max_bytes`
- `disk_hits`
- `disk_misses`
- `disk_path`
### `get_cache_stats(*, map_tag=None) -> CacheStats`
Return memory and disk cache diagnostics. With `map_tag=None`, returns global disk-cache stats and
configured memory limit.
### `clear_memory_cache(*, map_tag=None) -> None`
Clear decoded in-memory tile data. If `map_tag` is omitted outside a map context, all registered
maps are targeted.
### `clear_disk_cache(provider=None, *, map_tag=None) -> None`
Clear persistent tile cache data.
- `provider=None`: clear all provider namespaces.
- `provider="osm"`: clear only one provider namespace.
- `map_tag="map"`: enqueue a map-scoped clear through the renderer.
## Diagnostics
### `get_map_debug_state(*, map_tag=None) -> dict[str, Any]`
Return a diagnostic snapshot for a map. Keys include:
- `tag`
- `center`
- `zoom`
- `requested_size`
- `measured_size`
- `visible`
- `provider`
- `overlay_count`
- `layers`
- `dirty_flags`
- `pending_command_count`
- `generation`
- `active_drag`
- `last_mouse_position`
- `tiles`
This output is for debugging and logging. It is not guaranteed to be a stable serialization
format.
## Exceptions
Public exception classes are defined in `dpg_map.exceptions`:
- `DpgMapError`
- `ProviderError`
- `ProviderExistsError`
- `ProviderNotFoundError`
- `InvalidProviderError`
- `ProjectionError`
- `MapNotFoundError`
- `OverlayNotFoundError`
- `CoordinateError`
- `ThreadingError`
- `CacheError`
Common error cases:
- Invalid latitude/longitude raises `CoordinateError`.
- Unknown map tags raise `MapNotFoundError`.
- Unknown overlay tags raise `OverlayNotFoundError`.
- Unknown or invalid providers raise provider-specific errors.
## Threading Contract
GUI-thread only:
- `map_widget(...)`
- Dear PyGui setup and application code that creates widgets
Runtime-safe public areas:
- View updates: `set_center`, `set_zoom`, `set_view`, `fit_bounds`
- Overlay updates: `add_marker`, `update_marker`, `update_trajectory`, `delete_overlay`
- Layer updates: `add_layer`, `show_layer`, `hide_layer`, `clear_layer`
- Provider and cache updates: `set_provider`, `clear_memory_cache`, `clear_disk_cache`
The renderer owns Dear PyGui draw commands, textures, input handlers, and frame callbacks.

146
docs/EXAMPLES.md Normal file
View File

@@ -0,0 +1,146 @@
# Examples
The `examples/` directory contains runnable Dear PyGui programs:
```bash
uv run python examples/basic_map.py
uv run python examples/custom_provider.py
uv run python examples/cache_stress.py
uv run python examples/markers_live_thread.py
uv run python examples/trajectory_live_thread.py
uv run python examples/sizing_window.py
uv run python examples/sizing_child.py
uv run python examples/sizing_table.py
uv run python examples/hidden_tab.py
```
## Basic Map
Creates one map and one marker.
```python
with dpg.window(label="Map", width=-1, height=-1):
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)
```
Run:
```bash
uv run python examples/basic_map.py
```
## Live Marker Updates
Use explicit `map_tag` from background threads or callbacks.
```python
dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093, map_tag="map")
def update_from_telemetry(lat: float, lon: float) -> None:
dpgm.update_marker("vehicle", lat=lat, lon=lon, map_tag="map")
```
Run a threaded stress example:
```bash
uv run python examples/markers_live_thread.py
```
## Live Trajectory
Keep an application-owned point buffer and pass immutable snapshots to `update_trajectory(...)`.
```python
points: list[tuple[float, float]] = []
dpgm.add_trajectory("track", points=[], show_points=True, point_stride=12, map_tag="map")
def push_point(lat: float, lon: float) -> None:
points.append((lat, lon))
dpgm.update_trajectory("track", points=tuple(points), map_tag="map")
```
Run:
```bash
uv run python examples/trajectory_live_thread.py
```
## Multiple Maps
Each map is independent. Reusing overlay tags across different maps is valid, but runtime updates
should pass `map_tag` to avoid ambiguity.
```python
with dpgm.map_widget(tag="live-map", center=(47.9, 1.9), zoom=15, width=-1, height=300):
dpgm.add_marker("vehicle", lat=47.9, lon=1.9)
with dpgm.map_widget(tag="recap-map", center=(47.9, 1.9), zoom=13, width=-1, height=300):
dpgm.add_trajectory("vehicle", points=[])
dpgm.update_marker("vehicle", lat=47.901, lon=1.902, map_tag="live-map")
dpgm.update_trajectory("vehicle", points=lap_points, map_tag="recap-map")
```
## Custom Provider Switch
Register a provider, create the map with it, and switch at runtime.
```python
carto = dpgm.TileProvider(
name="carto-light",
url_template="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
subdomains=("a", "b", "c", "d"),
attribution="(c) OpenStreetMap contributors (c) CARTO",
)
dpgm.register_provider(carto)
dpgm.set_provider("carto-light", map_tag="map")
dpgm.set_provider("osm", map_tag="map")
```
Run:
```bash
uv run python examples/custom_provider.py
```
## Cache Controls
Configure a local cache and expose buttons in your UI.
```python
dpgm.configure(cache_dir=".tile-cache", memory_cache_max_tiles=128)
def clear_memory() -> None:
dpgm.clear_memory_cache(map_tag="map")
def clear_disk() -> None:
dpgm.clear_disk_cache(map_tag="map")
def refresh_stats() -> str:
stats = dpgm.get_cache_stats(map_tag="map")
return f"{stats.memory_tiles} memory tiles, {stats.disk_bytes} disk bytes"
```
Run:
```bash
uv run python examples/cache_stress.py
```
## Layout And Sizing
The widget works inside windows, child windows, tables, and hidden tabs.
```python
with dpg.child_window(width=-1, height=420):
with dpgm.map_widget(tag="map-child", width=-1, height=-1):
dpgm.add_marker("inside-child", lat=47.0, lon=2.0)
```
Run:
```bash
uv run python examples/sizing_child.py
```

286
docs/GETTING_STARTED.md Normal file
View File

@@ -0,0 +1,286 @@
# Getting Started
This guide walks through installing `dpg-map`, creating a map widget, adding overlays, updating
the map from runtime code, and configuring tile providers and caches.
## Requirements
`dpg-map` currently targets Python `>=3.14` and uses:
- Dear PyGui for the UI
- Pillow for decoding tile images
- Requests for tile HTTP requests
- Platformdirs for the default disk cache location
The project is managed with `uv`.
## Install From This Repository
For development inside this repository:
```bash
uv sync
uv run python -c "import dpg_map as dpgm; print(dpgm.list_providers())"
```
For another local app using this checkout:
```bash
cd /path/to/your-app
uv add --editable /home/hector/projects/dpg-map
```
Then import it as:
```python
import dpg_map as dpgm
```
## Configure The Package
Configure package-wide defaults before creating maps:
```python
dpgm.configure(
user_agent="my-product/1.0 contact@example.com",
cache_dir=".tile-cache",
memory_cache_max_tiles=512,
disk_cache_max_bytes=2_000_000_000,
)
```
Set an application-specific `user_agent` when using OpenStreetMap tiles. If no user agent is
configured, `dpg-map` emits a runtime warning and uses a fallback package user agent.
## Create A Map
`map_widget(...)` is a Dear PyGui context manager. It creates a child window containing the map
drawlist and installs internal render and input handlers.
```python
from typing import Any
import dearpygui.dearpygui as _dpg
import dpg_map as dpgm
dpg: Any = _dpg
dpgm.configure(user_agent="getting-started/0.1 contact@example.com")
dpg.create_context()
dpg.create_viewport(title="dpg-map", width=900, height=600)
with dpg.window(label="Map", width=-1, height=-1):
with dpgm.map_widget(tag="map", center=(47.9029, 1.9093), zoom=15, width=-1, height=-1):
dpgm.add_marker("start", lat=47.9029, lon=1.9093, label="Start")
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.start_dearpygui()
dpg.destroy_context()
```
`map_widget(...)` must be called on the GUI thread inside an active Dear PyGui context.
## Tags And Map Scope
Each map has a `tag`. Runtime calls should pass `map_tag` when there may be more than one map:
```python
dpgm.set_center(47.9029, 1.9093, map_tag="live-map")
dpgm.update_marker("vehicle", lat=47.9030, lon=1.9098, map_tag="live-map")
```
Inside a `with dpgm.map_widget(...)` block, overlay creation can omit `map_tag` because the map is
the current map context:
```python
with dpgm.map_widget(tag="live-map"):
dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093)
```
Use explicit `map_tag` in callbacks, worker threads, and apps with multiple maps.
## Add Overlays
Markers:
```python
dpgm.add_marker(
"vehicle",
lat=47.9029,
lon=1.9093,
label="Vehicle",
show_label=True,
color=(255, 80, 80, 255),
radius=6,
map_tag="map",
)
```
Polylines:
```python
dpgm.add_polyline(
"route",
points=[(47.9029, 1.9093), (47.9050, 1.9150), (47.9080, 1.9180)],
color=(80, 180, 255, 255),
thickness=3,
map_tag="map",
)
```
Trajectories:
```python
dpgm.add_trajectory(
"track",
points=[],
color=(255, 180, 60, 255),
thickness=3,
show_points=True,
point_stride=10,
map_tag="map",
)
```
Coordinates are `(lat, lon)` pairs in degrees. Latitude must be between `-90` and `90`;
longitude must be between `-180` and `180`.
## Runtime Updates
Runtime public calls are intended to be safe from background threads. They update logical state
and enqueue GUI-thread work instead of calling Dear PyGui directly.
```python
dpgm.update_marker("vehicle", lat=current_lat, lon=current_lon, map_tag="map")
dpgm.update_trajectory("track", points=tuple(track_points), map_tag="map")
```
Overlay updates do not reset map center or zoom.
## View Control
```python
dpgm.set_center(47.9029, 1.9093, map_tag="map")
dpgm.set_zoom(15, map_tag="map")
dpgm.set_view(center=(47.9029, 1.9093), zoom=15, map_tag="map")
dpgm.fit_bounds(((47.89, 1.89), (47.92, 1.93)), map_tag="map")
```
`set_zoom(...)` and `set_view(...)` clamp zoom to the active provider range.
Coordinate conversion helpers use map-local screen coordinates:
```python
lat, lon = dpgm.screen_to_latlon(120, 80, map_tag="map")
x, y = dpgm.latlon_to_screen(47.9029, 1.9093, map_tag="map")
```
## Layers
Overlays belong to named layers. Layers can be shown, hidden, cleared, and ordered with
`z_index`.
```python
dpgm.add_layer("vehicles", z_index=80, map_tag="map")
dpgm.add_marker("car-1", lat=47.9, lon=1.9, layer="vehicles", map_tag="map")
dpgm.hide_layer("vehicles", map_tag="map")
dpgm.show_layer("vehicles", map_tag="map")
dpgm.clear_layer("vehicles", map_tag="map")
```
Default layers include `default`, `markers`, `lines`, and `trajectories`.
## Tile Providers
OpenStreetMap is registered by default as `osm`.
Custom providers use XYZ URL templates. Required template fields are `{z}`, `{x}`, and `{y}`.
Optional fields are `{s}` for subdomain, `{r}` for retina suffix, and `{ext}` for file extension.
```python
provider = dpgm.TileProvider(
name="carto-light",
url_template="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
subdomains=("a", "b", "c", "d"),
attribution="(c) OpenStreetMap contributors (c) CARTO",
file_extension="png",
)
dpgm.register_provider(provider)
dpgm.set_provider("carto-light", map_tag="map")
```
Provider switching preserves overlays and center, clamps zoom to the new provider range, and
invalidates stale tile loads from the previous provider.
## Cache
`dpg-map` uses:
- A memory cache for decoded runtime tiles
- A persistent provider-namespaced disk cache
```python
stats = dpgm.get_cache_stats(map_tag="map")
print(stats.memory_tiles, stats.disk_bytes, stats.disk_path)
dpgm.clear_memory_cache(map_tag="map")
dpgm.clear_disk_cache(provider="osm")
```
`clear_memory_cache(...)` is routed through the renderer command queue so Dear PyGui textures are
deleted on the GUI thread. `clear_disk_cache(...)` can clear all providers, one provider, or a
map-scoped cache directory.
## Sizing
The map widget is a Dear PyGui child window containing a measured drawlist.
Common sizing modes:
- `width=-1`, `height=-1`: fill available space where Dear PyGui supports it
- Positive `width` and `height`: fixed requested size
- `autosize_x=True` or `autosize_y=True`: pass Dear PyGui autosize flags to the child
- Hidden layouts preserve the last non-zero measured size until visible again
Examples:
```bash
uv run python examples/sizing_window.py
uv run python examples/sizing_child.py
uv run python examples/sizing_table.py
uv run python examples/hidden_tab.py
```
## Multiple Maps
Multiple maps can be used in the same Dear PyGui app. Give each map a distinct tag and pass
`map_tag` for runtime updates.
```python
with dpgm.map_widget(tag="live-map", center=(47.9, 1.9), zoom=15, width=-1, height=300):
dpgm.add_marker("vehicle", lat=47.9, lon=1.9)
with dpgm.map_widget(tag="recap-map", center=(47.9, 1.9), zoom=13, width=-1, height=300):
dpgm.add_trajectory("lap", points=[])
dpgm.update_marker("vehicle", lat=47.901, lon=1.902, map_tag="live-map")
dpgm.update_trajectory("lap", points=lap_points, map_tag="recap-map")
```
Pan, zoom, tile rendering, overlays, providers, and caches are isolated per map unless you choose
to share global configuration.
## Diagnostics
Use `get_map_debug_state(...)` and `get_cache_stats(...)` while developing or troubleshooting:
```python
debug = dpgm.get_map_debug_state(map_tag="map")
stats = dpgm.get_cache_stats(map_tag="map")
```
These snapshots are intended for diagnostics and logging, not as a stable serialization format.

View File

@@ -21,15 +21,37 @@ def main() -> None:
dpg.create_context()
dpg.create_viewport(title="dpg-map cache stress", width=1000, height=700)
def clear_memory() -> None:
dpgm.clear_memory_cache(map_tag="cache-map")
def clear_disk() -> None:
dpgm.clear_disk_cache(map_tag="cache-map")
def refresh_stats() -> None:
stats = dpgm.get_cache_stats(map_tag="cache-map")
dpg.set_value(
"cache-stats",
(
f"memory {stats.memory_tiles}/{stats.memory_max_tiles} tiles | "
f"disk {stats.disk_bytes // 1024} KiB | "
f"hits m:{stats.memory_hits} d:{stats.disk_hits}"
),
)
with (
dpg.window(label="Cache Stress", width=-1, height=-1),
dpgm.map_widget(
):
with dpg.group(horizontal=True):
dpg.add_button(label="Clear Memory", callback=clear_memory)
dpg.add_button(label="Clear Disk", callback=clear_disk)
dpg.add_button(label="Stats", callback=refresh_stats)
dpg.add_text("", tag="cache-stats")
with dpgm.map_widget(
tag="cache-map",
center=(47.9029, 1.9093),
zoom=14,
width=-1,
height=-1,
),
):
dpgm.add_marker("start", lat=47.9029, lon=1.9093, label="Orleans")

View File

@@ -0,0 +1,52 @@
from typing import Any
import dearpygui.dearpygui as _dpg
import dpg_map as dpgm
dpg: Any = _dpg
def main() -> None:
dpgm.configure(user_agent="dpg-map custom_provider example")
provider = dpgm.TileProvider(
name="carto-light",
url_template="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
subdomains=("a", "b", "c", "d"),
attribution="(c) OpenStreetMap contributors (c) CARTO",
file_extension="png",
)
if "carto-light" not in dpgm.list_providers():
dpgm.register_provider(provider)
dpg.create_context()
dpg.create_viewport(title="dpg-map custom provider", width=900, height=600)
def use_osm() -> None:
dpgm.set_provider("osm", map_tag="custom-provider-map")
def use_carto() -> None:
dpgm.set_provider("carto-light", map_tag="custom-provider-map")
with dpg.window(label="Custom Provider", width=-1, height=-1):
with dpg.group(horizontal=True):
dpg.add_button(label="OSM", callback=use_osm)
dpg.add_button(label="Carto", callback=use_carto)
with dpgm.map_widget(
tag="custom-provider-map",
provider="carto-light",
center=(47.9029, 1.9093),
zoom=13,
width=-1,
height=-1,
):
dpgm.add_marker("orleans", lat=47.9029, lon=1.9093, label="Orleans")
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.start_dearpygui()
dpg.destroy_context()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from math import cos, sin
from threading import Event, Thread
from time import sleep
from typing import Any
import dearpygui.dearpygui as _dpg
import dpg_map as dpgm
dpg: Any = _dpg
def main() -> None:
dpgm.configure(user_agent="dpg-map markers_live_thread example")
stop = Event()
dpg.create_context()
dpg.create_viewport(title="dpg-map live markers", width=1000, height=700)
with (
dpg.window(label="Live Markers", width=-1, height=-1),
dpgm.map_widget(
tag="live-markers-map", center=(47.9029, 1.9093), zoom=15, width=-1, height=-1
),
):
for index in range(12):
dpgm.add_marker(
f"vehicle-{index}",
lat=47.9029,
lon=1.9093,
label=str(index + 1),
show_label=True,
color=(240, 92, 70, 255),
)
def update_markers() -> None:
tick = 0
while not stop.is_set():
for index in range(12):
angle = tick * 0.08 + index * 0.52
radius = 0.0015 + (index % 4) * 0.0002
dpgm.update_marker(
f"vehicle-{index}",
lat=47.9029 + sin(angle) * radius,
lon=1.9093 + cos(angle) * radius,
map_tag="live-markers-map",
)
tick += 1
sleep(1 / 30)
worker = Thread(target=update_markers, name="dpg-map-live-markers", daemon=True)
worker.start()
try:
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.start_dearpygui()
finally:
stop.set()
worker.join(timeout=1.0)
dpg.destroy_context()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
from collections import deque
from math import cos, sin
from threading import Event, Thread
from time import sleep
from typing import Any
import dearpygui.dearpygui as _dpg
import dpg_map as dpgm
dpg: Any = _dpg
def main() -> None:
dpgm.configure(user_agent="dpg-map trajectory_live_thread example")
stop = Event()
points: deque[tuple[float, float]] = deque(maxlen=240)
dpg.create_context()
dpg.create_viewport(title="dpg-map live trajectory", width=1000, height=700)
with (
dpg.window(label="Live Trajectory", width=-1, height=-1),
dpgm.map_widget(
tag="live-trajectory-map",
center=(47.9029, 1.9093),
zoom=15,
width=-1,
height=-1,
),
):
dpgm.add_trajectory(
"track",
points=[],
color=(250, 190, 80, 255),
thickness=3.0,
show_points=True,
point_stride=12,
)
dpgm.add_marker(
"head",
lat=47.9029,
lon=1.9093,
color=(72, 205, 154, 255),
radius=6,
)
def update_trajectory() -> None:
tick = 0
while not stop.is_set():
angle = tick * 0.06
lat = 47.9029 + sin(angle) * 0.0016 + sin(angle * 2.7) * 0.00025
lon = 1.9093 + cos(angle) * 0.0016
points.append((lat, lon))
snapshot = tuple(points)
dpgm.update_trajectory("track", points=snapshot, map_tag="live-trajectory-map")
dpgm.update_marker("head", lat=lat, lon=lon, map_tag="live-trajectory-map")
tick += 1
sleep(1 / 20)
worker = Thread(target=update_trajectory, name="dpg-map-live-trajectory", daemon=True)
worker.start()
try:
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.start_dearpygui()
finally:
stop.set()
worker.join(timeout=1.0)
dpg.destroy_context()
if __name__ == "__main__":
main()

View File

@@ -1,7 +1,7 @@
[project]
name = "dpg-map"
version = "0.1.0"
description = "Add your description here"
version = "1.0.0"
description = "Dear PyGui map widget for XYZ raster tiles and geographic overlays"
readme = "README.md"
authors = [
{ name = "Hector van der Aa", email = "hector@h3cx.dev" }
@@ -37,3 +37,5 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.pyright]
typeCheckingMode = "basic"
venvPath = "."
venv = ".venv"

View File

@@ -32,6 +32,7 @@ from .api import (
update_polyline,
update_trajectory,
)
from .cache import CacheStats
from .providers import (
TileProvider,
get_provider,
@@ -42,6 +43,7 @@ from .providers import (
from .widget import map_widget
__all__ = [
"CacheStats",
"TileProvider",
"add_layer",
"add_marker",

View File

@@ -10,15 +10,24 @@ from typing import Any
from .cache import CacheStats, clear_disk_cache_path, disk_cache_root, disk_cache_size_bytes
from .commands import CommandKind, MapCommand
from .exceptions import CoordinateError, OverlayNotFoundError
from .exceptions import (
CoordinateError,
InvalidProviderError,
MapNotFoundError,
OverlayNotFoundError,
)
from .interaction import latlon_to_screen_in_state, screen_to_latlon_in_state
from .overlays import LayerState, MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay
from .projection import latlon_to_world
from .providers import TileProvider, get_provider
from .sizing import effective_draw_size
from .state import (
DirtyFlags,
configure_state,
find_map_for_overlay,
get_config,
get_map_state,
list_map_states,
mark_dirty,
)
from .types import Bounds, LatLon, Point, Tag
@@ -87,6 +96,8 @@ def configure(
overlay_update_policy: str = "coalesce",
debug: bool = False,
) -> None:
"""Configure package-wide defaults used by subsequently created maps."""
configure_state(
user_agent=user_agent,
cache_dir=cache_dir,
@@ -101,20 +112,28 @@ def configure(
def set_center(lat: float, lon: float, *, map_tag: Tag | None = None) -> None:
"""Set a map center without changing its zoom."""
set_view(center=_validate_latlon(lat, lon), map_tag=map_tag)
def get_center(*, map_tag: Tag | None = None) -> LatLon:
"""Return the current logical center of a map."""
state = get_map_state(map_tag)
with state.lock:
return state.center
def set_zoom(zoom: int, *, map_tag: Tag | None = None) -> None:
"""Set a map zoom, clamped to the map/provider zoom range."""
set_view(zoom=zoom, map_tag=map_tag)
def get_zoom(*, map_tag: Tag | None = None) -> int:
"""Return the current logical zoom of a map."""
state = get_map_state(map_tag)
with state.lock:
return state.zoom
@@ -126,6 +145,8 @@ def set_view(
zoom: int | None = None,
map_tag: Tag | None = None,
) -> None:
"""Set map center and/or zoom as one logical view update."""
state = get_map_state(map_tag)
with state.lock:
payload: dict[str, Any] = {}
@@ -142,22 +163,46 @@ def set_view(
def fit_bounds(bounds: Bounds, *, map_tag: Tag | None = None) -> None:
"""Set center and zoom so geographic bounds fit the current draw area."""
(south_west, north_east) = bounds
south, west = _validate_latlon(south_west[0], south_west[1])
north, east = _validate_latlon(north_east[0], north_east[1])
set_center((south + north) / 2.0, (west + east) / 2.0, map_tag=map_tag)
south, north = min(south, north), max(south, north)
state = get_map_state(map_tag)
with state.lock:
width, height = effective_draw_size(state)
padding = 32
usable_width = max(1, width - padding * 2)
usable_height = max(1, height - padding * 2)
tile_size = state.provider.tile_size
target_zoom = state.min_zoom
for candidate_zoom in range(state.max_zoom, state.min_zoom - 1, -1):
west_x, north_y = latlon_to_world(north, west, candidate_zoom, tile_size)
east_x, south_y = latlon_to_world(south, east, candidate_zoom, tile_size)
world_size = tile_size * (2**candidate_zoom)
x_span = abs(east_x - west_x)
x_span = min(x_span, world_size - x_span)
y_span = abs(south_y - north_y)
if x_span <= usable_width and y_span <= usable_height:
target_zoom = candidate_zoom
break
set_view(center=((south + north) / 2.0, (west + east) / 2.0), zoom=target_zoom, map_tag=map_tag)
def screen_to_latlon(x: float, y: float, *, map_tag: Tag | None = None) -> LatLon:
_ = (x, y)
return get_center(map_tag=map_tag)
"""Convert map-local screen coordinates to latitude/longitude."""
state = get_map_state(map_tag)
return screen_to_latlon_in_state(state, float(x), float(y))
def latlon_to_screen(lat: float, lon: float, *, map_tag: Tag | None = None) -> Point:
_validate_latlon(lat, lon)
"""Convert latitude/longitude to map-local screen coordinates."""
lat_value, lon_value = _validate_latlon(lat, lon)
state = get_map_state(map_tag)
with state.lock:
return (state.measured_width / 2.0, state.measured_height / 2.0)
return latlon_to_screen_in_state(state, lat_value, lon_value)
def add_marker(
@@ -171,6 +216,8 @@ def add_marker(
map_tag: Tag | None = None,
**kwargs: Any,
) -> Tag:
"""Add or replace a marker overlay and return its tag."""
state = get_map_state(map_tag)
color = kwargs.get("color", (255, 80, 80, 255))
radius = float(kwargs.get("radius", 5.0))
@@ -210,6 +257,8 @@ def add_polyline(
map_tag: Tag | None = None,
**kwargs: Any,
) -> Tag:
"""Add or replace a polyline overlay and return its tag."""
state = get_map_state(map_tag)
copied_points = _points_from_inputs(points, lats=lats, lons=lons)
with state.lock:
@@ -243,6 +292,8 @@ def add_trajectory(
map_tag: Tag | None = None,
**kwargs: Any,
) -> Tag:
"""Add or replace a trajectory overlay and return its tag."""
state = get_map_state(map_tag)
copied_points = _points_from_inputs(points, lats=lats, lons=lons)
timestamps = kwargs.get("timestamps")
@@ -282,6 +333,8 @@ def update_marker(
map_tag: Tag | None = None,
**kwargs: Any,
) -> None:
"""Update marker properties without changing the map view."""
state = find_map_for_overlay(tag, map_tag)
with state.lock:
overlay = state.overlays.get(tag)
@@ -314,6 +367,8 @@ def update_polyline(
map_tag: Tag | None = None,
**kwargs: Any,
) -> None:
"""Update polyline properties without changing the map view."""
state = find_map_for_overlay(tag, map_tag)
with state.lock:
overlay = state.overlays.get(tag)
@@ -341,6 +396,8 @@ def update_trajectory(
map_tag: Tag | None = None,
**kwargs: Any,
) -> None:
"""Update trajectory properties without changing the map view."""
state = find_map_for_overlay(tag, map_tag)
with state.lock:
overlay = state.overlays.get(tag)
@@ -365,10 +422,14 @@ def update_trajectory(
def set_marker_position(tag: Tag, lat: float, lon: float, *, map_tag: Tag | None = None) -> None:
"""Set a marker latitude/longitude."""
update_marker(tag, lat=lat, lon=lon, map_tag=map_tag)
def set_marker_label(tag: Tag, label: str, *, map_tag: Tag | None = None) -> None:
"""Set a marker label."""
update_marker(tag, label=label, map_tag=map_tag)
@@ -378,10 +439,14 @@ def set_polyline_points(
*,
map_tag: Tag | None = None,
) -> None:
"""Replace a polyline point sequence."""
update_polyline(tag, points=points, map_tag=map_tag)
def set_overlay_show(tag: Tag, show: bool, *, map_tag: Tag | None = None) -> None:
"""Show or hide an overlay without deleting it."""
state = find_map_for_overlay(tag, map_tag)
with state.lock:
overlay = state.overlays.get(tag)
@@ -394,6 +459,8 @@ def set_overlay_show(tag: Tag, show: bool, *, map_tag: Tag | None = None) -> Non
def delete_overlay(tag: Tag, *, map_tag: Tag | None = None) -> None:
"""Delete an overlay from its map and layer."""
state = find_map_for_overlay(tag, map_tag)
with state.lock:
overlay = state.overlays.pop(tag, None)
@@ -406,16 +473,46 @@ def delete_overlay(tag: Tag, *, map_tag: Tag | None = None) -> None:
_queue(state, CommandKind.DELETE_OVERLAY, {"tag": tag})
def add_layer(name: str, *, show: bool = True, map_tag: Tag | None = None) -> None:
def _cache_target_states(map_tag: Tag | None) -> list[Any]:
if map_tag is not None:
return [get_map_state(map_tag)]
try:
return [get_map_state(None)]
except MapNotFoundError:
return list_map_states()
def add_layer(
name: str,
*,
z_index: int | None = None,
show: bool = True,
map_tag: Tag | None = None,
) -> None:
"""Create or update a logical overlay layer."""
state = get_map_state(map_tag)
with state.lock:
layer = _ensure_layer(state, name, z_index=len(state.layers), show=show)
layer = _ensure_layer(
state,
name,
z_index=len(state.layers) if z_index is None else int(z_index),
show=show,
)
if z_index is not None:
layer.z_index = int(z_index)
layer.show = show
mark_dirty(state, DirtyFlags.OVERLAYS)
_queue(state, CommandKind.ADD_LAYER, {"name": name, "show": show})
_queue(
state,
CommandKind.ADD_LAYER,
{"name": name, "show": show, "z_index": layer.z_index},
)
def show_layer(name: str, *, map_tag: Tag | None = None) -> None:
"""Show all overlays assigned to a layer."""
state = get_map_state(map_tag)
with state.lock:
_ensure_layer(state, name).show = True
@@ -424,6 +521,8 @@ def show_layer(name: str, *, map_tag: Tag | None = None) -> None:
def hide_layer(name: str, *, map_tag: Tag | None = None) -> None:
"""Hide all overlays assigned to a layer."""
state = get_map_state(map_tag)
with state.lock:
_ensure_layer(state, name).show = False
@@ -432,6 +531,8 @@ def hide_layer(name: str, *, map_tag: Tag | None = None) -> None:
def clear_layer(name: str, *, map_tag: Tag | None = None) -> None:
"""Delete all overlays assigned to a layer."""
state = get_map_state(map_tag)
with state.lock:
layer = _ensure_layer(state, name)
@@ -443,6 +544,8 @@ def clear_layer(name: str, *, map_tag: Tag | None = None) -> None:
def clear_map(*, map_tag: Tag | None = None) -> None:
"""Delete all overlays and invalidate map tile resources."""
state = get_map_state(map_tag)
with state.lock:
state.overlays.clear()
@@ -454,36 +557,55 @@ def clear_map(*, map_tag: Tag | None = None) -> None:
def set_provider(provider: str | TileProvider, *, map_tag: Tag | None = None) -> None:
provider_obj = get_provider(provider) if isinstance(provider, str) else provider
"""Switch a map to another tile provider while preserving overlays."""
if isinstance(provider, str):
provider_obj = get_provider(provider)
elif isinstance(provider, TileProvider):
provider_obj = provider
else:
raise InvalidProviderError("provider must be a provider name or TileProvider")
state = get_map_state(map_tag)
with state.lock:
if state.provider == provider_obj:
return
state.provider = provider_obj
state.min_zoom = max(state.min_zoom, provider_obj.min_zoom)
state.max_zoom = min(state.max_zoom, provider_obj.max_zoom)
state.min_zoom = provider_obj.min_zoom
state.max_zoom = provider_obj.max_zoom
state.zoom = max(state.min_zoom, min(state.max_zoom, state.zoom))
state.generation += 1
mark_dirty(state, DirtyFlags.PROVIDER | DirtyFlags.TILES)
mark_dirty(state, DirtyFlags.PROVIDER | DirtyFlags.TILES | DirtyFlags.OVERLAYS)
_queue(state, CommandKind.SET_PROVIDER, {"provider": provider_obj.name})
def clear_memory_cache(*, map_tag: Tag | None = None) -> None:
state = get_map_state(map_tag)
"""Clear decoded in-memory tile data through the renderer command queue."""
for state in _cache_target_states(map_tag):
with state.lock:
state.generation += 1
mark_dirty(state, DirtyFlags.TILES)
_queue(state, CommandKind.CLEAR_MEMORY_CACHE, {})
def clear_disk_cache(*, map_tag: Tag | None = None) -> None:
def clear_disk_cache(provider: str | None = None, *, map_tag: Tag | None = None) -> None:
"""Clear persistent tile cache data globally or for one map/provider."""
if provider is not None:
get_provider(provider)
if map_tag is None:
clear_disk_cache_path(get_config().cache_dir)
clear_disk_cache_path(get_config().cache_dir, provider=provider)
return
state = get_map_state(map_tag)
with state.lock:
state.generation += 1
mark_dirty(state, DirtyFlags.TILES)
_queue(state, CommandKind.CLEAR_DISK_CACHE, {})
_queue(state, CommandKind.CLEAR_DISK_CACHE, {"provider": provider})
def get_cache_stats(*, map_tag: Tag | None = None) -> CacheStats:
"""Return memory and disk cache diagnostics."""
config = get_config()
if map_tag is None:
cache_dir = config.cache_dir
@@ -510,6 +632,8 @@ def get_cache_stats(*, map_tag: Tag | None = None) -> CacheStats:
def get_map_debug_state(*, map_tag: Tag | None = None) -> dict[str, Any]:
"""Return a diagnostic snapshot for a map."""
state = get_map_state(map_tag)
with state.lock:
return {
@@ -533,5 +657,6 @@ def get_map_debug_state(*, map_tag: Tag | None = None) -> dict[str, Any]:
"pending_command_count": len(state.command_queue),
"generation": state.generation,
"active_drag": state.interaction.active_drag,
"last_mouse_position": state.interaction.last_mouse_position,
"tiles": asdict(state.tile_manager.snapshot()),
}

View File

@@ -225,10 +225,20 @@ def scan_disk_cache(cache_dir: str | Path | None) -> list[DiskCacheEntry]:
return entries
def disk_cache_size_bytes(cache_dir: str | Path | None) -> int:
"""Return total bytes for cached tile files."""
def disk_cache_size_bytes(
cache_dir: str | Path | None,
*,
provider: str | None = None,
) -> int:
"""Return total bytes for cached tile files, optionally scoped to one provider."""
if provider is None:
return sum(entry.metadata.size_bytes for entry in scan_disk_cache(cache_dir))
safe_provider = provider.replace("/", "_")
provider_root = disk_cache_root(cache_dir) / safe_provider
if not provider_root.exists():
return 0
return sum(entry.metadata.size_bytes for entry in scan_disk_cache(provider_root))
def plan_disk_prune(
@@ -277,10 +287,12 @@ def prune_disk_cache(
return planned
def clear_disk_cache_path(cache_dir: str | Path | None) -> None:
"""Remove all persistent tile cache files under a cache root."""
def clear_disk_cache_path(cache_dir: str | Path | None, *, provider: str | None = None) -> None:
"""Remove persistent tile cache files under a cache root."""
root = disk_cache_root(cache_dir)
if provider is not None:
root = root / provider.replace("/", "_")
if not root.exists():
return
try:

View File

@@ -1 +1,46 @@
"""Draw layer bookkeeping helpers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .types import Tag
@dataclass(frozen=True, slots=True)
class DrawLayerTags:
"""Internal Dear PyGui draw layer tags for one map."""
background: str
tiles: str
overlays: str
attribution: str
def draw_layer_tags(map_tag: Tag) -> DrawLayerTags:
"""Return stable internal draw layer tags for a map."""
return DrawLayerTags(
background=f"{map_tag}##layer-background",
tiles=f"{map_tag}##layer-tiles",
overlays=f"{map_tag}##layer-overlays",
attribution=f"{map_tag}##layer-attribution",
)
def ensure_draw_layers(dpg: Any, *, drawlist_tag: Tag, map_tag: Tag) -> DrawLayerTags:
"""Create draw layers if needed and return their tags."""
tags = draw_layer_tags(map_tag)
for layer_tag in (tags.background, tags.tiles, tags.overlays, tags.attribution):
if not dpg.does_item_exist(layer_tag):
dpg.add_draw_layer(parent=drawlist_tag, tag=layer_tag)
return tags
def clear_draw_layer(dpg: Any, layer_tag: Tag) -> None:
"""Clear one draw layer without touching sibling layers."""
if dpg.does_item_exist(layer_tag):
dpg.delete_item(layer_tag, children_only=True)

View File

@@ -3,9 +3,18 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from threading import RLock
from typing import Any
from .commands import CommandKind, MapCommand
from .projection import latlon_to_world, screen_to_world, world_to_latlon
from .sizing import effective_draw_size
from .state import MapState
from .state import DirtyFlags, MapState, mark_dirty
from .types import LatLon, Point, Tag
_drag_owner_lock = RLock()
_drag_owner_map: Tag | None = None
@dataclass(frozen=True, slots=True)
@@ -34,3 +43,215 @@ def calculate_hit_rect(state: MapState, drawlist_pos: tuple[float, float]) -> Hi
width, height = effective_draw_size(state)
return HitRect(float(drawlist_pos[0]), float(drawlist_pos[1]), float(width), float(height))
def screen_to_latlon_in_state(state: MapState, x: float, y: float) -> LatLon:
"""Convert map-local screen coordinates to latitude/longitude."""
with state.lock:
width, height = effective_draw_size(state)
center = state.center
zoom = state.zoom
tile_size = state.provider.tile_size
world_x, world_y = screen_to_world(
float(x),
float(y),
center=center,
zoom=zoom,
width=width,
height=height,
tile_size=tile_size,
)
return world_to_latlon(world_x, world_y, zoom, tile_size)
def latlon_to_screen_in_state(state: MapState, lat: float, lon: float) -> Point:
"""Convert latitude/longitude to map-local screen coordinates."""
with state.lock:
width, height = effective_draw_size(state)
center = state.center
zoom = state.zoom
tile_size = state.provider.tile_size
world_x, world_y = latlon_to_world(lat, lon, zoom, tile_size)
center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size)
return (world_x - center_x + width / 2.0, world_y - center_y + height / 2.0)
def pan_state_by_pixels(state: MapState, dx: float, dy: float) -> LatLon:
"""Pan the map by a mouse drag delta in screen pixels."""
with state.lock:
if dx == 0 and dy == 0:
return state.center
center = state.center
zoom = state.zoom
tile_size = state.provider.tile_size
world_size = tile_size * (2**zoom)
center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size)
new_x = (center_x - dx) % world_size
new_y = min(max(center_y - dy, 0.0), float(world_size))
state.center = world_to_latlon(new_x, new_y, zoom, tile_size)
mark_dirty(state, DirtyFlags.VIEW | DirtyFlags.TILES | DirtyFlags.OVERLAYS)
state.command_queue.put(
MapCommand(
kind=CommandKind.SET_VIEW,
map_tag=state.tag,
payload={"center": state.center},
)
)
return state.center
def zoom_state_at_screen_point(
state: MapState,
*,
screen_x: float,
screen_y: float,
delta: float,
) -> int:
"""Zoom the map around a map-local screen point where possible."""
if delta == 0 or not isfinite(delta):
with state.lock:
return state.zoom
with state.lock:
old_zoom = state.zoom
new_zoom = max(state.min_zoom, min(state.max_zoom, old_zoom + (1 if delta > 0 else -1)))
if new_zoom == old_zoom:
return old_zoom
width, height = effective_draw_size(state)
tile_size = state.provider.tile_size
anchor_latlon = screen_to_latlon_in_state(state, screen_x, screen_y)
anchor_x, anchor_y = latlon_to_world(
anchor_latlon[0],
anchor_latlon[1],
new_zoom,
tile_size,
)
world_size = tile_size * (2**new_zoom)
center_x = (anchor_x - (screen_x - width / 2.0)) % world_size
center_y = min(max(anchor_y - (screen_y - height / 2.0), 0.0), float(world_size))
state.zoom = new_zoom
state.center = world_to_latlon(center_x, center_y, new_zoom, tile_size)
mark_dirty(state, DirtyFlags.VIEW | DirtyFlags.TILES | DirtyFlags.OVERLAYS)
state.command_queue.put(
MapCommand(
kind=CommandKind.SET_VIEW,
map_tag=state.tag,
payload={"center": state.center, "zoom": state.zoom},
)
)
return state.zoom
def handle_mouse_down(state: MapState, mouse_pos: tuple[float, float], hit_rect: HitRect) -> None:
"""Begin a drag if the left mouse button starts inside the map rectangle."""
with state.lock:
if not state.is_visible or not hit_rect.contains(mouse_pos[0], mouse_pos[1]):
state.interaction.active_drag = False
state.interaction.last_mouse_position = None
return
with _drag_owner_lock:
global _drag_owner_map
if _drag_owner_map is not None and _drag_owner_map != state.tag:
return
_drag_owner_map = state.tag
with state.lock:
state.interaction.active_drag = True
state.interaction.last_mouse_position = mouse_pos
def handle_mouse_drag(state: MapState, mouse_pos: tuple[float, float]) -> None:
"""Update center from a mouse drag event."""
with _drag_owner_lock:
if _drag_owner_map != state.tag:
return
with state.lock:
if not state.interaction.active_drag:
return
last_pos = state.interaction.last_mouse_position
state.interaction.last_mouse_position = mouse_pos
if last_pos is None:
return
pan_state_by_pixels(state, mouse_pos[0] - last_pos[0], mouse_pos[1] - last_pos[1])
def handle_mouse_release(state: MapState) -> None:
"""End any active drag."""
with _drag_owner_lock:
global _drag_owner_map
if _drag_owner_map == state.tag:
_drag_owner_map = None
with state.lock:
state.interaction.active_drag = False
state.interaction.last_mouse_position = None
def handle_mouse_wheel(
state: MapState,
*,
mouse_pos: tuple[float, float],
wheel_delta: float,
hit_rect: HitRect,
) -> None:
"""Apply wheel zoom when the cursor is over the concrete map rectangle."""
with state.lock:
if not state.is_visible:
return
if not hit_rect.contains(mouse_pos[0], mouse_pos[1]):
return
zoom_state_at_screen_point(
state,
screen_x=mouse_pos[0] - hit_rect.x,
screen_y=mouse_pos[1] - hit_rect.y,
delta=wheel_delta,
)
def update_drag_from_button_state(
state: MapState,
*,
mouse_pos: tuple[float, float],
hit_rect: HitRect,
is_down: bool,
can_start: bool = True,
) -> None:
"""Poll left-button state and keep drag interaction moving."""
with state.lock:
active_drag = state.interaction.active_drag
if not is_down:
if active_drag:
handle_mouse_release(state)
return
if active_drag:
handle_mouse_drag(state, mouse_pos)
return
if not can_start:
return
if hit_rect.contains(mouse_pos[0], mouse_pos[1]):
handle_mouse_down(state, mouse_pos, hit_rect)
def wheel_delta_from_app_data(app_data: Any) -> float:
"""Normalize Dear PyGui mouse wheel callback data."""
if isinstance(app_data, int | float):
return float(app_data)
if isinstance(app_data, (list, tuple)) and app_data:
value = app_data[-1]
if isinstance(value, int | float):
return float(value)
return 0.0

View File

@@ -3,13 +3,24 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import replace
from threading import RLock
from typing import Any
from .commands import CommandKind, MapCommand
from .interaction import HitRect, calculate_hit_rect
from .draw_layers import DrawLayerTags, clear_draw_layer, ensure_draw_layers
from .interaction import HitRect, calculate_hit_rect, update_drag_from_button_state
from .overlays import MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay
from .projection import latlon_to_world
from .sizing import SizeMeasurement, apply_size_measurement
from .state import DirtyFlags, MapState
from .tiles import Tile, VisibleTile
from .types import Color, LatLon, Tag
_scheduler_lock = RLock()
_scheduled_renderers: dict[Tag, MapRenderer] = {}
_scheduler_dpg: Any | None = None
_scheduler_frame_scheduled = False
class MapRenderer:
@@ -18,29 +29,21 @@ class MapRenderer:
def __init__(self, state: MapState, dpg: Any) -> None:
self.state = state
self._dpg = dpg
self._background_tag = f"{state.tag}##background"
self._attribution_tag = f"{state.tag}##attribution"
self._layers: DrawLayerTags | None = None
self.last_drained_commands: tuple[MapCommand, ...] = ()
self.last_hit_rect: HitRect | None = None
self.last_overlay_count: int = 0
def schedule_next_frame(self) -> None:
"""Schedule this renderer to run on the next Dear PyGui frame."""
"""Register this renderer with the shared Dear PyGui frame pump."""
with self.state.lock:
if self.state.frame_scheduled:
return
self.state.frame_scheduled = True
frame = self._dpg.get_frame_count() + 1
self._dpg.set_frame_callback(frame, self._frame_callback)
_schedule_renderer(self)
def _frame_callback(self, sender: Any | None = None, app_data: Any | None = None) -> None:
_ = (sender, app_data)
with self.state.lock:
self.state.frame_scheduled = False
if not self._dpg.does_item_exist(self.state.drawlist_tag):
return
self.render_frame()
self.schedule_next_frame()
def render_frame(self) -> None:
"""Drain pending commands, refresh size, process tiles, and redraw."""
@@ -48,10 +51,12 @@ class MapRenderer:
commands = drain_renderer_commands(self.state)
self.last_drained_commands = tuple(commands)
self._update_size_from_dpg()
self._poll_mouse_drag()
with self.state.lock:
dirty = self.state.dirty
should_draw = bool(dirty & (DirtyFlags.FULL | DirtyFlags.SIZE | DirtyFlags.TILES))
draw_tiles = bool(dirty & (DirtyFlags.SIZE | DirtyFlags.TILES | DirtyFlags.PROVIDER))
draw_overlays = bool(dirty & (DirtyFlags.SIZE | DirtyFlags.OVERLAYS))
visible = self.state.is_visible
width = self.state.measured_width or self.state.last_nonzero_width
height = self.state.measured_height or self.state.last_nonzero_height
@@ -61,6 +66,12 @@ class MapRenderer:
zoom = self.state.zoom
generation = self.state.generation
cache_dir = self.state.cache_dir
overlays = tuple(
_copy_overlay_for_render(overlay) for overlay in self.state.overlays.values()
)
layers = {
name: (layer.show, layer.z_index) for name, layer in self.state.layers.items()
}
self.state.dirty = DirtyFlags.NONE
accepted_tiles = self.state.tile_manager.drain_results(
@@ -84,7 +95,7 @@ class MapRenderer:
margin=self._prefetch_margin(),
)
if visible and (should_draw or accepted_tiles):
if visible and (draw_tiles or accepted_tiles):
self._draw_tile_layer(
visible_tiles=visible_tiles,
width=width,
@@ -92,6 +103,16 @@ class MapRenderer:
attribution=provider_attribution,
tile_size=provider.tile_size,
)
if visible and draw_overlays:
self._draw_overlay_layer(
overlays=overlays,
layers=layers,
center=center,
zoom=zoom,
width=width,
height=height,
tile_size=provider.tile_size,
)
def _update_size_from_dpg(self) -> None:
width, height = self._measure_child_content()
@@ -104,10 +125,28 @@ class MapRenderer:
draw_width = update.effective_width
draw_height = update.effective_height
self._dpg.configure_item(self.state.drawlist_tag, width=draw_width, height=draw_height)
draw_pos = tuple(float(value) for value in self._dpg.get_item_pos(self.state.drawlist_tag))
draw_pos = tuple(
float(value) for value in self._dpg.get_item_rect_min(self.state.drawlist_tag)
)
with self.state.lock:
self.last_hit_rect = calculate_hit_rect(self.state, (draw_pos[0], draw_pos[1]))
def _poll_mouse_drag(self) -> None:
if self.last_hit_rect is None:
return
try:
is_down = bool(self._dpg.is_mouse_button_down(self._dpg.mvMouseButton_Left))
mouse_pos = self._dpg.get_mouse_pos(local=False)
except Exception:
return
update_drag_from_button_state(
self.state,
mouse_pos=(float(mouse_pos[0]), float(mouse_pos[1])),
hit_rect=self.last_hit_rect,
is_down=is_down,
can_start=False,
)
def _measure_child_content(self) -> tuple[int, int]:
try:
width, height = self._dpg.get_item_rect_size(self.state.child_window_tag)
@@ -126,12 +165,14 @@ class MapRenderer:
) -> None:
width = max(1, int(width))
height = max(1, int(height))
self._dpg.delete_item(self.state.drawlist_tag, children_only=True)
layers = self._ensure_draw_layers()
clear_draw_layer(self._dpg, layers.background)
clear_draw_layer(self._dpg, layers.tiles)
clear_draw_layer(self._dpg, layers.attribution)
self._dpg.draw_rectangle(
(0, 0),
(width, height),
parent=self.state.drawlist_tag,
tag=self._background_tag,
parent=layers.background,
color=(54, 68, 78, 255),
fill=(29, 38, 45, 255),
)
@@ -139,25 +180,199 @@ class MapRenderer:
tile = self.state.tile_manager.get_ready_tile(visible_tile.tile_id)
if tile is None or tile.texture_tag is None:
continue
screen_x = visible_tile.screen_x
screen_y = visible_tile.screen_y
screen_x = _snap_tile_position(visible_tile.screen_x)
screen_y = _snap_tile_position(visible_tile.screen_y)
self._dpg.draw_image(
tile.texture_tag,
(screen_x, screen_y),
(screen_x + tile_size, screen_y + tile_size),
parent=self.state.drawlist_tag,
parent=layers.tiles,
)
label = attribution or "Map tiles"
text_y = max(28, height - 24)
self._dpg.draw_text(
(12, text_y),
label,
parent=self.state.drawlist_tag,
tag=self._attribution_tag,
parent=layers.attribution,
color=(172, 184, 192, 255),
size=12,
)
def _draw_overlay_layer(
self,
*,
overlays: tuple[Overlay, ...],
layers: dict[str, tuple[bool, int]],
center: LatLon,
zoom: int,
width: int,
height: int,
tile_size: int,
) -> None:
draw_layers = self._ensure_draw_layers()
clear_draw_layer(self._dpg, draw_layers.overlays)
if width <= 0 or height <= 0:
self.last_overlay_count = 0
return
center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size)
visible_overlays = [
overlay
for overlay in overlays
if overlay.show and layers.get(overlay.layer, (True, 0))[0]
]
visible_overlays.sort(key=lambda overlay: layers.get(overlay.layer, (True, 0))[1])
drawn = 0
for overlay in visible_overlays:
if isinstance(overlay, MarkerOverlay):
self._draw_marker_overlay(
overlay, center_x, center_y, zoom, width, height, tile_size
)
drawn += 1
elif isinstance(overlay, PolylineOverlay):
self._draw_polyline_overlay(
overlay,
center_x,
center_y,
zoom,
width,
height,
tile_size,
draw_layers.overlays,
)
drawn += 1
elif isinstance(overlay, TrajectoryOverlay):
self._draw_trajectory_overlay(
overlay,
center_x,
center_y,
zoom,
width,
height,
tile_size,
draw_layers.overlays,
)
drawn += 1
self.last_overlay_count = drawn
def _draw_marker_overlay(
self,
overlay: MarkerOverlay,
center_x: float,
center_y: float,
zoom: int,
width: int,
height: int,
tile_size: int,
) -> None:
layers = self._ensure_draw_layers()
x, y = _latlon_to_screen(
overlay.lat,
overlay.lon,
center_x,
center_y,
zoom,
width,
height,
tile_size,
)
radius = max(1.0, float(overlay.radius))
self._dpg.draw_circle(
(x, y),
radius,
parent=layers.overlays,
color=(255, 255, 255, 230),
fill=_rgba(overlay.color),
thickness=1.5,
segments=20,
)
if overlay.show_label and overlay.label:
self._dpg.draw_text(
(x + radius + 4.0, y - 7.0),
overlay.label,
parent=layers.overlays,
color=(245, 248, 250, 255),
size=12,
)
def _draw_polyline_overlay(
self,
overlay: PolylineOverlay,
center_x: float,
center_y: float,
zoom: int,
width: int,
height: int,
tile_size: int,
parent: str,
) -> None:
points = _screen_points(
overlay.points,
center_x=center_x,
center_y=center_y,
zoom=zoom,
width=width,
height=height,
tile_size=tile_size,
)
if len(points) < 2:
return
self._dpg.draw_polyline(
points,
parent=parent,
closed=overlay.closed,
color=_rgba(overlay.color),
thickness=max(1.0, float(overlay.thickness)),
)
def _draw_trajectory_overlay(
self,
overlay: TrajectoryOverlay,
center_x: float,
center_y: float,
zoom: int,
width: int,
height: int,
tile_size: int,
parent: str,
) -> None:
points = _screen_points(
overlay.points,
center_x=center_x,
center_y=center_y,
zoom=zoom,
width=width,
height=height,
tile_size=tile_size,
)
if len(points) >= 2:
self._dpg.draw_polyline(
points,
parent=parent,
color=_rgba(overlay.color),
thickness=max(1.0, float(overlay.thickness)),
)
if overlay.show_points and points:
stride = max(1, int(overlay.point_stride))
for point in points[::stride]:
self._dpg.draw_circle(
point,
2.5,
parent=parent,
color=_rgba(overlay.color),
fill=_rgba(overlay.color),
segments=8,
)
def _ensure_draw_layers(self) -> DrawLayerTags:
if self._layers is None or not self._dpg.does_item_exist(self._layers.overlays):
self._layers = ensure_draw_layers(
self._dpg,
drawlist_tag=self.state.drawlist_tag,
map_tag=self.state.tag,
)
return self._layers
def _ensure_texture(self, tile: Tile) -> None:
if tile.texture_tag is not None:
return
@@ -186,6 +401,54 @@ class MapRenderer:
return get_config().prefetch_margin_tiles
def _schedule_renderer(renderer: MapRenderer) -> None:
"""Schedule the shared frame pump for all registered map renderers."""
global _scheduler_dpg, _scheduler_frame_scheduled
with _scheduler_lock:
_scheduled_renderers[renderer.state.tag] = renderer
_scheduler_dpg = renderer._dpg
if _scheduler_frame_scheduled:
return
_scheduler_frame_scheduled = True
dpg = renderer._dpg
frame = dpg.get_frame_count() + 1
dpg.set_frame_callback(frame, _shared_frame_callback)
def _shared_frame_callback(sender: Any | None = None, app_data: Any | None = None) -> None:
"""Render every live map from one Dear PyGui frame callback."""
_ = (sender, app_data)
global _scheduler_frame_scheduled
with _scheduler_lock:
renderers = tuple(_scheduled_renderers.values())
_scheduler_frame_scheduled = False
for renderer in renderers:
renderer._frame_callback()
with _scheduler_lock:
active_renderers = [
renderer
for renderer in _scheduled_renderers.values()
if renderer._dpg.does_item_exist(renderer.state.drawlist_tag)
]
_scheduled_renderers.clear()
_scheduled_renderers.update((renderer.state.tag, renderer) for renderer in active_renderers)
if not active_renderers or _scheduler_frame_scheduled:
return
_scheduler_frame_scheduled = True
dpg = _scheduler_dpg
if dpg is not None:
frame = dpg.get_frame_count() + 1
dpg.set_frame_callback(frame, _shared_frame_callback)
def drain_renderer_commands(state: MapState) -> list[MapCommand]:
"""Drain and apply GUI-thread command side effects."""
@@ -199,7 +462,7 @@ def drain_renderer_commands(state: MapState) -> list[MapCommand]:
state.dirty |= DirtyFlags.VIEW | DirtyFlags.TILES | DirtyFlags.OVERLAYS
elif command.kind is CommandKind.SET_PROVIDER:
state.tile_manager.clear_memory_cache()
state.dirty |= DirtyFlags.PROVIDER | DirtyFlags.TILES
state.dirty |= DirtyFlags.PROVIDER | DirtyFlags.TILES | DirtyFlags.OVERLAYS
elif command.kind in {
CommandKind.ADD_OVERLAY,
CommandKind.UPDATE_OVERLAY,
@@ -216,7 +479,10 @@ def drain_renderer_commands(state: MapState) -> list[MapCommand]:
state.tile_manager.clear_memory_cache()
state.dirty |= DirtyFlags.TILES
elif command.kind is CommandKind.CLEAR_DISK_CACHE:
state.tile_manager.clear_disk_cache(state.cache_dir)
provider = command.payload.get("provider")
if not isinstance(provider, str):
provider = None
state.tile_manager.clear_disk_cache(state.cache_dir, provider=provider)
state.dirty |= DirtyFlags.TILES
return commands
@@ -229,3 +495,47 @@ def make_frame_pump(state: MapState, dpg: Any) -> Callable[[], None]:
state.renderer = renderer
renderer.schedule_next_frame()
return renderer.render_frame
def _rgba(color: Color) -> tuple[int, int, int, int]:
if len(color) == 3:
return (int(color[0]), int(color[1]), int(color[2]), 255)
return (int(color[0]), int(color[1]), int(color[2]), int(color[3]))
def _snap_tile_position(value: float) -> int:
return int(round(value))
def _latlon_to_screen(
lat: float,
lon: float,
center_x: float,
center_y: float,
zoom: int,
width: int,
height: int,
tile_size: int,
) -> tuple[float, float]:
world_x, world_y = latlon_to_world(lat, lon, zoom, tile_size)
return (world_x - center_x + width / 2.0, world_y - center_y + height / 2.0)
def _screen_points(
points: tuple[LatLon, ...],
*,
center_x: float,
center_y: float,
zoom: int,
width: int,
height: int,
tile_size: int,
) -> list[tuple[float, float]]:
return [
_latlon_to_screen(lat, lon, center_x, center_y, zoom, width, height, tile_size)
for lat, lon in points
]
def _copy_overlay_for_render(overlay: Overlay) -> Overlay:
return replace(overlay)

View File

@@ -47,7 +47,7 @@ class DpgMapConfig:
@dataclass(slots=True)
class InteractionState:
"""Logical interaction state until GUI interaction is implemented."""
"""Logical mouse interaction state."""
active_drag: bool = False
last_mouse_position: tuple[float, float] | None = None
@@ -276,6 +276,13 @@ def resolve_map_tag(map_tag: Tag | None = None) -> Tag:
raise MapNotFoundError("map_tag is required outside a map_widget context")
def list_map_states() -> list[MapState]:
"""Return registered map states as a snapshot."""
with _maps_lock:
return list(_maps.values())
def find_map_for_overlay(tag: Tag, map_tag: Tag | None = None) -> MapState:
"""Find the map containing an overlay, optionally scoped by map tag."""

View File

@@ -316,7 +316,7 @@ class TileManager:
RuntimeWarning,
stacklevel=3,
)
headers["User-Agent"] = "dpg-map/0.1"
headers["User-Agent"] = "dpg-map/1.0.0"
return headers
def _visible_disk_paths(self, cache_dir: str | Path | None) -> list[Path]:
@@ -428,10 +428,12 @@ class TileManager:
self._failed.clear()
return tags
def clear_disk_cache(self, cache_dir: str | Path | None) -> None:
"""Clear the persistent cache root."""
def clear_disk_cache(
self, cache_dir: str | Path | None, *, provider: str | None = None
) -> None:
"""Clear the persistent cache root or one provider namespace."""
clear_disk_cache_path(cache_dir)
clear_disk_cache_path(cache_dir, provider=provider)
def snapshot(self) -> TileManagerSnapshot:
"""Return diagnostic counters."""

View File

@@ -7,6 +7,14 @@ from contextlib import contextmanager
from pathlib import Path
from typing import Any
from .interaction import (
calculate_hit_rect,
handle_mouse_down,
handle_mouse_drag,
handle_mouse_release,
handle_mouse_wheel,
wheel_delta_from_app_data,
)
from .providers import TileProvider
from .renderer import MapRenderer
from .state import create_map_state, current_map_context
@@ -63,6 +71,64 @@ def map_widget(
)
dpg.add_texture_registry(tag=state.texture_registry_tag)
dpg.add_drawlist(1, 1, tag=state.drawlist_tag, parent=state.child_window_tag)
dpg.add_handler_registry(tag=state.handler_registry_tag)
def _mouse_pos() -> tuple[float, float]:
pos = dpg.get_mouse_pos(local=False)
return (float(pos[0]), float(pos[1]))
def _hit_rect() -> Any:
draw_pos = tuple(float(value) for value in dpg.get_item_rect_min(state.drawlist_tag))
return calculate_hit_rect(state, (draw_pos[0], draw_pos[1]))
def _is_drawlist_hovered() -> bool:
try:
return bool(dpg.is_item_hovered(state.drawlist_tag))
except Exception:
return False
def _on_mouse_down(sender: Any, app_data: Any, user_data: Any) -> None:
_ = (sender, app_data, user_data)
if not _is_drawlist_hovered():
return
handle_mouse_down(state, _mouse_pos(), _hit_rect())
def _on_mouse_drag(sender: Any, app_data: Any, user_data: Any) -> None:
_ = (sender, app_data, user_data)
handle_mouse_drag(state, _mouse_pos())
def _on_mouse_release(sender: Any, app_data: Any, user_data: Any) -> None:
_ = (sender, app_data, user_data)
handle_mouse_release(state)
def _on_mouse_wheel(sender: Any, app_data: Any, user_data: Any) -> None:
_ = (sender, user_data)
if not _is_drawlist_hovered():
return
handle_mouse_wheel(
state,
mouse_pos=_mouse_pos(),
wheel_delta=wheel_delta_from_app_data(app_data),
hit_rect=_hit_rect(),
)
dpg.add_mouse_down_handler(
button=dpg.mvMouseButton_Left,
callback=_on_mouse_down,
parent=state.handler_registry_tag,
)
dpg.add_mouse_drag_handler(
button=dpg.mvMouseButton_Left,
threshold=0.0,
callback=_on_mouse_drag,
parent=state.handler_registry_tag,
)
dpg.add_mouse_release_handler(
button=dpg.mvMouseButton_Left,
callback=_on_mouse_release,
parent=state.handler_registry_tag,
)
dpg.add_mouse_wheel_handler(callback=_on_mouse_wheel, parent=state.handler_registry_tag)
renderer = MapRenderer(state, dpg)
with state.lock:

View File

@@ -7,6 +7,8 @@ from dpg_map.cache import (
DiskCacheConfig,
DiskCacheMetadata,
MemoryCacheConfig,
clear_disk_cache_path,
disk_cache_size_bytes,
plan_disk_prune,
tile_cache_path,
write_disk_metadata,
@@ -66,3 +68,23 @@ def test_disk_cache_prune_ordering(tmp_path: Path) -> None:
planned = plan_disk_prune(tmp_path, 5, protected_paths={protected})
assert planned == [first, second]
def test_provider_scoped_disk_cache_clear(tmp_path: Path) -> None:
osm = tile_cache_path(tmp_path, "osm", 1, 1, 1)
custom = tile_cache_path(tmp_path, "custom", 1, 1, 1)
for path in (osm, custom):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"abcde")
write_disk_metadata(
path.with_suffix(".json"),
DiskCacheMetadata(url=str(path), last_accessed_at=1.0, size_bytes=5),
)
assert disk_cache_size_bytes(tmp_path, provider="osm") == 5
clear_disk_cache_path(tmp_path, provider="osm")
assert not osm.exists()
assert custom.exists()
assert disk_cache_size_bytes(tmp_path) == 5

140
tests/test_hardening.py Normal file
View File

@@ -0,0 +1,140 @@
from __future__ import annotations
from math import nan
import pytest
import dpg_map as dpgm
from dpg_map.commands import CommandKind
from dpg_map.exceptions import (
CoordinateError,
MapNotFoundError,
OverlayNotFoundError,
ProviderNotFoundError,
)
from dpg_map.overlays import TrajectoryOverlay
from dpg_map.providers import TileProvider
from dpg_map.renderer import drain_renderer_commands
from dpg_map.state import DirtyFlags, InteractionState, create_map_state, get_map_state
from dpg_map.tiles import TileID, TileResult, TileStatus
def test_public_callables_have_docstrings() -> None:
for name in dpgm.__all__:
value = getattr(dpgm, name)
if callable(value):
assert value.__doc__, name
def test_unknown_map_raises_public_error() -> None:
with pytest.raises(MapNotFoundError):
dpgm.get_center(map_tag="missing-map")
def test_unknown_overlay_raises_public_error() -> None:
create_map_state(tag="missing-overlay")
with pytest.raises(OverlayNotFoundError):
dpgm.update_marker("vehicle", lat=47.0, lon=2.0, map_tag="missing-overlay")
def test_unknown_provider_raises_public_error() -> None:
create_map_state(tag="missing-provider")
with pytest.raises(ProviderNotFoundError):
dpgm.set_provider("missing-provider-name", map_tag="missing-provider")
def test_invalid_coordinates_raise_public_error() -> None:
create_map_state(tag="invalid-coordinates")
with pytest.raises(CoordinateError):
dpgm.add_marker("bad-lat", lat=91.0, lon=2.0, map_tag="invalid-coordinates")
with pytest.raises(CoordinateError):
dpgm.set_center(nan, 2.0, map_tag="invalid-coordinates")
def test_mismatched_polyline_lat_lon_lengths_raise_public_error() -> None:
create_map_state(tag="mismatched-polyline")
with pytest.raises(CoordinateError):
dpgm.add_polyline("line", lats=[47.0, 47.1], lons=[2.0], map_tag="mismatched-polyline")
def test_empty_trajectory_is_valid_for_live_updates() -> None:
create_map_state(tag="empty-trajectory")
dpgm.add_trajectory("track", points=[], map_tag="empty-trajectory")
state = get_map_state("empty-trajectory")
overlay = state.overlays["track"]
assert isinstance(overlay, TrajectoryOverlay)
assert overlay.points == ()
def test_clear_deleted_overlay_raises_public_error() -> None:
create_map_state(tag="deleted-overlay")
dpgm.add_marker("vehicle", lat=47.0, lon=2.0, map_tag="deleted-overlay")
dpgm.delete_overlay("vehicle", map_tag="deleted-overlay")
with pytest.raises(OverlayNotFoundError):
dpgm.delete_overlay("vehicle", map_tag="deleted-overlay")
def test_provider_switch_ignores_tiles_that_finish_after_switch() -> None:
provider = TileProvider(
name="hardening-switch-provider",
url_template="https://tiles.example.test/{z}/{x}/{y}.png",
min_zoom=0,
max_zoom=4,
)
dpgm.register_provider(provider)
try:
state = create_map_state(tag="provider-switch-loading", zoom=3)
old_tile = TileID("osm", 3, 1, 2)
with state.tile_manager._lock:
state.tile_manager._loading.add(old_tile)
dpgm.set_provider("hardening-switch-provider", map_tag="provider-switch-loading")
state.tile_manager._result_queue.put(
TileResult(
old_tile,
generation=0,
status=TileStatus.READY,
width=1,
height=1,
pixels=(1.0, 1.0, 1.0, 1.0),
source="network",
)
)
commands = drain_renderer_commands(state)
accepted = state.tile_manager.drain_results(
generation=state.generation,
provider_name=state.provider.name,
)
assert [command.kind for command in commands] == [CommandKind.SET_PROVIDER]
assert accepted == []
assert state.tile_manager.snapshot().stale_results == 1
assert state.tile_manager.get_ready_tile(old_tile) is None
finally:
dpgm.unregister_provider("hardening-switch-provider")
def test_overlay_update_preserves_active_drag_model_state() -> None:
create_map_state(tag="update-while-dragging", center=(47.0, 2.0), zoom=9)
dpgm.add_marker("vehicle", lat=47.0, lon=2.0, map_tag="update-while-dragging")
state = get_map_state("update-while-dragging")
state.command_queue.drain()
state.dirty = DirtyFlags.NONE
state.interaction = InteractionState(active_drag=True, last_mouse_position=(20.0, 30.0))
dpgm.update_marker("vehicle", lat=47.1, lon=2.1, map_tag="update-while-dragging")
assert state.interaction.active_drag is True
assert state.interaction.last_mouse_position == (20.0, 30.0)
assert state.center == (47.0, 2.0)
assert state.zoom == 9
assert state.dirty == DirtyFlags.OVERLAYS

View File

@@ -1,8 +1,21 @@
from __future__ import annotations
from dpg_map.interaction import calculate_hit_rect
import pytest
import dpg_map as dpgm
import dpg_map.interaction as interaction_module
from dpg_map.commands import CommandKind
from dpg_map.interaction import (
calculate_hit_rect,
handle_mouse_down,
handle_mouse_drag,
handle_mouse_release,
handle_mouse_wheel,
pan_state_by_pixels,
update_drag_from_button_state,
)
from dpg_map.sizing import SizeMeasurement, apply_size_measurement
from dpg_map.state import create_map_state
from dpg_map.state import DirtyFlags, create_map_state, get_map_state
def test_hit_rect_uses_effective_map_size() -> None:
@@ -17,3 +30,118 @@ def test_hit_rect_uses_effective_map_size() -> None:
assert rect.height == 250
assert rect.contains(410.0, 270.0)
assert not rect.contains(411.0, 270.0)
def test_pan_updates_center_and_queues_view_command() -> None:
state = create_map_state(tag="pan", center=(0.0, 0.0), zoom=3)
apply_size_measurement(state, SizeMeasurement(width=400, height=300, visible=True))
old_center = state.center
pan_state_by_pixels(state, 40.0, 0.0)
assert state.center != old_center
assert state.center[1] < old_center[1]
assert state.dirty & DirtyFlags.VIEW
drained = state.command_queue.drain()
assert drained[-1].kind is CommandKind.SET_VIEW
assert drained[-1].payload["center"] == state.center
def test_mouse_drag_uses_active_drag_state() -> None:
state = create_map_state(tag="drag", center=(0.0, 0.0), zoom=3)
apply_size_measurement(state, SizeMeasurement(width=400, height=300, visible=True))
rect = calculate_hit_rect(state, (10.0, 20.0))
handle_mouse_down(state, (20.0, 30.0), rect)
assert state.interaction.active_drag is True
handle_mouse_drag(state, (45.0, 30.0))
handle_mouse_release(state)
assert state.interaction.active_drag is False
assert state.interaction.last_mouse_position is None
assert state.center[1] < 0.0
def test_polled_drag_starts_and_moves_while_button_is_down() -> None:
state = create_map_state(tag="polled-drag", center=(0.0, 0.0), zoom=3)
apply_size_measurement(state, SizeMeasurement(width=400, height=300, visible=True))
rect = calculate_hit_rect(state, (10.0, 20.0))
update_drag_from_button_state(state, mouse_pos=(20.0, 30.0), hit_rect=rect, is_down=True)
update_drag_from_button_state(state, mouse_pos=(45.0, 30.0), hit_rect=rect, is_down=True)
update_drag_from_button_state(state, mouse_pos=(45.0, 30.0), hit_rect=rect, is_down=False)
assert state.interaction.active_drag is False
assert state.center[1] < 0.0
def test_renderer_poll_does_not_start_drag_for_inactive_map() -> None:
state = create_map_state(tag="poll-no-start", center=(0.0, 0.0), zoom=3)
apply_size_measurement(state, SizeMeasurement(width=400, height=300, visible=True))
rect = calculate_hit_rect(state, (10.0, 20.0))
update_drag_from_button_state(
state,
mouse_pos=(20.0, 30.0),
hit_rect=rect,
is_down=True,
can_start=False,
)
assert state.interaction.active_drag is False
assert state.center == (0.0, 0.0)
def test_drag_owner_prevents_second_map_from_tracking_first_map() -> None:
with interaction_module._drag_owner_lock:
interaction_module._drag_owner_map = None
state_a = create_map_state(tag="drag-owner-a", center=(0.0, 0.0), zoom=3)
state_b = create_map_state(tag="drag-owner-b", center=(0.0, 0.0), zoom=3)
apply_size_measurement(state_a, SizeMeasurement(width=400, height=300, visible=True))
apply_size_measurement(state_b, SizeMeasurement(width=400, height=300, visible=True))
rect_a = calculate_hit_rect(state_a, (10.0, 20.0))
rect_b = calculate_hit_rect(state_b, (10.0, 20.0))
handle_mouse_down(state_a, (20.0, 30.0), rect_a)
handle_mouse_down(state_b, (20.0, 30.0), rect_b)
handle_mouse_drag(state_a, (45.0, 30.0))
handle_mouse_drag(state_b, (45.0, 30.0))
assert state_a.center[1] < 0.0
assert state_b.center == (0.0, 0.0)
handle_mouse_release(state_a)
def test_wheel_zoom_keeps_cursor_latlon_stable() -> None:
state = create_map_state(tag="wheel", center=(47.9029, 1.9093), zoom=8)
apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True))
rect = calculate_hit_rect(state, (100.0, 50.0))
before = dpgm.screen_to_latlon(300.0, 200.0, map_tag="wheel")
handle_mouse_wheel(state, mouse_pos=(400.0, 250.0), wheel_delta=1.0, hit_rect=rect)
after = dpgm.screen_to_latlon(300.0, 200.0, map_tag="wheel")
assert state.zoom == 9
assert after == pytest.approx(before, abs=1e-7)
def test_view_coordinate_helpers_roundtrip() -> None:
create_map_state(tag="view-roundtrip", center=(47.9029, 1.9093), zoom=12)
state = get_map_state("view-roundtrip")
apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True))
screen = dpgm.latlon_to_screen(47.91, 1.92, map_tag="view-roundtrip")
latlon = dpgm.screen_to_latlon(*screen, map_tag="view-roundtrip")
assert latlon == pytest.approx((47.91, 1.92), abs=1e-7)
def test_fit_bounds_sets_center_and_zoom() -> None:
create_map_state(tag="fit", center=(0.0, 0.0), zoom=2)
state = get_map_state("fit")
apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True))
dpgm.fit_bounds(((47.8, 1.8), (48.0, 2.0)), map_tag="fit")
assert dpgm.get_center(map_tag="fit") == pytest.approx((47.9, 1.9))
assert dpgm.get_zoom(map_tag="fit") > 2

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
from threading import Thread
import pytest
import dpg_map as dpgm
from dpg_map.commands import CommandKind
from dpg_map.exceptions import CoordinateError
from dpg_map.overlays import TrajectoryOverlay
from dpg_map.state import DirtyFlags, create_map_state, get_map_state
@@ -61,3 +64,50 @@ def test_layer_state_tracks_visibility_and_overlay_membership() -> None:
assert state.layers["fleet"].overlay_tags == set()
assert "vehicle" not in state.overlays
def test_add_layer_can_update_visibility_and_z_index() -> None:
create_map_state(tag="layer-order")
dpgm.add_layer("fleet", z_index=25, show=False, map_tag="layer-order")
dpgm.add_layer("fleet", z_index=30, show=True, map_tag="layer-order")
state = get_map_state("layer-order")
assert state.layers["fleet"].show is True
assert state.layers["fleet"].z_index == 30
def test_threaded_marker_updates_coalesce_without_touching_view_or_drag_state() -> None:
create_map_state(tag="threaded-marker", center=(47.0, 2.0), zoom=9)
dpgm.add_marker("vehicle", lat=47.0, lon=2.0, map_tag="threaded-marker")
state = get_map_state("threaded-marker")
state.command_queue.drain()
state.dirty = DirtyFlags.NONE
state.interaction.active_drag = True
state.interaction.last_mouse_position = (100.0, 100.0)
before_center = state.center
before_zoom = state.zoom
def update_worker(offset: float) -> None:
for index in range(100):
dpgm.update_marker(
"vehicle",
lat=47.0 + offset,
lon=2.0 + index * 0.00001,
map_tag="threaded-marker",
)
threads = [Thread(target=update_worker, args=(worker * 0.0001,)) for worker in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
commands = state.command_queue.drain()
assert state.center == before_center
assert state.zoom == before_zoom
assert state.interaction.active_drag is True
assert state.interaction.last_mouse_position == (100.0, 100.0)
assert state.dirty == DirtyFlags.OVERLAYS
assert [command.kind for command in commands] == [CommandKind.UPDATE_OVERLAY]

View File

@@ -6,6 +6,7 @@ def test_package_exports_required_public_api() -> None:
expected = {
"configure",
"CacheStats",
"TileProvider",
"register_provider",
"unregister_provider",

View File

@@ -1,8 +1,98 @@
from __future__ import annotations
from typing import Any
import dpg_map as dpgm
import dpg_map.renderer as renderer_module
from dpg_map.commands import CommandKind, MapCommand
from dpg_map.renderer import drain_renderer_commands
from dpg_map.providers import TileProvider
from dpg_map.renderer import MapRenderer, drain_renderer_commands
from dpg_map.state import DirtyFlags, create_map_state
from dpg_map.tiles import TileID, TileResult, TileStatus, VisibleTile
class FakeDpg:
def __init__(self) -> None:
self.items: set[str | int] = set()
self.deleted: list[tuple[str | int, bool]] = []
self.drawn: list[tuple[str, str | int]] = []
self.images: list[tuple[Any, Any, str | int]] = []
def does_item_exist(self, tag: str | int) -> bool:
return tag in self.items
def add_draw_layer(self, *, parent: str | int, tag: str | int) -> None:
_ = parent
self.items.add(tag)
def delete_item(self, tag: str | int, *, children_only: bool = False) -> None:
self.deleted.append((tag, children_only))
def draw_rectangle(self, *args: Any, parent: str | int, **kwargs: Any) -> None:
_ = (args, kwargs)
self.drawn.append(("rectangle", parent))
def draw_image(self, *args: Any, parent: str | int, **kwargs: Any) -> None:
_ = (args, kwargs)
self.drawn.append(("image", parent))
self.images.append((args[1], args[2], parent))
def draw_text(self, *args: Any, parent: str | int, **kwargs: Any) -> None:
_ = (args, kwargs)
self.drawn.append(("text", parent))
def draw_circle(self, *args: Any, parent: str | int, **kwargs: Any) -> None:
_ = (args, kwargs)
self.drawn.append(("circle", parent))
def draw_polyline(self, *args: Any, parent: str | int, **kwargs: Any) -> None:
_ = (args, kwargs)
self.drawn.append(("polyline", parent))
class FrameCallbackDpg(FakeDpg):
def __init__(self) -> None:
super().__init__()
self.frame_count = 0
self.callbacks: dict[int, Any] = {}
def get_frame_count(self) -> int:
return self.frame_count
def set_frame_callback(self, frame: int, callback: Any) -> None:
self.callbacks[frame] = callback
def test_shared_frame_pump_renders_multiple_maps_from_one_frame_callback() -> None:
with renderer_module._scheduler_lock:
renderer_module._scheduled_renderers.clear()
renderer_module._scheduler_frame_scheduled = False
renderer_module._scheduler_dpg = None
state_a = create_map_state(tag="frame-pump-a")
state_b = create_map_state(tag="frame-pump-b")
fake = FrameCallbackDpg()
fake.items.update({state_a.drawlist_tag, state_b.drawlist_tag})
renderer_a = MapRenderer(state_a, fake)
renderer_b = MapRenderer(state_b, fake)
rendered: list[str] = []
renderer_a.render_frame = lambda: rendered.append("a") # type: ignore[method-assign]
renderer_b.render_frame = lambda: rendered.append("b") # type: ignore[method-assign]
renderer_a.schedule_next_frame()
renderer_b.schedule_next_frame()
assert list(fake.callbacks) == [1]
fake.frame_count = 1
fake.callbacks[1]()
assert rendered == ["a", "b"]
assert sorted(renderer_module._scheduled_renderers) == ["frame-pump-a", "frame-pump-b"]
with renderer_module._scheduler_lock:
renderer_module._scheduled_renderers.clear()
renderer_module._scheduler_frame_scheduled = False
renderer_module._scheduler_dpg = None
def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> None:
@@ -28,3 +118,138 @@ def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> No
assert commands[2].payload == {"tag": "a", "v": 2}
assert state.dirty & DirtyFlags.VIEW
assert state.dirty & DirtyFlags.OVERLAYS
def test_overlay_draw_clears_only_overlay_layer() -> None:
state = create_map_state(tag="overlay-draw", center=(47.0, 2.0), zoom=8)
dpgm.add_marker(
"vehicle",
lat=47.0,
lon=2.0,
show_label=True,
label="Vehicle",
map_tag="overlay-draw",
)
fake = FakeDpg()
fake.items.add(state.drawlist_tag)
renderer = MapRenderer(state, fake)
renderer._draw_tile_layer(
visible_tiles=[], width=400, height=300, attribution="Tiles", tile_size=256
)
fake.deleted.clear()
with state.lock:
overlays = tuple(state.overlays.values())
layers = {name: (layer.show, layer.z_index) for name, layer in state.layers.items()}
renderer._draw_overlay_layer(
overlays=overlays,
layers=layers,
center=state.center,
zoom=state.zoom,
width=400,
height=300,
tile_size=256,
)
assert fake.deleted == [("overlay-draw##layer-overlays", True)]
assert ("circle", "overlay-draw##layer-overlays") in fake.drawn
assert ("text", "overlay-draw##layer-overlays") in fake.drawn
def test_tile_draw_snaps_fractional_positions_to_integer_pixels() -> None:
state = create_map_state(tag="tile-snap", center=(47.0, 2.0), zoom=8)
tile_id = TileID("osm", 8, 129, 89)
state.tile_manager._result_queue.put(
TileResult(
tile_id,
generation=state.generation,
status=TileStatus.READY,
width=1,
height=1,
pixels=(1.0, 1.0, 1.0, 1.0),
source="disk",
)
)
state.tile_manager.drain_results(generation=state.generation, provider_name="osm")
state.tile_manager.set_texture_tag(tile_id, "tile-texture")
fake = FakeDpg()
fake.items.add(state.drawlist_tag)
renderer = MapRenderer(state, fake)
renderer._draw_tile_layer(
visible_tiles=[VisibleTile(tile_id, 10.4, 20.6)],
width=400,
height=300,
attribution="Tiles",
tile_size=256,
)
assert fake.images == [((10, 21), (266, 277), "tile-snap##layer-tiles")]
def test_overlay_update_drain_sets_only_overlay_dirty() -> None:
state = create_map_state(tag="overlay-dirty")
state.dirty = DirtyFlags.NONE
state.command_queue.put(MapCommand(CommandKind.UPDATE_OVERLAY, state.tag, {"tag": "a"}))
drain_renderer_commands(state)
assert state.dirty == DirtyFlags.OVERLAYS
def test_provider_switch_keeps_overlays_and_invalidates_tiles() -> None:
provider = TileProvider(
name="renderer-switch-provider",
url_template="https://tiles.example.test/{z}/{x}/{y}.png",
min_zoom=3,
max_zoom=4,
attribution="Example",
)
dpgm.register_provider(provider)
try:
state = create_map_state(tag="provider-switch", center=(47.0, 2.0), zoom=8)
dpgm.add_marker("vehicle", lat=47.0, lon=2.0, map_tag="provider-switch")
state.command_queue.drain()
state.dirty = DirtyFlags.NONE
tile_id = TileID("osm", 3, 1, 2)
state.tile_manager._result_queue.put(
TileResult(
tile_id,
generation=state.generation,
status=TileStatus.READY,
width=1,
height=1,
pixels=(1.0, 1.0, 1.0, 1.0),
source="disk",
)
)
state.tile_manager.drain_results(generation=state.generation, provider_name="osm")
state.tile_manager.set_texture_tag(tile_id, "old-texture")
dpgm.set_provider("renderer-switch-provider", map_tag="provider-switch")
drain_renderer_commands(state)
assert "vehicle" in state.overlays
assert state.center == (47.0, 2.0)
assert state.zoom == 4
assert state.generation == 1
assert state.provider.name == "renderer-switch-provider"
assert state.dirty & DirtyFlags.PROVIDER
assert state.dirty & DirtyFlags.TILES
assert state.dirty & DirtyFlags.OVERLAYS
assert state.tile_manager.get_ready_tile(tile_id) is None
assert state.tile_manager.take_texture_deletions() == ["old-texture"]
finally:
dpgm.unregister_provider("renderer-switch-provider")
def test_map_scoped_clear_disk_cache_command_keeps_dearpygui_out_of_caller_thread() -> None:
state = create_map_state(tag="clear-disk-command")
state.dirty = DirtyFlags.NONE
dpgm.clear_disk_cache(map_tag="clear-disk-command")
commands = state.command_queue.drain()
assert [command.kind for command in commands] == [CommandKind.CLEAR_DISK_CACHE]
assert state.dirty == DirtyFlags.TILES

2
uv.lock generated
View File

@@ -74,7 +74,7 @@ wheels = [
[[package]]
name = "dpg-map"
version = "0.1.0"
version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "dearpygui" },