Skip to content

Instantly share code, notes, and snippets.

@jdboachie
Created August 18, 2025 13:55
Show Gist options
  • Save jdboachie/1b19a1e0d4ece9a2c06a683ee8235260 to your computer and use it in GitHub Desktop.
Save jdboachie/1b19a1e0d4ece9a2c06a683ee8235260 to your computer and use it in GitHub Desktop.
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