109 lines
3.9 KiB
Python
Executable File
109 lines
3.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""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 raster tiles into one
|
|
PNG with Pillow and caches them. The quad re-renders this at a new centre to pan.
|
|
|
|
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
|
|
|
|
import io
|
|
import math
|
|
import os
|
|
import sys
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
TILE = 256
|
|
CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astro-menu" / "tiles"
|
|
UA = "astro-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
|
|
lat_r = math.radians(lat)
|
|
x = (lon + 180.0) / 360.0 * n
|
|
y = (1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n
|
|
return x * TILE, y * TILE
|
|
|
|
|
|
def _tile(z: int, x: int, y: int, style: str) -> Image.Image | None:
|
|
CACHE.mkdir(parents=True, exist_ok=True)
|
|
fp = CACHE / f"{style}_{z}_{x}_{y}.png"
|
|
if fp.exists():
|
|
try:
|
|
return Image.open(fp).convert("RGB")
|
|
except Exception:
|
|
pass
|
|
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 {style} {z}/{x}/{y}: {exc}", file=sys.stderr)
|
|
return None
|
|
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,
|
|
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
|
|
img = Image.new("RGB", (w, h), BG)
|
|
x0, x1 = int(left // TILE), int((left + w) // TILE)
|
|
y0, y1 = int(top // TILE), int((top + h) // TILE)
|
|
for tx in range(x0, x1 + 1):
|
|
for ty in range(y0, y1 + 1):
|
|
if ty < 0 or ty >= n:
|
|
continue
|
|
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)
|
|
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
|
|
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])
|