Created
August 18, 2025 13:55
-
-
Save jdboachie/1b19a1e0d4ece9a2c06a683ee8235260 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from __future__ import annotations | |
import contextlib | |
import math | |
from dataclasses import dataclass | |
from PySide6.QtCore import ( | |
QEasingCurve, | |
QPointF, | |
QRect, | |
QRectF, | |
QSize, | |
QStandardPaths, | |
Qt, | |
QTimeLine, | |
QUrl, | |
) | |
from PySide6.QtGui import ( | |
QColor, | |
QFont, | |
QMouseEvent, | |
QPainter, | |
QPaintEvent, | |
QPen, | |
QPixmap, | |
QResizeEvent, | |
QWheelEvent, | |
) | |
from PySide6.QtNetwork import ( | |
QNetworkAccessManager, | |
QNetworkDiskCache, | |
QNetworkReply, | |
QNetworkRequest, | |
) | |
from PySide6.QtWidgets import ( | |
QApplication, | |
QButtonGroup, | |
QComboBox, | |
QToolButton, | |
QWidget, | |
) | |
TILE_SIZE = 256 | |
MIN_ZOOM = 0 | |
MAX_ZOOM = 19 | |
# Default OSM template (fallback) | |
OSM_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" | |
def _world_size(zoom: int) -> int: | |
return TILE_SIZE * (1 << zoom) | |
def lonlat_to_pixels(lon: float, lat: float, zoom: int) -> tuple[float, float]: | |
lat = max(min(lat, 85.05112878), -85.05112878) | |
s = _world_size(zoom) | |
x = (lon + 180.0) / 360.0 * s | |
sin_lat = math.sin(math.radians(lat)) | |
y = (0.5 - math.log((1 + sin_lat) / (1 - sin_lat)) / (4 * math.pi)) * s | |
return x, y | |
def pixels_to_lonlat(x: float, y: float, zoom: int) -> tuple[float, float]: | |
s = _world_size(zoom) | |
lon = x / s * 360.0 - 180.0 | |
n = math.pi - 2.0 * math.pi * y / s | |
lat = math.degrees(math.atan(math.sinh(n))) | |
return lon, lat | |
def clamp_zoom(z: int) -> int: | |
return max(MIN_ZOOM, min(MAX_ZOOM, z)) | |
@dataclass(frozen=True) | |
class TileKey: | |
z: int | |
x: int | |
y: int | |
@dataclass | |
class Marker: | |
lon: float | |
lat: float | |
label: str | None = None | |
color: QColor = QColor(220, 46, 46) | |
radius: int = 6 | |
class MapWidget(QWidget): | |
"""Simple OSM raster tile map widget with pan and zoom""" | |
def __init__(self, parent: QWidget | None = None) -> None: | |
super().__init__(parent) | |
self.setMouseTracking(True) | |
self.setMinimumSize(QSize(320, 240)) | |
# View state | |
self._zoom: int = 3 | |
self._center_lon: float = 0.0 | |
self._center_lat: float = 0.0 | |
self._panning: bool = False | |
self._last_mouse_pos: QPointF = QPointF() | |
self._drag_offset_px: QPointF = QPointF(0.0, 0.0) | |
# Zoom animation state | |
self._zoom_anim_active: bool = False | |
self._zoom_anim_anchor: QPointF = QPointF(0.0, 0.0) | |
self._zoom_anim_scale: float = 1.0 | |
self._zoom_anim_target_step: int = 0 # +1 or -1 | |
self._zoom_anim = QTimeLine(180, self) # ms | |
self._zoom_anim.setFrameRange(0, 100) | |
# Prefer easing via API when available | |
with contextlib.suppress(Exception): | |
self._zoom_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.InOutCubic)) | |
# Prefer eased valueChanged; fallback to frameChanged | |
_connected = False | |
try: | |
self._zoom_anim.valueChanged.connect(self._on_zoom_anim_value) # type: ignore[attr-defined] | |
_connected = True | |
except Exception: | |
pass | |
if not _connected: | |
self._zoom_anim.frameChanged.connect(self._on_zoom_anim_frame) | |
self._zoom_anim.finished.connect(self._on_zoom_anim_finished) | |
# Networking and caching | |
self._nam = QNetworkAccessManager(self) | |
self._nam.finished.connect(self._on_reply) | |
cache_location = QStandardPaths.writableLocation( | |
QStandardPaths.StandardLocation.CacheLocation | |
) | |
disk_cache = QNetworkDiskCache(self) | |
disk_cache.setCacheDirectory(cache_location + "/osm_tiles") | |
disk_cache.setMaximumCacheSize(512 * 1024 * 1024) # 512 MB | |
self._nam.setCache(disk_cache) | |
self._tile_cache: dict[TileKey, QPixmap] = {} | |
self._loading: set[TileKey] = set() | |
self._inflight: int = 0 | |
self._max_inflight: int = 8 | |
self._queue: set[TileKey] = set() | |
# Markers | |
self._markers: list[Marker] = [] | |
# Styling | |
self._bg_color = QColor(235, 235, 235) | |
self._grid_pen = QPen(QColor(0, 0, 0, 25)) | |
self._grid_pen.setWidth(1) | |
# Identify ourselves to tile servers | |
self._user_agent = b"sig-vault/0.1 (+https://github.com/Soko-Aerial/sig-vault)" | |
# Tile source | |
self._tile_url_template: str = OSM_TILE_URL | |
self._tile_source: str = "osm" # or 'mapbox' | |
self._mapbox_token: str | None = None | |
self._mapbox_style: str = "mapbox/streets-v12" | |
self._retina_tiles: bool = self.devicePixelRatioF() >= 1.5 | |
# UI controls | |
self._zoom_in_btn = QToolButton(self) | |
self._zoom_in_btn.setText("\ue710") | |
self._zoom_in_btn.setToolTip("Zoom in") | |
self._zoom_in_btn.setFixedSize(28, 28) | |
self._zoom_in_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) | |
self._zoom_out_btn = QToolButton(self) | |
self._zoom_out_btn.setText("\ue738") | |
self._zoom_out_btn.setToolTip("Zoom out") | |
self._zoom_out_btn.setFixedSize(28, 28) | |
self._zoom_out_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) | |
self._style_combo = QComboBox(self) | |
self._style_combo.addItems(["Street", "Satellite"]) | |
self._style_combo.setCurrentIndex(0) | |
self._style_combo.setMinimumWidth(80) | |
self._style_combo.activated.connect(self._on_style_changed) | |
self._zoom_group = QButtonGroup(self) | |
# Style zoom buttons (appearance, cursor, font) | |
zoom_font = QFont("Segoe MDL2 Assets") | |
for btn in (self._zoom_in_btn, self._zoom_out_btn): | |
# btn.setAutoRaise(True) | |
btn.setFont(zoom_font) | |
btn.setCursor(Qt.CursorShape.PointingHandCursor) | |
self._zoom_group.setExclusive(False) | |
self._zoom_group.addButton(self._zoom_in_btn, 1) | |
self._zoom_group.addButton(self._zoom_out_btn, -1) | |
self._zoom_group.idClicked.connect(self._on_zoom_group_clicked) | |
# Controls placement defaults | |
self._controls_alignment: Qt.AlignmentFlag = ( | |
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop | |
) | |
self._controls_margin: int = 8 | |
self._controls_spacing: int = 6 | |
self._position_controls() | |
# Marker API | |
def addMarker( | |
self, | |
lon: float, | |
lat: float, | |
label: str | None = None, | |
color: QColor | None = None, | |
radius: int = 6, | |
) -> None: | |
self._markers.append( | |
Marker(lon, lat, label, color or QColor(220, 46, 46), radius) | |
) | |
self.update() | |
def clearMarkers(self) -> None: | |
self._markers.clear() | |
self.update() | |
# Public API | |
def setCenter(self, lon: float, lat: float) -> None: | |
self._center_lon = float(lon) | |
self._center_lat = float(lat) | |
self.update() | |
def setZoom(self, zoom: int) -> None: | |
z = clamp_zoom(int(zoom)) | |
if z != self._zoom: | |
self._zoom = z | |
self.update() | |
def _on_zoom_group_clicked(self, btn_id: int) -> None: | |
# Zoom step: positive id -> zoom in, negative -> zoom out | |
step = 1 if btn_id > 0 else -1 | |
self._start_zoom_anim(QPointF(self.width() / 2, self.height() / 2), step) | |
def center(self) -> tuple[float, float]: | |
return self._center_lon, self._center_lat | |
def zoom(self) -> int: | |
return self._zoom | |
# Tile sources | |
def setTileSourceOSM(self) -> None: | |
"""Use OpenStreetMap raster tiles (default).""" | |
self._tile_source = "osm" | |
self._tile_url_template = OSM_TILE_URL | |
self._tile_cache.clear() | |
self.update() | |
def setTileSourceMapbox( | |
self, | |
token: str, | |
style: str = "mapbox/streets-v12", | |
tile_size: int = 256, | |
retina: bool | None = None, | |
) -> None: | |
"""Use Mapbox raster tiles from a style. | |
token: your public Mapbox access token | |
style: e.g., 'mapbox/streets-v12', 'mapbox/satellite-v9' | |
tile_size: 256 or 512 (widget currently assumes 256px tiles) | |
retina: when True, requests @2x tiles (recommended for HiDPI). If None, auto-detect from display DPI. | |
""" | |
# Use 256 logical px with optional @2x for sharp rendering | |
tile_size = 256 | |
self._tile_source = "mapbox" | |
self._mapbox_token = token | |
self._mapbox_style = style | |
# Auto-select retina if not explicitly provided | |
self._retina_tiles = self._retina_tiles if retina is None else bool(retina) | |
scale_suffix = "@2x" if self._retina_tiles else "" | |
self._tile_url_template = f"https://api.mapbox.com/styles/v1/{style}/tiles/{tile_size}/{{z}}/{{x}}/{{y}}{scale_suffix}?access_token={token}" | |
self._tile_cache.clear() | |
self.update() | |
def setMapboxStyle(self, style: str) -> None: | |
"""Switch Mapbox style if Mapbox tiles are active; preserves token/retina settings.""" | |
if self._tile_source != "mapbox" or not self._mapbox_token: | |
return | |
self.setTileSourceMapbox( | |
self._mapbox_token, | |
style=style, | |
tile_size=256, | |
retina=self._retina_tiles, | |
) | |
# Events | |
def paintEvent(self, event: QPaintEvent) -> None: | |
painter = QPainter(self) | |
painter.fillRect(self.rect(), self._bg_color) | |
view_rect = self.rect() | |
z = self._zoom | |
# Apply zoom animation transform (scales map content around anchor) | |
if self._zoom_anim_active: | |
painter.save() | |
a = self._zoom_anim_anchor | |
s = self._zoom_anim_scale | |
painter.translate(a) | |
painter.scale(s, s) | |
painter.translate(-a) | |
cx, cy = lonlat_to_pixels(self._center_lon, self._center_lat, z) | |
cx -= self._drag_offset_px.x() | |
cy -= self._drag_offset_px.y() | |
top_left_world_x = cx - view_rect.width() / 2 | |
top_left_world_y = cy - view_rect.height() / 2 | |
# Determine tile index ranges to cover viewport (+1 padding) | |
first_tx = math.floor(top_left_world_x / TILE_SIZE) | |
first_ty = math.floor(top_left_world_y / TILE_SIZE) | |
tiles_x = math.ceil((view_rect.width()) / TILE_SIZE) + 2 | |
tiles_y = math.ceil((view_rect.height()) / TILE_SIZE) + 2 | |
for dy in range(tiles_y): | |
for dx in range(tiles_x): | |
tx = first_tx + dx | |
ty = first_ty + dy | |
# wrap x horizontally | |
wrapped_tx = tx % (1 << z) | |
# clamp y vertically | |
if ty < 0 or ty >= (1 << z): | |
continue | |
key = TileKey(z, wrapped_tx, ty) | |
# screen position where this tile's top-left should be drawn | |
tile_px = tx * TILE_SIZE - top_left_world_x | |
tile_py = ty * TILE_SIZE - top_left_world_y | |
dest = QRectF(tile_px, tile_py, TILE_SIZE, TILE_SIZE) | |
pix = self._tile_cache.get(key) | |
if pix is None: | |
self._request_tile(key) | |
# placeholder | |
painter.fillRect(dest, QColor(220, 220, 220)) | |
else: | |
painter.drawPixmap(dest.toRect(), pix) | |
# optional subtle grid overlay | |
painter.setPen(self._grid_pen) | |
painter.drawRect(dest) | |
# Draw markers over tiles | |
if self._markers: | |
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) | |
for m in self._markers: | |
mx, my = lonlat_to_pixels(m.lon, m.lat, z) | |
# apply view transform | |
sx = mx - top_left_world_x | |
sy = my - top_left_world_y | |
# Only render if within expanded bounds | |
if ( | |
-TILE_SIZE <= sx <= view_rect.width() + TILE_SIZE | |
and -TILE_SIZE <= sy <= view_rect.height() + TILE_SIZE | |
): | |
painter.setPen(QPen(QColor(255, 255, 255), 2)) | |
painter.setBrush(m.color) | |
painter.drawEllipse(QPointF(sx, sy), m.radius, m.radius) | |
if m.label: | |
painter.setPen(QColor(20, 20, 20)) | |
painter.drawText( | |
QRect(int(sx) + m.radius + 4, int(sy) - 12, 200, 24), | |
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, | |
m.label, | |
) | |
# Restore after animated transform so overlays are not scaled | |
if self._zoom_anim_active: | |
painter.restore() | |
# Overlay text with center and zoom (bottom-left) | |
painter.setPen(QColor(0, 0, 0)) | |
info = f"Zoom: {z} Center: {self._center_lat:.5f}, {self._center_lon:.5f}" | |
text_h = 24 | |
painter.drawText( | |
QRect(8, self.height() - 8 - text_h, self.width() - 16, text_h), | |
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, | |
info, | |
) | |
def resizeEvent(self, event: QResizeEvent) -> None: | |
super().resizeEvent(event) | |
self._position_controls() | |
def mousePressEvent(self, event: QMouseEvent) -> None: | |
if event.button() == Qt.MouseButton.LeftButton: | |
# If clicking on a marker, trigger marker click handler | |
hit = self._marker_at(event.position()) | |
if hit is not None: | |
# Simple click action for now | |
print("hello from map") | |
event.accept() | |
return | |
# Otherwise begin panning | |
self._panning = True | |
self._last_mouse_pos = event.position() | |
self.setCursor(Qt.CursorShape.ClosedHandCursor) | |
elif event.button() == Qt.MouseButton.RightButton: | |
# Drop marker at clicked geographic location | |
lon, lat = self._screen_to_lonlat(event.position()) | |
self.addMarker(lon, lat) | |
event.accept() | |
def mouseMoveEvent(self, event: QMouseEvent) -> None: | |
if self._panning: | |
delta = event.position() - self._last_mouse_pos | |
self._last_mouse_pos = event.position() | |
self._drag_offset_px += QPointF(delta) | |
self.update() | |
def mouseReleaseEvent(self, event: QMouseEvent) -> None: | |
if event.button() == Qt.MouseButton.LeftButton and self._panning: | |
self._panning = False | |
self.setCursor(Qt.CursorShape.ArrowCursor) | |
# Commit drag offset to center | |
if self._drag_offset_px.manhattanLength() != 0: | |
cx, cy = lonlat_to_pixels( | |
self._center_lon, self._center_lat, self._zoom | |
) | |
cx -= self._drag_offset_px.x() | |
cy -= self._drag_offset_px.y() | |
self._drag_offset_px = QPointF(0.0, 0.0) | |
# wrap horizontally | |
s = _world_size(self._zoom) | |
cx = (cx % s + s) % s | |
# clamp vertically | |
cy = max(0.0, min(float(s - 1), cy)) | |
self._center_lon, self._center_lat = pixels_to_lonlat( | |
cx, cy, self._zoom | |
) | |
self.update() | |
def _zoom_at(self, mouse_pos: QPointF, steps: int) -> None: | |
new_zoom = clamp_zoom(self._zoom + steps) | |
if new_zoom == self._zoom: | |
return | |
view_rect = self.rect() | |
# world coordinates at current zoom under the mouse | |
cx, cy = lonlat_to_pixels(self._center_lon, self._center_lat, self._zoom) | |
cx -= self._drag_offset_px.x() | |
cy -= self._drag_offset_px.y() | |
top_left_world_x = cx - view_rect.width() / 2 | |
top_left_world_y = cy - view_rect.height() / 2 | |
world_x = top_left_world_x + mouse_pos.x() | |
world_y = top_left_world_y + mouse_pos.y() | |
# convert that world point to lon/lat at old zoom, then to pixels at new zoom | |
lon, lat = pixels_to_lonlat(world_x, world_y, self._zoom) | |
new_cx, new_cy = lonlat_to_pixels(lon, lat, new_zoom) | |
# new center so that mouse stays over same geographic point | |
new_top_left_world_x = new_cx - mouse_pos.x() | |
new_top_left_world_y = new_cy - mouse_pos.y() | |
target_cx = new_top_left_world_x + view_rect.width() / 2 | |
target_cy = new_top_left_world_y + view_rect.height() / 2 | |
self._zoom = new_zoom | |
self._center_lon, self._center_lat = pixels_to_lonlat( | |
target_cx, target_cy, self._zoom | |
) | |
self._drag_offset_px = QPointF(0.0, 0.0) | |
self.update() | |
def wheelEvent(self, event: QWheelEvent) -> None: | |
# Zoom relative to mouse position | |
angle = event.angleDelta().y() | |
if angle == 0: | |
return | |
steps = 1 if angle > 0 else -1 | |
self._start_zoom_anim(event.position(), steps) | |
def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: | |
if event.button() == Qt.MouseButton.LeftButton: | |
# quick zoom in at cursor | |
self._start_zoom_anim(event.position(), 1) | |
# Networking | |
def _request_tile(self, key: TileKey) -> None: | |
if key in self._tile_cache or key in self._loading: | |
return | |
if self._inflight >= self._max_inflight: | |
self._queue.add(key) | |
return | |
self._loading.add(key) | |
self._inflight += 1 | |
url = QUrl(self._tile_url_template.format(z=key.z, x=key.x, y=key.y)) | |
req = QNetworkRequest(url) | |
# Set User-Agent using raw header to avoid QVariant type issues | |
req.setRawHeader(b"User-Agent", self._user_agent) | |
req.setAttribute( | |
QNetworkRequest.Attribute.CacheLoadControlAttribute, | |
QNetworkRequest.CacheLoadControl.PreferCache, | |
) | |
self._nam.get(req).setProperty("tile_key", (key.z, key.x, key.y)) | |
def _on_reply(self, reply) -> None: | |
props = reply.property("tile_key") | |
try: | |
z, x, y = props | |
key = TileKey(int(z), int(x), int(y)) | |
except Exception: | |
reply.deleteLater() | |
return | |
data = reply.readAll() | |
# Robust error check across PySide6 enum variants | |
try: | |
no_error = QNetworkReply.NetworkError.NoError # Qt6 style | |
except AttributeError: | |
no_error = getattr(QNetworkReply, "NoError", 0) # fallback | |
if reply.error() == no_error and not data.isEmpty(): | |
pix = QPixmap() | |
if pix.loadFromData(bytes(data)): | |
# If using retina tiles, set devicePixelRatio to avoid unnecessary resampling | |
if ( | |
self._tile_source == "mapbox" | |
and self._retina_tiles | |
and pix.width() > TILE_SIZE | |
): | |
try: | |
ratio = max(1.0, pix.width() / float(TILE_SIZE)) | |
pix.setDevicePixelRatio(ratio) | |
except Exception: | |
pass | |
self._tile_cache[key] = pix | |
# cleanup loading state | |
self._loading.discard(key) | |
self._inflight = max(0, self._inflight - 1) | |
reply.deleteLater() | |
# Dequeue next | |
if self._queue and self._inflight < self._max_inflight: | |
next_key = self._queue.pop() | |
self._request_tile(next_key) | |
# trigger repaint if tile is visible | |
self.update() | |
# Helpers | |
def _position_controls(self) -> None: | |
margin = self._controls_margin | |
spacing = self._controls_spacing | |
col_w = max( | |
self._zoom_in_btn.width(), | |
self._zoom_out_btn.width(), | |
self._style_combo.width(), | |
) | |
btn_h_in = self._zoom_in_btn.height() | |
btn_h_out = self._zoom_out_btn.height() | |
combo_h = self._style_combo.height() | |
total_h = combo_h + spacing + btn_h_in + spacing + btn_h_out | |
# Horizontal placement | |
if self._controls_alignment & Qt.AlignmentFlag.AlignRight: | |
x = self.width() - margin - col_w | |
else: # default to left | |
x = margin | |
# Vertical placement | |
if self._controls_alignment & Qt.AlignmentFlag.AlignBottom: | |
y_start = self.height() - margin - total_h | |
else: # default to top | |
y_start = margin | |
# Place: style combo on top of the column, then + and - | |
self._style_combo.move(int(x), int(y_start)) | |
y_next = y_start + combo_h + spacing | |
self._zoom_in_btn.move(int(x), int(y_next)) | |
y_next += btn_h_in + spacing | |
self._zoom_out_btn.move(int(x), int(y_next)) | |
# Public API for controls placement | |
def setControlsAlignment(self, alignment: Qt.AlignmentFlag | int) -> None: | |
"""Set overlay controls alignment using Qt.Alignment flags. | |
Example: AlignLeft|AlignBottom (default), AlignRight|AlignTop, etc. | |
""" | |
# Normalize to AlignmentFlag bitset | |
if isinstance(alignment, int): # Qt.Alignment is int-like | |
self._controls_alignment = Qt.AlignmentFlag(alignment) | |
else: | |
self._controls_alignment = alignment | |
self._position_controls() | |
def setControlsMargin(self, margin: int) -> None: | |
self._controls_margin = max(0, int(margin)) | |
self._position_controls() | |
def setControlsSpacing(self, spacing: int) -> None: | |
self._controls_spacing = max(0, int(spacing)) | |
self._position_controls() | |
def _on_style_changed(self, index: int) -> None: | |
# Only acts when Mapbox is active and token present | |
if self._tile_source != "mapbox" or not self._mapbox_token: | |
return | |
if index == 0: # Streets | |
self.setMapboxStyle("mapbox/streets-v12") | |
else: | |
self.setMapboxStyle("mapbox/satellite-streets-v12") | |
def _screen_to_lonlat(self, p: QPointF) -> tuple[float, float]: | |
view_rect = self.rect() | |
cx, cy = lonlat_to_pixels(self._center_lon, self._center_lat, self._zoom) | |
cx -= self._drag_offset_px.x() | |
cy -= self._drag_offset_px.y() | |
top_left_world_x = cx - view_rect.width() / 2 | |
top_left_world_y = cy - view_rect.height() / 2 | |
world_x = top_left_world_x + p.x() | |
world_y = top_left_world_y + p.y() | |
return pixels_to_lonlat(world_x, world_y, self._zoom) | |
def _marker_at(self, p: QPointF) -> Marker | None: | |
"""Return the marker under the given screen position, if any.""" | |
if not self._markers: | |
return None | |
view_rect = self.rect() | |
z = self._zoom | |
cx, cy = lonlat_to_pixels(self._center_lon, self._center_lat, z) | |
cx -= self._drag_offset_px.x() | |
cy -= self._drag_offset_px.y() | |
top_left_world_x = cx - view_rect.width() / 2 | |
top_left_world_y = cy - view_rect.height() / 2 | |
# Iterate in reverse so later-added markers get priority if overlapping | |
for m in reversed(self._markers): | |
mx, my = lonlat_to_pixels(m.lon, m.lat, z) | |
sx = mx - top_left_world_x | |
sy = my - top_left_world_y | |
dx = sx - p.x() | |
dy = sy - p.y() | |
# Small tolerance around visual radius for easier clicking | |
if (dx * dx + dy * dy) <= (m.radius + 4) * (m.radius + 4): | |
return m | |
return None | |
# Zoom animation helpers | |
def _start_zoom_anim(self, anchor: QPointF, step: int) -> None: | |
if step == 0: | |
return | |
# Respect zoom bounds | |
if (step > 0 and self._zoom >= MAX_ZOOM) or ( | |
step < 0 and self._zoom <= MIN_ZOOM | |
): | |
return | |
# Cancel any ongoing animation | |
if self._zoom_anim.state() != QTimeLine.State.NotRunning: | |
with contextlib.suppress(Exception): | |
self._zoom_anim.stop() | |
self._zoom_anim_active = True | |
self._zoom_anim_anchor = QPointF(anchor) | |
self._zoom_anim_target_step = 1 if step > 0 else -1 | |
self._zoom_anim_scale = 1.0 | |
# Prepare and start | |
self._zoom_anim.setCurrentTime(0) | |
self._zoom_anim.start() | |
self.update() | |
def _on_zoom_anim_frame(self, frame: int) -> None: | |
# frame in [0..100] | |
t = max(0.0, min(1.0, frame / 100.0)) | |
try: | |
easing = QEasingCurve(QEasingCurve.Type.InOutCubic) | |
t = float(easing.valueForProgress(t)) | |
except Exception: | |
pass | |
target = 2.0 if self._zoom_anim_target_step > 0 else 0.5 | |
self._zoom_anim_scale = 1.0 + (target - 1.0) * t | |
self.update() | |
def _on_zoom_anim_value(self, value: float) -> None: | |
# value is already eased [0..1] when easing is set on QTimeLine | |
t = max(0.0, min(1.0, float(value))) | |
target = 2.0 if self._zoom_anim_target_step > 0 else 0.5 | |
self._zoom_anim_scale = 1.0 + (target - 1.0) * t | |
self.update() | |
def _on_zoom_anim_finished(self) -> None: | |
# Commit the zoom step at the anchor position | |
anchor = QPointF(self._zoom_anim_anchor) | |
step = self._zoom_anim_target_step | |
self._zoom_anim_active = False | |
self._zoom_anim_scale = 1.0 | |
self._zoom_anim_target_step = 0 | |
self._zoom_at(anchor, step) | |
if __name__ == "__main__": | |
import sys | |
app = QApplication(sys.argv) | |
font = QFont() | |
font.setPixelSize(13) | |
app.setFont(font) | |
w = MapWidget() | |
w.setCenter(0, 0) | |
w.setZoom(5) | |
w.setTileSourceMapbox( | |
token="pk.eyJ1IjoianVkZWJvYWNoaWUiLCJhIjoiY200OXE1dTVwMGRqMzJxczZraDVibjljbiJ9.JRwfJ6Chb1S0lN3mUidfuA", | |
style="mapbox/streets-v12", | |
tile_size=256, | |
retina=False, | |
) | |
w.resize(800, 600) | |
w.setWindowTitle("Map") | |
w.show() | |
sys.exit(app.exec()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment