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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -37,3 +37,5 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
|
||||
[tool.pyright]
|
||||
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 .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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user