Compare commits
4 Commits
563ddd962b
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 50e38e18ee | |||
| d0ba8c4218 | |||
| 815d8a2d88 | |||
| 2d6242bd3f |
61
AGENTS.md
61
AGENTS.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
Step 4 complete.
|
Step 8 complete.
|
||||||
|
|
||||||
## Completed steps
|
## Completed steps
|
||||||
|
|
||||||
@@ -10,10 +10,14 @@ Step 1 - Public API contract and pure core.
|
|||||||
Step 2 - Thread-safe state, commands, overlays, and cache model.
|
Step 2 - Thread-safe state, commands, overlays, and cache model.
|
||||||
Step 3 - Widget shell, sizing system, and GUI-thread frame pump.
|
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 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 5 - Interaction: pan, zoom, and view commands.
|
Internal rebuilt beta prepared.
|
||||||
|
|
||||||
## Design decisions
|
## Design decisions
|
||||||
|
|
||||||
@@ -68,7 +72,58 @@ None yet.
|
|||||||
- Ran `uv run ruff format --check .`.
|
- Ran `uv run ruff format --check .`.
|
||||||
- Ran `uv run pyright`.
|
- Ran `uv run pyright`.
|
||||||
- Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist/texture-registry creation.
|
- Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist/texture-registry creation.
|
||||||
|
- Implemented left mouse drag panning using the measured drawlist rectangle.
|
||||||
|
- Implemented mouse wheel zoom around the cursor.
|
||||||
|
- Implemented projection-backed `screen_to_latlon`, `latlon_to_screen`, and zoom-fitting `fit_bounds`.
|
||||||
|
- Attached Dear PyGui mouse handlers through the map handler registry.
|
||||||
|
- Added interaction debug state for active drag and last mouse position.
|
||||||
|
- Added Step 5 tests for pan, cursor zoom, view conversion, bounds fitting, and overlay/view isolation.
|
||||||
|
- Added Pyright virtualenv settings so `uv run pyright` resolves installed dependencies.
|
||||||
|
- Ran `uv run pytest`.
|
||||||
|
- Ran `uv run ruff check .`.
|
||||||
|
- Ran `uv run ruff format --check .`.
|
||||||
|
- Ran `uv run pyright`.
|
||||||
|
- Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist/texture-registry/handler-registry creation.
|
||||||
|
- Implemented Dear PyGui draw-layer bookkeeping for background, tiles, overlays, and attribution.
|
||||||
|
- Rendered markers, polylines, and trajectories from GUI-thread overlay snapshots.
|
||||||
|
- Isolated overlay redraws so they clear only the overlay draw layer and do not clear tile draw commands or textures.
|
||||||
|
- Added live background-thread marker and trajectory stress examples.
|
||||||
|
- Added Step 6 tests for overlay draw-layer isolation, overlay-only dirty flags, threaded update coalescing, and view/drag-state isolation.
|
||||||
|
- Updated `README.md` with Step 6 overlay rendering behavior and examples.
|
||||||
|
- Ran `uv run ruff format .`.
|
||||||
|
- Ran `uv run pytest`.
|
||||||
|
- Ran `uv run pyright`.
|
||||||
|
- Ran `uv run ruff check .`.
|
||||||
|
- Ran `uv run ruff format --check .`.
|
||||||
|
- Ran a Dear PyGui context smoke check for `map_widget` with marker, polyline, and trajectory overlays.
|
||||||
|
- Added `z_index` support for `add_layer`, layer visibility/clearing tests, and cache-safe map-wide memory clearing.
|
||||||
|
- Implemented provider switching that validates providers, clamps zoom to the new provider range, preserves overlays/center, increments generation, and queues GUI-thread tile invalidation.
|
||||||
|
- Implemented provider-scoped disk cache clearing and cache-size scanning.
|
||||||
|
- Exported `CacheStats` publicly.
|
||||||
|
- Added `examples/custom_provider.py` and cache control/stat buttons to `examples/cache_stress.py`.
|
||||||
|
- Updated `README.md` with Step 7 behavior and examples.
|
||||||
|
- Added Step 7 tests for provider switch tile invalidation, provider-scoped disk clearing, queued disk clear commands, layer z-order updates, and public exports.
|
||||||
|
- Ran `uv run ruff format .`.
|
||||||
|
- Ran `uv run pytest`.
|
||||||
|
- Ran `uv run ruff check .`.
|
||||||
|
- Ran `uv run ruff format --check .`.
|
||||||
|
- Ran `uv run pyright`.
|
||||||
|
- Ran `uv run python -c "import dpg_map as dpgm; print(dpgm.list_providers())"`.
|
||||||
|
- Added docstrings for public API functions.
|
||||||
|
- Rewrote `README.md` with uv install, local editable dependency, basic usage, sizing, live update, custom provider, cache, OpenStreetMap, and thread-safety documentation.
|
||||||
|
- Added Step 8 hardening tests for unknown maps, overlays, providers, invalid coordinates, mismatched coordinate lengths, empty trajectory support, deleted overlays, provider switching while tiles are loading, overlay updates during dragging, and public docstrings.
|
||||||
|
- Bumped package version to `0.3.0b1` and updated the fallback OpenStreetMap User-Agent version.
|
||||||
|
- Ran `uv run pytest`.
|
||||||
|
- Ran `uv run ruff format .`.
|
||||||
|
- Ran `uv run ruff check .`.
|
||||||
|
- Ran `uv run ruff format --check .`.
|
||||||
|
- Ran `uv run pyright`.
|
||||||
|
- Tested editable install from `/tmp/dpg-map-editable-test` with `uv add --editable /home/hector/projects/dpg-map`.
|
||||||
|
- Ran editable install import check: `uv run python -c "import dpg_map as dpgm; print(dpgm.list_providers())"`.
|
||||||
|
- Ran `uv run python -m py_compile` across all example files.
|
||||||
|
- Started `examples/basic_map.py` under a 5-second timeout; it launched without terminal errors and was stopped by timeout because the GUI loop blocks.
|
||||||
|
- Ran `uv sync`.
|
||||||
|
|
||||||
## Next action
|
## Next action
|
||||||
|
|
||||||
Implement Step 5.
|
Commit and tag `v0.3.0b1`.
|
||||||
|
|||||||
181
README.md
181
README.md
@@ -1,51 +1,176 @@
|
|||||||
# dpg-map
|
# dpg-map
|
||||||
|
|
||||||
`dpg-map` is a Dear PyGui map widget package under rebuild.
|
`dpg-map` is a Dear PyGui map widget for XYZ raster tiles and geographic overlays.
|
||||||
|
|
||||||
The Step 4 tile manager is in place:
|
The rebuilt beta exposes a stable public import:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import dpg_map as dpgm
|
import dpg_map as dpgm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Use `uv` for development and dependency management:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
From another local project, add this package as an editable dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add --editable ../dpg-map
|
||||||
|
uv run python -c "import dpg_map as dpgm; print(dpgm.list_providers())"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Map
|
||||||
|
|
||||||
|
```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=1000, height=700)
|
||||||
|
|
||||||
|
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()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sizing
|
||||||
|
|
||||||
|
The widget is a Dear PyGui `child_window` containing a measured drawlist. The child keeps the requested Dear PyGui sizing intent while the drawlist uses concrete measured pixels.
|
||||||
|
|
||||||
|
Supported sizing modes:
|
||||||
|
|
||||||
|
- `width=0` and `height=0` keep Dear PyGui default sizing.
|
||||||
|
- `width=-1` and `height=-1` fill available space where Dear PyGui supports it.
|
||||||
|
- Positive dimensions request fixed sizes.
|
||||||
|
- `autosize_x` and `autosize_y` are passed to the child window.
|
||||||
|
- 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Live Updates
|
||||||
|
|
||||||
|
Runtime overlay updates are safe to call from background threads. They update logical state and enqueue renderer commands; Dear PyGui drawing, texture, handler, and viewport calls stay on the GUI thread.
|
||||||
|
|
||||||
|
Live marker update:
|
||||||
|
|
||||||
|
```python
|
||||||
|
dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093, map_tag="map")
|
||||||
|
dpgm.update_marker("vehicle", lat=current_lat, lon=current_lon, map_tag="map")
|
||||||
|
```
|
||||||
|
|
||||||
|
Live trajectory update:
|
||||||
|
|
||||||
|
```python
|
||||||
|
dpgm.add_trajectory("track", points=[], map_tag="map")
|
||||||
|
dpgm.update_trajectory("track", points=tuple(points), map_tag="map")
|
||||||
|
```
|
||||||
|
|
||||||
|
Stress examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python examples/markers_live_thread.py
|
||||||
|
uv run python examples/trajectory_live_thread.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Providers
|
||||||
|
|
||||||
|
OpenStreetMap is registered as `osm` by default. Custom XYZ providers can be registered and selected at runtime:
|
||||||
|
|
||||||
|
```python
|
||||||
provider = dpgm.TileProvider(
|
provider = dpgm.TileProvider(
|
||||||
name="custom",
|
name="custom",
|
||||||
url_template="https://example.com/{z}/{x}/{y}.png",
|
url_template="https://example.com/{z}/{x}/{y}.png",
|
||||||
attribution="Tiles (c) Example",
|
attribution="Tiles (c) Example",
|
||||||
)
|
)
|
||||||
dpgm.register_provider(provider)
|
dpgm.register_provider(provider)
|
||||||
|
dpgm.set_provider("custom", map_tag="map")
|
||||||
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:
|
Provider switching preserves overlays and center, clamps zoom to the new provider range, increments the tile generation, and ignores stale tile results from the previous provider.
|
||||||
|
|
||||||
- public package exports
|
Example:
|
||||||
- tile provider definitions and registry
|
|
||||||
- Web Mercator projection helpers
|
|
||||||
- thread-safe logical map state and map registry
|
|
||||||
- command queue with coalescing for overlay and view updates
|
|
||||||
- logical marker, polyline, trajectory, and layer models
|
|
||||||
- persistent disk cache paths, metadata, scanning, pruning, and clearing
|
|
||||||
- Dear PyGui `child_window` + measured-size `drawlist` widget shell
|
|
||||||
- GUI-thread frame pump that drains commands, manages textures, and draws raster tiles
|
|
||||||
- sizing helpers that preserve the last non-zero size across hidden layouts
|
|
||||||
- interaction hit-rectangle calculation for the measured map area
|
|
||||||
- asynchronous tile workers that read disk cache, fetch HTTP, and decode images
|
|
||||||
- memory cache with visible-tile protection and GUI-thread texture deletion
|
|
||||||
|
|
||||||
Overlay drawing is not implemented yet. Step 5 will add pan/zoom interaction and
|
```bash
|
||||||
view command projection.
|
uv run python examples/custom_provider.py
|
||||||
|
```
|
||||||
|
|
||||||
Examples:
|
## Cache
|
||||||
|
|
||||||
|
Tiles use an in-memory cache and a persistent provider-namespaced disk cache.
|
||||||
|
|
||||||
|
```python
|
||||||
|
dpgm.configure(
|
||||||
|
user_agent="my-app/0.1 contact@example.com",
|
||||||
|
cache_dir=".tile-cache",
|
||||||
|
memory_cache_max_tiles=512,
|
||||||
|
disk_cache_max_bytes=2_000_000_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = dpgm.get_cache_stats(map_tag="map")
|
||||||
|
dpgm.clear_memory_cache(map_tag="map")
|
||||||
|
dpgm.clear_disk_cache(provider="osm")
|
||||||
|
```
|
||||||
|
|
||||||
|
`disk_cache_max_bytes=None` disables the disk size limit. Memory cache clears are routed through the renderer command queue so texture deletion happens on the GUI thread. Disk cache clears can target all providers or a single provider namespace.
|
||||||
|
|
||||||
|
Cache example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python examples/cache_stress.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenStreetMap Usage
|
||||||
|
|
||||||
|
The default OpenStreetMap provider requires attribution and should use an application-specific `User-Agent`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
dpgm.configure(user_agent="my-product/1.0 contact@example.com")
|
||||||
|
```
|
||||||
|
|
||||||
|
If no user agent is configured, `dpg-map` emits a runtime warning and falls back to a package user agent. Applications are responsible for displaying provider attribution in accordance with provider terms; the renderer draws the provider attribution text on the map.
|
||||||
|
|
||||||
|
## Thread-Safety Contract
|
||||||
|
|
||||||
|
Public runtime functions are intended to be callable from non-GUI threads unless explicitly documented otherwise. They acquire map locks briefly, update logical state, and/or enqueue commands.
|
||||||
|
|
||||||
|
Thread-safe runtime areas include:
|
||||||
|
|
||||||
|
- 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/cache updates: `set_provider`, `clear_memory_cache`, `clear_disk_cache`
|
||||||
|
|
||||||
|
`map_widget(...)` creates Dear PyGui items and must be used on the GUI thread inside an active Dear PyGui context.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
```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
|
|
||||||
uv run python examples/hidden_tab.py
|
|
||||||
uv run python examples/cache_stress.py
|
uv run python examples/cache_stress.py
|
||||||
|
uv run python examples/custom_provider.py
|
||||||
|
uv run python examples/markers_live_thread.py
|
||||||
|
uv run python examples/trajectory_live_thread.py
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -21,17 +21,39 @@ 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")
|
|
||||||
|
|
||||||
dpg.setup_dearpygui()
|
dpg.setup_dearpygui()
|
||||||
dpg.show_viewport()
|
dpg.show_viewport()
|
||||||
|
|||||||
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()
|
||||||
66
examples/markers_live_thread.py
Normal file
66
examples/markers_live_thread.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from math import cos, sin
|
||||||
|
from threading import Event, Thread
|
||||||
|
from time import sleep
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as _dpg
|
||||||
|
|
||||||
|
import dpg_map as dpgm
|
||||||
|
|
||||||
|
dpg: Any = _dpg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dpgm.configure(user_agent="dpg-map markers_live_thread example")
|
||||||
|
stop = Event()
|
||||||
|
|
||||||
|
dpg.create_context()
|
||||||
|
dpg.create_viewport(title="dpg-map live markers", width=1000, height=700)
|
||||||
|
|
||||||
|
with (
|
||||||
|
dpg.window(label="Live Markers", width=-1, height=-1),
|
||||||
|
dpgm.map_widget(
|
||||||
|
tag="live-markers-map", center=(47.9029, 1.9093), zoom=15, width=-1, height=-1
|
||||||
|
),
|
||||||
|
):
|
||||||
|
for index in range(12):
|
||||||
|
dpgm.add_marker(
|
||||||
|
f"vehicle-{index}",
|
||||||
|
lat=47.9029,
|
||||||
|
lon=1.9093,
|
||||||
|
label=str(index + 1),
|
||||||
|
show_label=True,
|
||||||
|
color=(240, 92, 70, 255),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_markers() -> None:
|
||||||
|
tick = 0
|
||||||
|
while not stop.is_set():
|
||||||
|
for index in range(12):
|
||||||
|
angle = tick * 0.08 + index * 0.52
|
||||||
|
radius = 0.0015 + (index % 4) * 0.0002
|
||||||
|
dpgm.update_marker(
|
||||||
|
f"vehicle-{index}",
|
||||||
|
lat=47.9029 + sin(angle) * radius,
|
||||||
|
lon=1.9093 + cos(angle) * radius,
|
||||||
|
map_tag="live-markers-map",
|
||||||
|
)
|
||||||
|
tick += 1
|
||||||
|
sleep(1 / 30)
|
||||||
|
|
||||||
|
worker = Thread(target=update_markers, name="dpg-map-live-markers", daemon=True)
|
||||||
|
worker.start()
|
||||||
|
try:
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
worker.join(timeout=1.0)
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
76
examples/trajectory_live_thread.py
Normal file
76
examples/trajectory_live_thread.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from math import cos, sin
|
||||||
|
from threading import Event, Thread
|
||||||
|
from time import sleep
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as _dpg
|
||||||
|
|
||||||
|
import dpg_map as dpgm
|
||||||
|
|
||||||
|
dpg: Any = _dpg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dpgm.configure(user_agent="dpg-map trajectory_live_thread example")
|
||||||
|
stop = Event()
|
||||||
|
points: deque[tuple[float, float]] = deque(maxlen=240)
|
||||||
|
|
||||||
|
dpg.create_context()
|
||||||
|
dpg.create_viewport(title="dpg-map live trajectory", width=1000, height=700)
|
||||||
|
|
||||||
|
with (
|
||||||
|
dpg.window(label="Live Trajectory", width=-1, height=-1),
|
||||||
|
dpgm.map_widget(
|
||||||
|
tag="live-trajectory-map",
|
||||||
|
center=(47.9029, 1.9093),
|
||||||
|
zoom=15,
|
||||||
|
width=-1,
|
||||||
|
height=-1,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
dpgm.add_trajectory(
|
||||||
|
"track",
|
||||||
|
points=[],
|
||||||
|
color=(250, 190, 80, 255),
|
||||||
|
thickness=3.0,
|
||||||
|
show_points=True,
|
||||||
|
point_stride=12,
|
||||||
|
)
|
||||||
|
dpgm.add_marker(
|
||||||
|
"head",
|
||||||
|
lat=47.9029,
|
||||||
|
lon=1.9093,
|
||||||
|
color=(72, 205, 154, 255),
|
||||||
|
radius=6,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_trajectory() -> None:
|
||||||
|
tick = 0
|
||||||
|
while not stop.is_set():
|
||||||
|
angle = tick * 0.06
|
||||||
|
lat = 47.9029 + sin(angle) * 0.0016 + sin(angle * 2.7) * 0.00025
|
||||||
|
lon = 1.9093 + cos(angle) * 0.0016
|
||||||
|
points.append((lat, lon))
|
||||||
|
snapshot = tuple(points)
|
||||||
|
dpgm.update_trajectory("track", points=snapshot, map_tag="live-trajectory-map")
|
||||||
|
dpgm.update_marker("head", lat=lat, lon=lon, map_tag="live-trajectory-map")
|
||||||
|
tick += 1
|
||||||
|
sleep(1 / 20)
|
||||||
|
|
||||||
|
worker = Thread(target=update_trajectory, name="dpg-map-live-trajectory", daemon=True)
|
||||||
|
worker.start()
|
||||||
|
try:
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
worker.join(timeout=1.0)
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "dpg-map"
|
name = "dpg-map"
|
||||||
version = "0.1.0"
|
version = "0.3.0b1"
|
||||||
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" }
|
||||||
@@ -37,3 +37,5 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
|
|||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
typeCheckingMode = "basic"
|
typeCheckingMode = "basic"
|
||||||
|
venvPath = "."
|
||||||
|
venv = ".venv"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -10,15 +10,24 @@ 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 .overlays import LayerState, MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay
|
from .overlays import LayerState, MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay
|
||||||
|
from .projection import latlon_to_world
|
||||||
from .providers import TileProvider, get_provider
|
from .providers import TileProvider, get_provider
|
||||||
|
from .sizing import effective_draw_size
|
||||||
from .state import (
|
from .state import (
|
||||||
DirtyFlags,
|
DirtyFlags,
|
||||||
configure_state,
|
configure_state,
|
||||||
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
|
||||||
@@ -87,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,
|
||||||
@@ -101,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
|
||||||
@@ -126,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] = {}
|
||||||
@@ -142,22 +163,46 @@ 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])
|
||||||
set_center((south + north) / 2.0, (west + east) / 2.0, map_tag=map_tag)
|
south, north = min(south, north), max(south, north)
|
||||||
|
state = get_map_state(map_tag)
|
||||||
|
with state.lock:
|
||||||
|
width, height = effective_draw_size(state)
|
||||||
|
padding = 32
|
||||||
|
usable_width = max(1, width - padding * 2)
|
||||||
|
usable_height = max(1, height - padding * 2)
|
||||||
|
tile_size = state.provider.tile_size
|
||||||
|
target_zoom = state.min_zoom
|
||||||
|
for candidate_zoom in range(state.max_zoom, state.min_zoom - 1, -1):
|
||||||
|
west_x, north_y = latlon_to_world(north, west, candidate_zoom, tile_size)
|
||||||
|
east_x, south_y = latlon_to_world(south, east, candidate_zoom, tile_size)
|
||||||
|
world_size = tile_size * (2**candidate_zoom)
|
||||||
|
x_span = abs(east_x - west_x)
|
||||||
|
x_span = min(x_span, world_size - x_span)
|
||||||
|
y_span = abs(south_y - north_y)
|
||||||
|
if x_span <= usable_width and y_span <= usable_height:
|
||||||
|
target_zoom = candidate_zoom
|
||||||
|
break
|
||||||
|
set_view(center=((south + north) / 2.0, (west + east) / 2.0), zoom=target_zoom, map_tag=map_tag)
|
||||||
|
|
||||||
|
|
||||||
def screen_to_latlon(x: float, y: float, *, map_tag: Tag | None = None) -> LatLon:
|
def screen_to_latlon(x: float, y: float, *, map_tag: Tag | None = None) -> LatLon:
|
||||||
_ = (x, y)
|
"""Convert map-local screen coordinates to latitude/longitude."""
|
||||||
return get_center(map_tag=map_tag)
|
|
||||||
|
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:
|
def latlon_to_screen(lat: float, lon: float, *, map_tag: Tag | None = None) -> Point:
|
||||||
_validate_latlon(lat, lon)
|
"""Convert latitude/longitude to map-local screen coordinates."""
|
||||||
|
|
||||||
|
lat_value, lon_value = _validate_latlon(lat, lon)
|
||||||
state = get_map_state(map_tag)
|
state = get_map_state(map_tag)
|
||||||
with state.lock:
|
return latlon_to_screen_in_state(state, lat_value, lon_value)
|
||||||
return (state.measured_width / 2.0, state.measured_height / 2.0)
|
|
||||||
|
|
||||||
|
|
||||||
def add_marker(
|
def add_marker(
|
||||||
@@ -171,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))
|
||||||
@@ -210,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:
|
||||||
@@ -243,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")
|
||||||
@@ -282,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)
|
||||||
@@ -314,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)
|
||||||
@@ -341,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)
|
||||||
@@ -365,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -378,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)
|
||||||
@@ -394,6 +459,8 @@ def set_overlay_show(tag: Tag, show: bool, *, map_tag: Tag | None = None) -> Non
|
|||||||
|
|
||||||
|
|
||||||
def delete_overlay(tag: Tag, *, map_tag: Tag | None = None) -> None:
|
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)
|
||||||
@@ -406,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
|
||||||
@@ -424,6 +521,8 @@ def show_layer(name: str, *, map_tag: Tag | None = None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def hide_layer(name: str, *, map_tag: Tag | None = None) -> None:
|
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
|
||||||
@@ -432,6 +531,8 @@ def hide_layer(name: str, *, map_tag: Tag | None = None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def clear_layer(name: str, *, map_tag: Tag | None = None) -> None:
|
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)
|
||||||
@@ -443,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()
|
||||||
@@ -454,36 +557,55 @@ def clear_map(*, map_tag: Tag | None = None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def set_provider(provider: str | TileProvider, *, map_tag: Tag | None = None) -> None:
|
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."""
|
||||||
with state.lock:
|
|
||||||
mark_dirty(state, DirtyFlags.TILES)
|
for state in _cache_target_states(map_tag):
|
||||||
_queue(state, CommandKind.CLEAR_MEMORY_CACHE, {})
|
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:
|
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
|
||||||
@@ -510,6 +632,8 @@ def get_cache_stats(*, map_tag: Tag | None = None) -> CacheStats:
|
|||||||
|
|
||||||
|
|
||||||
def get_map_debug_state(*, map_tag: Tag | None = None) -> dict[str, Any]:
|
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 {
|
||||||
@@ -533,5 +657,6 @@ def get_map_debug_state(*, map_tag: Tag | None = None) -> dict[str, Any]:
|
|||||||
"pending_command_count": len(state.command_queue),
|
"pending_command_count": len(state.command_queue),
|
||||||
"generation": state.generation,
|
"generation": state.generation,
|
||||||
"active_drag": state.interaction.active_drag,
|
"active_drag": state.interaction.active_drag,
|
||||||
|
"last_mouse_position": state.interaction.last_mouse_position,
|
||||||
"tiles": asdict(state.tile_manager.snapshot()),
|
"tiles": asdict(state.tile_manager.snapshot()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
return sum(entry.metadata.size_bytes for entry in scan_disk_cache(cache_dir))
|
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(
|
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:
|
||||||
|
|||||||
@@ -1 +1,46 @@
|
|||||||
"""Draw layer bookkeeping helpers."""
|
"""Draw layer bookkeeping helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .types import Tag
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DrawLayerTags:
|
||||||
|
"""Internal Dear PyGui draw layer tags for one map."""
|
||||||
|
|
||||||
|
background: str
|
||||||
|
tiles: str
|
||||||
|
overlays: str
|
||||||
|
attribution: str
|
||||||
|
|
||||||
|
|
||||||
|
def draw_layer_tags(map_tag: Tag) -> DrawLayerTags:
|
||||||
|
"""Return stable internal draw layer tags for a map."""
|
||||||
|
|
||||||
|
return DrawLayerTags(
|
||||||
|
background=f"{map_tag}##layer-background",
|
||||||
|
tiles=f"{map_tag}##layer-tiles",
|
||||||
|
overlays=f"{map_tag}##layer-overlays",
|
||||||
|
attribution=f"{map_tag}##layer-attribution",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_draw_layers(dpg: Any, *, drawlist_tag: Tag, map_tag: Tag) -> DrawLayerTags:
|
||||||
|
"""Create draw layers if needed and return their tags."""
|
||||||
|
|
||||||
|
tags = draw_layer_tags(map_tag)
|
||||||
|
for layer_tag in (tags.background, tags.tiles, tags.overlays, tags.attribution):
|
||||||
|
if not dpg.does_item_exist(layer_tag):
|
||||||
|
dpg.add_draw_layer(parent=drawlist_tag, tag=layer_tag)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def clear_draw_layer(dpg: Any, layer_tag: Tag) -> None:
|
||||||
|
"""Clear one draw layer without touching sibling layers."""
|
||||||
|
|
||||||
|
if dpg.does_item_exist(layer_tag):
|
||||||
|
dpg.delete_item(layer_tag, children_only=True)
|
||||||
|
|||||||
@@ -3,9 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from math import isfinite
|
||||||
|
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 .sizing import effective_draw_size
|
||||||
from .state import MapState
|
from .state import DirtyFlags, MapState, mark_dirty
|
||||||
|
from .types import LatLon, Point
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -34,3 +39,195 @@ def calculate_hit_rect(state: MapState, drawlist_pos: tuple[float, float]) -> Hi
|
|||||||
|
|
||||||
width, height = effective_draw_size(state)
|
width, height = effective_draw_size(state)
|
||||||
return HitRect(float(drawlist_pos[0]), float(drawlist_pos[1]), float(width), float(height))
|
return HitRect(float(drawlist_pos[0]), float(drawlist_pos[1]), float(width), float(height))
|
||||||
|
|
||||||
|
|
||||||
|
def screen_to_latlon_in_state(state: MapState, x: float, y: float) -> LatLon:
|
||||||
|
"""Convert map-local screen coordinates to latitude/longitude."""
|
||||||
|
|
||||||
|
with state.lock:
|
||||||
|
width, height = effective_draw_size(state)
|
||||||
|
center = state.center
|
||||||
|
zoom = state.zoom
|
||||||
|
tile_size = state.provider.tile_size
|
||||||
|
world_x, world_y = screen_to_world(
|
||||||
|
float(x),
|
||||||
|
float(y),
|
||||||
|
center=center,
|
||||||
|
zoom=zoom,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
tile_size=tile_size,
|
||||||
|
)
|
||||||
|
return world_to_latlon(world_x, world_y, zoom, tile_size)
|
||||||
|
|
||||||
|
|
||||||
|
def latlon_to_screen_in_state(state: MapState, lat: float, lon: float) -> Point:
|
||||||
|
"""Convert latitude/longitude to map-local screen coordinates."""
|
||||||
|
|
||||||
|
with state.lock:
|
||||||
|
width, height = effective_draw_size(state)
|
||||||
|
center = state.center
|
||||||
|
zoom = state.zoom
|
||||||
|
tile_size = state.provider.tile_size
|
||||||
|
world_x, world_y = latlon_to_world(lat, lon, zoom, tile_size)
|
||||||
|
center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size)
|
||||||
|
return (world_x - center_x + width / 2.0, world_y - center_y + height / 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def pan_state_by_pixels(state: MapState, dx: float, dy: float) -> LatLon:
|
||||||
|
"""Pan the map by a mouse drag delta in screen pixels."""
|
||||||
|
|
||||||
|
with state.lock:
|
||||||
|
if dx == 0 and dy == 0:
|
||||||
|
return state.center
|
||||||
|
center = state.center
|
||||||
|
zoom = state.zoom
|
||||||
|
tile_size = state.provider.tile_size
|
||||||
|
world_size = tile_size * (2**zoom)
|
||||||
|
center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size)
|
||||||
|
new_x = (center_x - dx) % world_size
|
||||||
|
new_y = min(max(center_y - dy, 0.0), float(world_size))
|
||||||
|
state.center = world_to_latlon(new_x, new_y, zoom, tile_size)
|
||||||
|
mark_dirty(state, DirtyFlags.VIEW | DirtyFlags.TILES | DirtyFlags.OVERLAYS)
|
||||||
|
state.command_queue.put(
|
||||||
|
MapCommand(
|
||||||
|
kind=CommandKind.SET_VIEW,
|
||||||
|
map_tag=state.tag,
|
||||||
|
payload={"center": state.center},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return state.center
|
||||||
|
|
||||||
|
|
||||||
|
def zoom_state_at_screen_point(
|
||||||
|
state: MapState,
|
||||||
|
*,
|
||||||
|
screen_x: float,
|
||||||
|
screen_y: float,
|
||||||
|
delta: float,
|
||||||
|
) -> int:
|
||||||
|
"""Zoom the map around a map-local screen point where possible."""
|
||||||
|
|
||||||
|
if delta == 0 or not isfinite(delta):
|
||||||
|
with state.lock:
|
||||||
|
return state.zoom
|
||||||
|
|
||||||
|
with state.lock:
|
||||||
|
old_zoom = state.zoom
|
||||||
|
new_zoom = max(state.min_zoom, min(state.max_zoom, old_zoom + (1 if delta > 0 else -1)))
|
||||||
|
if new_zoom == old_zoom:
|
||||||
|
return old_zoom
|
||||||
|
|
||||||
|
width, height = effective_draw_size(state)
|
||||||
|
tile_size = state.provider.tile_size
|
||||||
|
anchor_latlon = screen_to_latlon_in_state(state, screen_x, screen_y)
|
||||||
|
anchor_x, anchor_y = latlon_to_world(
|
||||||
|
anchor_latlon[0],
|
||||||
|
anchor_latlon[1],
|
||||||
|
new_zoom,
|
||||||
|
tile_size,
|
||||||
|
)
|
||||||
|
world_size = tile_size * (2**new_zoom)
|
||||||
|
center_x = (anchor_x - (screen_x - width / 2.0)) % world_size
|
||||||
|
center_y = min(max(anchor_y - (screen_y - height / 2.0), 0.0), float(world_size))
|
||||||
|
|
||||||
|
state.zoom = new_zoom
|
||||||
|
state.center = world_to_latlon(center_x, center_y, new_zoom, tile_size)
|
||||||
|
mark_dirty(state, DirtyFlags.VIEW | DirtyFlags.TILES | DirtyFlags.OVERLAYS)
|
||||||
|
state.command_queue.put(
|
||||||
|
MapCommand(
|
||||||
|
kind=CommandKind.SET_VIEW,
|
||||||
|
map_tag=state.tag,
|
||||||
|
payload={"center": state.center, "zoom": state.zoom},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return state.zoom
|
||||||
|
|
||||||
|
|
||||||
|
def handle_mouse_down(state: MapState, mouse_pos: tuple[float, float], hit_rect: HitRect) -> None:
|
||||||
|
"""Begin a drag if the left mouse button starts inside the map rectangle."""
|
||||||
|
|
||||||
|
with state.lock:
|
||||||
|
if not state.is_visible or not hit_rect.contains(mouse_pos[0], mouse_pos[1]):
|
||||||
|
state.interaction.active_drag = False
|
||||||
|
state.interaction.last_mouse_position = None
|
||||||
|
return
|
||||||
|
state.interaction.active_drag = True
|
||||||
|
state.interaction.last_mouse_position = mouse_pos
|
||||||
|
|
||||||
|
|
||||||
|
def handle_mouse_drag(state: MapState, mouse_pos: tuple[float, float]) -> None:
|
||||||
|
"""Update center from a mouse drag event."""
|
||||||
|
|
||||||
|
with state.lock:
|
||||||
|
if not state.interaction.active_drag:
|
||||||
|
return
|
||||||
|
last_pos = state.interaction.last_mouse_position
|
||||||
|
state.interaction.last_mouse_position = mouse_pos
|
||||||
|
if last_pos is None:
|
||||||
|
return
|
||||||
|
pan_state_by_pixels(state, mouse_pos[0] - last_pos[0], mouse_pos[1] - last_pos[1])
|
||||||
|
|
||||||
|
|
||||||
|
def handle_mouse_release(state: MapState) -> None:
|
||||||
|
"""End any active drag."""
|
||||||
|
|
||||||
|
with state.lock:
|
||||||
|
state.interaction.active_drag = False
|
||||||
|
state.interaction.last_mouse_position = None
|
||||||
|
|
||||||
|
|
||||||
|
def handle_mouse_wheel(
|
||||||
|
state: MapState,
|
||||||
|
*,
|
||||||
|
mouse_pos: tuple[float, float],
|
||||||
|
wheel_delta: float,
|
||||||
|
hit_rect: HitRect,
|
||||||
|
) -> None:
|
||||||
|
"""Apply wheel zoom when the cursor is over the concrete map rectangle."""
|
||||||
|
|
||||||
|
if not hit_rect.contains(mouse_pos[0], mouse_pos[1]):
|
||||||
|
return
|
||||||
|
zoom_state_at_screen_point(
|
||||||
|
state,
|
||||||
|
screen_x=mouse_pos[0] - hit_rect.x,
|
||||||
|
screen_y=mouse_pos[1] - hit_rect.y,
|
||||||
|
delta=wheel_delta,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_drag_from_button_state(
|
||||||
|
state: MapState,
|
||||||
|
*,
|
||||||
|
mouse_pos: tuple[float, float],
|
||||||
|
hit_rect: HitRect,
|
||||||
|
is_down: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Poll left-button state and keep drag interaction moving."""
|
||||||
|
|
||||||
|
with state.lock:
|
||||||
|
active_drag = state.interaction.active_drag
|
||||||
|
|
||||||
|
if not is_down:
|
||||||
|
if active_drag:
|
||||||
|
handle_mouse_release(state)
|
||||||
|
return
|
||||||
|
|
||||||
|
if active_drag:
|
||||||
|
handle_mouse_drag(state, mouse_pos)
|
||||||
|
return
|
||||||
|
|
||||||
|
if hit_rect.contains(mouse_pos[0], mouse_pos[1]):
|
||||||
|
handle_mouse_down(state, mouse_pos, hit_rect)
|
||||||
|
|
||||||
|
|
||||||
|
def wheel_delta_from_app_data(app_data: Any) -> float:
|
||||||
|
"""Normalize Dear PyGui mouse wheel callback data."""
|
||||||
|
|
||||||
|
if isinstance(app_data, int | float):
|
||||||
|
return float(app_data)
|
||||||
|
if isinstance(app_data, (list, tuple)) and app_data:
|
||||||
|
value = app_data[-1]
|
||||||
|
if isinstance(value, int | float):
|
||||||
|
return float(value)
|
||||||
|
return 0.0
|
||||||
|
|||||||
@@ -3,13 +3,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from dataclasses import replace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .commands import CommandKind, MapCommand
|
from .commands import CommandKind, MapCommand
|
||||||
from .interaction import HitRect, calculate_hit_rect
|
from .draw_layers import DrawLayerTags, clear_draw_layer, ensure_draw_layers
|
||||||
|
from .interaction import HitRect, calculate_hit_rect, update_drag_from_button_state
|
||||||
|
from .overlays import MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay
|
||||||
|
from .projection import latlon_to_world
|
||||||
from .sizing import SizeMeasurement, apply_size_measurement
|
from .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
|
||||||
|
|
||||||
|
|
||||||
class MapRenderer:
|
class MapRenderer:
|
||||||
@@ -18,10 +23,10 @@ class MapRenderer:
|
|||||||
def __init__(self, state: MapState, dpg: Any) -> None:
|
def __init__(self, state: MapState, dpg: Any) -> None:
|
||||||
self.state = state
|
self.state = state
|
||||||
self._dpg = dpg
|
self._dpg = dpg
|
||||||
self._background_tag = f"{state.tag}##background"
|
self._layers: DrawLayerTags | None = None
|
||||||
self._attribution_tag = f"{state.tag}##attribution"
|
|
||||||
self.last_drained_commands: tuple[MapCommand, ...] = ()
|
self.last_drained_commands: tuple[MapCommand, ...] = ()
|
||||||
self.last_hit_rect: HitRect | None = None
|
self.last_hit_rect: HitRect | None = None
|
||||||
|
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."""
|
"""Schedule this renderer to run on the next Dear PyGui frame."""
|
||||||
@@ -48,10 +53,12 @@ class MapRenderer:
|
|||||||
commands = drain_renderer_commands(self.state)
|
commands = drain_renderer_commands(self.state)
|
||||||
self.last_drained_commands = tuple(commands)
|
self.last_drained_commands = tuple(commands)
|
||||||
self._update_size_from_dpg()
|
self._update_size_from_dpg()
|
||||||
|
self._poll_mouse_drag()
|
||||||
|
|
||||||
with self.state.lock:
|
with self.state.lock:
|
||||||
dirty = self.state.dirty
|
dirty = self.state.dirty
|
||||||
should_draw = bool(dirty & (DirtyFlags.FULL | DirtyFlags.SIZE | DirtyFlags.TILES))
|
draw_tiles = bool(dirty & (DirtyFlags.SIZE | DirtyFlags.TILES | DirtyFlags.PROVIDER))
|
||||||
|
draw_overlays = bool(dirty & (DirtyFlags.SIZE | DirtyFlags.OVERLAYS))
|
||||||
visible = self.state.is_visible
|
visible = self.state.is_visible
|
||||||
width = self.state.measured_width or self.state.last_nonzero_width
|
width = self.state.measured_width or self.state.last_nonzero_width
|
||||||
height = self.state.measured_height or self.state.last_nonzero_height
|
height = self.state.measured_height or self.state.last_nonzero_height
|
||||||
@@ -61,6 +68,12 @@ class MapRenderer:
|
|||||||
zoom = self.state.zoom
|
zoom = self.state.zoom
|
||||||
generation = self.state.generation
|
generation = self.state.generation
|
||||||
cache_dir = self.state.cache_dir
|
cache_dir = self.state.cache_dir
|
||||||
|
overlays = tuple(
|
||||||
|
_copy_overlay_for_render(overlay) for overlay in self.state.overlays.values()
|
||||||
|
)
|
||||||
|
layers = {
|
||||||
|
name: (layer.show, layer.z_index) for name, layer in self.state.layers.items()
|
||||||
|
}
|
||||||
self.state.dirty = DirtyFlags.NONE
|
self.state.dirty = DirtyFlags.NONE
|
||||||
|
|
||||||
accepted_tiles = self.state.tile_manager.drain_results(
|
accepted_tiles = self.state.tile_manager.drain_results(
|
||||||
@@ -84,7 +97,7 @@ class MapRenderer:
|
|||||||
margin=self._prefetch_margin(),
|
margin=self._prefetch_margin(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if visible and (should_draw or accepted_tiles):
|
if visible and (draw_tiles or accepted_tiles):
|
||||||
self._draw_tile_layer(
|
self._draw_tile_layer(
|
||||||
visible_tiles=visible_tiles,
|
visible_tiles=visible_tiles,
|
||||||
width=width,
|
width=width,
|
||||||
@@ -92,6 +105,16 @@ class MapRenderer:
|
|||||||
attribution=provider_attribution,
|
attribution=provider_attribution,
|
||||||
tile_size=provider.tile_size,
|
tile_size=provider.tile_size,
|
||||||
)
|
)
|
||||||
|
if visible and draw_overlays:
|
||||||
|
self._draw_overlay_layer(
|
||||||
|
overlays=overlays,
|
||||||
|
layers=layers,
|
||||||
|
center=center,
|
||||||
|
zoom=zoom,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
tile_size=provider.tile_size,
|
||||||
|
)
|
||||||
|
|
||||||
def _update_size_from_dpg(self) -> None:
|
def _update_size_from_dpg(self) -> None:
|
||||||
width, height = self._measure_child_content()
|
width, height = self._measure_child_content()
|
||||||
@@ -104,10 +127,27 @@ class MapRenderer:
|
|||||||
draw_width = update.effective_width
|
draw_width = update.effective_width
|
||||||
draw_height = update.effective_height
|
draw_height = update.effective_height
|
||||||
self._dpg.configure_item(self.state.drawlist_tag, width=draw_width, height=draw_height)
|
self._dpg.configure_item(self.state.drawlist_tag, width=draw_width, height=draw_height)
|
||||||
draw_pos = tuple(float(value) for value in self._dpg.get_item_pos(self.state.drawlist_tag))
|
draw_pos = tuple(
|
||||||
|
float(value) for value in self._dpg.get_item_rect_min(self.state.drawlist_tag)
|
||||||
|
)
|
||||||
with self.state.lock:
|
with self.state.lock:
|
||||||
self.last_hit_rect = calculate_hit_rect(self.state, (draw_pos[0], draw_pos[1]))
|
self.last_hit_rect = calculate_hit_rect(self.state, (draw_pos[0], draw_pos[1]))
|
||||||
|
|
||||||
|
def _poll_mouse_drag(self) -> None:
|
||||||
|
if self.last_hit_rect is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
is_down = bool(self._dpg.is_mouse_button_down(self._dpg.mvMouseButton_Left))
|
||||||
|
mouse_pos = self._dpg.get_mouse_pos(local=False)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
update_drag_from_button_state(
|
||||||
|
self.state,
|
||||||
|
mouse_pos=(float(mouse_pos[0]), float(mouse_pos[1])),
|
||||||
|
hit_rect=self.last_hit_rect,
|
||||||
|
is_down=is_down,
|
||||||
|
)
|
||||||
|
|
||||||
def _measure_child_content(self) -> tuple[int, int]:
|
def _measure_child_content(self) -> tuple[int, int]:
|
||||||
try:
|
try:
|
||||||
width, height = self._dpg.get_item_rect_size(self.state.child_window_tag)
|
width, height = self._dpg.get_item_rect_size(self.state.child_window_tag)
|
||||||
@@ -126,12 +166,14 @@ class MapRenderer:
|
|||||||
) -> None:
|
) -> None:
|
||||||
width = max(1, int(width))
|
width = max(1, int(width))
|
||||||
height = max(1, int(height))
|
height = max(1, int(height))
|
||||||
self._dpg.delete_item(self.state.drawlist_tag, children_only=True)
|
layers = self._ensure_draw_layers()
|
||||||
|
clear_draw_layer(self._dpg, layers.background)
|
||||||
|
clear_draw_layer(self._dpg, layers.tiles)
|
||||||
|
clear_draw_layer(self._dpg, layers.attribution)
|
||||||
self._dpg.draw_rectangle(
|
self._dpg.draw_rectangle(
|
||||||
(0, 0),
|
(0, 0),
|
||||||
(width, height),
|
(width, height),
|
||||||
parent=self.state.drawlist_tag,
|
parent=layers.background,
|
||||||
tag=self._background_tag,
|
|
||||||
color=(54, 68, 78, 255),
|
color=(54, 68, 78, 255),
|
||||||
fill=(29, 38, 45, 255),
|
fill=(29, 38, 45, 255),
|
||||||
)
|
)
|
||||||
@@ -145,19 +187,193 @@ class MapRenderer:
|
|||||||
tile.texture_tag,
|
tile.texture_tag,
|
||||||
(screen_x, screen_y),
|
(screen_x, screen_y),
|
||||||
(screen_x + tile_size, screen_y + tile_size),
|
(screen_x + tile_size, screen_y + tile_size),
|
||||||
parent=self.state.drawlist_tag,
|
parent=layers.tiles,
|
||||||
)
|
)
|
||||||
label = attribution or "Map tiles"
|
label = attribution or "Map tiles"
|
||||||
text_y = max(28, height - 24)
|
text_y = max(28, height - 24)
|
||||||
self._dpg.draw_text(
|
self._dpg.draw_text(
|
||||||
(12, text_y),
|
(12, text_y),
|
||||||
label,
|
label,
|
||||||
parent=self.state.drawlist_tag,
|
parent=layers.attribution,
|
||||||
tag=self._attribution_tag,
|
|
||||||
color=(172, 184, 192, 255),
|
color=(172, 184, 192, 255),
|
||||||
size=12,
|
size=12,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _draw_overlay_layer(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
overlays: tuple[Overlay, ...],
|
||||||
|
layers: dict[str, tuple[bool, int]],
|
||||||
|
center: LatLon,
|
||||||
|
zoom: int,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
tile_size: int,
|
||||||
|
) -> None:
|
||||||
|
draw_layers = self._ensure_draw_layers()
|
||||||
|
clear_draw_layer(self._dpg, draw_layers.overlays)
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
self.last_overlay_count = 0
|
||||||
|
return
|
||||||
|
|
||||||
|
center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size)
|
||||||
|
visible_overlays = [
|
||||||
|
overlay
|
||||||
|
for overlay in overlays
|
||||||
|
if overlay.show and layers.get(overlay.layer, (True, 0))[0]
|
||||||
|
]
|
||||||
|
visible_overlays.sort(key=lambda overlay: layers.get(overlay.layer, (True, 0))[1])
|
||||||
|
drawn = 0
|
||||||
|
for overlay in visible_overlays:
|
||||||
|
if isinstance(overlay, MarkerOverlay):
|
||||||
|
self._draw_marker_overlay(
|
||||||
|
overlay, center_x, center_y, zoom, width, height, tile_size
|
||||||
|
)
|
||||||
|
drawn += 1
|
||||||
|
elif isinstance(overlay, PolylineOverlay):
|
||||||
|
self._draw_polyline_overlay(
|
||||||
|
overlay,
|
||||||
|
center_x,
|
||||||
|
center_y,
|
||||||
|
zoom,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
tile_size,
|
||||||
|
draw_layers.overlays,
|
||||||
|
)
|
||||||
|
drawn += 1
|
||||||
|
elif isinstance(overlay, TrajectoryOverlay):
|
||||||
|
self._draw_trajectory_overlay(
|
||||||
|
overlay,
|
||||||
|
center_x,
|
||||||
|
center_y,
|
||||||
|
zoom,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
tile_size,
|
||||||
|
draw_layers.overlays,
|
||||||
|
)
|
||||||
|
drawn += 1
|
||||||
|
self.last_overlay_count = drawn
|
||||||
|
|
||||||
|
def _draw_marker_overlay(
|
||||||
|
self,
|
||||||
|
overlay: MarkerOverlay,
|
||||||
|
center_x: float,
|
||||||
|
center_y: float,
|
||||||
|
zoom: int,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
tile_size: int,
|
||||||
|
) -> None:
|
||||||
|
layers = self._ensure_draw_layers()
|
||||||
|
x, y = _latlon_to_screen(
|
||||||
|
overlay.lat,
|
||||||
|
overlay.lon,
|
||||||
|
center_x,
|
||||||
|
center_y,
|
||||||
|
zoom,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
tile_size,
|
||||||
|
)
|
||||||
|
radius = max(1.0, float(overlay.radius))
|
||||||
|
self._dpg.draw_circle(
|
||||||
|
(x, y),
|
||||||
|
radius,
|
||||||
|
parent=layers.overlays,
|
||||||
|
color=(255, 255, 255, 230),
|
||||||
|
fill=_rgba(overlay.color),
|
||||||
|
thickness=1.5,
|
||||||
|
segments=20,
|
||||||
|
)
|
||||||
|
if overlay.show_label and overlay.label:
|
||||||
|
self._dpg.draw_text(
|
||||||
|
(x + radius + 4.0, y - 7.0),
|
||||||
|
overlay.label,
|
||||||
|
parent=layers.overlays,
|
||||||
|
color=(245, 248, 250, 255),
|
||||||
|
size=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_polyline_overlay(
|
||||||
|
self,
|
||||||
|
overlay: PolylineOverlay,
|
||||||
|
center_x: float,
|
||||||
|
center_y: float,
|
||||||
|
zoom: int,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
tile_size: int,
|
||||||
|
parent: str,
|
||||||
|
) -> None:
|
||||||
|
points = _screen_points(
|
||||||
|
overlay.points,
|
||||||
|
center_x=center_x,
|
||||||
|
center_y=center_y,
|
||||||
|
zoom=zoom,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
tile_size=tile_size,
|
||||||
|
)
|
||||||
|
if len(points) < 2:
|
||||||
|
return
|
||||||
|
self._dpg.draw_polyline(
|
||||||
|
points,
|
||||||
|
parent=parent,
|
||||||
|
closed=overlay.closed,
|
||||||
|
color=_rgba(overlay.color),
|
||||||
|
thickness=max(1.0, float(overlay.thickness)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_trajectory_overlay(
|
||||||
|
self,
|
||||||
|
overlay: TrajectoryOverlay,
|
||||||
|
center_x: float,
|
||||||
|
center_y: float,
|
||||||
|
zoom: int,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
tile_size: int,
|
||||||
|
parent: str,
|
||||||
|
) -> None:
|
||||||
|
points = _screen_points(
|
||||||
|
overlay.points,
|
||||||
|
center_x=center_x,
|
||||||
|
center_y=center_y,
|
||||||
|
zoom=zoom,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
tile_size=tile_size,
|
||||||
|
)
|
||||||
|
if len(points) >= 2:
|
||||||
|
self._dpg.draw_polyline(
|
||||||
|
points,
|
||||||
|
parent=parent,
|
||||||
|
color=_rgba(overlay.color),
|
||||||
|
thickness=max(1.0, float(overlay.thickness)),
|
||||||
|
)
|
||||||
|
if overlay.show_points and points:
|
||||||
|
stride = max(1, int(overlay.point_stride))
|
||||||
|
for point in points[::stride]:
|
||||||
|
self._dpg.draw_circle(
|
||||||
|
point,
|
||||||
|
2.5,
|
||||||
|
parent=parent,
|
||||||
|
color=_rgba(overlay.color),
|
||||||
|
fill=_rgba(overlay.color),
|
||||||
|
segments=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ensure_draw_layers(self) -> DrawLayerTags:
|
||||||
|
if self._layers is None or not self._dpg.does_item_exist(self._layers.overlays):
|
||||||
|
self._layers = ensure_draw_layers(
|
||||||
|
self._dpg,
|
||||||
|
drawlist_tag=self.state.drawlist_tag,
|
||||||
|
map_tag=self.state.tag,
|
||||||
|
)
|
||||||
|
return self._layers
|
||||||
|
|
||||||
def _ensure_texture(self, tile: Tile) -> None:
|
def _ensure_texture(self, tile: Tile) -> None:
|
||||||
if tile.texture_tag is not None:
|
if tile.texture_tag is not None:
|
||||||
return
|
return
|
||||||
@@ -199,7 +415,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,
|
||||||
@@ -216,7 +432,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
|
||||||
|
|
||||||
@@ -229,3 +448,43 @@ def make_frame_pump(state: MapState, dpg: Any) -> Callable[[], None]:
|
|||||||
state.renderer = renderer
|
state.renderer = renderer
|
||||||
renderer.schedule_next_frame()
|
renderer.schedule_next_frame()
|
||||||
return renderer.render_frame
|
return renderer.render_frame
|
||||||
|
|
||||||
|
|
||||||
|
def _rgba(color: Color) -> tuple[int, int, int, int]:
|
||||||
|
if len(color) == 3:
|
||||||
|
return (int(color[0]), int(color[1]), int(color[2]), 255)
|
||||||
|
return (int(color[0]), int(color[1]), int(color[2]), int(color[3]))
|
||||||
|
|
||||||
|
|
||||||
|
def _latlon_to_screen(
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
center_x: float,
|
||||||
|
center_y: float,
|
||||||
|
zoom: int,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
tile_size: int,
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
world_x, world_y = latlon_to_world(lat, lon, zoom, tile_size)
|
||||||
|
return (world_x - center_x + width / 2.0, world_y - center_y + height / 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _screen_points(
|
||||||
|
points: tuple[LatLon, ...],
|
||||||
|
*,
|
||||||
|
center_x: float,
|
||||||
|
center_y: float,
|
||||||
|
zoom: int,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
tile_size: int,
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
return [
|
||||||
|
_latlon_to_screen(lat, lon, center_x, center_y, zoom, width, height, tile_size)
|
||||||
|
for lat, lon in points
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_overlay_for_render(overlay: Overlay) -> Overlay:
|
||||||
|
return replace(overlay)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class DpgMapConfig:
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class InteractionState:
|
class InteractionState:
|
||||||
"""Logical interaction state until GUI interaction is implemented."""
|
"""Logical mouse interaction state."""
|
||||||
|
|
||||||
active_drag: bool = False
|
active_drag: bool = False
|
||||||
last_mouse_position: tuple[float, float] | None = None
|
last_mouse_position: tuple[float, float] | None = None
|
||||||
@@ -276,6 +276,13 @@ def resolve_map_tag(map_tag: Tag | None = None) -> Tag:
|
|||||||
raise MapNotFoundError("map_tag is required outside a map_widget context")
|
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."""
|
||||||
|
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ class TileManager:
|
|||||||
RuntimeWarning,
|
RuntimeWarning,
|
||||||
stacklevel=3,
|
stacklevel=3,
|
||||||
)
|
)
|
||||||
headers["User-Agent"] = "dpg-map/0.1"
|
headers["User-Agent"] = "dpg-map/0.3.0b1"
|
||||||
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."""
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ from contextlib import contextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from .interaction import (
|
||||||
|
calculate_hit_rect,
|
||||||
|
handle_mouse_down,
|
||||||
|
handle_mouse_drag,
|
||||||
|
handle_mouse_release,
|
||||||
|
handle_mouse_wheel,
|
||||||
|
wheel_delta_from_app_data,
|
||||||
|
)
|
||||||
from .providers import TileProvider
|
from .providers import TileProvider
|
||||||
from .renderer import MapRenderer
|
from .renderer import MapRenderer
|
||||||
from .state import create_map_state, current_map_context
|
from .state import create_map_state, current_map_context
|
||||||
@@ -63,6 +71,54 @@ def map_widget(
|
|||||||
)
|
)
|
||||||
dpg.add_texture_registry(tag=state.texture_registry_tag)
|
dpg.add_texture_registry(tag=state.texture_registry_tag)
|
||||||
dpg.add_drawlist(1, 1, tag=state.drawlist_tag, parent=state.child_window_tag)
|
dpg.add_drawlist(1, 1, tag=state.drawlist_tag, parent=state.child_window_tag)
|
||||||
|
dpg.add_handler_registry(tag=state.handler_registry_tag)
|
||||||
|
|
||||||
|
def _mouse_pos() -> tuple[float, float]:
|
||||||
|
pos = dpg.get_mouse_pos(local=False)
|
||||||
|
return (float(pos[0]), float(pos[1]))
|
||||||
|
|
||||||
|
def _hit_rect() -> Any:
|
||||||
|
draw_pos = tuple(float(value) for value in dpg.get_item_rect_min(state.drawlist_tag))
|
||||||
|
return calculate_hit_rect(state, (draw_pos[0], draw_pos[1]))
|
||||||
|
|
||||||
|
def _on_mouse_down(sender: Any, app_data: Any, user_data: Any) -> None:
|
||||||
|
_ = (sender, app_data, user_data)
|
||||||
|
handle_mouse_down(state, _mouse_pos(), _hit_rect())
|
||||||
|
|
||||||
|
def _on_mouse_drag(sender: Any, app_data: Any, user_data: Any) -> None:
|
||||||
|
_ = (sender, app_data, user_data)
|
||||||
|
handle_mouse_drag(state, _mouse_pos())
|
||||||
|
|
||||||
|
def _on_mouse_release(sender: Any, app_data: Any, user_data: Any) -> None:
|
||||||
|
_ = (sender, app_data, user_data)
|
||||||
|
handle_mouse_release(state)
|
||||||
|
|
||||||
|
def _on_mouse_wheel(sender: Any, app_data: Any, user_data: Any) -> None:
|
||||||
|
_ = (sender, user_data)
|
||||||
|
handle_mouse_wheel(
|
||||||
|
state,
|
||||||
|
mouse_pos=_mouse_pos(),
|
||||||
|
wheel_delta=wheel_delta_from_app_data(app_data),
|
||||||
|
hit_rect=_hit_rect(),
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.add_mouse_down_handler(
|
||||||
|
button=dpg.mvMouseButton_Left,
|
||||||
|
callback=_on_mouse_down,
|
||||||
|
parent=state.handler_registry_tag,
|
||||||
|
)
|
||||||
|
dpg.add_mouse_drag_handler(
|
||||||
|
button=dpg.mvMouseButton_Left,
|
||||||
|
threshold=0.0,
|
||||||
|
callback=_on_mouse_drag,
|
||||||
|
parent=state.handler_registry_tag,
|
||||||
|
)
|
||||||
|
dpg.add_mouse_release_handler(
|
||||||
|
button=dpg.mvMouseButton_Left,
|
||||||
|
callback=_on_mouse_release,
|
||||||
|
parent=state.handler_registry_tag,
|
||||||
|
)
|
||||||
|
dpg.add_mouse_wheel_handler(callback=_on_mouse_wheel, parent=state.handler_registry_tag)
|
||||||
|
|
||||||
renderer = MapRenderer(state, dpg)
|
renderer = MapRenderer(state, dpg)
|
||||||
with state.lock:
|
with state.lock:
|
||||||
|
|||||||
@@ -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
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
|
||||||
@@ -1,8 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dpg_map.interaction import calculate_hit_rect
|
import pytest
|
||||||
|
|
||||||
|
import dpg_map as dpgm
|
||||||
|
from dpg_map.commands import CommandKind
|
||||||
|
from dpg_map.interaction import (
|
||||||
|
calculate_hit_rect,
|
||||||
|
handle_mouse_down,
|
||||||
|
handle_mouse_drag,
|
||||||
|
handle_mouse_release,
|
||||||
|
handle_mouse_wheel,
|
||||||
|
pan_state_by_pixels,
|
||||||
|
update_drag_from_button_state,
|
||||||
|
)
|
||||||
from dpg_map.sizing import SizeMeasurement, apply_size_measurement
|
from dpg_map.sizing import SizeMeasurement, apply_size_measurement
|
||||||
from dpg_map.state import create_map_state
|
from dpg_map.state import DirtyFlags, create_map_state, get_map_state
|
||||||
|
|
||||||
|
|
||||||
def test_hit_rect_uses_effective_map_size() -> None:
|
def test_hit_rect_uses_effective_map_size() -> None:
|
||||||
@@ -17,3 +29,81 @@ def test_hit_rect_uses_effective_map_size() -> None:
|
|||||||
assert rect.height == 250
|
assert rect.height == 250
|
||||||
assert rect.contains(410.0, 270.0)
|
assert rect.contains(410.0, 270.0)
|
||||||
assert not rect.contains(411.0, 270.0)
|
assert not rect.contains(411.0, 270.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pan_updates_center_and_queues_view_command() -> None:
|
||||||
|
state = create_map_state(tag="pan", center=(0.0, 0.0), zoom=3)
|
||||||
|
apply_size_measurement(state, SizeMeasurement(width=400, height=300, visible=True))
|
||||||
|
|
||||||
|
old_center = state.center
|
||||||
|
pan_state_by_pixels(state, 40.0, 0.0)
|
||||||
|
|
||||||
|
assert state.center != old_center
|
||||||
|
assert state.center[1] < old_center[1]
|
||||||
|
assert state.dirty & DirtyFlags.VIEW
|
||||||
|
drained = state.command_queue.drain()
|
||||||
|
assert drained[-1].kind is CommandKind.SET_VIEW
|
||||||
|
assert drained[-1].payload["center"] == state.center
|
||||||
|
|
||||||
|
|
||||||
|
def test_mouse_drag_uses_active_drag_state() -> None:
|
||||||
|
state = create_map_state(tag="drag", center=(0.0, 0.0), zoom=3)
|
||||||
|
apply_size_measurement(state, SizeMeasurement(width=400, height=300, visible=True))
|
||||||
|
rect = calculate_hit_rect(state, (10.0, 20.0))
|
||||||
|
|
||||||
|
handle_mouse_down(state, (20.0, 30.0), rect)
|
||||||
|
assert state.interaction.active_drag is True
|
||||||
|
handle_mouse_drag(state, (45.0, 30.0))
|
||||||
|
handle_mouse_release(state)
|
||||||
|
|
||||||
|
assert state.interaction.active_drag is False
|
||||||
|
assert state.interaction.last_mouse_position is None
|
||||||
|
assert state.center[1] < 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_polled_drag_starts_and_moves_while_button_is_down() -> None:
|
||||||
|
state = create_map_state(tag="polled-drag", center=(0.0, 0.0), zoom=3)
|
||||||
|
apply_size_measurement(state, SizeMeasurement(width=400, height=300, visible=True))
|
||||||
|
rect = calculate_hit_rect(state, (10.0, 20.0))
|
||||||
|
|
||||||
|
update_drag_from_button_state(state, mouse_pos=(20.0, 30.0), hit_rect=rect, is_down=True)
|
||||||
|
update_drag_from_button_state(state, mouse_pos=(45.0, 30.0), hit_rect=rect, is_down=True)
|
||||||
|
update_drag_from_button_state(state, mouse_pos=(45.0, 30.0), hit_rect=rect, is_down=False)
|
||||||
|
|
||||||
|
assert state.interaction.active_drag is False
|
||||||
|
assert state.center[1] < 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_wheel_zoom_keeps_cursor_latlon_stable() -> None:
|
||||||
|
state = create_map_state(tag="wheel", center=(47.9029, 1.9093), zoom=8)
|
||||||
|
apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True))
|
||||||
|
rect = calculate_hit_rect(state, (100.0, 50.0))
|
||||||
|
|
||||||
|
before = dpgm.screen_to_latlon(300.0, 200.0, map_tag="wheel")
|
||||||
|
handle_mouse_wheel(state, mouse_pos=(400.0, 250.0), wheel_delta=1.0, hit_rect=rect)
|
||||||
|
after = dpgm.screen_to_latlon(300.0, 200.0, map_tag="wheel")
|
||||||
|
|
||||||
|
assert state.zoom == 9
|
||||||
|
assert after == pytest.approx(before, abs=1e-7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_coordinate_helpers_roundtrip() -> None:
|
||||||
|
create_map_state(tag="view-roundtrip", center=(47.9029, 1.9093), zoom=12)
|
||||||
|
state = get_map_state("view-roundtrip")
|
||||||
|
apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True))
|
||||||
|
|
||||||
|
screen = dpgm.latlon_to_screen(47.91, 1.92, map_tag="view-roundtrip")
|
||||||
|
latlon = dpgm.screen_to_latlon(*screen, map_tag="view-roundtrip")
|
||||||
|
|
||||||
|
assert latlon == pytest.approx((47.91, 1.92), abs=1e-7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_bounds_sets_center_and_zoom() -> None:
|
||||||
|
create_map_state(tag="fit", center=(0.0, 0.0), zoom=2)
|
||||||
|
state = get_map_state("fit")
|
||||||
|
apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True))
|
||||||
|
|
||||||
|
dpgm.fit_bounds(((47.8, 1.8), (48.0, 2.0)), map_tag="fit")
|
||||||
|
|
||||||
|
assert dpgm.get_center(map_tag="fit") == pytest.approx((47.9, 1.9))
|
||||||
|
assert dpgm.get_zoom(map_tag="fit") > 2
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import dpg_map as dpgm
|
import dpg_map as dpgm
|
||||||
|
from dpg_map.commands import CommandKind
|
||||||
from dpg_map.exceptions import CoordinateError
|
from dpg_map.exceptions import CoordinateError
|
||||||
from dpg_map.overlays import TrajectoryOverlay
|
from dpg_map.overlays import TrajectoryOverlay
|
||||||
from dpg_map.state import DirtyFlags, create_map_state, get_map_state
|
from dpg_map.state import DirtyFlags, create_map_state, get_map_state
|
||||||
@@ -61,3 +64,50 @@ def test_layer_state_tracks_visibility_and_overlay_membership() -> None:
|
|||||||
|
|
||||||
assert state.layers["fleet"].overlay_tags == set()
|
assert state.layers["fleet"].overlay_tags == set()
|
||||||
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:
|
||||||
|
create_map_state(tag="threaded-marker", center=(47.0, 2.0), zoom=9)
|
||||||
|
dpgm.add_marker("vehicle", lat=47.0, lon=2.0, map_tag="threaded-marker")
|
||||||
|
state = get_map_state("threaded-marker")
|
||||||
|
state.command_queue.drain()
|
||||||
|
state.dirty = DirtyFlags.NONE
|
||||||
|
state.interaction.active_drag = True
|
||||||
|
state.interaction.last_mouse_position = (100.0, 100.0)
|
||||||
|
before_center = state.center
|
||||||
|
before_zoom = state.zoom
|
||||||
|
|
||||||
|
def update_worker(offset: float) -> None:
|
||||||
|
for index in range(100):
|
||||||
|
dpgm.update_marker(
|
||||||
|
"vehicle",
|
||||||
|
lat=47.0 + offset,
|
||||||
|
lon=2.0 + index * 0.00001,
|
||||||
|
map_tag="threaded-marker",
|
||||||
|
)
|
||||||
|
|
||||||
|
threads = [Thread(target=update_worker, args=(worker * 0.0001,)) for worker in range(4)]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
commands = state.command_queue.drain()
|
||||||
|
|
||||||
|
assert state.center == before_center
|
||||||
|
assert state.zoom == before_zoom
|
||||||
|
assert state.interaction.active_drag is True
|
||||||
|
assert state.interaction.last_mouse_position == (100.0, 100.0)
|
||||||
|
assert state.dirty == DirtyFlags.OVERLAYS
|
||||||
|
assert [command.kind for command in commands] == [CommandKind.UPDATE_OVERLAY]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,8 +1,50 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import dpg_map as dpgm
|
||||||
from dpg_map.commands import CommandKind, MapCommand
|
from dpg_map.commands import CommandKind, MapCommand
|
||||||
from dpg_map.renderer import drain_renderer_commands
|
from dpg_map.providers import TileProvider
|
||||||
|
from dpg_map.renderer import MapRenderer, drain_renderer_commands
|
||||||
from dpg_map.state import DirtyFlags, create_map_state
|
from dpg_map.state import DirtyFlags, create_map_state
|
||||||
|
from dpg_map.tiles import TileID, TileResult, TileStatus
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDpg:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.items: set[str | int] = set()
|
||||||
|
self.deleted: list[tuple[str | int, bool]] = []
|
||||||
|
self.drawn: list[tuple[str, str | int]] = []
|
||||||
|
|
||||||
|
def does_item_exist(self, tag: str | int) -> bool:
|
||||||
|
return tag in self.items
|
||||||
|
|
||||||
|
def add_draw_layer(self, *, parent: str | int, tag: str | int) -> None:
|
||||||
|
_ = parent
|
||||||
|
self.items.add(tag)
|
||||||
|
|
||||||
|
def delete_item(self, tag: str | int, *, children_only: bool = False) -> None:
|
||||||
|
self.deleted.append((tag, children_only))
|
||||||
|
|
||||||
|
def draw_rectangle(self, *args: Any, parent: str | int, **kwargs: Any) -> None:
|
||||||
|
_ = (args, kwargs)
|
||||||
|
self.drawn.append(("rectangle", parent))
|
||||||
|
|
||||||
|
def draw_image(self, *args: Any, parent: str | int, **kwargs: Any) -> None:
|
||||||
|
_ = (args, kwargs)
|
||||||
|
self.drawn.append(("image", parent))
|
||||||
|
|
||||||
|
def draw_text(self, *args: Any, parent: str | int, **kwargs: Any) -> None:
|
||||||
|
_ = (args, kwargs)
|
||||||
|
self.drawn.append(("text", parent))
|
||||||
|
|
||||||
|
def draw_circle(self, *args: Any, parent: str | int, **kwargs: Any) -> None:
|
||||||
|
_ = (args, kwargs)
|
||||||
|
self.drawn.append(("circle", parent))
|
||||||
|
|
||||||
|
def draw_polyline(self, *args: Any, parent: str | int, **kwargs: Any) -> None:
|
||||||
|
_ = (args, kwargs)
|
||||||
|
self.drawn.append(("polyline", parent))
|
||||||
|
|
||||||
|
|
||||||
def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> None:
|
def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> None:
|
||||||
@@ -28,3 +70,107 @@ def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> No
|
|||||||
assert commands[2].payload == {"tag": "a", "v": 2}
|
assert commands[2].payload == {"tag": "a", "v": 2}
|
||||||
assert state.dirty & DirtyFlags.VIEW
|
assert state.dirty & DirtyFlags.VIEW
|
||||||
assert state.dirty & DirtyFlags.OVERLAYS
|
assert state.dirty & DirtyFlags.OVERLAYS
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_draw_clears_only_overlay_layer() -> None:
|
||||||
|
state = create_map_state(tag="overlay-draw", center=(47.0, 2.0), zoom=8)
|
||||||
|
dpgm.add_marker(
|
||||||
|
"vehicle",
|
||||||
|
lat=47.0,
|
||||||
|
lon=2.0,
|
||||||
|
show_label=True,
|
||||||
|
label="Vehicle",
|
||||||
|
map_tag="overlay-draw",
|
||||||
|
)
|
||||||
|
fake = FakeDpg()
|
||||||
|
fake.items.add(state.drawlist_tag)
|
||||||
|
renderer = MapRenderer(state, fake)
|
||||||
|
|
||||||
|
renderer._draw_tile_layer(
|
||||||
|
visible_tiles=[], width=400, height=300, attribution="Tiles", tile_size=256
|
||||||
|
)
|
||||||
|
fake.deleted.clear()
|
||||||
|
with state.lock:
|
||||||
|
overlays = tuple(state.overlays.values())
|
||||||
|
layers = {name: (layer.show, layer.z_index) for name, layer in state.layers.items()}
|
||||||
|
|
||||||
|
renderer._draw_overlay_layer(
|
||||||
|
overlays=overlays,
|
||||||
|
layers=layers,
|
||||||
|
center=state.center,
|
||||||
|
zoom=state.zoom,
|
||||||
|
width=400,
|
||||||
|
height=300,
|
||||||
|
tile_size=256,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fake.deleted == [("overlay-draw##layer-overlays", True)]
|
||||||
|
assert ("circle", "overlay-draw##layer-overlays") in fake.drawn
|
||||||
|
assert ("text", "overlay-draw##layer-overlays") in fake.drawn
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_update_drain_sets_only_overlay_dirty() -> None:
|
||||||
|
state = create_map_state(tag="overlay-dirty")
|
||||||
|
state.dirty = DirtyFlags.NONE
|
||||||
|
state.command_queue.put(MapCommand(CommandKind.UPDATE_OVERLAY, state.tag, {"tag": "a"}))
|
||||||
|
|
||||||
|
drain_renderer_commands(state)
|
||||||
|
|
||||||
|
assert state.dirty == DirtyFlags.OVERLAYS
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_switch_keeps_overlays_and_invalidates_tiles() -> None:
|
||||||
|
provider = TileProvider(
|
||||||
|
name="renderer-switch-provider",
|
||||||
|
url_template="https://tiles.example.test/{z}/{x}/{y}.png",
|
||||||
|
min_zoom=3,
|
||||||
|
max_zoom=4,
|
||||||
|
attribution="Example",
|
||||||
|
)
|
||||||
|
dpgm.register_provider(provider)
|
||||||
|
try:
|
||||||
|
state = create_map_state(tag="provider-switch", center=(47.0, 2.0), zoom=8)
|
||||||
|
dpgm.add_marker("vehicle", lat=47.0, lon=2.0, map_tag="provider-switch")
|
||||||
|
state.command_queue.drain()
|
||||||
|
state.dirty = DirtyFlags.NONE
|
||||||
|
tile_id = TileID("osm", 3, 1, 2)
|
||||||
|
state.tile_manager._result_queue.put(
|
||||||
|
TileResult(
|
||||||
|
tile_id,
|
||||||
|
generation=state.generation,
|
||||||
|
status=TileStatus.READY,
|
||||||
|
width=1,
|
||||||
|
height=1,
|
||||||
|
pixels=(1.0, 1.0, 1.0, 1.0),
|
||||||
|
source="disk",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state.tile_manager.drain_results(generation=state.generation, provider_name="osm")
|
||||||
|
state.tile_manager.set_texture_tag(tile_id, "old-texture")
|
||||||
|
|
||||||
|
dpgm.set_provider("renderer-switch-provider", map_tag="provider-switch")
|
||||||
|
drain_renderer_commands(state)
|
||||||
|
|
||||||
|
assert "vehicle" in state.overlays
|
||||||
|
assert state.center == (47.0, 2.0)
|
||||||
|
assert state.zoom == 4
|
||||||
|
assert state.generation == 1
|
||||||
|
assert state.provider.name == "renderer-switch-provider"
|
||||||
|
assert state.dirty & DirtyFlags.PROVIDER
|
||||||
|
assert state.dirty & DirtyFlags.TILES
|
||||||
|
assert state.dirty & DirtyFlags.OVERLAYS
|
||||||
|
assert state.tile_manager.get_ready_tile(tile_id) is None
|
||||||
|
assert state.tile_manager.take_texture_deletions() == ["old-texture"]
|
||||||
|
finally:
|
||||||
|
dpgm.unregister_provider("renderer-switch-provider")
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_scoped_clear_disk_cache_command_keeps_dearpygui_out_of_caller_thread() -> None:
|
||||||
|
state = create_map_state(tag="clear-disk-command")
|
||||||
|
state.dirty = DirtyFlags.NONE
|
||||||
|
|
||||||
|
dpgm.clear_disk_cache(map_tag="clear-disk-command")
|
||||||
|
|
||||||
|
commands = state.command_queue.drain()
|
||||||
|
assert [command.kind for command in commands] == [CommandKind.CLEAR_DISK_CACHE]
|
||||||
|
assert state.dirty == DirtyFlags.TILES
|
||||||
|
|||||||
Reference in New Issue
Block a user