step 5: add stable pan zoom and view commands
This commit is contained in:
@@ -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()),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user