Public: anyone can read this; the crew can change it.
A GeoJSON file renders as an outline of its features and a table of their properties #10640
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10284-a-geojson-file-renders-as-an-outline-of → main 3 commits fork 3 threads · 3 unresolved
10 files
changed +404
−3
docs/design/areas/render.md +2
| @@ -25,3 +25,5 @@ A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the exis | ||
| 25 | 25 | ## GPS track (.gpx) |
| 26 | 26 | |
| 27 | 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`. |
| 28 | ||
| 29 | `src/web/render/geojson.ts` draws a GeoJSON file as one inline SVG in `.gj-view` (same rules as `.gpx-view`: no fixed size, `max-width: 100%`, `currentColor`), and the first 100 features' properties as a plain table in `.gj-table`, which scrolls sideways inside its own box on a phone. |
docs/features/geojson.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # GeoJSON (.geojson) as an outline and a properties table | |
| 2 | ||
| 3 | Open a `.geojson` file in a repository and see its features drawn as an outline, with a table of their properties. | |
| 4 | ||
| 5 | - **Web:** the blob page draws an inline SVG on the server (`src/web/render/geojson.ts`): points, lines and polygons, equirectangular with longitude scaled by the cosine of the middle latitude, fitted to the bounding box. It scales to the column. Below it is a table of the first 100 features (geometry type, then up to 12 property columns, cells cut at 200 characters). A wide table scrolls sideways in a focusable region (Tab to it, arrow keys to scroll). `?view=source`, Raw and Download work as for any file. | |
| 6 | - **Terminal:** the workspace opens a `.geojson` as the same table (`src/tui/screens/workspace.ts`, sharing `parseGeoJson` and `propertyRows`); `s` flips to the source. The status line reads `3 features`, with `first 100 shown` when cut. | |
| 7 | - A FeatureCollection, a single Feature or a bare geometry is accepted. A feature without geometry is listed but not drawn; with none drawable a sentence says so. An empty collection shows only the count. | |
| 8 | - Above 5,000 features nothing is drawn; above 20,000 vertices the outline is thinned evenly. The count stays true. No map tiles, no network. | |
| 9 | - A file that is not valid GeoJSON (bad coordinates, unknown type) shows as JSON with a one-line reason. All text is escaped. |
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 { parseGeoJson, propertyRows } from "../../web/render/geojson.ts"; | |
| 58 | 59 | import { gpxFacts, parseGpx } from "../../web/render/gpx.ts"; |
| 59 | 60 | import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts"; |
| 60 | 61 | import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts"; |
| @@ -352,6 +353,19 @@ function gpxTable(source: string, path: string): Table | null { | ||
| 352 | 353 | return { header: ["Fact", "Value"], rows, more: 0, delimiter: ",", columns: 2 }; |
| 353 | 354 | } |
| 354 | 355 | |
| 356 | /** `.geojson`: features, drawn as a table of their geometry type and properties. */ | |
| 357 | function isGeoJsonPath(path: string): boolean { | |
| 358 | return /\.geojson$/i.test(path); | |
| 359 | } | |
| 360 | ||
| 361 | /** The properties table of a GeoJSON file; null when it is not GeoJSON. Shares the web's parser. */ | |
| 362 | function geoJsonTable(source: string): Table | null { | |
| 363 | const parsed = parseGeoJson(source); | |
| 364 | if (parsed === undefined) return null; | |
| 365 | const { header, rows } = propertyRows(parsed.features); | |
| 366 | return { header, rows, more: Math.max(0, parsed.features.length - rows.length), delimiter: ",", columns: header.length }; | |
| 367 | } | |
| 368 | ||
| 355 | 369 | /** `README.md`, `NOTES.markdown`, `readme` — the prose a repo opens on. */ |
| 356 | 370 | function isMarkdownPath(path: string): boolean { |
| 357 | 371 | const name = path.slice(path.lastIndexOf("/") + 1); |
| @@ -514,6 +528,9 @@ class Tab { | ||
| 514 | 528 | } else if (isGpxPath(this.path)) { |
| 515 | 529 | this.table = gpxTable(new TextDecoder().decode(source), this.path); |
| 516 | 530 | if (this.table !== null) this.grid = layoutTable(this.table); |
| 531 | } else if (isGeoJsonPath(this.path)) { | |
| 532 | this.table = geoJsonTable(new TextDecoder().decode(source)); | |
| 533 | if (this.table !== null) this.grid = layoutTable(this.table); | |
| 517 | 534 | } else if (isTablePath(this.path)) { |
| 518 | 535 | // The decoded source, not `plain`: `expandTabs` has eaten every tab there. |
| 519 | 536 | const delimiter = /\.tsv$/i.test(this.path) ? "\t" : undefined; |
| @@ -2596,7 +2613,7 @@ export function workspaceScreen( | ||
| 2596 | 2613 | ? "" |
| 2597 | 2614 | : `${t.path} · ${ |
| 2598 | 2615 | t.tabular && t.table !== null |
| 2599 | ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path), isGpxPath(t.path)) | |
| 2616 | ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path), isGpxPath(t.path), isGeoJsonPath(t.path)) | |
| 2600 | 2617 | : `${plural( |
| 2601 | 2618 | t.reading ? t.doc.length : t.plain.length, |
| 2602 | 2619 | "line", |
| @@ -2930,9 +2947,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void { | ||
| 2930 | 2947 | } |
| 2931 | 2948 | |
| 2932 | 2949 | /** `80 rows, first 50 shown · 3 more columns`: what the table leaves out. */ |
| 2933 | function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false): string { | |
| 2950 | function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false, features = false): string { | |
| 2934 | 2951 | const rows = table.rows.length + table.more; |
| 2935 | return `${plural(rows, subs ? "cue" : events ? "event" : facts ? "fact" : "row")}${ | |
| 2952 | return `${plural(rows, subs ? "cue" : events ? "event" : facts ? "fact" : features ? "feature" : "row")}${ | |
| 2936 | 2953 | unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : "" |
| 2937 | 2954 | }${ |
| 2938 | 2955 | table.more > 0 ? `, first ${table.rows.length} shown` : "" |
src/web/render/geojson.ts added +214
| @@ -0,0 +1,214 @@ | ||
| 1 | /** | |
| 2 | * A GeoJSON file (`.geojson`, RFC 7946) drawn as an outline of its features | |
| 3 | * and a table of their properties. The outline is server-rendered inline SVG: | |
| 4 | * an equirectangular projection (longitude scaled by the cosine of the middle | |
| 5 | * latitude, as `gpx.ts` does) fitted to the bounding box. No tiles, no | |
| 6 | * network. Past `FEATURE_CAP` features only the table and a sentence show. | |
| 7 | * A file that is not valid GeoJSON is shown as the JSON config view. | |
| 8 | */ | |
| 9 | ||
| 10 | import { html, raw } from "../html.ts"; | |
| 11 | import { config } from "./config.ts"; | |
| 12 | import { thin } from "./gpx.ts"; | |
| 13 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 14 | import { fileSize } from "../ui.ts"; | |
| 15 | ||
| 16 | /** Above this many features no outline is drawn. */ | |
| 17 | export const FEATURE_CAP = 5_000; | |
| 18 | /** Rows of the properties table. */ | |
| 19 | export const TABLE_ROWS = 100; | |
| 20 | /** Columns of the table, and characters of one cell. */ | |
| 21 | const COLUMN_CAP = 12; | |
| 22 | const CELL_CHARS = 200; | |
| 23 | /** Vertices drawn in all; more are thinned evenly per part. */ | |
| 24 | const VERTEX_CAP = 20_000; | |
| 25 | ||
| 26 | type Position = readonly [number, number]; | |
| 27 | /** A geometry as drawable parts. */ | |
| 28 | interface Shape { | |
| 29 | readonly points: Position[]; | |
| 30 | readonly lines: Position[][]; | |
| 31 | readonly rings: Position[][]; | |
| 32 | } | |
| 33 | export interface Feature { | |
| 34 | readonly type: string; | |
| 35 | readonly props: Record<string, unknown>; | |
| 36 | readonly shape: Shape; | |
| 37 | } | |
| 38 | export interface GeoJson { | |
| 39 | readonly features: Feature[]; | |
| 40 | } | |
| 41 | ||
| 42 | const isObj = (v: unknown): v is Record<string, unknown> => | |
| 43 | typeof v === "object" && v !== null && !Array.isArray(v); | |
| 44 | ||
| 45 | function position(v: unknown): Position { | |
| 46 | if (!Array.isArray(v) || v.length < 2) throw new Error("position"); | |
| 47 | const [lon, lat] = v; | |
| 48 | if (typeof lon !== "number" || typeof lat !== "number" || !(Math.abs(lon) <= 180) || !(Math.abs(lat) <= 90)) | |
| 49 | throw new Error("range"); | |
| 50 | return [lon, lat]; | |
| 51 | } | |
| 52 | const list = (v: unknown, min = 0): unknown[] => { | |
| 53 | if (!Array.isArray(v) || v.length < min) throw new Error("array"); | |
| 54 | return v; | |
| 55 | }; | |
| 56 | ||
| 57 | function addGeometry(g: unknown, into: Shape, depth = 0): string { | |
| 58 | if (!isObj(g) || depth > 8) throw new Error("geometry"); | |
| 59 | const c = g.coordinates; | |
| 60 | switch (g.type) { | |
| 61 | case "Point": | |
| 62 | into.points.push(position(c)); | |
| 63 | break; | |
| 64 | case "MultiPoint": | |
| 65 | for (const p of list(c)) into.points.push(position(p)); | |
| 66 | break; | |
| 67 | case "LineString": | |
| 68 | into.lines.push(list(c, 2).map(position)); | |
| 69 | break; | |
| 70 | case "MultiLineString": | |
| 71 | for (const l of list(c)) into.lines.push(list(l, 2).map(position)); | |
| 72 | break; | |
| 73 | case "Polygon": | |
| 74 | for (const r of list(c)) into.rings.push(list(r, 4).map(position)); | |
| 75 | break; | |
| 76 | case "MultiPolygon": | |
| 77 | for (const p of list(c)) for (const r of list(p)) into.rings.push(list(r, 4).map(position)); | |
| 78 | break; | |
| 79 | case "GeometryCollection": | |
| 80 | for (const m of list(g.geometries)) addGeometry(m, into, depth + 1); | |
| 81 | break; | |
| 82 | default: | |
| 83 | throw new Error("type"); | |
| 84 | } | |
| 85 | return g.type; | |
| 86 | } | |
| 87 | ||
| 88 | function feature(f: unknown): Feature { | |
| 89 | if (!isObj(f)) throw new Error("feature"); | |
| 90 | const shape: Shape = { points: [], lines: [], rings: [] }; | |
| 91 | let type = "none"; | |
| 92 | if (f.type === "Feature") { | |
| 93 | if (f.geometry !== null && f.geometry !== undefined) type = addGeometry(f.geometry, shape); | |
| 94 | if (f.properties !== null && f.properties !== undefined && !isObj(f.properties)) throw new Error("properties"); | |
| 95 | return { type, props: (f.properties as Record<string, unknown> | null | undefined) ?? {}, shape }; | |
| 96 | } | |
| 97 | type = addGeometry(f, shape); | |
| 98 | return { type, props: {}, shape }; | |
| 99 | } | |
| 100 | ||
| 101 | /** Parse GeoJSON text. Pure. Undefined when it is not valid GeoJSON. */ | |
| 102 | export function parseGeoJson(source: string): GeoJson | undefined { | |
| 103 | try { | |
| 104 | const doc: unknown = JSON.parse(source.replace(/^/, "")); | |
| 105 | if (!isObj(doc)) return undefined; | |
| 106 | if (doc.type === "FeatureCollection") return { features: list(doc.features).map(feature) }; | |
| 107 | return { features: [feature(doc)] }; | |
| 108 | } catch { | |
| 109 | return undefined; | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | const W = 640; | |
| 114 | const MAX_H = 480; | |
| 115 | const PAD = 12; | |
| 116 | const rad = (d: number): number => (d * Math.PI) / 180; | |
| 117 | ||
| 118 | /** The drawing: SVG markup for the shapes, in a viewBox fitted to their bounds. */ | |
| 119 | export function outline(features: readonly Feature[]): { width: number; height: number; body: string } | undefined { | |
| 120 | let minLat = 90, maxLat = -90, minLon = 180, maxLon = -180; | |
| 121 | let vertices = 0; | |
| 122 | const see = (p: Position): void => { | |
| 123 | minLon = Math.min(minLon, p[0]); | |
| 124 | maxLon = Math.max(maxLon, p[0]); | |
| 125 | minLat = Math.min(minLat, p[1]); | |
| 126 | maxLat = Math.max(maxLat, p[1]); | |
| 127 | vertices++; | |
| 128 | }; | |
| 129 | for (const f of features) { | |
| 130 | f.shape.points.forEach(see); | |
| 131 | f.shape.lines.forEach((l) => l.forEach(see)); | |
| 132 | f.shape.rings.forEach((l) => l.forEach(see)); | |
| 133 | } | |
| 134 | if (vertices === 0) return undefined; | |
| 135 | const keep = vertices > VERTEX_CAP ? VERTEX_CAP / vertices : 1; | |
| 136 | const k = Math.cos(rad((minLat + maxLat) / 2)); | |
| 137 | const spanX = Math.max((maxLon - minLon) * k, 1e-9); | |
| 138 | const spanY = Math.max(maxLat - minLat, 1e-9); | |
| 139 | let scale = (W - 2 * PAD) / spanX; | |
| 140 | let height = spanY * scale + 2 * PAD; | |
| 141 | if (height > MAX_H) { | |
| 142 | scale = (MAX_H - 2 * PAD) / spanY; | |
| 143 | height = MAX_H; | |
| 144 | } | |
| 145 | const width = Math.max(spanX * scale + 2 * PAD, 2 * PAD + 1); | |
| 146 | const x = (p: Position): string => (PAD + (p[0] - minLon) * k * scale).toFixed(1); | |
| 147 | const y = (p: Position): string => (PAD + (maxLat - p[1]) * scale).toFixed(1); | |
| 148 | const path = (pts: Position[], min: number, close: boolean): string => | |
| 149 | thin(pts, Math.max(min, Math.floor(pts.length * keep))) | |
| 150 | .map((p, i) => `${i === 0 ? "M" : "L"}${x(p)} ${y(p)}`) | |
| 151 | .join("") + (close ? "Z" : ""); | |
| 152 | let areas = "", lines = "", dots = ""; | |
| 153 | for (const f of features) { | |
| 154 | for (const r of f.shape.rings) areas += path(r, 4, true); | |
| 155 | for (const l of f.shape.lines) lines += path(l, 2, false); | |
| 156 | for (const p of f.shape.points) dots += `<circle cx="${x(p)}" cy="${y(p)}" r="3"/>`; | |
| 157 | } | |
| 158 | const body = | |
| 159 | (areas === "" ? "" : `<path class="gj-area" d="${areas}" fill="currentColor" fill-opacity="0.2" fill-rule="evenodd"/>`) + | |
| 160 | (lines === "" ? "" : `<path class="gj-line" d="${lines}" fill="none"/>`) + | |
| 161 | (dots === "" ? "" : `<g class="gj-points" fill="currentColor" stroke="none">${dots}</g>`); | |
| 162 | return { width: Math.round(width), height: Math.round(height), body }; | |
| 163 | } | |
| 164 | ||
| 165 | const cell = (v: unknown): string => { | |
| 166 | const s = typeof v === "string" ? v : v === undefined ? "" : (JSON.stringify(v) ?? ""); | |
| 167 | return s.length > CELL_CHARS ? `${s.slice(0, CELL_CHARS)}…` : s; | |
| 168 | }; | |
| 169 | ||
| 170 | /** The properties table as plain text: the header, and a row per feature (first TABLE_ROWS). Shared with the terminal. */ | |
| 171 | export function propertyRows(features: readonly Feature[]): { header: string[]; rows: string[][] } { | |
| 172 | const rows = features.slice(0, TABLE_ROWS); | |
| 173 | const keys: string[] = []; | |
| 174 | for (const f of rows) | |
| 175 | for (const key of Object.keys(f.props)) if (!keys.includes(key) && keys.length < COLUMN_CAP) keys.push(key); | |
| 176 | return { | |
| 177 | header: ["#", "Geometry", ...keys], | |
| 178 | rows: rows.map((f, i) => [String(i + 1), f.type, ...keys.map((k) => cell(f.props[k]))]), | |
| 179 | }; | |
| 180 | } | |
| 181 | ||
| 182 | const count = (n: number): string => `${n.toLocaleString("en")} feature${n === 1 ? "" : "s"}`; | |
| 183 | ||
| 184 | export function geojson(input: RenderInput): Rendered { | |
| 185 | const parsed = parseGeoJson(new TextDecoder().decode(input.body)); | |
| 186 | if (parsed === undefined) { | |
| 187 | // Not GeoJSON: show it as the JSON document it may still be. | |
| 188 | const as = config({ ...input, path: input.path.replace(/\.geojson$/i, ".json") }); | |
| 189 | return { ...as, content: html`<p class="muted small">This file is not valid GeoJSON; shown as JSON.</p>${as.content}` }; | |
| 190 | } | |
| 191 | const { features } = parsed; | |
| 192 | const n = features.length; | |
| 193 | const { header, rows } = propertyRows(features); | |
| 194 | const table = html`<div class="gj-table" tabindex="0" role="region" aria-label="Feature properties"><table> | |
| 195 | <caption class="sr-only">Properties of ${count(n)}</caption> | |
| 196 | <thead><tr>${header.map((k) => html`<th scope="col">${k}</th>`)}</tr></thead> | |
| 197 | <tbody>${rows.map((r) => html`<tr>${r.map((v) => html`<td>${v}</td>`)}</tr>`)}</tbody> | |
| 198 | </table></div>`; | |
| 199 | const label = count(n); | |
| 200 | const drawn = n > FEATURE_CAP ? undefined : outline(features); | |
| 201 | const note = | |
| 202 | n > FEATURE_CAP | |
| 203 | ? `This file has more than ${FEATURE_CAP.toLocaleString("en")} features, too many to draw; only their properties are listed.` | |
| 204 | : drawn === undefined | |
| 205 | ? "No feature has a geometry to draw." | |
| 206 | : ""; | |
| 207 | const more = n > TABLE_ROWS ? ` Properties of the first ${TABLE_ROWS} are listed.` : ""; | |
| 208 | const content = html`<figure class="gj-view"> | |
| 209 | ${drawn === undefined ? null : html`<svg viewBox="0 0 ${drawn.width} ${drawn.height}" role="img" aria-label="Outline of ${label}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${raw(drawn.body)}</svg>`} | |
| 210 | <p class="muted small">${label}. ${note}${more}</p> | |
| 211 | ${n === 0 ? null : table} | |
| 212 | </figure>`; | |
| 213 | return { content, lines: 0, summary: `${label} · ${fileSize(input.size)}` }; | |
| 214 | } |
src/web/render/kinds.ts +3
| @@ -29,6 +29,7 @@ export type FileKind = | ||
| 29 | 29 | | "log" |
| 30 | 30 | | "subtitles" |
| 31 | 31 | | "gpx" |
| 32 | | "geojson" | |
| 32 | 33 | | "calendar" |
| 33 | 34 | | "text" |
| 34 | 35 | | "binary"; |
| @@ -58,6 +59,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 58 | 59 | log: 1 * MB, |
| 59 | 60 | subtitles: 5 * MB, |
| 60 | 61 | gpx: 20 * MB, |
| 62 | geojson: 5 * MB, | |
| 61 | 63 | calendar: 5 * MB, |
| 62 | 64 | text: 1 * MB, |
| 63 | 65 | binary: 0, |
| @@ -166,6 +168,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 166 | 168 | srt: "subtitles", |
| 167 | 169 | vtt: "subtitles", |
| 168 | 170 | gpx: "gpx", |
| 171 | geojson: "geojson", | |
| 169 | 172 | ics: "calendar", |
| 170 | 173 | }; |
| 171 | 174 |
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 { geojson } from "./geojson.ts"; | |
| 14 | 15 | import { gpx } from "./gpx.ts"; |
| 15 | 16 | import { font } from "./font.ts"; |
| 16 | 17 | import { ics } from "./ics.ts"; |
| @@ -73,6 +74,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 73 | 74 | log, |
| 74 | 75 | subtitles, |
| 75 | 76 | gpx, |
| 77 | geojson, | |
| 76 | 78 | calendar: ics, |
| 77 | 79 | archive, |
| 78 | 80 | font, |
src/web/style/content.ts +6
| @@ -481,6 +481,12 @@ form.inline { display: inline; margin-left: auto; } | ||
| 481 | 481 | .gpx-view { margin: 0; } |
| 482 | 482 | .gpx-view svg { display: block; max-width: 100%; height: auto; margin: 0 auto; color: var(--fg); } |
| 483 | 483 | .gpx-view .status-rows { margin-top: 12px; } |
| 484 | .gj-view { margin: 0; } | |
| 485 | .gj-view svg { display: block; max-width: 100%; height: auto; margin: 0 auto; color: var(--fg); } | |
| 486 | .gj-table { overflow-x: auto; max-width: 100%; } | |
Principle 6/accessibility: this scroll container needs tabindex=0, role=region and an aria-label in the markup (geojson.ts), or keyboard users cannot reach the overflow. Also keep it hairline-only, no box. |
||
| 487 | .gj-table:focus-visible { outline: 1px solid var(--fg); outline-offset: 2px; } | |
| 488 | .gj-table table { width: 100%; font-size: 13px; } | |
| 489 | .gj-table th, .gj-table td { text-align: left; padding: 3px 8px; overflow-wrap: anywhere; } | |
| 484 | 490 | .font-view { padding: 16px 20px; } |
| 485 | 491 | .font-name { margin: 0 0 12px; font-size: 1.75rem; line-height: 1.3; overflow-wrap: anywhere; } |
| 486 | 492 | .font-line { margin: 0 0 10px; line-height: 1.4; overflow-wrap: anywhere; } |
test/render-geojson.test.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | /** GeoJSON files as an outline and a properties table. */ | |
Missing tests: single Feature / bare geometry input, null geometry note, column cap (12), cell truncation (200 chars), empty collection. |
||
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { geojson, parseGeoJson } from "../src/web/render/geojson.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) => | |
| 9 | geojson({ path: "r/a.geojson", size: text.length, body: enc(text), media: "/m", download: "/d" }); | |
| 10 | const fc = (features: unknown[]) => JSON.stringify({ type: "FeatureCollection", features }); | |
| 11 | const feat = (geometry: unknown, properties: unknown = {}) => ({ type: "Feature", geometry, properties }); | |
| 12 | ||
| 13 | const ALL = fc([ | |
| 14 | feat({ type: "Point", coordinates: [10, 20] }, { name: "<b>bold</b>" }), | |
| 15 | feat({ type: "LineString", coordinates: [[10, 20], [11, 21]] }, { name: "road" }), | |
| 16 | feat({ type: "Polygon", coordinates: [[[10, 20], [12, 20], [12, 22], [10, 20]]] }, { name: "lot" }), | |
| 17 | ]); | |
| 18 | ||
| 19 | describe("geojson", () => { | |
| 20 | test("registered", () => { | |
| 21 | expect(fileKind("a.geojson", enc(ALL))).toBe("geojson"); | |
| 22 | expect(RENDERERS.geojson).toBe(geojson); | |
| 23 | }); | |
| 24 | ||
| 25 | test("a point, a line and a polygon are drawn; the count is shown", () => { | |
| 26 | const out = String(draw(ALL).content); | |
| 27 | expect(out).toContain("<svg"); | |
| 28 | expect(out).toContain("<circle"); | |
| 29 | expect(out).toContain('class="gj-line"'); | |
| 30 | expect(out).toContain('class="gj-area"'); | |
| 31 | expect(out).toContain("3 features"); | |
| 32 | expect(out).toContain("<td>Polygon</td>"); | |
| 33 | }); | |
| 34 | ||
| 35 | test("projection: a point at the south-west corner sits at the padding", () => { | |
| 36 | const out = String(draw(fc([feat({ type: "LineString", coordinates: [[0, 0], [10, 10]] })])).content); | |
| 37 | expect(out).toContain("M12.0 "); | |
| 38 | expect(out).not.toMatch(/<svg[^>]*\s(width|height)=/); | |
| 39 | }); | |
| 40 | ||
| 41 | test("a property with markup arrives escaped", () => { | |
| 42 | const out = String(draw(ALL).content); | |
| 43 | expect(out).not.toContain("<b>bold"); | |
| 44 | expect(out).toContain("<b>bold</b>"); | |
| 45 | }); | |
| 46 | ||
| 47 | test("the table lists only the first 100 features", () => { | |
| 48 | const many = fc(Array.from({ length: 150 }, (_, i) => feat({ type: "Point", coordinates: [i / 10, 0] }, { id: `n${i}` }))); | |
| 49 | const out = String(draw(many).content); | |
| 50 | expect(out).toContain("<td>n99</td>"); | |
| 51 | expect(out).not.toContain("<td>n100</td>"); | |
| 52 | }); | |
| 53 | ||
| 54 | test("over 5,000 features only the table and a sentence show", () => { | |
| 55 | const huge = fc(Array.from({ length: 5001 }, () => feat({ type: "Point", coordinates: [1, 1] }, { a: 1 }))); | |
| 56 | const out = String(draw(huge).content); | |
| 57 | expect(out).not.toContain("<svg"); | |
| 58 | expect(out).toContain("too many to draw"); | |
| 59 | expect(out).toContain("5,001 features"); | |
| 60 | expect(out).toContain("<table"); | |
| 61 | }); | |
| 62 | ||
| 63 | test("invalid GeoJSON falls back to the JSON config view", () => { | |
| 64 | for (const bad of ['{"type":"Feature","geometry":{"type":"Point","coordinates":[500,0]}}', '{"a":1}', "nope"]) { | |
| 65 | expect(parseGeoJson(bad)).toBeUndefined(); | |
| 66 | const r = draw(bad); | |
| 67 | expect(r.fallback).toBeUndefined(); | |
| 68 | expect(String(r.content)).not.toContain("<svg"); | |
| 69 | expect(String(r.content)).toContain("not valid GeoJSON"); | |
| 70 | } | |
| 71 | expect(String(draw('{"a":1}').content)).toContain("config-form"); | |
| 72 | }); | |
| 73 | ||
| 74 | test("a single Feature and a bare geometry are one feature each", () => { | |
| 75 | const one = String(draw(JSON.stringify(feat({ type: "Point", coordinates: [1, 2] }, { name: "solo" }))).content); | |
| 76 | expect(one).toContain("1 feature."); | |
| 77 | expect(one).toContain("<td>solo</td>"); | |
| 78 | expect(one).toContain("<circle"); | |
| 79 | const bare = String(draw('{"type":"LineString","coordinates":[[0,0],[1,1]]}').content); | |
| 80 | expect(bare).toContain("1 feature."); | |
| 81 | expect(bare).toContain("<td>LineString</td>"); | |
| 82 | expect(bare).toContain('class="gj-line"'); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a feature with null geometry says there is nothing to draw", () => { | |
| 86 | const out = String(draw(fc([feat(null, { name: "nowhere" })])).content); | |
| 87 | expect(out).not.toContain("<svg"); | |
| 88 | expect(out).toContain("No feature has a geometry to draw."); | |
| 89 | expect(out).toContain("<td>nowhere</td>"); | |
| 90 | }); | |
| 91 | ||
| 92 | test("the table has at most 12 property columns", () => { | |
| 93 | const props = Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`k${i}`, i])); | |
| 94 | const out = String(draw(fc([feat({ type: "Point", coordinates: [0, 0] }, props)])).content); | |
| 95 | expect(out).toContain("<th scope=\"col\">k11</th>"); | |
| 96 | expect(out).not.toContain(">k12<"); | |
| 97 | expect(out.match(/<th /g)?.length).toBe(14); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a cell is cut at 200 characters", () => { | |
| 101 | const out = String(draw(fc([feat({ type: "Point", coordinates: [0, 0] }, { t: "x".repeat(300) })])).content); | |
| 102 | expect(out).toContain(`<td>${"x".repeat(200)}…</td>`); | |
| 103 | expect(out).not.toContain("x".repeat(201)); | |
| 104 | }); | |
| 105 | ||
| 106 | test("an empty collection shows the count and no table", () => { | |
| 107 | const out = String(draw(fc([])).content); | |
| 108 | expect(out).toContain("0 features"); | |
| 109 | expect(out).not.toContain("<table"); | |
| 110 | expect(out).not.toContain("<svg"); | |
| 111 | }); | |
| 112 | ||
| 113 | test("the scrolling table is reachable by keyboard and captioned", () => { | |
| 114 | const out = String(draw(ALL).content); | |
| 115 | expect(out).toContain('class="gj-table" tabindex="0" role="region" aria-label="Feature properties"'); | |
| 116 | expect(out).toContain("<caption"); | |
| 117 | expect(out).toContain('<th scope="col">'); | |
| 118 | }); | |
| 119 | }); |
test/render-kinds.test.ts +1
| @@ -70,6 +70,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = { | ||
| 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 | 72 | gpx: [enc("<gpx/>"), "gpx", "application/octet-stream"], |
| 73 | geojson: [enc('{"type":"FeatureCollection","features":[]}'), "geojson", "application/octet-stream"], | |
| 73 | 74 | ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"], |
| 74 | 75 | }; |
| 75 | 76 |
test/tui-workspace.test.ts +28
| @@ -449,6 +449,34 @@ describe("the terminal workspace", () => { | ||
| 449 | 449 | } |
| 450 | 450 | }); |
| 451 | 451 | |
| 452 | test("a .geojson opens as its features table, sharing the web parser, and s flips to the source", async () => { | |
| 453 | const made = await createRepo(forge, alice, { name: "places", visibility: "public", description: "geo" }); | |
| 454 | if (!made.ok) throw new Error("repo failed"); | |
| 455 | await commitToBranch(forge, alice, made.value, { | |
| 456 | message: "seed", | |
| 457 | files: [ | |
| 458 | { | |
| 459 | path: "p.geojson", | |
| 460 | content: | |
| 461 | '{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[1,2]},"properties":{"name":"Harbour"}}]}', | |
| 462 | }, | |
| 463 | ], | |
| 464 | }); | |
| 465 | const h = boot(); | |
| 466 | try { | |
| 467 | await h.settle(); | |
| 468 | await open(h, "places"); | |
| 469 | await openFile(h, "p.geojson"); | |
| 470 | expect(h.text()).toContain("Harbour"); | |
| 471 | expect(h.text()).toContain("Point"); | |
| 472 | expect(h.text()).toContain("1 feature"); | |
| 473 | await h.press("s"); | |
| 474 | expect(h.text()).toContain("FeatureCollection"); | |
| 475 | } finally { | |
| 476 | h.stop(); | |
| 477 | } | |
| 478 | }); | |
| 479 | ||
| 452 | 480 | test("tables: search, edit, the caps in the status line, a ; file, and s on a broken one", async () => { |
| 453 | 481 | const made = await createRepo(forge, alice, { |
| 454 | 482 | name: "sheets", |
Both doors: no src/tui/ screen and no docs/features/geojson.md. GPX has both. Add them.