Compare commits

...

4 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
27 changed files with 1668 additions and 105 deletions

115
README.md
View File

@@ -1,53 +1,88 @@
# dpg-map # dpg-map
`dpg-map` is a Dear PyGui map widget package under rebuild. `dpg-map` is a Dear PyGui widget for interactive XYZ raster maps, tile caching, and
geographic overlays.
The Step 6 overlay rendering layer is in place:
```python ```python
import dpg_map as dpgm 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 ## Install
- 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
- asynchronous tile workers that read disk cache, fetch HTTP, and decode images
- memory cache with visible-tile protection and GUI-thread texture deletion
- measured-rectangle mouse interaction for left-drag panning and wheel zooming
- programmatic view commands and screen/latitude-longitude conversion helpers
- marker, polyline, and trajectory rendering on a draw layer separate from tiles
- background-thread overlay updates that coalesce without resetting center or zoom
Examples: For local development:
```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 ```bash
uv run python examples/basic_map.py 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 ## Documentation
uv run python examples/hidden_tab.py
uv run python examples/cache_stress.py - [Getting Started](docs/GETTING_STARTED.md)
uv run python examples/markers_live_thread.py - [Examples](docs/EXAMPLES.md)
uv run python examples/trajectory_live_thread.py - [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
``` ```

View File

@@ -2,7 +2,7 @@
## Current status ## Current status
Step 6 complete. Step 8 complete.
## Completed steps ## Completed steps
@@ -12,10 +12,12 @@ Step 3 - Widget shell, sizing system, and GUI-thread frame pump.
Step 4 - Tile manager, persistent cache, and asynchronous loading. Step 4 - Tile manager, persistent cache, and asynchronous loading.
Step 5 - Interaction: pan, zoom, and view commands. Step 5 - Interaction: pan, zoom, and view commands.
Step 6 - Overlay rendering and runtime update stress tests. 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 ## Current step
Step 7 - Layers, provider switching, and clearing APIs. Internal rebuilt beta prepared.
## Design decisions ## Design decisions
@@ -34,7 +36,7 @@ None yet.
## Commands used ## Commands used
- Read `STEPS.md`, `FEATURES.md`, and `ARCHITECTURE.md`. - Read `codex/STEPS.md`, `codex/FEATURES.md`, and `codex/ARCHITECTURE.md`.
- Created initial package, examples, tests, and agent-log structure. - 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. - 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. - Added Step 1 tests for imports, providers, projection, and cache dataclasses.
@@ -94,7 +96,34 @@ None yet.
- Ran `uv run ruff check .`. - Ran `uv run ruff check .`.
- Ran `uv run ruff format --check .`. - Ran `uv run ruff format --check .`.
- Ran a Dear PyGui context smoke check for `map_widget` with marker, polyline, and trajectory overlays. - 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 ## Next action
Implement Step 7. Commit and tag `v0.3.0b1`.

View File

@@ -684,14 +684,14 @@ Create:
src/dpg_map/ src/dpg_map/
examples/ examples/
tests/ tests/
FEATURES.md codex/FEATURES.md
ARCHITECTURE.md codex/ARCHITECTURE.md
STEPS.md codex/STEPS.md
AGENTS.md codex/AGENTS.md
README.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 ```bash
uv run pytest 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 ## Workflow rules
1. Use `uv` for all Python package and dependency management. 1. Use `uv` for all Python package and dependency management.
2. Always read `FEATURES.md`, `ARCHITECTURE.md`, and `AGENTS.md` before making code changes. 2. Always read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, and `codex/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. 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. 4. Update `README.md` whenever public behaviour or examples change.
5. After every step: 5. After every step:
- update `AGENTS.md` - update `codex/AGENTS.md`
- run relevant checks - run relevant checks
- commit to git - commit to git
6. Do not casually change public API once introduced. 6. Do not casually change public API once introduced.
@@ -55,10 +55,10 @@ src/dpg_map/
exceptions.py exceptions.py
examples/ examples/
tests/ tests/
FEATURES.md codex/FEATURES.md
ARCHITECTURE.md codex/ARCHITECTURE.md
STEPS.md codex/STEPS.md
AGENTS.md codex/AGENTS.md
README.md README.md
``` ```
@@ -76,7 +76,7 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
typeCheckingMode = "basic" typeCheckingMode = "basic"
``` ```
Create `AGENTS.md` with: Create `codex/AGENTS.md` with:
```markdown ```markdown
# AGENTS.md # AGENTS.md
@@ -753,7 +753,7 @@ Acceptance criteria:
- disk cache limit works - disk cache limit works
- sizing examples work - sizing examples work
- README accurately documents thread-safety and cache behaviour - README accurately documents thread-safety and cache behaviour
- AGENTS.md accurately describes status - codex/AGENTS.md accurately describes status
Commit: 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_context()
dpg.create_viewport(title="dpg-map cache stress", width=1000, height=700) 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 ( with (
dpg.window(label="Cache Stress", width=-1, height=-1), 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", tag="cache-map",
center=(47.9029, 1.9093), center=(47.9029, 1.9093),
zoom=14, zoom=14,
width=-1, width=-1,
height=-1, height=-1,
),
): ):
dpgm.add_marker("start", lat=47.9029, lon=1.9093, label="Orleans") 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

