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