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
|
||||
"""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])
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue