"""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. "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 — 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 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): def __init__(self, ctx: ModuleContext, zoom: int, size: tuple[int, int], tag: str, show_info: bool): super().__init__(orientation=Gtk.Orientation.VERTICAL) self.add_css_class("map-view") self.ctx = ctx 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") self.append(self.picture) self._info = None if show_info: self._info = Gtk.Label(label="Locating…", xalign=0.0) 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: 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} · {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 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, valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER) box.add_css_class("map-view") box.append(Gtk.Label(label="IP location is off")) return box def build(ctx: ModuleContext) -> ModuleInstance: # "Locate via IP" gates geolocation entirely: when off we neither traceroute nor # hit any geolocation API, and just show a placeholder. Toggling it rebuilds the # card (via QuadCard), so flipping it back on re-triggers the lookup. if not ctx.feature("ip_locate", True): return ModuleInstance(compact=_placeholder(), expanded=None) ctx.services.location.get() # kick off geolocation (traceroute → API) if not started compact = _MapView(ctx, zoom=12, size=(620, 150), tag="compact", show_info=False) expanded = _MapView(ctx, zoom=13, size=(1100, 620), tag="expanded", show_info=True) return ModuleInstance(compact=compact, expanded=expanded, scroll_expanded=False) SPEC = ModuleSpec( id="location", title="Location", icon="", # nf-fa-map_marker build=build, features=[Feature("ip_locate", "Locate via IP", True), Feature("satellite", "Satellite view", False)], )