Compare commits
4 Commits
d0ba8c4218
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 192ce33d64 | |||
| 6990d011ff | |||
| a5e560937c | |||
| 50e38e18ee |
115
README.md
115
README.md
@@ -1,53 +1,88 @@
|
||||
# dpg-map
|
||||
|
||||
`dpg-map` is a Dear PyGui map widget package under rebuild.
|
||||
|
||||
The Step 6 overlay rendering layer 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
|
||||
- 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
|
||||
## Install
|
||||
|
||||
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
|
||||
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
|
||||
uv run python examples/markers_live_thread.py
|
||||
uv run python examples/trajectory_live_thread.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
|
||||
```
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Current status
|
||||
|
||||
Step 6 complete.
|
||||
Step 8 complete.
|
||||
|
||||
## 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 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
|
||||
|
||||
Step 7 - Layers, provider switching, and clearing APIs.
|
||||
Internal rebuilt beta prepared.
|
||||
|
||||
## Design decisions
|
||||
|
||||
@@ -34,7 +36,7 @@ None yet.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
@@ -94,7 +96,34 @@ None yet.
|
||||
- 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
|
||||
|
||||
Implement Step 7.
|
||||
Commit and tag `v0.3.0b1`.
|
||||
@@ -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
18
codex/README.md
Normal 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`
|
||||
@@ -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
411
docs/API_REFERENCE.md
Normal 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
146
docs/EXAMPLES.md
Normal 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
286
docs/GETTING_STARTED.md
Normal 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.
|
||||
@@ -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")
|
||||
|
||||
|
||||
52
examples/custom_provider.py
Normal file
52
examples/custom_provider.py
Normal 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()
|
||||
@@ -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" }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,7 +10,12 @@ 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
|
||||
@@ -22,6 +27,7 @@ from .state import (
|
||||
find_map_for_overlay,
|
||||
get_config,
|
||||
get_map_state,
|
||||
list_map_states,
|
||||
mark_dirty,
|
||||
)
|
||||
from .types import Bounds, LatLon, Point, Tag
|
||||
@@ -90,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,
|
||||
@@ -104,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
|
||||
@@ -129,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] = {}
|
||||
@@ -145,6 +163,8 @@ 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])
|
||||
@@ -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:
|
||||
"""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:
|
||||
"""Convert latitude/longitude to map-local screen coordinates."""
|
||||
|
||||
lat_value, lon_value = _validate_latlon(lat, lon)
|
||||
state = get_map_state(map_tag)
|
||||
return latlon_to_screen_in_state(state, lat_value, lon_value)
|
||||
@@ -192,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))
|
||||
@@ -231,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:
|
||||
@@ -264,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")
|
||||
@@ -303,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)
|
||||
@@ -335,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)
|
||||
@@ -362,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)
|
||||
@@ -386,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)
|
||||
|
||||
|
||||
@@ -399,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)
|
||||
@@ -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:
|
||||
"""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)
|
||||
@@ -427,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
|
||||
@@ -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:
|
||||
"""Hide all overlays assigned to a layer."""
|
||||
|
||||
state = get_map_state(map_tag)
|
||||
with state.lock:
|
||||
_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:
|
||||
"""Delete all overlays assigned to a layer."""
|
||||
|
||||
state = get_map_state(map_tag)
|
||||
with state.lock:
|
||||
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:
|
||||
"""Delete all overlays and invalidate map tile resources."""
|
||||
|
||||
state = get_map_state(map_tag)
|
||||
with state.lock:
|
||||
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:
|
||||
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
|
||||
@@ -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]:
|
||||
"""Return a diagnostic snapshot for a map."""
|
||||
|
||||
state = get_map_state(map_tag)
|
||||
with state.lock:
|
||||
return {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,13 +4,17 @@ 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 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)
|
||||
@@ -152,6 +156,12 @@ def handle_mouse_down(state: MapState, mouse_pos: tuple[float, float], hit_rect:
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
"""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
|
||||
@@ -172,6 +185,10 @@ def handle_mouse_drag(state: MapState, mouse_pos: tuple[float, float]) -> None:
|
||||
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
|
||||
@@ -186,6 +203,9 @@ def handle_mouse_wheel(
|
||||
) -> 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(
|
||||
@@ -202,6 +222,7 @@ def update_drag_from_button_state(
|
||||
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."""
|
||||
|
||||
@@ -217,6 +238,9 @@ def update_drag_from_button_state(
|
||||
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)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -14,7 +15,12 @@ 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
|
||||
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:
|
||||
@@ -29,23 +35,15 @@ class MapRenderer:
|
||||
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."""
|
||||
@@ -146,6 +144,7 @@ class MapRenderer:
|
||||
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]:
|
||||
@@ -181,8 +180,8 @@ 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),
|
||||
@@ -402,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."""
|
||||
|
||||
@@ -415,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,
|
||||
@@ -432,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
|
||||
|
||||
@@ -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]))
|
||||
|
||||
|
||||
def _snap_tile_position(value: float) -> int:
|
||||
return int(round(value))
|
||||
|
||||
|
||||
def _latlon_to_screen(
|
||||
lat: float,
|
||||
lon: float,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -81,8 +81,16 @@ def map_widget(
|
||||
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:
|
||||
@@ -95,6 +103,8 @@ def map_widget(
|
||||
|
||||
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(),
|
||||
|
||||
@@ -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
140
tests/test_hardening.py
Normal 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
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
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,
|
||||
@@ -74,6 +75,43 @@ def test_polled_drag_starts_and_moves_while_button_is_down() -> None:
|
||||
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))
|
||||
|
||||
@@ -66,6 +66,17 @@ def test_layer_state_tracks_visibility_and_overlay_membership() -> None:
|
||||
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")
|
||||
|
||||
@@ -6,6 +6,7 @@ def test_package_exports_required_public_api() -> None:
|
||||
|
||||
expected = {
|
||||
"configure",
|
||||
"CacheStats",
|
||||
"TileProvider",
|
||||
"register_provider",
|
||||
"unregister_provider",
|
||||
|
||||
@@ -3,9 +3,12 @@ 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.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:
|
||||
@@ -13,6 +16,7 @@ class FakeDpg:
|
||||
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
|
||||
@@ -31,6 +35,7 @@ class FakeDpg:
|
||||
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)
|
||||
@@ -45,6 +50,51 @@ class FakeDpg:
|
||||
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:
|
||||
state = create_map_state(tag="renderer-drain")
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
@@ -115,3 +196,60 @@ def test_overlay_update_drain_sets_only_overlay_dirty() -> None:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user