"""Thread-safe state models and registries.""" from __future__ import annotations from collections.abc import Iterator from contextlib import contextmanager from dataclasses import dataclass, field from enum import IntFlag, auto from pathlib import Path from threading import RLock from uuid import uuid4 from .commands import MapCommandQueue from .exceptions import InvalidProviderError, MapNotFoundError from .overlays import LayerState, Overlay from .providers import TileProvider, get_default_provider, get_provider from .tiles import TileManager from .types import LatLon, Tag class DirtyFlags(IntFlag): """Reasons the GUI renderer needs to refresh part of a map.""" NONE = 0 VIEW = auto() TILES = auto() OVERLAYS = auto() SIZE = auto() PROVIDER = auto() FULL = VIEW | TILES | OVERLAYS | SIZE | PROVIDER @dataclass(slots=True) class DpgMapConfig: """Global package configuration.""" user_agent: str | None = None cache_dir: Path | None = None default_provider: str | 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 @dataclass(slots=True) class InteractionState: """Logical mouse interaction state.""" active_drag: bool = False last_mouse_position: tuple[float, float] | None = None def default_layers() -> dict[str, LayerState]: """Return the default logical map layers.""" layers = [ LayerState("background", z_index=0), LayerState("tiles", z_index=10), LayerState("default", z_index=50), LayerState("markers", z_index=60), LayerState("lines", z_index=70), LayerState("trajectories", z_index=80), LayerState("attribution", z_index=100), ] return {layer.name: layer for layer in layers} @dataclass(slots=True) class MapState: """Thread-safe logical state for one map widget.""" tag: Tag child_window_tag: Tag drawlist_tag: Tag texture_registry_tag: Tag handler_registry_tag: Tag requested_width: int = 0 requested_height: int = 0 requested_autosize_x: bool = False requested_autosize_y: bool = False measured_width: int = 0 measured_height: int = 0 last_nonzero_width: int = 0 last_nonzero_height: int = 0 is_visible: bool = False center: LatLon = (0.0, 0.0) zoom: int = 2 min_zoom: int = 0 max_zoom: int = 19 provider: TileProvider = field(default_factory=get_default_provider) overlays: dict[Tag, Overlay] = field(default_factory=dict) layers: dict[str, LayerState] = field(default_factory=default_layers) command_queue: MapCommandQueue = field(default_factory=MapCommandQueue) tile_manager: TileManager = field(default_factory=TileManager) renderer: object | None = None interaction: InteractionState = field(default_factory=InteractionState) lock: RLock = field(default_factory=RLock) dirty: DirtyFlags = DirtyFlags.FULL frame_scheduled: bool = False generation: int = 0 cache_dir: Path | None = None user_agent: str | None = None _config = DpgMapConfig() _config_lock = RLock() _maps: dict[Tag, MapState] = {} _maps_lock = RLock() _current_map_stack: list[Tag] = [] _current_map_lock = RLock() def _resolve_provider(provider: str | TileProvider | None) -> TileProvider: if provider is None: with _config_lock: provider = _config.default_provider if isinstance(provider, TileProvider): return provider if isinstance(provider, str): return get_provider(provider) raise InvalidProviderError("provider must be a provider name or TileProvider") def configure_state( *, user_agent: str | None = None, cache_dir: str | Path | None = None, default_provider: str | 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: """Replace global dpg-map configuration.""" if memory_cache_max_tiles < 0: raise ValueError("memory_cache_max_tiles must be >= 0") if disk_cache_max_bytes is not None and disk_cache_max_bytes < 0: raise ValueError("disk_cache_max_bytes must be >= 0 or None") if prefetch_margin_tiles < 0: raise ValueError("prefetch_margin_tiles must be >= 0") if tile_worker_count < 1: raise ValueError("tile_worker_count must be >= 1") if overlay_update_policy != "coalesce": raise ValueError('overlay_update_policy must be "coalesce"') resolved_cache_dir = Path(cache_dir).expanduser() if cache_dir is not None else None _resolve_provider(default_provider) with _config_lock: _config.user_agent = user_agent _config.cache_dir = resolved_cache_dir _config.default_provider = default_provider _config.memory_cache_max_tiles = memory_cache_max_tiles _config.disk_cache_max_bytes = disk_cache_max_bytes _config.prefetch_margin_tiles = prefetch_margin_tiles _config.tile_worker_count = tile_worker_count _config.overlay_update_policy = overlay_update_policy _config.debug = debug def get_config() -> DpgMapConfig: """Return a copy of current global configuration.""" with _config_lock: return DpgMapConfig( user_agent=_config.user_agent, cache_dir=_config.cache_dir, default_provider=_config.default_provider, memory_cache_max_tiles=_config.memory_cache_max_tiles, disk_cache_max_bytes=_config.disk_cache_max_bytes, prefetch_margin_tiles=_config.prefetch_margin_tiles, tile_worker_count=_config.tile_worker_count, overlay_update_policy=_config.overlay_update_policy, debug=_config.debug, ) def create_map_state( *, tag: Tag | None = None, center: LatLon = (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, provider: str | TileProvider | None = None, cache_dir: str | Path | None = None, user_agent: str | None = None, ) -> MapState: """Create and register a logical map state.""" map_tag = tag if tag is not None else f"dpg_map_{uuid4().hex}" provider_obj = _resolve_provider(provider) min_zoom_value = provider_obj.min_zoom if min_zoom is None else min_zoom max_zoom_value = provider_obj.max_zoom if max_zoom is None else max_zoom if min_zoom_value < provider_obj.min_zoom: min_zoom_value = provider_obj.min_zoom if max_zoom_value > provider_obj.max_zoom: max_zoom_value = provider_obj.max_zoom if max_zoom_value < min_zoom_value: raise ValueError("max_zoom must be >= min_zoom") zoom_value = max(min_zoom_value, min(max_zoom_value, int(zoom))) config = get_config() resolved_cache_dir = Path(cache_dir).expanduser() if cache_dir is not None else config.cache_dir state = MapState( tag=map_tag, child_window_tag=f"{map_tag}##child", drawlist_tag=f"{map_tag}##drawlist", texture_registry_tag=f"{map_tag}##textures", handler_registry_tag=f"{map_tag}##handlers", requested_width=width, requested_height=height, requested_autosize_x=autosize_x, requested_autosize_y=autosize_y, center=(float(center[0]), float(center[1])), zoom=zoom_value, min_zoom=min_zoom_value, max_zoom=max_zoom_value, provider=provider_obj, cache_dir=resolved_cache_dir, user_agent=user_agent if user_agent is not None else config.user_agent, ) state.tile_manager = TileManager( memory_cache_max_tiles=config.memory_cache_max_tiles, disk_cache_max_bytes=config.disk_cache_max_bytes, worker_count=config.tile_worker_count, user_agent=state.user_agent, ) with _maps_lock: _maps[map_tag] = state return state def register_map_state(state: MapState) -> None: """Register a prebuilt map state.""" with _maps_lock: _maps[state.tag] = state def unregister_map_state(tag: Tag) -> None: """Remove a map state from the registry.""" with _maps_lock: _maps.pop(tag, None) def get_map_state(map_tag: Tag | None = None) -> MapState: """Resolve a map by explicit tag or current map context.""" resolved_tag = resolve_map_tag(map_tag) with _maps_lock: try: return _maps[resolved_tag] except KeyError as exc: raise MapNotFoundError(f"map not registered: {resolved_tag}") from exc def resolve_map_tag(map_tag: Tag | None = None) -> Tag: """Resolve an explicit tag or the current context map tag.""" if map_tag is not None: return map_tag with _current_map_lock: if _current_map_stack: return _current_map_stack[-1] raise MapNotFoundError("map_tag is required outside a map_widget context") def list_map_states() -> list[MapState]: """Return registered map states as a snapshot.""" with _maps_lock: return list(_maps.values()) def find_map_for_overlay(tag: Tag, map_tag: Tag | None = None) -> MapState: """Find the map containing an overlay, optionally scoped by map tag.""" if map_tag is not None: return get_map_state(map_tag) with _current_map_lock: if _current_map_stack: current = get_map_state(_current_map_stack[-1]) with current.lock: if tag in current.overlays: return current matches: list[MapState] = [] with _maps_lock: states = list(_maps.values()) for state in states: with state.lock: if tag in state.overlays: matches.append(state) if len(matches) == 1: return matches[0] if not matches: raise MapNotFoundError(f"no map contains overlay: {tag}") raise MapNotFoundError(f"overlay tag is ambiguous across maps: {tag}") @contextmanager def current_map_context(map_tag: Tag) -> Iterator[None]: """Push a current map tag for context-style overlay creation.""" with _current_map_lock: _current_map_stack.append(map_tag) try: yield finally: with _current_map_lock: if _current_map_stack and _current_map_stack[-1] == map_tag: _current_map_stack.pop() elif map_tag in _current_map_stack: _current_map_stack.remove(map_tag) def mark_dirty(state: MapState, flags: DirtyFlags) -> None: """Mark a map dirty while holding or acquiring its state lock.""" state.dirty |= flags