feat(astal-menu): pannable map with night/satellite tiles

The Location map was a fixed static image with no way to move the viewport, on the
light OSM tiles. Now:
- Drag to pan (re-renders at the new centre; the pin stays anchored to the real
  location and clamps to the edge when panned away), scroll to zoom, double-click to
  recentre. Panning/zooming coalesce into ~11 renders/sec.
- Tiles default to the dark CartoDB "night" map (fits the theme); a new "Satellite
  view" feature toggle switches to Esri World Imagery. staticmap.py gained a style
  arg + a separate marker coordinate, and caches tiles per style.

Verified: dark + satellite render, drag re-centres the view (pin goes off-centre),
and the coordinate round-trip is exact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUWCXM4KhjRkwheaA3X7bP
feat/astal-menu
Amir Alexander Abdelbaki 2026-07-06 08:59:05 +02:00
parent abea1bec43
commit e63c99f299
2 changed files with 141 additions and 27 deletions

View File

@ -1,12 +1,17 @@
#!/usr/bin/env python3
"""Render a static OpenStreetMap image centred on a coordinate, with a marker.
"""Render a static slippy-map image centred on a coordinate, with a marker.
Used by the Location quad because libshumate does not render tiles in this
environment (the official shumate-demo shows the same blank map a library/GTK
render bug), while plain tile downloads work fine. Stitches OSM raster tiles into
one PNG with Pillow and caches them.
render bug), while plain tile downloads work fine. Stitches raster tiles into one
PNG with Pillow and caches them. The quad re-renders this at a new centre to pan.
staticmap.py <lat> <lon> <zoom> <width_px> <height_px> <out_path>
staticmap.py <center_lat> <center_lon> <zoom> <w> <h> <out> \
[style] [marker_lat] [marker_lon]
style: dark (default, CartoDB dark_matter) | satellite (Esri) | standard (OSM)
marker_*: where to draw the location pin; defaults to the centre. When the centre
is panned away, the pin clamps to the nearest edge so it stays visible.
"""
from __future__ import annotations
@ -26,6 +31,13 @@ UA = "astal-menu/1.0 (personal dotfiles)"
BG = (26, 26, 26)
ACCENT = (228, 0, 70)
STYLES = {
"standard": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"dark": "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
"satellite": "https://server.arcgisonline.com/ArcGIS/rest/services/"
"World_Imagery/MapServer/tile/{z}/{y}/{x}",
}
def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]:
n = 2 ** zoom
@ -35,26 +47,30 @@ def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]:
return x * TILE, y * TILE
def _tile(z: int, x: int, y: int) -> Image.Image | None:
def _tile(z: int, x: int, y: int, style: str) -> Image.Image | None:
CACHE.mkdir(parents=True, exist_ok=True)
fp = CACHE / f"{z}_{x}_{y}.png"
fp = CACHE / f"{style}_{z}_{x}_{y}.png"
if fp.exists():
try:
return Image.open(fp).convert("RGB")
except Exception:
pass
url = f"https://tile.openstreetmap.org/{z}/{x}/{y}.png"
url = STYLES[style].format(z=z, x=x, y=y)
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
data = urllib.request.urlopen(req, timeout=8).read()
except Exception as exc:
print(f"tile {z}/{x}/{y}: {exc}", file=sys.stderr)
print(f"tile {style} {z}/{x}/{y}: {exc}", file=sys.stderr)
return None
fp.write_bytes(data)
return Image.open(io.BytesIO(data)).convert("RGB")
img = Image.open(io.BytesIO(data)).convert("RGB")
img.save(fp) # normalise to PNG in the cache (satellite is served as JPEG)
return img
def render(lat: float, lon: float, zoom: int, w: int, h: int, out: str) -> None:
def render(lat: float, lon: float, zoom: int, w: int, h: int, out: str,
style: str = "dark", marker: tuple[float, float] | None = None) -> None:
if style not in STYLES:
style = "dark"
n = 2 ** zoom
cx, cy = _center_px(lat, lon, zoom)
left, top = cx - w / 2, cy - h / 2
@ -65,18 +81,28 @@ def render(lat: float, lon: float, zoom: int, w: int, h: int, out: str) -> None:
for ty in range(y0, y1 + 1):
if ty < 0 or ty >= n:
continue
tile = _tile(zoom, tx % n, ty)
tile = _tile(zoom, tx % n, ty, style)
if tile is None:
continue
img.paste(tile, (int(tx * TILE - left), int(ty * TILE - top)))
# marker at the real location (defaults to centre); clamp to the edge so it stays
# on-screen when the view is panned away from it.
mlat, mlon = marker if marker else (lat, lon)
mwx, mwy = _center_px(mlat, mlon, zoom)
r = 8
mx = max(r, min(w - r, mwx - left))
my = max(r, min(h - r, mwy - top))
d = ImageDraw.Draw(img)
mx, my, r = w // 2, h // 2, 8
d.ellipse([mx - r, my - r, mx + r, my + r], fill=ACCENT, outline=(255, 255, 255), width=2)
Path(out).parent.mkdir(parents=True, exist_ok=True)
img.save(out)
if __name__ == "__main__":
a = sys.argv
render(float(a[1]), float(a[2]), int(a[3]), int(a[4]), int(a[5]), a[6])
style = a[7] if len(a) > 7 else "dark"
marker = (float(a[8]), float(a[9])) if len(a) > 9 else None
render(float(a[1]), float(a[2]), int(a[3]), int(a[4]), int(a[5]), a[6], style, marker)
print(a[6])

View File

@ -1,31 +1,48 @@
"""Location quad: a static OpenStreetMap image centred on the device's IP-geolocated
position, with a marker.
"""Location quad: a slippy-map image centred on the device's IP-geolocated position,
with a marker. Drag to pan, scroll to zoom, double-click to recentre.
The position comes from the shared LocationService (backend/geolocate.py), which
traceroutes to 1.1.1.1, takes the first public hop (the ISP egress) and resolves it
through a public geolocation API. The "Locate via IP" feature toggle gates that
lookup entirely; when off, this quad shows a placeholder instead.
lookup entirely; when off, this quad shows a placeholder instead. "Satellite view"
switches the tiles between the dark night map and Esri satellite imagery.
We render a static map (backend/staticmap.py) rather than an interactive libshumate
map: Shumate does not paint tiles in this environment (the official shumate-demo
shows the same blank map), while tile downloads themselves work fine. The Weather
quad consumes the same LocationService.
shows the same blank map), while tile downloads themselves work fine so panning is
done by re-rendering at a new centre. The Weather quad consumes the same service.
"""
from __future__ import annotations
import math
import sys
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
from gi.repository import Gdk, GLib, Gtk # noqa: E402
from lib.proc import run_text
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
from paths import BACKEND_DIR, CACHE_DIR
_SCRIPT = str(BACKEND_DIR / "staticmap.py")
_TILE = 256
def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]:
n = 2 ** zoom
y = (1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n
x = (lon + 180.0) / 360.0 * n
return x * _TILE, y * _TILE
def _px_latlon(px: float, py: float, zoom: int) -> tuple[float, float]:
n = 2 ** zoom
lon = px / (_TILE * n) * 360.0 - 180.0
lat = math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * py / (_TILE * n)))))
return lat, lon
class _MapView(Gtk.Box):
@ -37,6 +54,10 @@ class _MapView(Gtk.Box):
self.zoom = zoom
self.size = size
self._out = str(CACHE_DIR / f"map_{tag}.png")
self._loc: tuple[float, float] | None = None # geolocated position (marker)
self._view: tuple[float, float] | None = None # current view centre
self._drag_from: tuple[float, float] | None = None
self._render_pending = False
self.picture = Gtk.Picture(content_fit=Gtk.ContentFit.COVER, vexpand=True)
self.picture.add_css_class("map-picture")
@ -48,22 +69,88 @@ class _MapView(Gtk.Box):
self._info.add_css_class("map-info")
self.append(self._info)
self._wire_gestures()
ctx.services.location.subscribe(self._on_location)
def _style(self) -> str:
return "satellite" if self.ctx.feature("satellite", False) else "dark"
# -- data / render -----------------------------------------------------
def _on_location(self, data: dict) -> None:
lat, lon = data["lat"], data["lon"]
w, h = self.size
run_text([sys.executable, _SCRIPT, str(lat), str(lon), str(self.zoom),
str(w), str(h), self._out], self._on_rendered)
self._loc = (data["lat"], data["lon"])
self._view = (data["lat"], data["lon"]) # recentre on a fresh fix
self._render()
if self._info is not None:
city = data.get("city") or "Unknown"
country = data.get("country") or ""
self._info.set_text(f"{city}, {country} · {lat:.3f}, {lon:.3f}")
self._info.set_text(f"{city}, {country} · {data['lat']:.3f}, {data['lon']:.3f}"
f" · drag to pan · scroll to zoom · double-click to recentre")
def _render(self) -> None:
if self._view is None:
return
vlat, vlon = self._view
mlat, mlon = self._loc or self._view
w, h = self.size
run_text([sys.executable, _SCRIPT, str(vlat), str(vlon), str(self.zoom),
str(w), str(h), self._out, self._style(), str(mlat), str(mlon)],
self._on_rendered)
def _schedule_render(self) -> None:
# coalesce the flood of drag/scroll updates into ~11 renders/sec
if self._render_pending:
return
self._render_pending = True
def go() -> bool:
self._render_pending = False
self._render()
return GLib.SOURCE_REMOVE
GLib.timeout_add(90, go)
def _on_rendered(self, ok: bool, out: str, err: str) -> None:
if ok:
if not ok:
return
# load a fresh texture; set_filename would ignore an unchanged path on re-pan
try:
self.picture.set_paintable(Gdk.Texture.new_from_filename(self._out))
except GLib.Error:
self.picture.set_filename(self._out)
# -- gestures: drag to pan, scroll to zoom, double-click to recentre ----
def _wire_gestures(self) -> None:
drag = Gtk.GestureDrag()
drag.connect("drag-begin", lambda *_a: setattr(self, "_drag_from", self._view))
drag.connect("drag-update", self._on_drag)
self.picture.add_controller(drag)
scroll = Gtk.EventControllerScroll(
flags=Gtk.EventControllerScrollFlags.VERTICAL)
scroll.connect("scroll", self._on_scroll)
self.picture.add_controller(scroll)
click = Gtk.GestureClick()
click.connect("pressed", self._on_click)
self.picture.add_controller(click)
def _on_drag(self, _gesture, ox: float, oy: float) -> None:
if self._drag_from is None:
return
cx, cy = _center_px(self._drag_from[0], self._drag_from[1], self.zoom)
# drag right → the view centre moves left (map content follows the cursor)
self._view = _px_latlon(cx - ox, cy - oy, self.zoom)
self._schedule_render()
def _on_scroll(self, _controller, _dx: float, dy: float) -> bool:
self.zoom = max(3, min(18, self.zoom + (1 if dy < 0 else -1)))
self._schedule_render()
return True
def _on_click(self, _gesture, n_press: int, _x: float, _y: float) -> None:
if n_press >= 2 and self._loc is not None:
self._view = self._loc
self._schedule_render()
def _placeholder() -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8,
@ -90,5 +177,6 @@ SPEC = ModuleSpec(
title="Location",
icon="", # nf-fa-map_marker
build=build,
features=[Feature("ip_locate", "Locate via IP", True)],
features=[Feature("ip_locate", "Locate via IP", True),
Feature("satellite", "Satellite view", False)],
)