@@ -1,7 +1,7 @@
[project] [project]
name = "dpg-map" name = "dpg-map"
version = "0.1.0" version = "1.0.0"
description = "Add your description here" description = "Dear PyGui map widget for XYZ raster tiles and geographic overlays"
readme = "README.md" readme = "README.md"
authors = [ authors = [
{ name = "Hector van der Aa", email = "hector@h3cx.dev" } { name = "Hector van der Aa", email = "hector@h3cx.dev" }

View File

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

View File

@@ -10,7 +10,12 @@ from typing import Any
from .cache import CacheStats, clear_disk_cache_path, disk_cache_root, disk_cache_size_bytes from .cache import CacheStats, clear_disk_cache_path, disk_cache_root, disk_cache_size_bytes
from .commands import CommandKind, MapCommand 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 .interaction import latlon_to_screen_in_state, screen_to_latlon_in_state
from .overlays import LayerState, MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay from .overlays import LayerState, MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay
from .projection import latlon_to_world from .projection import latlon_to_world
@@ -22,6 +27,7 @@ from .state import (
find_map_for_overlay, find_map_for_overlay,
get_config, get_config,
get_map_state, get_map_state,
list_map_states,
mark_dirty, mark_dirty,
) )
from .types import Bounds, LatLon, Point, Tag from .types import Bounds, LatLon, Point, Tag
@@ -90,6 +96,8 @@ def configure(
overlay_update_policy: str = "coalesce", overlay_update_policy: str = "coalesce",
debug: bool = False, debug: bool = False,
) -> None: ) -> None:
"""Configure package-wide defaults used by subsequently created maps."""
configure_state( configure_state(
user_agent=user_agent, user_agent=user_agent,
cache_dir=cache_dir, cache_dir=cache_dir,
@@ -104,20 +112,28 @@ def configure(
def set_center(lat: float, lon: float, *, map_tag: Tag | None = None) -> None: 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) set_view(center=_validate_latlon(lat, lon), map_tag=map_tag)
def get_center(*, map_tag: Tag | None = None) -> LatLon: def get_center(*, map_tag: Tag | None = None) -> LatLon:
"""Return the current logical center of a map."""
state = get_map_state(map_tag) state = get_map_state(map_tag)
with state.lock: with state.lock:
return state.center return state.center
def set_zoom(zoom: int, *, map_tag: Tag | None = None) -> None: 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) set_view(zoom=zoom, map_tag=map_tag)
def get_zoom(*, map_tag: Tag | None = None) -> int: def get_zoom(*, map_tag: Tag | None = None) -> int:
"""Return the current logical zoom of a map."""
state = get_map_state(map_tag) state = get_map_state(map_tag)
with state.lock: with state.lock:
return state.zoom return state.zoom
@@ -129,6 +145,8 @@ def set_view(
zoom: int | None = None, zoom: int | None = None,
map_tag: Tag | None = None, map_tag: Tag | None = None,
) -> None: ) -> None:
"""Set map center and/or zoom as one logical view update."""
state = get_map_state(map_tag) state = get_map_state(map_tag)
with state.lock: with state.lock:
payload: dict[str, Any] = {} payload: dict[str, Any] = {}
@@ -145,6 +163,8 @@ def set_view(
def fit_bounds(bounds: Bounds, *, map_tag: Tag | None = None) -> None: 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, north_east) = bounds
south, west = _validate_latlon(south_west[0], south_west[1]) south, west = _validate_latlon(south_west[0], south_west[1])
north, east = _validate_latlon(north_east[0], north_east[1]) north, east = _validate_latlon(north_east[0], north_east[1])
@@ -171,11 +191,15 @@ def fit_bounds(bounds: Bounds, *, map_tag: Tag | None = None) -> None:
def screen_to_latlon(x: float, y: float, *, map_tag: Tag | None = None) -> LatLon: def screen_to_latlon(x: float, y: float, *, map_tag: Tag | None = None) -> LatLon:
"""Convert map-local screen coordinates to latitude/longitude."""
state = get_map_state(map_tag) state = get_map_state(map_tag)
return screen_to_latlon_in_state(state, float(x), float(y)) 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: def latlon_to_screen(lat: float, lon: float, *, map_tag: Tag | None = None) -> Point:
"""Convert latitude/longitude to map-local screen coordinates."""
lat_value, lon_value = _validate_latlon(lat, lon) lat_value, lon_value = _validate_latlon(lat, lon)
state = get_map_state(map_tag) state = get_map_state(map_tag)
return latlon_to_screen_in_state(state, lat_value, lon_value) return latlon_to_screen_in_state(state, lat_value, lon_value)
@@ -192,6 +216,8 @@ def add_marker(
map_tag: Tag | None = None, map_tag: Tag | None = None,
**kwargs: Any, **kwargs: Any,
) -> Tag: ) -> Tag:
"""Add or replace a marker overlay and return its tag."""
state = get_map_state(map_tag) state = get_map_state(map_tag)
color = kwargs.get("color", (255, 80, 80, 255)) color = kwargs.get("color", (255, 80, 80, 255))
radius = float(kwargs.get("radius", 5.0)) radius = float(kwargs.get("radius", 5.0))
@@ -231,6 +257,8 @@ def add_polyline(
map_tag: Tag | None = None, map_tag: Tag | None = None,
**kwargs: Any, **kwargs: Any,
) -> Tag: ) -> Tag:
"""Add or replace a polyline overlay and return its tag."""
state = get_map_state(map_tag) state = get_map_state(map_tag)
copied_points = _points_from_inputs(points, lats=lats, lons=lons) copied_points = _points_from_inputs(points, lats=lats, lons=lons)
with state.lock: with state.lock:
@@ -264,6 +292,8 @@ def add_trajectory(
map_tag: Tag | None = None, map_tag: Tag | None = None,
**kwargs: Any, **kwargs: Any,
) -> Tag: ) -> Tag:
"""Add or replace a trajectory overlay and return its tag."""
state = get_map_state(map_tag) state = get_map_state(map_tag)
copied_points = _points_from_inputs(points, lats=lats, lons=lons) copied_points = _points_from_inputs(points, lats=lats, lons=lons)
timestamps = kwargs.get("timestamps") timestamps = kwargs.get("timestamps")
@@ -303,6 +333,8 @@ def update_marker(
map_tag: Tag | None = None, map_tag: Tag | None = None,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
"""Update marker properties without changing the map view."""
state = find_map_for_overlay(tag, map_tag) state = find_map_for_overlay(tag, map_tag)
with state.lock: with state.lock:
overlay = state.overlays.get(tag) overlay = state.overlays.get(tag)
@@ -335,6 +367,8 @@ def update_polyline(
map_tag: Tag | None = None, map_tag: Tag | None = None,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
"""Update polyline properties without changing the map view."""
state = find_map_for_overlay(tag, map_tag) state = find_map_for_overlay(tag, map_tag)
with state.lock: with state.lock:
overlay = state.overlays.get(tag) overlay = state.overlays.get(tag)
@@ -362,6 +396,8 @@ def update_trajectory(
map_tag: Tag | None = None, map_tag: Tag | None = None,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
"""Update trajectory properties without changing the map view."""
state = find_map_for_overlay(tag, map_tag) state = find_map_for_overlay(tag, map_tag)
with state.lock: with state.lock:
overlay = state.overlays.get(tag) overlay = state.overlays.get(tag)
@@ -386,10 +422,14 @@ def update_trajectory(
def set_marker_position(tag: Tag, lat: float, lon: float, *, map_tag: Tag | None = None) -> None: 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) 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: 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) update_marker(tag, label=label, map_tag=map_tag)
@@ -399,10 +439,14 @@ def set_polyline_points(
*, *,
map_tag: Tag | None = None, map_tag: Tag | None = None,
) -> None: ) -> None:
"""Replace a polyline point sequence."""
update_polyline(tag, points=points, map_tag=map_tag) update_polyline(tag, points=points, map_tag=map_tag)
def set_overlay_show(tag: Tag, show: bool, *, map_tag: Tag | None = None) -> None: 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) state = find_map_for_overlay(tag, map_tag)
with state.lock: with state.lock:
overlay = state.overlays.get(tag) overlay = state.overlays.get(tag)
@@ -415,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: 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) state = find_map_for_overlay(tag, map_tag)
with state.lock: with state.lock:
overlay = state.overlays.pop(tag, None) overlay = state.overlays.pop(tag, None)
@@ -427,16 +473,46 @@ def delete_overlay(tag: Tag, *, map_tag: Tag | None = None) -> None:
_queue(state, CommandKind.DELETE_OVERLAY, {"tag": tag}) _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) state = get_map_state(map_tag)
with state.lock: 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 layer.show = show
mark_dirty(state, DirtyFlags.OVERLAYS) 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: def show_layer(name: str, *, map_tag: Tag | None = None) -> None:
"""Show all overlays assigned to a layer."""
state = get_map_state(map_tag) state = get_map_state(map_tag)
with state.lock: with state.lock:
_ensure_layer(state, name).show = True _ensure_layer(state, name).show = True
@@ -445,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: def hide_layer(name: str, *, map_tag: Tag | None = None) -> None:
"""Hide all overlays assigned to a layer."""
state = get_map_state(map_tag) state = get_map_state(map_tag)
with state.lock: with state.lock:
_ensure_layer(state, name).show = False _ensure_layer(state, name).show = False
@@ -453,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: def clear_layer(name: str, *, map_tag: Tag | None = None) -> None:
"""Delete all overlays assigned to a layer."""
state = get_map_state(map_tag) state = get_map_state(map_tag)
with state.lock: with state.lock:
layer = _ensure_layer(state, name) layer = _ensure_layer(state, name)
@@ -464,6 +544,8 @@ def clear_layer(name: str, *, map_tag: Tag | None = None) -> None:
def clear_map(*, 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) state = get_map_state(map_tag)
with state.lock: with state.lock:
state.overlays.clear() state.overlays.clear()
@@ -475,36 +557,55 @@ def clear_map(*, map_tag: Tag | None = None) -> None:
def set_provider(provider: str | TileProvider, *, 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) state = get_map_state(map_tag)
with state.lock: with state.lock:
if state.provider == provider_obj:
return
state.provider = provider_obj state.provider = provider_obj
state.min_zoom = max(state.min_zoom, provider_obj.min_zoom) state.min_zoom = provider_obj.min_zoom
state.max_zoom = min(state.max_zoom, provider_obj.max_zoom) state.max_zoom = provider_obj.max_zoom
state.zoom = max(state.min_zoom, min(state.max_zoom, state.zoom)) state.zoom = max(state.min_zoom, min(state.max_zoom, state.zoom))
state.generation += 1 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}) _queue(state, CommandKind.SET_PROVIDER, {"provider": provider_obj.name})
def clear_memory_cache(*, map_tag: Tag | None = None) -> None: 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: with state.lock:
state.generation += 1
mark_dirty(state, DirtyFlags.TILES) mark_dirty(state, DirtyFlags.TILES)
_queue(state, CommandKind.CLEAR_MEMORY_CACHE, {}) _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: if map_tag is None:
clear_disk_cache_path(get_config().cache_dir) clear_disk_cache_path(get_config().cache_dir, provider=provider)
return return
state = get_map_state(map_tag) state = get_map_state(map_tag)
with state.lock: with state.lock:
state.generation += 1
mark_dirty(state, DirtyFlags.TILES) 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: def get_cache_stats(*, map_tag: Tag | None = None) -> CacheStats:
"""Return memory and disk cache diagnostics."""
config = get_config() config = get_config()
if map_tag is None: if map_tag is None:
cache_dir = config.cache_dir cache_dir = config.cache_dir
@@ -531,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]: 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) state = get_map_state(map_tag)
with state.lock: with state.lock:
return { return {

View File

@@ -225,10 +225,20 @@ def scan_disk_cache(cache_dir: str | Path | None) -> list[DiskCacheEntry]:
return entries return entries
def disk_cache_size_bytes(cache_dir: str | Path | None) -> int: def disk_cache_size_bytes(
"""Return total bytes for cached tile files.""" 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)) 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( def plan_disk_prune(
@@ -277,10 +287,12 @@ def prune_disk_cache(
return planned return planned
def clear_disk_cache_path(cache_dir: str | Path | None) -> None: def clear_disk_cache_path(cache_dir: str | Path | None, *, provider: str | None = None) -> None:
"""Remove all persistent tile cache files under a cache root.""" """Remove persistent tile cache files under a cache root."""
root = disk_cache_root(cache_dir) root = disk_cache_root(cache_dir)
if provider is not None:
root = root / provider.replace("/", "_")
if not root.exists(): if not root.exists():
return return
try: try:

View File

@@ -4,13 +4,17 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from math import isfinite from math import isfinite
from threading import RLock
from typing import Any from typing import Any
from .commands import CommandKind, MapCommand from .commands import CommandKind, MapCommand
from .projection import latlon_to_world, screen_to_world, world_to_latlon from .projection import latlon_to_world, screen_to_world, world_to_latlon
from .sizing import effective_draw_size from .sizing import effective_draw_size
from .state import DirtyFlags, MapState, mark_dirty from .state import DirtyFlags, MapState, mark_dirty
from .types import LatLon, Point from .types import LatLon, Point, Tag
_drag_owner_lock = RLock()
_drag_owner_map: Tag | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -152,6 +156,12 @@ def handle_mouse_down(state: MapState, mouse_pos: tuple[float, float], hit_rect:
state.interaction.active_drag = False state.interaction.active_drag = False
state.interaction.last_mouse_position = None state.interaction.last_mouse_position = None
return 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.active_drag = True
state.interaction.last_mouse_position = mouse_pos state.interaction.last_mouse_position = mouse_pos
@@ -159,6 +169,9 @@ def handle_mouse_down(state: MapState, mouse_pos: tuple[float, float], hit_rect:
def handle_mouse_drag(state: MapState, mouse_pos: tuple[float, float]) -> None: def handle_mouse_drag(state: MapState, mouse_pos: tuple[float, float]) -> None:
"""Update center from a mouse drag event.""" """Update center from a mouse drag event."""
with _drag_owner_lock:
if _drag_owner_map != state.tag:
return
with state.lock: with state.lock:
if not state.interaction.active_drag: if not state.interaction.active_drag:
return return
@@ -172,6 +185,10 @@ def handle_mouse_drag(state: MapState, mouse_pos: tuple[float, float]) -> None:
def handle_mouse_release(state: MapState) -> None: def handle_mouse_release(state: MapState) -> None:
"""End any active drag.""" """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: with state.lock:
state.interaction.active_drag = False state.interaction.active_drag = False
state.interaction.last_mouse_position = None state.interaction.last_mouse_position = None
@@ -186,6 +203,9 @@ def handle_mouse_wheel(
) -> None: ) -> None:
"""Apply wheel zoom when the cursor is over the concrete map rectangle.""" """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]): if not hit_rect.contains(mouse_pos[0], mouse_pos[1]):
return return
zoom_state_at_screen_point( zoom_state_at_screen_point(
@@ -202,6 +222,7 @@ def update_drag_from_button_state(
mouse_pos: tuple[float, float], mouse_pos: tuple[float, float],
hit_rect: HitRect, hit_rect: HitRect,
is_down: bool, is_down: bool,
can_start: bool = True,
) -> None: ) -> None:
"""Poll left-button state and keep drag interaction moving.""" """Poll left-button state and keep drag interaction moving."""
@@ -217,6 +238,9 @@ def update_drag_from_button_state(
handle_mouse_drag(state, mouse_pos) handle_mouse_drag(state, mouse_pos)
return return
if not can_start:
return
if hit_rect.contains(mouse_pos[0], mouse_pos[1]): if hit_rect.contains(mouse_pos[0], mouse_pos[1]):
handle_mouse_down(state, mouse_pos, hit_rect) handle_mouse_down(state, mouse_pos, hit_rect)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import replace from dataclasses import replace
from threading import RLock
from typing import Any from typing import Any
from .commands import CommandKind, MapCommand from .commands import CommandKind, MapCommand
@@ -14,7 +15,12 @@ from .projection import latlon_to_world
from .sizing import SizeMeasurement, apply_size_measurement from .sizing import SizeMeasurement, apply_size_measurement
from .state import DirtyFlags, MapState from .state import DirtyFlags, MapState
from .tiles import Tile, VisibleTile from .tiles import Tile, VisibleTile
from .types import Color, LatLon 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: class MapRenderer:
@@ -29,23 +35,15 @@ class MapRenderer:
self.last_overlay_count: int = 0 self.last_overlay_count: int = 0
def schedule_next_frame(self) -> None: 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: _schedule_renderer(self)
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)
def _frame_callback(self, sender: Any | None = None, app_data: Any | None = None) -> None: def _frame_callback(self, sender: Any | None = None, app_data: Any | None = None) -> None:
_ = (sender, app_data) _ = (sender, app_data)
with self.state.lock:
self.state.frame_scheduled = False
if not self._dpg.does_item_exist(self.state.drawlist_tag): if not self._dpg.does_item_exist(self.state.drawlist_tag):
return return
self.render_frame() self.render_frame()
self.schedule_next_frame()
def render_frame(self) -> None: def render_frame(self) -> None:
"""Drain pending commands, refresh size, process tiles, and redraw.""" """Drain pending commands, refresh size, process tiles, and redraw."""
@@ -146,6 +144,7 @@ class MapRenderer:
mouse_pos=(float(mouse_pos[0]), float(mouse_pos[1])), mouse_pos=(float(mouse_pos[0]), float(mouse_pos[1])),
hit_rect=self.last_hit_rect, hit_rect=self.last_hit_rect,
is_down=is_down, is_down=is_down,
can_start=False,
) )
def _measure_child_content(self) -> tuple[int, int]: def _measure_child_content(self) -> tuple[int, int]:
@@ -181,8 +180,8 @@ class MapRenderer:
tile = self.state.tile_manager.get_ready_tile(visible_tile.tile_id) tile = self.state.tile_manager.get_ready_tile(visible_tile.tile_id)
if tile is None or tile.texture_tag is None: if tile is None or tile.texture_tag is None:
continue continue
screen_x = visible_tile.screen_x screen_x = _snap_tile_position(visible_tile.screen_x)
screen_y = visible_tile.screen_y screen_y = _snap_tile_position(visible_tile.screen_y)
self._dpg.draw_image( self._dpg.draw_image(
tile.texture_tag, tile.texture_tag,
(screen_x, screen_y), (screen_x, screen_y),
@@ -402,6 +401,54 @@ class MapRenderer:
return get_config().prefetch_margin_tiles 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]: def drain_renderer_commands(state: MapState) -> list[MapCommand]:
"""Drain and apply GUI-thread command side effects.""" """Drain and apply GUI-thread command side effects."""
@@ -415,7 +462,7 @@ def drain_renderer_commands(state: MapState) -> list[MapCommand]:
state.dirty |= DirtyFlags.VIEW | DirtyFlags.TILES | DirtyFlags.OVERLAYS state.dirty |= DirtyFlags.VIEW | DirtyFlags.TILES | DirtyFlags.OVERLAYS
elif command.kind is CommandKind.SET_PROVIDER: elif command.kind is CommandKind.SET_PROVIDER:
state.tile_manager.clear_memory_cache() state.tile_manager.clear_memory_cache()
state.dirty |= DirtyFlags.PROVIDER | DirtyFlags.TILES state.dirty |= DirtyFlags.PROVIDER | DirtyFlags.TILES | DirtyFlags.OVERLAYS
elif command.kind in { elif command.kind in {
CommandKind.ADD_OVERLAY, CommandKind.ADD_OVERLAY,
CommandKind.UPDATE_OVERLAY, CommandKind.UPDATE_OVERLAY,
@@ -432,7 +479,10 @@ def drain_renderer_commands(state: MapState) -> list[MapCommand]:
state.tile_manager.clear_memory_cache() state.tile_manager.clear_memory_cache()
state.dirty |= DirtyFlags.TILES state.dirty |= DirtyFlags.TILES
elif command.kind is CommandKind.CLEAR_DISK_CACHE: 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 state.dirty |= DirtyFlags.TILES
return commands return commands
@@ -453,6 +503,10 @@ def _rgba(color: Color) -> tuple[int, int, int, int]:
return (int(color[0]), int(color[1]), int(color[2]), int(color[3])) 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( def _latlon_to_screen(
lat: float, lat: float,
lon: float, lon: float,

View File

@@ -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") 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: def find_map_for_overlay(tag: Tag, map_tag: Tag | None = None) -> MapState:
"""Find the map containing an overlay, optionally scoped by map tag.""" """Find the map containing an overlay, optionally scoped by map tag."""

View File

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

View File

@@ -81,8 +81,16 @@ def map_widget(
draw_pos = tuple(float(value) for value in dpg.get_item_rect_min(state.drawlist_tag)) 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])) 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: def _on_mouse_down(sender: Any, app_data: Any, user_data: Any) -> None:
_ = (sender, app_data, user_data) _ = (sender, app_data, user_data)
if not _is_drawlist_hovered():
return
handle_mouse_down(state, _mouse_pos(), _hit_rect()) handle_mouse_down(state, _mouse_pos(), _hit_rect())
def _on_mouse_drag(sender: Any, app_data: Any, user_data: Any) -> None: def _on_mouse_drag(sender: Any, app_data: Any, user_data: Any) -> None:
@@ -95,6 +103,8 @@ def map_widget(
def _on_mouse_wheel(sender: Any, app_data: Any, user_data: Any) -> None: def _on_mouse_wheel(sender: Any, app_data: Any, user_data: Any) -> None:
_ = (sender, user_data) _ = (sender, user_data)
if not _is_drawlist_hovered():
return
handle_mouse_wheel( handle_mouse_wheel(
state, state,
mouse_pos=_mouse_pos(), mouse_pos=_mouse_pos(),

View File

@@ -7,6 +7,8 @@ from dpg_map.cache import (
DiskCacheConfig, DiskCacheConfig,
DiskCacheMetadata, DiskCacheMetadata,
MemoryCacheConfig, MemoryCacheConfig,
clear_disk_cache_path,
disk_cache_size_bytes,
plan_disk_prune, plan_disk_prune,
tile_cache_path, tile_cache_path,
write_disk_metadata, 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}) planned = plan_disk_prune(tmp_path, 5, protected_paths={protected})
assert planned == [first, second] 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

@@ -3,6 +3,7 @@ from __future__ import annotations
import pytest import pytest
import dpg_map as dpgm import dpg_map as dpgm
import dpg_map.interaction as interaction_module
from dpg_map.commands import CommandKind from dpg_map.commands import CommandKind
from dpg_map.interaction import ( from dpg_map.interaction import (
calculate_hit_rect, calculate_hit_rect,
@@ -74,6 +75,43 @@ def test_polled_drag_starts_and_moves_while_button_is_down() -> None:
assert state.center[1] < 0.0 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: def test_wheel_zoom_keeps_cursor_latlon_stable() -> None:
state = create_map_state(tag="wheel", center=(47.9029, 1.9093), zoom=8) state = create_map_state(tag="wheel", center=(47.9029, 1.9093), zoom=8)
apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True)) apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True))

View File

@@ -66,6 +66,17 @@ def test_layer_state_tracks_visibility_and_overlay_membership() -> None:
assert "vehicle" not in state.overlays 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: 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) 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") dpgm.add_marker("vehicle", lat=47.0, lon=2.0, map_tag="threaded-marker")

View File

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

View File

@@ -3,9 +3,12 @@ from __future__ import annotations
from typing import Any from typing import Any
import dpg_map as dpgm import dpg_map as dpgm
import dpg_map.renderer as renderer_module
from dpg_map.commands import CommandKind, MapCommand from dpg_map.commands import CommandKind, MapCommand
from dpg_map.providers import TileProvider
from dpg_map.renderer import MapRenderer, drain_renderer_commands from dpg_map.renderer import MapRenderer, drain_renderer_commands
from dpg_map.state import DirtyFlags, create_map_state from dpg_map.state import DirtyFlags, create_map_state
from dpg_map.tiles import TileID, TileResult, TileStatus, VisibleTile
class FakeDpg: class FakeDpg:
@@ -13,6 +16,7 @@ class FakeDpg:
self.items: set[str | int] = set() self.items: set[str | int] = set()
self.deleted: list[tuple[str | int, bool]] = [] self.deleted: list[tuple[str | int, bool]] = []
self.drawn: list[tuple[str, str | int]] = [] self.drawn: list[tuple[str, str | int]] = []
self.images: list[tuple[Any, Any, str | int]] = []
def does_item_exist(self, tag: str | int) -> bool: def does_item_exist(self, tag: str | int) -> bool:
return tag in self.items return tag in self.items
@@ -31,6 +35,7 @@ class FakeDpg:
def draw_image(self, *args: Any, parent: str | int, **kwargs: Any) -> None: def draw_image(self, *args: Any, parent: str | int, **kwargs: Any) -> None:
_ = (args, kwargs) _ = (args, kwargs)
self.drawn.append(("image", parent)) 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: def draw_text(self, *args: Any, parent: str | int, **kwargs: Any) -> None:
_ = (args, kwargs) _ = (args, kwargs)
@@ -45,6 +50,51 @@ class FakeDpg:
self.drawn.append(("polyline", parent)) 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: def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> None:
state = create_map_state(tag="renderer-drain") state = create_map_state(tag="renderer-drain")
state.dirty = DirtyFlags.NONE state.dirty = DirtyFlags.NONE
@@ -107,6 +157,37 @@ def test_overlay_draw_clears_only_overlay_layer() -> None:
assert ("text", "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: def test_overlay_update_drain_sets_only_overlay_dirty() -> None:
state = create_map_state(tag="overlay-dirty") state = create_map_state(tag="overlay-dirty")
state.dirty = DirtyFlags.NONE state.dirty = DirtyFlags.NONE
@@ -115,3 +196,60 @@ def test_overlay_update_drain_sets_only_overlay_dirty() -> None:
drain_renderer_commands(state) drain_renderer_commands(state)
assert state.dirty == DirtyFlags.OVERLAYS 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]] [[package]]
name = "dpg-map" name = "dpg-map"
version = "0.1.0" version = "1.0.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "dearpygui" }, { name = "dearpygui" },