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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/10284-a-geojson-file-renders-as-an-outline-of → main 3 commits fork 3 threads · 3 unresolved

  • Merged. torque/10284-a-geojson-file-renders-as-an-outline-of was merged into main as 3933c81.

Review

Sign in to review this change.
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
2525 ## GPS track (.gpx)
2626
2727 `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";
5555 import type { RepoFacts, RepoSource } from "../../view/source.ts";
5656 import { detectLanguage, highlightLines } from "../../web/highlight.ts";
5757 import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts";
58 import { parseGeoJson, propertyRows } from "../../web/render/geojson.ts";
5859 import { gpxFacts, parseGpx } from "../../web/render/gpx.ts";
5960 import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts";
6061 import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts";
@@ -352,6 +353,19 @@ function gpxTable(source: string, path: string): Table | null {
352353 return { header: ["Fact", "Value"], rows, more: 0, delimiter: ",", columns: 2 };
353354 }
354355
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
355369 /** `README.md`, `NOTES.markdown`, `readme` — the prose a repo opens on. */
356370 function isMarkdownPath(path: string): boolean {
357371 const name = path.slice(path.lastIndexOf("/") + 1);
@@ -514,6 +528,9 @@ class Tab {
514528 } else if (isGpxPath(this.path)) {
515529 this.table = gpxTable(new TextDecoder().decode(source), this.path);
516530 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);
517534 } else if (isTablePath(this.path)) {
518535 // The decoded source, not `plain`: `expandTabs` has eaten every tab there.
519536 const delimiter = /\.tsv$/i.test(this.path) ? "\t" : undefined;
@@ -2596,7 +2613,7 @@ export function workspaceScreen(
25962613 ? ""
25972614 : `${t.path} · ${
25982615 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))
26002617 : `${plural(
26012618 t.reading ? t.doc.length : t.plain.length,
26022619 "line",
@@ -2930,9 +2947,9 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void {
29302947 }
29312948
29322949 /** `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 {
29342951 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")}${
29362953 unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : ""
29372954 }${
29382955 table.more > 0 ? `, first ${table.rows.length} shown` : ""
src/web/render/geojson.ts added +214
@@ -0,0 +1,214 @@
1 /**

Both doors: no src/tui/ screen and no docs/features/geojson.md. GPX has both. Add them.

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 =
2929 | "log"
3030 | "subtitles"
3131 | "gpx"
32 | "geojson"
3233 | "calendar"
3334 | "text"
3435 | "binary";
@@ -58,6 +59,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
5859 log: 1 * MB,
5960 subtitles: 5 * MB,
6061 gpx: 20 * MB,
62 geojson: 5 * MB,
6163 calendar: 5 * MB,
6264 text: 1 * MB,
6365 binary: 0,
@@ -166,6 +168,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
166168 srt: "subtitles",
167169 vtt: "subtitles",
168170 gpx: "gpx",
171 geojson: "geojson",
169172 ics: "calendar",
170173 };
171174
src/web/render/renderers.ts +2
@@ -11,6 +11,7 @@ import { audio } from "./audio.ts";
1111 import { config } from "./config.ts";
1212 import { isFormConfig } from "./config-format.ts";
1313 import { csv } from "./csv.ts";
14 import { geojson } from "./geojson.ts";
1415 import { gpx } from "./gpx.ts";
1516 import { font } from "./font.ts";
1617 import { ics } from "./ics.ts";
@@ -73,6 +74,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
7374 log,
7475 subtitles,
7576 gpx,
77 geojson,
7678 calendar: ics,
7779 archive,
7880 font,
src/web/style/content.ts +6
@@ -481,6 +481,12 @@ form.inline { display: inline; margin-left: auto; }
481481 .gpx-view { margin: 0; }
482482 .gpx-view svg { display: block; max-width: 100%; height: auto; margin: 0 auto; color: var(--fg); }
483483 .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; }
484490 .font-view { padding: 16px 20px; }
485491 .font-name { margin: 0 0 12px; font-size: 1.75rem; line-height: 1.3; overflow-wrap: anywhere; }
486492 .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("&lt;b&gt;bold&lt;/b&gt;");
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]> = {
7070 srt: [enc("1\n00:00:01,000 --> 00:00:02,000\nHi\n"), "subtitles", "application/octet-stream"],
7171 vtt: [enc("WEBVTT\n"), "subtitles", "application/octet-stream"],
7272 gpx: [enc("<gpx/>"), "gpx", "application/octet-stream"],
73 geojson: [enc('{"type":"FeatureCollection","features":[]}'), "geojson", "application/octet-stream"],
7374 ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"],
7475 };
7576
test/tui-workspace.test.ts +28
@@ -449,6 +449,34 @@ describe("the terminal workspace", () => {
449449 }
450450 });
451451
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
452480 test("tables: search, edit, the caps in the status line, a ; file, and s on a broken one", async () => {
453481 const made = await createRepo(forge, alice, {
454482 name: "sheets",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs