"""Map interaction state and handlers.""" from __future__ import annotations 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 .state import DirtyFlags, MapState, mark_dirty from .types import LatLon, Point @dataclass(frozen=True, slots=True) class HitRect: """Screen-space rectangle used for map interaction tests.""" x: float y: float width: float height: float @property def right(self) -> float: return self.x + self.width @property def bottom(self) -> float: return self.y + self.height def contains(self, x: float, y: float) -> bool: return self.x <= x <= self.right and self.y <= y <= self.bottom def calculate_hit_rect(state: MapState, drawlist_pos: tuple[float, float]) -> HitRect: """Return the map interaction rectangle for a drawlist position.""" width, height = effective_draw_size(state) 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 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