diff --git a/desktopenvs/hyprlua/astal-menu/backend/staticmap.py b/desktopenvs/hyprlua/astal-menu/backend/staticmap.py index 988ada5..6830ad9 100755 --- a/desktopenvs/hyprlua/astal-menu/backend/staticmap.py +++ b/desktopenvs/hyprlua/astal-menu/backend/staticmap.py @@ -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 + staticmap.py \ + [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]) diff --git a/desktopenvs/hyprlua/astal-menu/modules/location.py b/desktopenvs/hyprlua/astal-menu/modules/location.py index 0a6eeba..c0387bc 100644 --- a/desktopenvs/hyprlua/astal-menu/modules/location.py +++ b/desktopenvs/hyprlua/astal-menu/modules/location.py @@ -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)], )