Files
dpg-map/FEATURES.md
2026-05-22 18:14:35 +02:00

15 KiB
Raw Blame History

dpg-map Feature Specification — Locked API Rebuild

This document defines the public behaviour and API contract for the next rebuild of dpg-map.

The first build reached overlay/layer functionality, but suffered from rendering instability, flickering, bad drag behaviour while overlays were updating, occasional unwanted recentering, cache limitations, and sizing mismatches between the child container and the drawlist.

The rebuild must treat the public API as a stable contract from the beginning.

Project summary

dpg-map is a Python dependency providing a Dear PyGui map widget for raster XYZ map tiles and geographic overlays.

It should be usable as:

import dpg_map as dpgm

The widget should feel natural in Dear PyGui code:

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)
    dpgm.add_trajectory("lap", lats=[], lons=[])

Runtime updates from telemetry or worker threads must be safe:

dpgm.update_marker("vehicle", lat=current_lat, lon=current_lon)
dpgm.update_trajectory("lap", lats=lat_buffer, lons=lon_buffer)

Those calls must not directly call Dear PyGui APIs, block the caller, reset the map view, flicker the map, or interfere with dragging/zooming.

Core requirements

1. Stable public API

The public API is considered locked once implemented. Internals may change, but user code should not need to change.

Required public areas:

  • global configuration
  • tile providers
  • map widget creation
  • map view state
  • overlays
  • layers
  • cache configuration
  • diagnostics/debugging

2. Thread-safe runtime updates

Any public runtime function may be called from a non-GUI thread unless explicitly documented otherwise.

Examples:

dpgm.update_marker(...)
dpgm.update_polyline(...)
dpgm.update_trajectory(...)
dpgm.set_overlay_show(...)
dpgm.delete_overlay(...)
dpgm.set_center(...)
dpgm.set_zoom(...)
dpgm.set_view(...)
dpgm.set_provider(...)
dpgm.clear_layer(...)

Rules:

  • Public runtime functions must be non-blocking or near non-blocking.
  • They must never call Dear PyGui draw, texture, item, handler, or viewport APIs directly.
  • They must update a thread-safe logical state model and/or enqueue commands.
  • The GUI thread drains the queue and performs Dear PyGui work.
  • Overlay updates must not mutate map center, zoom, pan state, active drag state, or tile provider unless the function is explicitly a view/provider function.
  • Overlay updates must be coalesced, so a telemetry thread can update at high rate without flooding the GUI thread.

3. Interaction must remain stable during overlay updates

While markers, polylines, or trajectories are being added/updated/deleted:

  • the user must still be able to drag the map
  • the user must still be able to zoom
  • the map must not recenter unexpectedly
  • the map must not flash through an empty state
  • tile draw commands should not be destroyed just because an overlay changed
  • overlay draw refreshes should be isolated from tile draw refreshes where possible

4. Persistent tile caching

The cache must have two independent limits:

memory_cache_max_tiles: int = 512
disk_cache_max_bytes: int | None = None

Memory cache:

  • runtime only
  • LRU or approximate LRU
  • stores decoded tile data and/or active texture references
  • protects currently visible tiles from eviction
  • evicts Dear PyGui textures only on the GUI thread

Disk cache:

  • persistent across application restarts
  • provider-namespaced
  • configurable path
  • configurable size limit
  • pruned in the background or during startup
  • avoids repeatedly fetching regularly used tiles

Default disk cache path should use platformdirs unless overridden.

5. Sizing must be robust

The Dear PyGui child container and internal drawlist must stay in sync.

Requirements:

  • width=0 and height=0 preserve Dear PyGui default behaviour.
  • width=-1 fills available width.
  • height=-1 fills available height where Dear PyGui allows it.
  • fixed positive dimensions are respected.
  • autosize_x and autosize_y are supported where practical.
  • the child window stores the requested sizing intent.
  • the drawlist always uses measured concrete pixel dimensions.
  • the drawlist must resize when the child content region changes.
  • the map must work inside windows, groups, child windows, tabs, tables, and hidden/show-later layouts.
  • the renderer must not get stuck at the first measured size.

6. Interchangeable TileProvider system

OpenStreetMap is the default provider, but users must be able to use custom XYZ providers.

provider = dpgm.TileProvider(
    name="custom",
    url_template="https://example.com/{z}/{x}/{y}.png",
    attribution="Tiles © Example",
)
dpgm.register_provider(provider)

Provider switching should preserve overlays and the current center where possible.

7. uv-first package management

The project must be managed with uv.

Users should be able to install locally with:

uv add -e ../dpg-map

The package must support normal uv add, uv sync, uv run pytest, and editable local development.

Non-goals for the first rebuilt version

Do not implement these in the first rebuild:

  • vector map rendering
  • Mapbox GL rendering
  • geocoding/search
  • routing
  • offline planet files
  • arbitrary projections
  • map rotation/pitch
  • complex hit testing for lines/polygons
  • GPU-accelerated custom renderers
  • tile blending/fade animations

Public API

Global configuration

dpgm.configure(
    *,
    user_agent: str | None = None,
    cache_dir: str | Path | None = None,
    default_provider: str | dpgm.TileProvider = "osm",
    memory_cache_max_tiles: int = 512,
    disk_cache_max_bytes: int | None = 2_000_000_000,
    prefetch_margin_tiles: int = 1,
    tile_worker_count: int = 4,
    overlay_update_policy: str = "coalesce",
    debug: bool = False,
) -> None

Notes:

  • disk_cache_max_bytes=None means unlimited disk cache.
  • overlay_update_policy="coalesce" means only the newest update for each overlay is rendered.
  • OpenStreetMap usage should require or strongly warn about a valid application-specific user_agent.

TileProvider

@dataclass(frozen=True)
class TileProvider:
    name: str
    url_template: str
    min_zoom: int = 0
    max_zoom: int = 19
    tile_size: int = 256
    attribution: str = ""
    headers: dict[str, str] = field(default_factory=dict)
    subdomains: tuple[str, ...] = ()
    retina: bool = False
    file_extension: str | None = None

Provider helpers:

dpgm.register_provider(provider: dpgm.TileProvider) -> None
dpgm.unregister_provider(name: str) -> None
dpgm.get_provider(name: str) -> dpgm.TileProvider
dpgm.list_providers() -> list[str]

Default provider:

OSM = TileProvider(
    name="osm",
    url_template="https://tile.openstreetmap.org/{z}/{x}/{y}.png",
    min_zoom=0,
    max_zoom=19,
    tile_size=256,
    attribution="© OpenStreetMap contributors",
)

Provider URL template rules:

  • {z}, {x}, and {y} are required.
  • {s} is optional for subdomains.
  • {r} is optional for retina suffixes.
  • invalid templates must raise a clear provider exception.

Map widget

with dpgm.map_widget(
    tag: str | int | None = None,
    center: tuple[float, float] = (0.0, 0.0),
    zoom: int = 2,
    min_zoom: int | None = None,
    max_zoom: int | None = None,
    width: int = 0,
    height: int = 0,
    autosize_x: bool = False,
    autosize_y: bool = False,
    border: bool = True,
    provider: str | dpgm.TileProvider | None = None,
    cache_dir: str | Path | None = None,
    user_agent: str | None = None,
    no_scrollbar: bool = True,
    delay_tile_load_until_visible: bool = True,
) as map_tag:
    ...

The returned map_tag is the logical map tag, not a Dear PyGui item tag.

View state API

dpgm.set_center(map_tag, lat: float, lon: float) -> None
dpgm.get_center(map_tag) -> tuple[float, float]

dpgm.set_zoom(map_tag, zoom: int) -> None
dpgm.get_zoom(map_tag) -> int

dpgm.set_view(
    map_tag,
    *,
    center: tuple[float, float] | None = None,
    zoom: int | None = None,
) -> None

dpgm.fit_bounds(
    map_tag,
    min_lat: float,
    min_lon: float,
    max_lat: float,
    max_lon: float,
    padding: int = 32,
) -> None

dpgm.screen_to_latlon(map_tag, x: float, y: float) -> tuple[float, float]
dpgm.latlon_to_screen(map_tag, lat: float, lon: float) -> tuple[float, float]

View state commands may be called from other threads, but they are queued and applied on the GUI thread. get_* functions return the latest committed logical state.

Overlay creation API

All overlay creation functions can be used inside a map context or at runtime with map_tag.

Marker

dpgm.add_marker(
    tag: str | int,
    lat: float,
    lon: float,
    *,
    label: str | None = None,
    color: tuple[int, int, int, int] = (255, 80, 80, 255),
    radius: float = 5.0,
    layer: str = "markers",
    show: bool = True,
    show_label: bool = False,
    user_data: Any = None,
    callback: Callable | None = None,
    map_tag: str | int | None = None,
) -> str | int

Polyline

dpgm.add_polyline(
    tag: str | int,
    points: Sequence[tuple[float, float]] | None = None,
    *,
    lats: Sequence[float] | None = None,
    lons: Sequence[float] | None = None,
    color: tuple[int, int, int, int] = (80, 180, 255, 255),
    thickness: float = 2.0,
    layer: str = "lines",
    show: bool = True,
    closed: bool = False,
    simplify: bool = True,
    user_data: Any = None,
    map_tag: str | int | None = None,
) -> str | int

Trajectory

dpgm.add_trajectory(
    tag: str | int,
    lats: Sequence[float],
    lons: Sequence[float],
    *,
    timestamps: Sequence[float] | None = None,
    color: tuple[int, int, int, int] = (255, 180, 60, 255),
    thickness: float = 2.0,
    show_points: bool = False,
    point_stride: int = 1,
    layer: str = "trajectories",
    show: bool = True,
    user_data: Any = None,
    map_tag: str | int | None = None,
) -> str | int

