Files
HealthLog/src/lib/workouts/route-svg.ts
T
Marc-André Bombeck e61fe42bd1 feat(workouts): enrich the per-workout detail view (#67)
The detail surface at /insights/workouts/[id] gains a real heart-rate
curve, effort zones, an aspect-true GPS route, and per-kilometre splits
— all read from data already stored, no new integration, no migration.

- HR curve: one server path with strict provenance — stored
  WorkoutSamples series first, a raw-PULSE window reconstruction second
  (quality-gated so sparse data hides rather than lies), nothing third.
  Adaptive bucketing keeps on-screen density constant across a 20-minute
  HIIT and a 6-hour ride; a min/max envelope surfaces intervals.
- Effort zones: five %HRmax bands via the Tanaka HRmax, with a device's
  own reported time-in-zone winning when present. Descriptive only.
- GPS route: self-rendered inline SVG with an equirectangular
  cos(midLat) longitude correction (fixes the ~40% horizontal stretch),
  aspect-clamped viewBox, RDP downsampling, start/end markers, and an
  optional elevation profile. Nothing leaves the origin — no map tiles.
- Per-km splits and a GPX export, both derived client/server-side from
  the geometry already on the payload.
- The endpoint extends additively (hrSeries, zones, splits, sportContext,
  a reserved insight seam) behind an opt-in compact=1 param that drops
  the raw sample/timestamp blobs for the web client; without the param
  the response is unchanged.
- Every data-less section now hides instead of rendering an empty card;
  the growing detail component is split into a workout-detail/ directory.
- List rows flag which sessions open into a rich detail.
2026-07-18 16:05:17 +02:00

354 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Client-safe route geometry math for the workout-detail map.
*
* PURE — no Prisma, no server imports, no Node APIs. It runs in the
* browser bundle (the v1.29 client/server-boundary lesson) and turns a
* stored GeoJSON `LineString` into an inline SVG polyline plus optional
* elevation profile and a GPX export blob. Nothing here reaches the
* network: the whole point of self-rendering the route is that the
* user's track never leaves the origin (binding privacy/CSP decision —
* no tile server, ever).
*
* Projection is equirectangular with a single `cos(midLat)` longitude
* scale factor. At workout scale (≤ a few tens of km) that is visually
* exact; the previous uncorrected normalisation stretched a route ~40 %
* horizontally at 50° N (a circular circuit read as an ellipse). The
* correction squashes the longitude axis by `cos(midLat)` so one screen
* unit east equals one screen unit north in real metres.
*/
export type RouteCoordinate =
[number, number] | [number, number, number] | number[];
export interface RoutePoint {
x: number;
y: number;
}
export interface ProjectedRoute {
/** SVG path data (`M … L …`) in viewBox units. */
path: string;
/** `0 0 W H` — W is fixed at 100, H is the aspect-derived, clamped height. */
viewBox: string;
width: number;
height: number;
/** Start marker position (viewBox units). */
start: RoutePoint;
/** End marker position (viewBox units). */
end: RoutePoint;
/** Number of points after RDP downsampling. */
pointCount: number;
}
export interface ElevationPoint {
/** Cumulative distance from the route start, metres. */
distanceM: number;
/** Altitude, metres. */
altitude: number;
}
const DEG_TO_RAD = Math.PI / 180;
const METERS_PER_DEG_LAT = 111_320;
/** viewBox width — fixed; height rides the projected aspect ratio. */
const VIEW_W = 100;
/** Aspect clamp so a straight out-and-back can't render as a 4 px sliver. */
const MIN_H = 40;
const MAX_H = 140;
/** Guards against a degenerate "route" (an indoor session's 3 GPS pings). */
const MIN_POINTS = 10;
const MIN_SPAN_M = 30;
/** Shape-preserving downsample ceiling. */
const MAX_RENDER_POINTS = 1200;
function isFiniteNumber(v: unknown): v is number {
return typeof v === "number" && Number.isFinite(v);
}
/**
* Pull the `[lon, lat]` pairs out of a GeoJSON `LineString`'s
* coordinates, dropping any malformed entry. Altitude (3rd element) is
* ignored here — `buildElevationProfile` reads it separately.
*/
export function extractLonLat(
coordinates: readonly RouteCoordinate[],
): Array<[number, number]> {
const out: Array<[number, number]> = [];
for (const c of coordinates) {
if (!Array.isArray(c) || c.length < 2) continue;
const lon = c[0];
const lat = c[1];
if (!isFiniteNumber(lon) || !isFiniteNumber(lat)) continue;
if (Math.abs(lon) > 180 || Math.abs(lat) > 90) continue;
out.push([lon, lat]);
}
return out;
}
/** Great-circle distance between two lon/lat points, metres. */
export function haversineMeters(
aLon: number,
aLat: number,
bLon: number,
bLat: number,
): number {
const R = 6_371_000;
const dLat = (bLat - aLat) * DEG_TO_RAD;
const dLon = (bLon - aLon) * DEG_TO_RAD;
const lat1 = aLat * DEG_TO_RAD;
const lat2 = bLat * DEG_TO_RAD;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
}
/** Perpendicular distance from `p` to the segment `a→b` (planar). */
function perpDistance(
p: [number, number],
a: [number, number],
b: [number, number],
): number {
const [px, py] = p;
const [ax, ay] = a;
const [bx, by] = b;
const dx = bx - ax;
const dy = by - ay;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(px - ax, py - ay);
let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}
/**
* Ramer–Douglas–Peucker line simplification. Keeps switchbacks that a
* naive stride would erase. Iterative (an explicit stack) so a
* 20 000-point ultra can't blow the call stack.
*/
export function rdpSimplify(
points: ReadonlyArray<[number, number]>,
tolerance: number,
): Array<[number, number]> {
if (points.length <= 2 || tolerance <= 0) return points.slice();
const keep = new Uint8Array(points.length);
keep[0] = 1;
keep[points.length - 1] = 1;
const stack: Array<[number, number]> = [[0, points.length - 1]];
while (stack.length > 0) {
const [first, last] = stack.pop() as [number, number];
let maxDist = 0;
let index = -1;
for (let i = first + 1; i < last; i++) {
const d = perpDistance(points[i], points[first], points[last]);
if (d > maxDist) {
maxDist = d;
index = i;
}
}
if (maxDist > tolerance && index !== -1) {
keep[index] = 1;
stack.push([first, index]);
stack.push([index, last]);
}
}
const out: Array<[number, number]> = [];
for (let i = 0; i < points.length; i++) if (keep[i]) out.push(points[i]);
return out;
}
/** Uniform stride down to at most `max` points (post-RDP safety cap). */
function strideTo<T>(points: readonly T[], max: number): T[] {
if (points.length <= max) return points.slice();
const step = points.length / max;
const out: T[] = [];
for (let i = 0; i < max; i++) out.push(points[Math.floor(i * step)]);
const last = points[points.length - 1];
if (out[out.length - 1] !== last) out.push(last);
return out;
}
/**
* Project a GeoJSON LineString into an aspect-true, downsampled SVG
* path. Returns `null` for a degenerate route (too few points or a
* bounding box under ~30 m) — the caller then hides the route card
* rather than painting a meaningless dot.
*/
export function projectRoute(
coordinates: readonly RouteCoordinate[],
): ProjectedRoute | null {
const lonLat = extractLonLat(coordinates);
if (lonLat.length < MIN_POINTS) return null;
let minLon = Infinity;
let maxLon = -Infinity;
let minLat = Infinity;
let maxLat = -Infinity;
for (const [lon, lat] of lonLat) {
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
}
const midLat = (minLat + maxLat) / 2;
const cosMid = Math.cos(midLat * DEG_TO_RAD);
// Real-world span (metres) — the small-route guard rejects the indoor
// session that logged a couple of stationary GPS pings.
const spanXm = (maxLon - minLon) * cosMid * METERS_PER_DEG_LAT;
const spanYm = (maxLat - minLat) * METERS_PER_DEG_LAT;
if (Math.hypot(spanXm, spanYm) < MIN_SPAN_M) return null;
// Projected coordinates: longitude squashed by cos(midLat); latitude
// flipped so SVG y grows downward.
const projected: Array<[number, number]> = lonLat.map(([lon, lat]) => [
(lon - minLon) * cosMid,
maxLat - lat,
]);
const xSpanRaw = (maxLon - minLon) * cosMid || 1e-9;
// RDP tolerance derived from the projected bbox (spec: xSpan / 2000).
let simplified = rdpSimplify(projected, xSpanRaw / 2000);
if (simplified.length > MAX_RENDER_POINTS) {
simplified = strideTo(simplified, MAX_RENDER_POINTS);
}
// Projected bbox of the (simplified) track + 5 % padding per side.
let pMinX = Infinity;
let pMaxX = -Infinity;
let pMinY = Infinity;
let pMaxY = -Infinity;
for (const [x, y] of simplified) {
if (x < pMinX) pMinX = x;
if (x > pMaxX) pMaxX = x;
if (y < pMinY) pMinY = y;
if (y > pMaxY) pMaxY = y;
}
const rawXSpan = pMaxX - pMinX || 1e-9;
const rawYSpan = pMaxY - pMinY || 1e-9;
const padX = rawXSpan * 0.05;
const padY = rawYSpan * 0.05;
pMinX -= padX;
pMaxX += padX;
pMinY -= padY;
pMaxY += padY;
const xSpan = pMaxX - pMinX;
const ySpan = pMaxY - pMinY;
// Height rides the true aspect ratio (uniform scale when unclamped);
// the clamp only bites on extreme slivers, where the intentional
// distortion trades geographic fidelity for on-screen readability.
const height = Math.max(MIN_H, Math.min(MAX_H, (VIEW_W * ySpan) / xSpan));
const scaleX = VIEW_W / xSpan;
const scaleY = height / ySpan;
const toVB = ([x, y]: [number, number]): RoutePoint => ({
x: (x - pMinX) * scaleX,
y: (y - pMinY) * scaleY,
});
const vbPoints = simplified.map(toVB);
const path = vbPoints
.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
.join(" ");
return {
path,
viewBox: `0 0 ${VIEW_W} ${height.toFixed(2)}`,
width: VIEW_W,
height,
start: vbPoints[0],
end: vbPoints[vbPoints.length - 1],
pointCount: vbPoints.length,
};
}
/** Fraction of coordinates that carry a finite altitude (3rd element). */
function altitudeCoverage(coordinates: readonly RouteCoordinate[]): number {
if (coordinates.length === 0) return 0;
let withAlt = 0;
for (const c of coordinates) {
if (Array.isArray(c) && c.length >= 3 && isFiniteNumber(c[2])) withAlt++;
}
return withAlt / coordinates.length;
}
/** Minimum altitude coverage before an elevation profile is honest. */
export const ELEVATION_COVERAGE_THRESHOLD = 0.6;
/**
* Build a cumulative-distance elevation profile when at least 60 % of
* coordinates carry altitude. Below the threshold partial altimeter
* data would draw a lie, so this returns `null` and the caller omits
* the profile.
*/
export function buildElevationProfile(
coordinates: readonly RouteCoordinate[],
): ElevationPoint[] | null {
if (altitudeCoverage(coordinates) < ELEVATION_COVERAGE_THRESHOLD) return null;
const out: ElevationPoint[] = [];
let cumulative = 0;
let prev: [number, number] | null = null;
for (const c of coordinates) {
if (!Array.isArray(c) || c.length < 3) continue;
const lon = c[0];
const lat = c[1];
const alt = c[2];
if (!isFiniteNumber(lon) || !isFiniteNumber(lat) || !isFiniteNumber(alt)) {
continue;
}
if (prev) cumulative += haversineMeters(prev[0], prev[1], lon, lat);
out.push({ distanceM: cumulative, altitude: alt });
prev = [lon, lat];
}
return out.length >= 2 ? out : null;
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* Serialise the route geometry to a GPX 1.1 document, entirely
* client-side over data already loaded — zero server work, zero egress.
* Timestamps (when present, parallel to the coordinates) are emitted so
* the exported track round-trips into any GPX consumer.
*/
export function buildGpxDocument(params: {
coordinates: readonly RouteCoordinate[];
timestamps?: readonly string[] | null;
sportType: string;
startedAt: string;
}): string {
const { coordinates, timestamps, sportType, startedAt } = params;
const segments: string[] = [];
coordinates.forEach((c, i) => {
if (!Array.isArray(c) || c.length < 2) return;
const lon = c[0];
const lat = c[1];
if (!isFiniteNumber(lon) || !isFiniteNumber(lat)) return;
const parts = [`<trkpt lat="${lat}" lon="${lon}">`];
if (c.length >= 3 && isFiniteNumber(c[2])) parts.push(`<ele>${c[2]}</ele>`);
const ts = timestamps?.[i];
if (typeof ts === "string" && ts.length > 0) {
parts.push(`<time>${escapeXml(ts)}</time>`);
}
parts.push(`</trkpt>`);
segments.push(parts.join(""));
});
return [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<gpx version="1.1" creator="HealthLog" xmlns="http://www.topografix.com/GPX/1/1">`,
`<metadata><time>${escapeXml(startedAt)}</time></metadata>`,
`<trk><name>${escapeXml(sportType)}</name><trkseg>`,
...segments,
`</trkseg></trk>`,
`</gpx>`,
].join("\n");
}