"""OG image generation — 400×400 track-on-dark PNG for social link previews.""" from __future__ import annotations import io import math from pathlib import Path from typing import Optional # Colour stops matching ActivityMap.svelte _linearColor stops _STOPS: list[tuple[float, tuple[int, int, int]]] = [ (0.00, (59, 130, 246)), # blue-500 (low) (0.33, (74, 222, 128)), # green-400 (0.66, (250, 204, 21)), # yellow-400 (1.00, (239, 68, 68)), # red-500 (high) ] _BG = (9, 9, 11) # zinc-950 _SIZE = 400 _PAD = 28 _WIDTH = 5 # logical line width; rendered at 2× then downscaled def _lerp_color(t: float) -> tuple[int, int, int]: t = max(0.0, min(1.0, t)) for i in range(len(_STOPS) - 1): t0, c0 = _STOPS[i] t1, c1 = _STOPS[i + 1] if t <= t1: f = (t - t0) / (t1 - t0) if t1 > t0 else 0.0 return ( round(c0[0] + f * (c1[0] - c0[0])), round(c0[1] + f * (c1[1] - c0[1])), round(c0[2] + f * (c1[2] - c0[2])), ) return _STOPS[-1][1] def generate( lat_arr: list[Optional[float]], lon_arr: list[Optional[float]], ele_arr: list[Optional[float]], ) -> bytes: """Return PNG bytes for a 400×400 elevation-coloured track image. Any of the three arrays may have None gaps (no-GPS seconds). Returns a plain dark square if there are fewer than 2 valid GPS points. """ try: from PIL import Image, ImageDraw # type: ignore[import] except ImportError as e: raise RuntimeError("Pillow is required for OG image generation") from e # Collect valid GPS points paired with elevation (None → 0 for colouring) pts: list[tuple[float, float, float]] = [] for lat, lon, ele in zip(lat_arr, lon_arr, ele_arr): if lat is not None and lon is not None: pts.append((float(lat), float(lon), float(ele) if ele is not None else 0.0)) if len(pts) < 2: img = Image.new("RGB", (_SIZE, _SIZE), _BG) buf = io.BytesIO() img.save(buf, "PNG", optimize=True) return buf.getvalue() lats = [p[0] for p in pts] lons = [p[1] for p in pts] eles = [p[2] for p in pts] min_lat, max_lat = min(lats), max(lats) min_lon, max_lon = min(lons), max(lons) min_ele, max_ele = min(eles), max(eles) ele_range = max_ele - min_ele or 1.0 # Mercator correction: compress longitude range by cos(mid_lat) so the # track doesn't look stretched horizontally at higher latitudes. cos_lat = math.cos(math.radians((min_lat + max_lat) / 2)) usable = _SIZE - 2 * _PAD lat_span = max_lat - min_lat or 1e-6 lon_span = (max_lon - min_lon) * cos_lat or 1e-6 scale = min(usable / lat_span, usable / lon_span) # Centre the track within the canvas x_off = _PAD + (usable - (max_lon - min_lon) * cos_lat * scale) / 2 y_off = _PAD + (usable - lat_span * scale) / 2 def project(lat: float, lon: float) -> tuple[float, float]: x = x_off + (lon - min_lon) * cos_lat * scale y = _SIZE - (y_off + (lat - min_lat) * scale) return x, y # Render at 2× resolution then downscale for cheap anti-aliasing S = _SIZE * 2 lw = _WIDTH * 2 img = Image.new("RGB", (S, S), _BG) draw = ImageDraw.Draw(img) for i in range(len(pts) - 1): x0, y0 = project(pts[i][0], pts[i][1]) x1, y1 = project(pts[i+1][0], pts[i+1][1]) t = (eles[i] - min_ele) / ele_range color = _lerp_color(t) draw.line([(x0 * 2, y0 * 2), (x1 * 2, y1 * 2)], fill=color, width=lw) img = img.resize((_SIZE, _SIZE), Image.LANCZOS) buf = io.BytesIO() img.save(buf, "PNG", optimize=True) return buf.getvalue() def generate_for_activity(ts_path: Path) -> bytes: """Convenience wrapper: read a .timeseries.json file and call generate().""" import json ts = json.loads(ts_path.read_text(encoding="utf-8")) return generate( ts.get("lat") or [], ts.get("lon") or [], ts.get("elevation_m") or [], )