Public: anyone can read this; the crew can change it.
A GPS track (.gpx) in a repository renders as its route drawn as an outline, with distance and climb #10382
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10329-a-gps-track-gpx-in-a-repository-renders → main 10 commits fork 2 threads · 2 unresolved
12 files
changed +445
−3
docs/design/areas/render.md +4
| @@ -21,3 +21,7 @@ Backgrounds, 256-colour above 15 and truecolour are dropped; so are cursor moves | ||
| 21 | 21 | ## Archive listing |
| 22 | 22 | |
| 23 | 23 | A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the existing `.table` alone, with no `.card` around it (path, size, modified; `data-dense`), and a `tfoot` for the count and unpacked size. No new class: `.archive-view` is only a hook on the table and has no rule. How it works: `docs/features/archive-listing.md`. A list that cannot be read falls back to the Download card with the reason as its one sentence. |
| 24 | ||
| 25 | ## GPS track (.gpx) | |
| 26 | ||
| 27 | `src/web/render/gpx.ts` draws the route as one inline SVG in `.gpx-view`: no fixed width or height, `max-width: 100%` and `height: auto` (tokens only, `currentColor` stroke, no accent), so it fits a 360px phone. Its facts (track, distance, climb, points) are the existing `.status-rows` / `.status-row`, with no box. How it works: `docs/features/gpx.md`. |
docs/features/gpx.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # GPS tracks (.gpx) as a route outline | |
| 2 | ||
| 3 | Open a `.gpx` file in a repository and see the route drawn as an outline, with its track name, distance, climb and point count. | |
| 4 | ||
| 5 | - **Web:** the blob page draws an inline SVG on the server (`src/web/render/gpx.ts`). It scales to the column (`max-width: 100%`), so it fits a phone; the facts are `status-rows`. `?view=source`, Raw and Download work as for any file. | |
| 6 | - **Terminal:** the workspace opens a `.gpx` as a Fact / Value table (`src/tui/screens/workspace.ts`, sharing `parseGpx` and `gpxFacts`); `s` flips to the source. The status line reads `4 facts`. | |
| 7 | - Distance is the haversine sum, climb the sum of rises. No map tiles, no network. | |
| 8 | - Beyond 20,000 points (`POINT_CAP`) the outline is thinned evenly, ends kept; the count stays true. | |
| 9 | - A file that is not readable GPX shows as source, with a one-line reason on the web. DOCTYPE entities are never expanded; all text is escaped. |
scripts/unreached-allow.json +1
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | "src/flow/review_rounds.ts", |
| 16 | 16 | "src/flow/shared_red.ts", |
| 17 | 17 | "src/flow/stale_fold.ts", |
| 18 | "src/kernel/why_now.ts", | |
| 18 | 19 | "src/spawn/pod.ts", |
| 19 | 20 | "src/tui/gallery.ts", |
| 20 | 21 | "src/tui/ui/qr.ts", |
src/tui/screens/workspace.ts +20 −3
| @@ -55,6 +55,7 @@ import { isModelPath, modelFacts } from "../../web/render/model-facts.ts"; | ||
| 55 | 55 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| 56 | 56 | import { detectLanguage, highlightLines } from "../../web/highlight.ts"; |
| 57 | 57 | import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts"; |
| 58 | import { gpxFacts, parseGpx } from "../../web/render/gpx.ts"; | |
| 58 | 59 | import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts"; |
| 59 | 60 | import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts"; |
| 60 | 61 | import { layoutTable, type Grid } from "../ui/grid.ts"; |
| @@ -336,6 +337,19 @@ function icsTable(source: string): Table | null { | ||
| 336 | 337 | }; |
| 337 | 338 | } |
| 338 | 339 | |
| 340 | /** `.gpx`: a GPS track, drawn as a table of its facts (track, distance, climb, points). */ | |
| 341 | function isGpxPath(path: string): boolean { | |
| 342 | return /\.gpx$/i.test(path); | |
| 343 | } | |
| 344 | ||
| 345 | /** The facts of a track as a table; null when the file is not a track. Shares the web's parser. */ | |
| 346 | function gpxTable(source: string, path: string): Table | null { | |
| 347 | const parsed = parseGpx(source); | |
| 348 | if (parsed === undefined) return null; | |
| 349 | const rows = gpxFacts(parsed, path); | |
| 350 | return { header: ["Fact", "Value"], rows, more: 0, delimiter: ",", columns: 2 }; | |
| 351 | } | |
| 352 | ||
| 339 | 353 | /** `README.md`, `NOTES.markdown`, `readme` — the prose a repo opens on. */ |
| 340 | 354 | function isMarkdownPath(path: string): boolean { |
| 341 | 355 | const name = path.slice(path.lastIndexOf("/") + 1); |
| @@ -495,6 +509,9 @@ class Tab { | ||
| 495 | 509 | } else if (isIcsPath(this.path)) { |
| 496 | 510 | this.table = icsTable(new TextDecoder().decode(source)); |
| 497 | 511 | if (this.table !== null) this.grid = layoutTable(this.table); |
| 512 | } else if (isGpxPath(this.path)) { | |
| 513 | this.table = gpxTable(new TextDecoder().decode(source), this.path); | |
| 514 | if (this.table !== null) this.grid = layoutTable(this.table); | |
| 498 | 515 | } else if (isTablePath(this.path)) { |
| 499 | 516 | // The decoded source, not `plain`: `expandTabs` has eaten every tab there. |
| 500 | 517 | const delimiter = /\.tsv$/i.test(this.path) ? "\t" : undefined; |
| @@ -2575,7 +2592,7 @@ export function workspaceScreen( | ||
| 2575 | 2592 | ? "" |
| 2576 | 2593 | : `${t.path} · ${ |
| 2577 | 2594 | t.tabular && t.table !== null |
| 2578 | ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path)) | |
| 2595 | ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path), isGpxPath(t.path)) | |
| 2579 | 2596 | : `${plural( |
| 2580 | 2597 | t.reading ? t.doc.length : t.plain.length, |
| 2581 | 2598 | "line", |
| @@ -2909,9 +2926,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void { | ||
| 2909 | 2926 | } |
| 2910 | 2927 | |
| 2911 | 2928 | /** `80 rows, first 50 shown · 3 more columns`: what the table leaves out. */ |
| 2912 | function tableStatus(table: Table, unread = 0, subs = false, events = false): string { | |
| 2929 | function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false): string { | |
| 2913 | 2930 | const rows = table.rows.length + table.more; |
| 2914 | return `${plural(rows, subs ? "cue" : events ? "event" : "row")}${ | |
| 2931 | return `${plural(rows, subs ? "cue" : events ? "event" : facts ? "fact" : "row")}${ | |
| 2915 | 2932 | unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : "" |
| 2916 | 2933 | }${ |
| 2917 | 2934 | table.more > 0 ? `, first ${table.rows.length} shown` : "" |
src/web/render/gpx.ts added +233
| @@ -0,0 +1,233 @@ | ||
| 1 | /** | |
| 2 | * A GPS track (`.gpx`) drawn as its route: an inline SVG outline of every | |
| 3 | * track segment and route, fitted to their bounds by an equirectangular | |
| 4 | * projection (longitude scaled by the cosine of the middle latitude). No map | |
| 5 | * tiles, no network. Distance is the haversine sum, climb the sum of rises. | |
| 6 | * | |
| 7 | * The parser is a small tag scanner, not an XML engine: it reads the five | |
| 8 | * predefined entities and numeric references in names and nothing else, so a | |
| 9 | * DOCTYPE with entity definitions is skipped and never expanded. A file whose | |
| 10 | * tags do not nest, or that holds no point, is not a GPX file to us: the | |
| 11 | * caller falls back to the text view. | |
| 12 | */ | |
| 13 | ||
| 14 | import { html, raw } from "../html.ts"; | |
| 15 | import { fileSize } from "../ui.ts"; | |
| 16 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 17 | ||
| 18 | /** Points drawn; more than this are thinned evenly across the whole track. */ | |
| 19 | export const POINT_CAP = 20_000; | |
| 20 | ||
| 21 | export interface Point { | |
| 22 | readonly lat: number; | |
| 23 | readonly lon: number; | |
| 24 | readonly ele?: number; | |
| 25 | } | |
| 26 | export interface Line { | |
| 27 | readonly name: string; | |
| 28 | readonly points: Point[]; | |
| 29 | } | |
| 30 | export interface Gpx { | |
| 31 | readonly lines: Line[]; | |
| 32 | readonly count: number; | |
| 33 | } | |
| 34 | ||
| 35 | const ENTITIES: Readonly<Record<string, string>> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" }; | |
| 36 | /** Predefined and numeric references only; any other `&name;` stays text. */ | |
| 37 | const decode = (s: string): string => | |
| 38 | s.replace(/&(?:#(\d{1,6})|#x([0-9a-fA-F]{1,5})|(amp|lt|gt|quot|apos));/g, (m, d, h, n) => { | |
| 39 | if (n !== undefined) return ENTITIES[n]!; | |
| 40 | const code = d !== undefined ? Number(d) : parseInt(h, 16); | |
| 41 | return code > 0x1f || code === 9 || code === 10 ? String.fromCodePoint(code) : m; | |
| 42 | }); | |
| 43 | ||
| 44 | const TAG = /<!--[\s\S]*?-->|<!DOCTYPE[^[>]*(?:\[[\s\S]*?\])?\s*>|<!\[CDATA\[([\s\S]*?)\]\]>|<[?!][^>]*>|<(\/?)([A-Za-z_][\w:.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)/g; | |
| 45 | const ATTR = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; | |
| 46 | ||
| 47 | const attr = (src: string, name: string): string | undefined => { | |
| 48 | for (const m of src.matchAll(ATTR)) if (m[1] === name) return m[2] ?? m[3]; | |
| 49 | return undefined; | |
| 50 | }; | |
| 51 | const local = (tag: string): string => tag.slice(tag.indexOf(":") + 1).toLowerCase(); | |
| 52 | const coord = (v: string | undefined, max: number): number | undefined => { | |
| 53 | if (v === undefined || !/^\s*[+-]?\d+(\.\d+)?\s*$/.test(v)) return undefined; | |
| 54 | const n = Number(v); | |
| 55 | return Math.abs(n) <= max ? n : undefined; | |
| 56 | }; | |
| 57 | ||
| 58 | /** Parse GPX text. Pure. Returns undefined when it is not well-formed GPX with a point. */ | |
| 59 | export function parseGpx(source: string): Gpx | undefined { | |
| 60 | const stack: string[] = []; | |
| 61 | const lines: Line[] = []; | |
| 62 | let name = ""; | |
| 63 | let current: { lat: number; lon: number; ele?: number } | undefined; | |
| 64 | let seg: Point[] | undefined; | |
| 65 | let text = ""; | |
| 66 | let sawRoot = false; | |
| 67 | let count = 0; | |
| 68 | ||
| 69 | const open = (tag: string, attrs: string): void => { | |
| 70 | if (stack.length === 0) { | |
| 71 | if (tag !== "gpx") throw new Error("root"); | |
| 72 | sawRoot = true; | |
| 73 | } | |
| 74 | if (tag === "trk" || tag === "rte") name = ""; | |
| 75 | if (tag === "trkseg" || tag === "rte") { | |
| 76 | seg = []; | |
| 77 | lines.push({ name, points: seg }); | |
| 78 | } | |
| 79 | if (tag === "trkpt" || tag === "rtept") { | |
| 80 | const lat = coord(attr(attrs, "lat"), 90); | |
| 81 | const lon = coord(attr(attrs, "lon"), 180); | |
| 82 | current = lat !== undefined && lon !== undefined ? { lat, lon } : undefined; | |
| 83 | } | |
| 84 | if (tag === "name" || tag === "ele") text = ""; | |
| 85 | }; | |
| 86 | const close = (tag: string): void => { | |
| 87 | const parent = stack[stack.length - 1]; | |
| 88 | if (tag === "ele" && current !== undefined && /^\s*[+-]?\d+(\.\d+)?([eE][+-]?\d+)?\s*$/.test(text)) | |
| 89 | current.ele = Number(text); | |
| 90 | if (tag === "name" && (parent === "trk" || parent === "rte") && name === "") name = decode(text.trim()); | |
| 91 | if ((tag === "trkpt" || tag === "rtept") && current !== undefined && seg !== undefined) { | |
| 92 | seg.push(current); | |
| 93 | count++; | |
| 94 | current = undefined; | |
| 95 | } | |
| 96 | if (tag === "trkseg" || tag === "rte") seg = undefined; | |
| 97 | if (tag === "name" || tag === "ele") text = ""; | |
| 98 | }; | |
| 99 | ||
| 100 | try { | |
| 101 | for (const m of source.replace(/^/, "").matchAll(TAG)) { | |
| 102 | const [, cdata, closing, rawTag, attrs, selfClose, chars] = m; | |
| 103 | if (cdata !== undefined) text += cdata; | |
| 104 | else if (chars !== undefined) { | |
| 105 | if (stack.length === 0 && chars.trim() !== "") throw new Error("text outside root"); | |
| 106 | text += chars; | |
| 107 | } else if (rawTag !== undefined) { | |
| 108 | const tag = local(rawTag); | |
| 109 | if (closing === "/") { | |
| 110 | if (stack.pop() !== tag) throw new Error("mismatch"); | |
| 111 | close(tag); | |
| 112 | } else { | |
| 113 | if (stack.length === 0 && sawRoot) throw new Error("two roots"); | |
| 114 | open(tag, attrs ?? ""); | |
| 115 | if (selfClose === "/") close(tag); | |
| 116 | else stack.push(tag); | |
| 117 | } | |
| 118 | } | |
| 119 | } | |
| 120 | } catch { | |
| 121 | return undefined; | |
| 122 | } | |
| 123 | if (!sawRoot || stack.length > 0 || count === 0) return undefined; | |
| 124 | return { lines: lines.filter((l) => l.points.length > 0), count }; | |
| 125 | } | |
| 126 | ||
| 127 | /** Keep at most `cap` points of a list, evenly spaced, first and last included. */ | |
| 128 | export function thin<T>(items: readonly T[], cap: number): T[] { | |
| 129 | if (items.length <= cap) return [...items]; | |
| 130 | if (cap < 2) return items.slice(0, cap); | |
| 131 | const out: T[] = []; | |
| 132 | for (let i = 0; i < cap; i++) out.push(items[Math.round((i * (items.length - 1)) / (cap - 1))]!); | |
| 133 | return out; | |
| 134 | } | |
| 135 | ||
| 136 | const R = 6_371_008.8; | |
| 137 | const rad = (d: number): number => (d * Math.PI) / 180; | |
| 138 | /** Great-circle distance in metres between two points. */ | |
| 139 | export function haversine(a: Point, b: Point): number { | |
| 140 | const dLat = rad(b.lat - a.lat); | |
| 141 | const dLon = rad(b.lon - a.lon); | |
| 142 | const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2; | |
| 143 | return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); | |
| 144 | } | |
| 145 | ||
| 146 | export interface Stats { | |
| 147 | /** Metres. */ | |
| 148 | readonly distance: number; | |
| 149 | /** Metres climbed. */ | |
| 150 | readonly gain: number; | |
| 151 | } | |
| 152 | export function gpxStats(gpx: Gpx): Stats { | |
| 153 | let distance = 0; | |
| 154 | let gain = 0; | |
| 155 | for (const line of gpx.lines) { | |
| 156 | for (let i = 1; i < line.points.length; i++) { | |
| 157 | const a = line.points[i - 1]!; | |
| 158 | const b = line.points[i]!; | |
| 159 | distance += haversine(a, b); | |
| 160 | if (a.ele !== undefined && b.ele !== undefined && b.ele > a.ele) gain += b.ele - a.ele; | |
| 161 | } | |
| 162 | } | |
| 163 | return { distance, gain }; | |
| 164 | } | |
| 165 | ||
| 166 | const W = 640; | |
| 167 | const MAX_H = 480; | |
| 168 | const PAD = 12; | |
| 169 | ||
| 170 | /** The outline as SVG path data in a viewBox, thinned to `POINT_CAP` points overall. */ | |
| 171 | export function outline(gpx: Gpx): { width: number; height: number; paths: string[] } { | |
| 172 | const keep = gpx.count > POINT_CAP ? POINT_CAP / gpx.count : 1; | |
| 173 | const lines = gpx.lines.map((l) => thin(l.points, Math.max(2, Math.floor(l.points.length * keep)))); | |
| 174 | let minLat = 90, maxLat = -90, minLon = 180, maxLon = -180; | |
| 175 | for (const l of gpx.lines) | |
| 176 | for (const p of l.points) { | |
| 177 | minLat = Math.min(minLat, p.lat); | |
| 178 | maxLat = Math.max(maxLat, p.lat); | |
| 179 | minLon = Math.min(minLon, p.lon); | |
| 180 | maxLon = Math.max(maxLon, p.lon); | |
| 181 | } | |
| 182 | const k = Math.cos(rad((minLat + maxLat) / 2)); | |
| 183 | const spanX = Math.max((maxLon - minLon) * k, 1e-9); | |
| 184 | const spanY = Math.max(maxLat - minLat, 1e-9); | |
| 185 | let scale = (W - 2 * PAD) / spanX; | |
| 186 | let height = spanY * scale + 2 * PAD; | |
| 187 | if (height > MAX_H) { | |
| 188 | scale = (MAX_H - 2 * PAD) / spanY; | |
| 189 | height = MAX_H; | |
| 190 | } | |
| 191 | const width = Math.max(spanX * scale + 2 * PAD, 2 * PAD + 1); | |
| 192 | const f = (n: number): string => n.toFixed(1); | |
| 193 | const paths = lines.map((pts) => | |
| 194 | pts | |
| 195 | .map((p, i) => `${i === 0 ? "M" : "L"}${f(PAD + (p.lon - minLon) * k * scale)} ${f(PAD + (maxLat - p.lat) * scale)}`) | |
| 196 | .join(""), | |
| 197 | ); | |
| 198 | return { width: Math.round(width), height: Math.round(height), paths }; | |
| 199 | } | |
| 200 | ||
| 201 | const km = (m: number): string => (m / 1000).toLocaleString("en", { maximumFractionDigits: 2 }); | |
| 202 | ||
| 203 | /** Track, Distance, Climb, Points as text: the facts both the page and the terminal show. */ | |
| 204 | export function gpxFacts(parsed: Gpx, path: string): [string, string][] { | |
| 205 | const { distance, gain } = gpxStats(parsed); | |
| 206 | const named = parsed.lines.map((l) => l.name).filter((n) => n !== ""); | |
| 207 | return [ | |
| 208 | ["Track", named[0] ?? path.slice(path.lastIndexOf("/") + 1)], | |
| 209 | ["Distance", `${km(distance)} km`], | |
| 210 | ["Climb", `${Math.round(gain).toLocaleString("en")} m`], | |
| 211 | ["Points", parsed.count.toLocaleString("en")], | |
| 212 | ]; | |
| 213 | } | |
| 214 | ||
outdated
Principles 7 and 1: use the named components for a fact list (a dl row) rather than a bespoke figcaption with strong; check docs/DESIGN.md for the existing dl/row class and use it so the facts sit in rhythm without a box. |
||
| 215 | export function gpx(input: RenderInput): Rendered { | |
| 216 | const parsed = parseGpx(new TextDecoder().decode(input.body)); | |
| 217 | if (parsed === undefined) | |
| 218 | return { content: html``, lines: 0, fallback: "This file is not a readable GPX track." }; | |
| 219 | const { width, height, paths } = outline(parsed); | |
| 220 | const facts = gpxFacts(parsed, input.path); | |
| 221 | const d = paths.map((p) => `<path d="${p}"/>`).join(""); | |
| 222 | const title = facts[0]![1]; | |
| 223 | const content = html`<figure class="gpx-view"> | |
| 224 | <svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Route of ${title}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${raw(d)}</svg> | |
| 225 | <dl class="status-rows">${facts.map(([k, v]) => html`<div class="status-row"><dt>${k}</dt><dd>${v}</dd></div>`)}</dl> | |
| 226 | </figure>`; | |
| 227 | const { distance, gain } = gpxStats(parsed); | |
| 228 | return { | |
| 229 | content, | |
| 230 | lines: 0, | |
| 231 | summary: `${km(distance)} km · ${Math.round(gain)} m climb · ${fileSize(input.size)}`, | |
| 232 | }; | |
| 233 | } |
src/web/render/kinds.ts +3
| @@ -28,6 +28,7 @@ export type FileKind = | ||
| 28 | 28 | | "patch" |
| 29 | 29 | | "log" |
| 30 | 30 | | "subtitles" |
| 31 | | "gpx" | |
| 31 | 32 | | "calendar" |
| 32 | 33 | | "text" |
| 33 | 34 | | "binary"; |
| @@ -56,6 +57,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 56 | 57 | patch: 1 * MB, |
| 57 | 58 | log: 1 * MB, |
| 58 | 59 | subtitles: 5 * MB, |
| 60 | gpx: 20 * MB, | |
| 59 | 61 | calendar: 5 * MB, |
| 60 | 62 | text: 1 * MB, |
| 61 | 63 | binary: 0, |
| @@ -163,6 +165,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 163 | 165 | ansi: "log", |
| 164 | 166 | srt: "subtitles", |
| 165 | 167 | vtt: "subtitles", |
| 168 | gpx: "gpx", | |
| 166 | 169 | ics: "calendar", |
| 167 | 170 | }; |
| 168 | 171 |
src/web/render/renderers.ts +2
| @@ -11,6 +11,7 @@ import { audio } from "./audio.ts"; | ||
| 11 | 11 | import { config } from "./config.ts"; |
| 12 | 12 | import { isFormConfig } from "./config-format.ts"; |
| 13 | 13 | import { csv } from "./csv.ts"; |
| 14 | import { gpx } from "./gpx.ts"; | |
| 14 | 15 | import { font } from "./font.ts"; |
| 15 | 16 | import { ics } from "./ics.ts"; |
| 16 | 17 | import { image } from "./image.ts"; |
| @@ -71,6 +72,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 71 | 72 | patch, |
| 72 | 73 | log, |
| 73 | 74 | subtitles, |
| 75 | gpx, | |
| 74 | 76 | calendar: ics, |
| 75 | 77 | archive, |
| 76 | 78 | font, |
src/web/style/content.ts +4
| @@ -477,6 +477,10 @@ form.inline { display: inline; margin-left: auto; } | ||
| 477 | 477 | |
| 478 | 478 | /* A font specimen: set in the font itself once the script has loaded it from |
| 479 | 479 | the media route; until then (or without script) in the page's own face. */ |
| 480 | /* ---- gpx view: the outline scales to the column; its facts are status rows ---- */ | |
| 481 | .gpx-view { margin: 0; } | |
| 482 | .gpx-view svg { display: block; max-width: 100%; height: auto; margin: 0 auto; color: var(--fg); } | |
| 483 | .gpx-view .status-rows { margin-top: 12px; } | |
| 480 | 484 | .font-view { padding: 16px 20px; } |
| 481 | 485 | .font-name { margin: 0 0 12px; font-size: 1.75rem; line-height: 1.3; overflow-wrap: anywhere; } |
| 482 | 486 | .font-line { margin: 0 0 10px; line-height: 1.4; overflow-wrap: anywhere; } |
test/blob-gpx-page.test.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | /** | |
| 2 | * A `.gpx` committed to a repository, opened on its blob page over HTTP by | |
| 3 | * an anonymous reader: the server-rendered route and its facts as rows. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | describe("a .gpx on its blob page", () => { | |
| 19 | let dataDir = ""; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let base = ""; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | dataDir = await mkdtemp(join(tmpdir(), "forge-blob-gpx-")); | |
| 26 | app = createApp({ dataDir, port: 0 }); | |
| 27 | servers = await serve(app); | |
| 28 | base = servers.url; | |
| 29 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-patch-passphrase" }); | |
| 30 | if (!a.ok) throw new Error("signup failed"); | |
| 31 | const created = await createRepo(app, a.value, { name: "p", visibility: "public" }); | |
| 32 | if (!created.ok) throw new Error("repo failed"); | |
| 33 | const fixture = '<gpx><trk><name>Up <b>x</b></name><trkseg><trkpt lat="10" lon="20"><ele>100</ele></trkpt><trkpt lat="11" lon="20"><ele>150</ele></trkpt></trkseg></trk></gpx>'; | |
| 34 | const made = await commitToBranch(app, a.value, created.value, { | |
| 35 | message: "a track", | |
| 36 | files: [{ path: "ride.gpx", content: fixture }], | |
| 37 | }); | |
| 38 | if (!made.ok) throw new Error("commit failed"); | |
| 39 | }, 60_000); | |
| 40 | ||
| 41 | afterAll(async () => { | |
| 42 | await servers?.stop(); | |
| 43 | app?.close(); | |
| 44 | await rm(dataDir, { recursive: true, force: true }); | |
| 45 | }); | |
| 46 | ||
| 47 | test("the page draws the route, scalable, with its facts as rows", async () => { | |
| 48 | const res = await fetch(`${base}/alice/p/blob/main/ride.gpx`); | |
| 49 | const text = await res.text(); | |
| 50 | expect(res.status).toBe(200); | |
| 51 | expect(text).toContain('class="gpx-view"'); | |
| 52 | expect(text).toMatch(/<svg viewBox="0 0 \d+ \d+" role="img"/); | |
| 53 | expect(text).toContain('<dt>Distance</dt><dd>111.2 km</dd>'); | |
| 54 | expect(text).toContain('<dt>Climb</dt><dd>50 m</dd>'); | |
| 55 | expect(text).toContain('Up <b>x</b>'); | |
| 56 | expect(text).not.toContain('<b>x'); | |
| 57 | }); | |
| 58 | }); |
test/render-gpx.test.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** GPX files as a route outline: distance, a malformed file, entity expansion. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { gpx, gpxStats, parseGpx, outline } from "../src/web/render/gpx.ts"; | |
| 4 | import { fileKind } from "../src/web/render/kinds.ts"; | |
| 5 | import { RENDERERS } from "../src/web/render/renderers.ts"; | |
| 6 | ||
| 7 | const enc = (s: string): Uint8Array => new TextEncoder().encode(s); | |
| 8 | const draw = (text: string) => gpx({ path: "r/a.gpx", size: text.length, body: enc(text), media: "/m", download: "/d" }); | |
| 9 | ||
| 10 | // One degree of latitude is about 111.195 km. | |
| 11 | const TWO = `<?xml version="1.0"?><gpx version="1.1"><trk><name>Up & <b>down</b></name><trkseg> | |
| 12 | <trkpt lat="10" lon="20"><ele>100</ele></trkpt><trkpt lat="11" lon="20"><ele>150.5</ele></trkpt></trkseg></trk></gpx>`; | |
| 13 | ||
| 14 | describe("gpx", () => { | |
| 15 | test("registered", () => { | |
| 16 | expect(fileKind("a.gpx", enc(TWO))).toBe("gpx"); | |
| 17 | expect(RENDERERS.gpx).toBe(gpx); | |
| 18 | }); | |
| 19 | ||
| 20 | test("distance of a known two-point track within 1%, climb and count", () => { | |
| 21 | const g = parseGpx(TWO)!; | |
| 22 | const s = gpxStats(g); | |
| 23 | expect(Math.abs(s.distance - 111_195) / 111_195).toBeLessThan(0.01); | |
| 24 | expect(s.gain).toBeCloseTo(50.5); | |
| 25 | const out = String(draw(TWO).content); | |
| 26 | expect(out).toContain("<svg"); | |
| 27 | expect(out).toContain("111.2 km"); | |
| 28 | expect(out).toContain("<dd>2</dd>"); | |
| 29 | }); | |
| 30 | ||
| 31 | test("the svg has no fixed size, so it scales on a phone; facts are status rows", () => { | |
| 32 | const out = String(draw(TWO).content); | |
| 33 | expect(out).toContain("viewBox="); | |
| 34 | expect(out).not.toMatch(/<svg[^>]*\s(width|height)=/); | |
| 35 | expect(out).toContain('<div class="status-row"><dt>Distance</dt><dd>111.2 km</dd></div>'); | |
| 36 | expect(out).not.toContain("<figcaption"); | |
| 37 | }); | |
| 38 | ||
| 39 | test("names are escaped", () => { | |
| 40 | const out = String(draw(TWO.replace("Up &", "<script>")).content); | |
| 41 | expect(out).not.toContain("<script>"); | |
| 42 | expect(out).toContain("<script> down"); | |
| 43 | }); | |
| 44 | ||
| 45 | test("a malformed file falls back to text", () => { | |
| 46 | for (const bad of ["", "hello", "<gpx><trk></gpx>", "<gpx></gpx>", "<html><trkpt lat='1' lon='1'/></html>"]) { | |
| 47 | expect(draw(bad).fallback).toBeDefined(); | |
| 48 | } | |
| 49 | }); | |
| 50 | ||
| 51 | test("an entity-expansion attempt is not expanded", () => { | |
| 52 | const evil = `<?xml version="1.0"?><!DOCTYPE gpx [<!ENTITY a "AAAAAAAAAA"><!ENTITY b "&a;&a;&a;&a;">]> | |
| 53 | <gpx><trk><name>&b;</name><trkseg><trkpt lat="1" lon="1"/><trkpt lat="1.1" lon="1.1"/></trkseg></trk></gpx>`; | |
| 54 | const g = parseGpx(evil)!; | |
| 55 | expect(g.count).toBe(2); | |
| 56 | expect(g.lines[0]!.name).toBe("&b;"); | |
| 57 | expect(String(draw(evil).content)).not.toContain("AAAA"); | |
| 58 | }); | |
| 59 | ||
| 60 | test("more than 20,000 points are thinned evenly, ends kept", () => { | |
| 61 | let pts = ""; | |
| 62 | for (let i = 0; i < 30_000; i++) pts += `<trkpt lat="${(i / 30_000).toFixed(6)}" lon="0"/>`; | |
| 63 | const g = parseGpx(`<gpx><trk><trkseg>${pts}</trkseg></trk></gpx>`)!; | |
| 64 | expect(g.count).toBe(30_000); | |
| 65 | const path = outline(g).paths[0]!; | |
| 66 | expect(path.split("L").length).toBeLessThanOrEqual(20_000); | |
| 67 | expect(path.split("L").length).toBeGreaterThan(19_000); | |
| 68 | expect(path.startsWith("M")).toBe(true); | |
| 69 | // The last point is drawn: the top-left of the path is the northmost end. | |
| 70 | expect(path.endsWith("12.0")).toBe(true); | |
| 71 | }); | |
| 72 | ||
| 73 | test("routes and a single point do not break the outline", () => { | |
| 74 | const g = parseGpx(`<gpx><rte><name>r</name><rtept lat="1" lon="1"/></rte></gpx>`)!; | |
| 75 | expect(outline(g).paths.length).toBe(1); | |
| 76 | }); | |
| 77 | }); |
test/render-kinds.test.ts +1
| @@ -69,6 +69,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = { | ||
| 69 | 69 | ansi: [enc("\x1b[31mok\n"), "log", "application/octet-stream"], |
| 70 | 70 | srt: [enc("1\n00:00:01,000 --> 00:00:02,000\nHi\n"), "subtitles", "application/octet-stream"], |
| 71 | 71 | vtt: [enc("WEBVTT\n"), "subtitles", "application/octet-stream"], |
| 72 | gpx: [enc("<gpx/>"), "gpx", "application/octet-stream"], | |
| 72 | 73 | ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"], |
| 73 | 74 | }; |
| 74 | 75 |
test/tui-workspace.test.ts +33
| @@ -416,6 +416,39 @@ describe("the terminal workspace", () => { | ||
| 416 | 416 | } |
| 417 | 417 | }); |
| 418 | 418 | |
| 419 | test("a .gpx opens as its facts, sharing the web parser, and s flips to the source", async () => { | |
| 420 | const made = await createRepo(forge, alice, { | |
| 421 | name: "tracks", | |
| 422 | visibility: "public", | |
| 423 | description: "routes", | |
| 424 | }); | |
| 425 | if (!made.ok) throw new Error("repo failed"); | |
| 426 | await commitToBranch(forge, alice, made.value, { | |
| 427 | message: "seed", | |
| 428 | files: [ | |
| 429 | { | |
| 430 | path: "ride.gpx", | |
| 431 | content: | |
| 432 | '<gpx><trk><name>Morning ride</name><trkseg><trkpt lat="10" lon="20"><ele>100</ele></trkpt><trkpt lat="11" lon="20"><ele>150</ele></trkpt></trkseg></trk></gpx>', | |
| 433 | }, | |
| 434 | ], | |
| 435 | }); | |
| 436 | const h = boot(); | |
| 437 | try { | |
| 438 | await h.settle(); | |
| 439 | await open(h, "tracks"); | |
| 440 | await openFile(h, "ride.gpx"); | |
| 441 | expect(h.text()).toContain("Morning ride"); | |
| 442 | expect(h.text()).toContain("111.2 km"); | |
| 443 | expect(h.text()).toContain("50 m"); | |
| 444 | expect(h.text()).toContain("4 facts"); | |
| 445 | await h.press("s"); | |
| 446 | expect(h.text()).toContain("<trkpt"); | |
| 447 | } finally { | |
| 448 | h.stop(); | |
| 449 | } | |
| 450 | }); | |
| 451 | ||
| 419 | 452 | test("tables: search, edit, the caps in the status line, a ; file, and s on a broken one", async () => { |
| 420 | 453 | const made = await createRepo(forge, alice, { |
| 421 | 454 | name: "sheets", |
Principle 2/phone: fixed width="${width}" height="${height}" overflows a 360px screen and nothing styles .gpx-view. Remove the attributes, keep viewBox, and add CSS max-width:100%;height:auto using tokens (stroke currentColor is fine, no accent).