Overlay update API

Overlay update APIs must be safe from background threads and must not directly touch Dear PyGui.

dpgm.update_marker(
    tag: str | int,
    *,
    lat: float | None = None,
    lon: float | None = None,
    label: str | None = None,
    show: bool | None = None,
    color: tuple[int, int, int, int] | None = None,
    radius: float | None = None,
) -> None
dpgm.update_polyline(
    tag: str | int,
    points: Sequence[tuple[float, float]] | None = None,
    *,
    lats: Sequence[float] | None = None,
    lons: Sequence[float] | None = None,
    show: bool | None = None,
    color: tuple[int, int, int, int] | None = None,
    thickness: float | None = None,
) -> None
dpgm.update_trajectory(
    tag: str | int,
    *,
    lats: Sequence[float] | None = None,
    lons: Sequence[float] | None = None,
    timestamps: Sequence[float] | None = None,
    show: bool | None = None,
    color: tuple[int, int, int, int] | None = None,
    thickness: float | None = None,
) -> None

Convenience aliases:

dpgm.set_marker_position(tag, lat, lon) -> None
dpgm.set_marker_label(tag, label) -> None
dpgm.set_polyline_points(tag, points=None, *, lats=None, lons=None) -> None
dpgm.set_overlay_show(tag, show: bool) -> None
dpgm.delete_overlay(tag) -> None

Layer API

dpgm.add_layer(map_tag, name: str, z_index: int) -> None
dpgm.show_layer(map_tag, name: str) -> None
dpgm.hide_layer(map_tag, name: str) -> None
dpgm.clear_layer(map_tag, name: str) -> None

Layer operations are queued and applied by the GUI thread.

Provider switching

dpgm.set_provider(map_tag, provider: str | dpgm.TileProvider) -> None

Provider switching must:

  • keep overlays
  • keep center where possible
  • clamp zoom to provider limits
  • clear or detach old tile draw layer only
  • update attribution
  • not rebuild overlays unnecessarily
  • not call Dear PyGui from the caller thread

Cache control API

dpgm.clear_memory_cache(map_tag: str | int | None = None) -> None
dpgm.clear_disk_cache(provider: str | None = None) -> None
dpgm.get_cache_stats(map_tag: str | int | None = None) -> dpgm.CacheStats

CacheStats should include at least:

@dataclass(frozen=True)
class CacheStats:
    memory_tiles: int
    memory_limit_tiles: int
    disk_bytes: int
    disk_limit_bytes: int | None
    queued_tiles: int
    loading_tiles: int
    failed_tiles: int

Diagnostics

dpgm.get_map_debug_state(map_tag) -> dict[str, Any]

Used to debug:

  • measured child size
  • drawlist size
  • center/zoom
  • visible tile count
  • overlay count
  • pending command count
  • cache stats
  • whether the map is currently considered visible/hovered/dragging

Rendering stability requirements

Dirty flags

The implementation must separate dirty reasons:

VIEW_DIRTY
TILES_DIRTY
OVERLAYS_DIRTY
SIZE_DIRTY
PROVIDER_DIRTY
FULL_DIRTY

Overlay updates should set OVERLAYS_DIRTY, not FULL_DIRTY.

Panning/zooming should set VIEW_DIRTY and likely TILES_DIRTY | OVERLAYS_DIRTY.

Resizing should set SIZE_DIRTY | TILES_DIRTY | OVERLAYS_DIRTY.

Provider switching should set PROVIDER_DIRTY | TILES_DIRTY, but should not delete overlay logical state.

Draw layers

Internally, the renderer should use separate draw layers/groups where possible:

background layer
tile layer
overlay layer
attribution/debug layer

Overlay redraws should not wipe tile draw commands unless the view, size, or provider changed.

View ownership

Only these may intentionally change map center/zoom:

  • user pan/zoom interaction
  • set_center
  • set_zoom
  • set_view
  • fit_bounds
  • provider switch only if zoom must be clamped

Overlay creation/update/delete must never recenter the map.

Acceptance tests for the rebuild

The following manual and automated examples are required before treating the rebuild as usable:

  1. Basic map shows OSM tiles.
  2. Map fills a resizable Dear PyGui window.
  3. Map inside a child window fills its container.
  4. Map inside a table cell fills its cell.
  5. Map inside a hidden tab starts loading when shown.
  6. Dragging works while a marker is updated at 2060 Hz from another thread.
  7. Dragging works while a trajectory is updated at 1030 Hz from another thread.
  8. No unexpected center change occurs during overlay updates.
  9. Returning to a recently viewed area uses memory cache.
  10. Restarting the app and viewing the same area uses disk cache.
  11. Disk cache prunes to the configured limit.
  12. Provider switch keeps overlays.
  13. uv add -e ../dpg-map works from another project.