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_01XUWCXM4KhjRkwheaA3X7bPfeat/astal-menu
parent
abea1bec43
commit
e63c99f299
|
|
@ -1,12 +1,17 @@
|
||||||
#!/usr/bin/env python3
|
#!/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
|
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
|
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
|
render bug), while plain tile downloads work fine. Stitches raster tiles into one
|
||||||
one PNG with Pillow and caches them.
|
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
|
from __future__ import annotations
|
||||||
|
|
@ -26,6 +31,13 @@ UA = "astal-menu/1.0 (personal dotfiles)"
|
||||||
BG = (26, 26, 26)
|
BG = (26, 26, 26)
|
||||||
ACCENT = (228, 0, 70)
|
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]:
|
def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]:
|
||||||
n = 2 ** zoom
|
n = 2 ** zoom
|
||||||
|
|
@ -35,26 +47,30 @@ def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]:
|
||||||
return x * TILE, y * TILE
|
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)
|
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():
|
if fp.exists():
|
||||||
try:
|
try:
|
||||||
return Image.open(fp).convert("RGB")
|
return Image.open(fp).convert("RGB")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
url = f"https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url = STYLES[style].format(z=z, x=x, y=y)
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||||
data = urllib.request.urlopen(req, timeout=8).read()
|
data = urllib.request.urlopen(req, timeout=8).read()
|
||||||
except Exception as exc:
|
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
|
return None
|
||||||
fp.write_bytes(data)
|
img = Image.open(io.BytesIO(data)).convert("RGB")
|
||||||
return 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
|
n = 2 ** zoom
|
||||||
cx, cy = _center_px(lat, lon, zoom)
|
cx, cy = _center_px(lat, lon, zoom)
|
||||||
left, top = cx - w / 2, cy - h / 2
|
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):
|
for ty in range(y0, y1 + 1):
|
||||||
if ty < 0 or ty >= n:
|
if ty < 0 or ty >= n:
|
||||||
continue
|
continue
|
||||||
tile = _tile(zoom, tx % n, ty)
|
tile = _tile(zoom, tx % n, ty, style)
|
||||||
if tile is None:
|
if tile is None:
|
||||||
continue
|
continue
|
||||||
img.paste(tile, (int(tx * TILE - left), int(ty * TILE - top)))
|
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)
|
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)
|
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)
|
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
||||||
img.save(out)
|
img.save(out)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
a = sys.argv
|
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])
|
print(a[6])
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,48 @@
|
||||||
"""Location quad: a static OpenStreetMap image centred on the device's IP-geolocated
|
"""Location quad: a slippy-map image centred on the device's IP-geolocated position,
|
||||||
position, with a marker.
|
with a marker. Drag to pan, scroll to zoom, double-click to recentre.
|
||||||
|
|
||||||
The position comes from the shared LocationService (backend/geolocate.py), which
|
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
|
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
|
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
|
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
|
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
|
shows the same blank map), while tile downloads themselves work fine — so panning is
|
||||||
quad consumes the same LocationService.
|
done by re-rendering at a new centre. The Weather quad consumes the same service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
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 lib.proc import run_text
|
||||||
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
|
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
|
||||||
from paths import BACKEND_DIR, CACHE_DIR
|
from paths import BACKEND_DIR, CACHE_DIR
|
||||||
|
|
||||||
_SCRIPT = str(BACKEND_DIR / "staticmap.py")
|
_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):
|
class _MapView(Gtk.Box):
|
||||||
|
|
@ -37,6 +54,10 @@ class _MapView(Gtk.Box):
|
||||||
self.zoom = zoom
|
self.zoom = zoom
|
||||||
self.size = size
|
self.size = size
|
||||||
self._out = str(CACHE_DIR / f"map_{tag}.png")
|
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 = Gtk.Picture(content_fit=Gtk.ContentFit.COVER, vexpand=True)
|
||||||
self.picture.add_css_class("map-picture")
|
self.picture.add_css_class("map-picture")
|
||||||
|
|
@ -48,22 +69,88 @@ class _MapView(Gtk.Box):
|
||||||
self._info.add_css_class("map-info")
|
self._info.add_css_class("map-info")
|
||||||
self.append(self._info)
|
self.append(self._info)
|
||||||
|
|
||||||
|
self._wire_gestures()
|
||||||
ctx.services.location.subscribe(self._on_location)
|
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:
|
def _on_location(self, data: dict) -> None:
|
||||||
lat, lon = data["lat"], data["lon"]
|
self._loc = (data["lat"], data["lon"])
|
||||||
w, h = self.size
|
self._view = (data["lat"], data["lon"]) # recentre on a fresh fix
|
||||||
run_text([sys.executable, _SCRIPT, str(lat), str(lon), str(self.zoom),
|
self._render()
|
||||||
str(w), str(h), self._out], self._on_rendered)
|
|
||||||
if self._info is not None:
|
if self._info is not None:
|
||||||
city = data.get("city") or "Unknown"
|
city = data.get("city") or "Unknown"
|
||||||
country = data.get("country") or ""
|
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:
|
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)
|
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:
|
def _placeholder() -> Gtk.Widget:
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8,
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8,
|
||||||
|
|
@ -90,5 +177,6 @@ SPEC = ModuleSpec(
|
||||||
title="Location",
|
title="Location",
|
||||||
icon="", # nf-fa-map_marker
|
icon="", # nf-fa-map_marker
|
||||||
build=build,
|
build=build,
|
||||||
features=[Feature("ip_locate", "Locate via IP", True)],
|
features=[Feature("ip_locate", "Locate via IP", True),
|
||||||
|
Feature("satellite", "Satellite view", False)],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue