step 5: add stable pan zoom and view commands
This commit is contained in:
19
AGENTS.md
19
AGENTS.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
Step 4 complete.
|
Step 5 complete.
|
||||||
|
|
||||||
## Completed steps
|
## 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 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.
|
||||||
|
|
||||||
## Current step
|
## Current step
|
||||||
|
|
||||||
Step 5 - Interaction: pan, zoom, and view commands.
|
Step 6 - Overlay rendering and runtime update stress tests.
|
||||||
|
|
||||||
## Design decisions
|
## Design decisions
|
||||||
|
|
||||||
@@ -68,7 +69,19 @@ 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.
|
||||||
|
|
||||||
## Next action
|
## Next action
|
||||||
|
|
||||||
Implement Step 5.
|
Implement Step 6.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
`dpg-map` is a Dear PyGui map widget package under rebuild.
|
`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
|
```python
|
||||||
import dpg_map as dpgm
|
import dpg_map as dpgm
|
||||||
@@ -32,12 +32,13 @@ Implemented so far:
|
|||||||
- Dear PyGui `child_window` + measured-size `drawlist` widget shell
|
- Dear PyGui `child_window` + measured-size `drawlist` widget shell
|
||||||
- GUI-thread frame pump that drains commands, manages textures, and draws raster tiles
|
- 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
|
- 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
|
- asynchronous tile workers that read disk cache, fetch HTTP, and decode images
|
||||||
- memory cache with visible-tile protection and GUI-thread texture deletion
|
- 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
|
Overlay drawing is not implemented yet. Step 6 will add overlay rendering and
|
||||||
view command projection.
|
live update stress examples.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
|
|||||||
@@ -37,3 +37,5 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
|
|||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
typeCheckingMode = "basic"
|
typeCheckingMode = "basic"
|
||||||
|
venvPath = "."
|
||||||
|
venv = ".venv"
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ 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, 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,
|
||||||
@@ -145,19 +148,37 @@ def fit_bounds(bounds: Bounds, *, map_tag: Tag | None = None) -> None:
|
|||||||
(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)
|
state = get_map_state(map_tag)
|
||||||
return get_center(map_tag=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)
|
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(
|
||||||
@@ -533,5 +554,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()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,170 @@ 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 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
|
||||||
|
|||||||
@@ -104,7 +104,9 @@ 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]))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
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,
|
||||||
|
)
|
||||||
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 +28,68 @@ 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_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
|
||||||
|
|||||||
Reference in New Issue
Block a user