Dotfiles/desktopenvs/hyprdrive/astro-menu/modules/location.py

183 lines
7.2 KiB
Python

"""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)],
)