From 2d6242bd3f32e447342a72e352e6700b811b70d0 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Sat, 23 May 2026 10:19:13 +0200 Subject: [PATCH] step 5: add stable pan zoom and view commands --- AGENTS.md | 19 +++- README.md | 9 +- pyproject.toml | 2 + src/dpg_map/api.py | 34 ++++++-- src/dpg_map/interaction.py | 174 ++++++++++++++++++++++++++++++++++++- src/dpg_map/renderer.py | 4 +- src/dpg_map/state.py | 2 +- src/dpg_map/widget.py | 56 ++++++++++++ tests/test_interaction.py | 80 ++++++++++++++++- 9 files changed, 362 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d7ec201..00aa78e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Current status -Step 4 complete. +Step 5 complete. ## Completed steps @@ -10,10 +10,11 @@ Step 1 - Public API contract and pure core. Step 2 - Thread-safe state, commands, overlays, and cache model. Step 3 - Widget shell, sizing system, and GUI-thread frame pump. Step 4 - Tile manager, persistent cache, and asynchronous loading. +Step 5 - Interaction: pan, zoom, and view commands. ## Current step -Step 5 - Interaction: pan, zoom, and view commands. +Step 6 - Overlay rendering and runtime update stress tests. ## Design decisions @@ -68,7 +69,19 @@ None yet. - 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 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. ## Next action -Implement Step 5. +Implement Step 6. diff --git a/README.md b/README.md index 0a6558e..dbab9fd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `dpg-map` is a Dear PyGui map widget package under rebuild. -The Step 4 tile manager is in place: +The Step 5 interaction layer is in place: ```python import dpg_map as dpgm @@ -32,12 +32,13 @@ Implemented so far: - 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 +- measured-rectangle mouse interaction for left-drag panning and wheel zooming +- programmatic view commands and screen/latitude-longitude conversion helpers -Overlay drawing is not implemented yet. Step 5 will add pan/zoom interaction and -view command projection. +Overlay drawing is not implemented yet. Step 6 will add overlay rendering and +live update stress examples. Examples: diff --git a/pyproject.toml b/pyproject.toml index 039d6ee..594434e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,3 +37,5 @@ select = ["E", "F", "I", "UP", "B", "SIM"] [tool.pyright] typeCheckingMode = "basic" +venvPath = "." +venv = ".venv" diff --git a/src/dpg_map/api.py b/src/dpg_map/api.py index 05e132c..000faa4 100644 --- a/src/dpg_map/api.py +++ b/src/dpg_map/api.py @@ -11,8 +11,11 @@ from typing import Any from .cache import CacheStats, clear_disk_cache_path, disk_cache_root, disk_cache_size_bytes from .commands import CommandKind, MapCommand from .exceptions import CoordinateError, OverlayNotFoundError +from .interaction import latlon_to_screen_in_state, screen_to_latlon_in_state from .overlays import LayerState, MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay +from .projection import latlon_to_world from .providers import TileProvider, get_provider +from .sizing import effective_draw_size from .state import ( DirtyFlags, configure_state, @@ -145,19 +148,37 @@ def fit_bounds(bounds: Bounds, *, map_tag: Tag | None = None) -> None: (south_west, north_east) = bounds south, west = _validate_latlon(south_west[0], south_west[1]) north, east = _validate_latlon(north_east[0], north_east[1]) - 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: - _ = (x, y) - 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: - _validate_latlon(lat, lon) + lat_value, lon_value = _validate_latlon(lat, lon) state = get_map_state(map_tag) - with state.lock: - return (state.measured_width / 2.0, state.measured_height / 2.0) + return latlon_to_screen_in_state(state, lat_value, lon_value) def add_marker( @@ -533,5 +554,6 @@ def get_map_debug_state(*, map_tag: Tag | None = None) -> dict[str, Any]: "pending_command_count": len(state.command_queue), "generation": state.generation, "active_drag": state.interaction.active_drag, + "last_mouse_position": state.interaction.last_mouse_position, "tiles": asdict(state.tile_manager.snapshot()), } diff --git a/src/dpg_map/interaction.py b/src/dpg_map/interaction.py index 59197b2..bdffb17 100644 --- a/src/dpg_map/interaction.py +++ b/src/dpg_map/interaction.py @@ -3,9 +3,14 @@ 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 MapState +from .state import DirtyFlags, MapState, mark_dirty +from .types import LatLon, Point @dataclass(frozen=True, slots=True) @@ -34,3 +39,170 @@ def calculate_hit_rect(state: MapState, drawlist_pos: tuple[float, float]) -> Hi 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 diff --git a/src/dpg_map/renderer.py b/src/dpg_map/renderer.py index fd3892a..c16c815 100644 --- a/src/dpg_map/renderer.py +++ b/src/dpg_map/renderer.py @@ -104,7 +104,9 @@ class MapRenderer: draw_width = update.effective_width draw_height = update.effective_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: self.last_hit_rect = calculate_hit_rect(self.state, (draw_pos[0], draw_pos[1])) diff --git a/src/dpg_map/state.py b/src/dpg_map/state.py index da5a6fa..0fa5b3c 100644 --- a/src/dpg_map/state.py +++ b/src/dpg_map/state.py @@ -47,7 +47,7 @@ class DpgMapConfig: @dataclass(slots=True) class InteractionState: - """Logical interaction state until GUI interaction is implemented.""" + """Logical mouse interaction state.""" active_drag: bool = False last_mouse_position: tuple[float, float] | None = None diff --git a/src/dpg_map/widget.py b/src/dpg_map/widget.py index 870a9f7..3767dbf 100644 --- a/src/dpg_map/widget.py +++ b/src/dpg_map/widget.py @@ -7,6 +7,14 @@ from contextlib import contextmanager from pathlib import Path 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 .renderer import MapRenderer 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_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) with state.lock: diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 465b01e..2376ca4 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -1,8 +1,19 @@ 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, +) 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: @@ -17,3 +28,68 @@ def test_hit_rect_uses_effective_map_size() -> None: assert rect.height == 250 assert rect.contains(410.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_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