183 lines
7.2 KiB
Python
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)],
|
|
)
|