clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / render / test / element.test.ts
element.test.ts 13.0K
1// A feature made of several pieces is drawn as several pieces.2//3// This suite exists because it was not. `chart/element.ts` built one4// `Polygon` per area out of a flat list of every ring the feature owned, and5// one `LineString` per path out of the *first* run of it. Both are lossy in6// the same direction, and both are silent:7//8// AN AREA. A polygon reads its first ring as the exterior and every ring9// after it as a hole. A district of two separate pieces of ground therefore10// drew its second piece as a hole punched out of its first — the city's11// `RS` zoning is fifty-three pieces, so fifty-two of them were subtracted12// from the one that happened to come first.13//14// A PATH. A line string holds one run. The Deschutes River Trail is15// recorded as a hundred and forty-seven runs, and a hundred and forty-six16// of them were dropped on the floor without a word.17//18// The model never lost the structure — `lines[i]` is a part and `holes[i]`19// belongs to *that* part (`world/model.ts`) — so the whole of the fix is to20// stop flattening it on the way to OpenLayers, and the whole of this suite is21// to hold the structure at every place it is read: the drawn geometry, the22// scrim's cut-outs, the title anchor and the containment test.23//24// The first cases are synthetic, because a two-piece square with a hole in25// each piece says in six coordinates what a real district says in six26// thousand. The last three are features read out of27// `testdata/corpus/bundles`, so the counts are a real producer's — two of28// the city's own, named, and then every shape of every corpus volume, plus29// the invented game plane's, held against the part structure its payload30// declares.3132import test from "node:test";33import { strict as assert } from "node:assert";34import type MultiLineString from "ol/geom/MultiLineString.js";35import type MultiPolygon from "ol/geom/MultiPolygon.js";36import { WorldModel, worldGrid } from "../world/model.ts";37import type { Coordinate, Line, ShapeRecord } from "../world/model.ts";38import { shapeContains } from "../world/visibility.ts";39import type { Collection } from "../data/payload.ts";40import { payloads, tileGrid, volumes } from "./fixtures.ts";41import { gamePlane } from "./models.ts";4243// The seam's chart element extends `HTMLElement` at the moment it is defined,44// which is one browser global too many for `node --test`. Nothing else in the45// module reaches for the page outside a method, so a bare stand-in is enough46// to import it and ask its geometry builders what they build.47(globalThis as unknown as { HTMLElement: unknown }).HTMLElement = class {};48const { centreOf, parts, scrimGeometry, shapeGeometry } = await import("../chart/element.ts");4950const AREAS: Collection = { id: 1, title: "Districts", kind: "area", visible: true };51const PATHS: Collection = { id: 2, title: "Trails", kind: "path", visible: true };5253/** A square, closed, running counter-clockwise. */54function square(x: number, y: number, side: number): Line {55 return [[x, y], [x + side, y], [x + side, y + side], [x, y + side], [x, y]];56}5758function shape(59 kind: "area" | "path", lines: Line[], holes: Line[][] = [],60): ShapeRecord {61 return {62 id: "1", title: "Two Pieces", subtitle: "", collection: kind === "area" ? AREAS : PATHS,63 kind, shard: 0, lines, holes, center: null,64 feature: { id: 1, title: "Two Pieces", geometry: [] },65 };66}6768/**69 * Two separate pieces of ground, each with a hole of its own.70 *71 * The holes are what tell a per-part builder from a flattening one: a72 * flattener produces four rings on one polygon, and there is no arrangement73 * of four rings on one polygon that means this.74 */75const TWO_PIECES = shape(76 "area",77 [square(0, 0, 10), square(20, 0, 10)],78 [[square(3, 3, 4)], [square(23, 3, 4)]],79);8081/** Twice the signed area of a ring: positive counter-clockwise. */82function signedArea(ring: readonly Coordinate[]): number {83 let area = 0;84 for (let i = 0, prior = ring.length - 1; i < ring.length; prior = i++) {85 const a = ring[prior];86 const b = ring[i];87 if (!a || !b) continue;88 area += a[0] * b[1] - b[0] * a[1];89 }90 return area;91}9293/** The same ring, whichever way round it is drawn. */94function sameRing(drawn: readonly Coordinate[], source: readonly Coordinate[]): boolean {95 const spell = (ring: readonly Coordinate[]) => JSON.stringify(ring);96 return spell(drawn) === spell(source) || spell(drawn) === spell([...source].reverse());97}9899test("an area of several pieces draws as several filled parts", () => {100 const geometry = shapeGeometry(TWO_PIECES) as MultiPolygon;101 assert.equal(geometry.getType(), "MultiPolygon");102 const polygons = geometry.getPolygons();103 assert.equal(polygons.length, 2, "two pieces of ground are two polygons");104 // The defect in one assertion: flattened, this is one polygon of four105 // rings, and the second piece of ground is the first piece's hole.106 for (const polygon of polygons) {107 assert.equal(polygon.getLinearRings().length, 2, "an exterior and its own hole");108 }109 assert.deepEqual(polygons[0]?.getLinearRing(0)?.getCoordinates(), square(0, 0, 10));110 assert.deepEqual(polygons[1]?.getLinearRing(0)?.getCoordinates(), square(20, 0, 10));111});112113test("a hole stays with the part that owns it", () => {114 assert.deepEqual(parts(TWO_PIECES), [115 [square(0, 0, 10), square(3, 3, 4)],116 [square(20, 0, 10), square(23, 3, 4)],117 ]);118 // And a part whose own hole list is missing is an exterior alone rather119 // than an exterior wearing the next part's holes.120 assert.deepEqual(parts(shape("area", [square(0, 0, 10), square(20, 0, 10)], [])), [121 [square(0, 0, 10)], [square(20, 0, 10)],122 ]);123});124125test("a path keeps every run of itself", () => {126 const runs: Line[] = [[[0, 0], [1, 1]], [[5, 5], [6, 6], [7, 7]], [[9, 9], [10, 10]]];127 const geometry = shapeGeometry(shape("path", runs)) as MultiLineString;128 assert.equal(geometry.getType(), "MultiLineString");129 assert.deepEqual(geometry.getCoordinates(), runs);130});131132test("the scrim cuts each part out and puts each part's holes back", () => {133 const world = square(0, -100, 100);134 const rings = scrimGeometry(world, [TWO_PIECES, shape("path", [[[0, 0], [1, 1]]])])135 .getLinearRings().map((ring) => ring.getCoordinates() as Coordinate[]);136 // The world, two exteriors and two interiors. A highlighted path has no137 // ring to cut from the dimming and contributes none.138 assert.equal(rings.length, 5);139 const worldRing = rings[0];140 assert.ok(worldRing);141 assert.ok(sameRing(worldRing, world), "the first ring is the world rectangle");142 const outward = Math.sign(signedArea(worldRing));143 assert.notEqual(outward, 0);144 const expected = [145 { source: square(0, 0, 10), winding: -outward },146 { source: square(3, 3, 4), winding: outward },147 { source: square(20, 0, 10), winding: -outward },148 { source: square(23, 3, 4), winding: outward },149 ];150 expected.forEach((want, index) => {151 const ring = rings[index + 1];152 assert.ok(ring);153 assert.ok(sameRing(ring, want.source), `ring ${index + 1} is its source ring`);154 assert.equal(155 Math.sign(signedArea(ring)), want.winding,156 `ring ${index + 1}: an exterior is wound against the world, an interior with it`);157 });158});159160test("a single-part shape's scrim is unchanged", () => {161 const one = shape("area", [square(0, 0, 10)]);162 const rings = scrimGeometry(square(0, -100, 100), [one])163 .getLinearRings().map((ring) => ring.getCoordinates() as Coordinate[]);164 assert.equal(rings.length, 2);165 const world = rings[0];166 const cut = rings[1];167 assert.ok(world && cut);168 assert.equal(Math.sign(signedArea(cut)), -Math.sign(signedArea(world)));169});170171test("the title anchors at the middle of every part, holes or not", () => {172 assert.deepEqual(centreOf(TWO_PIECES), [15, 5]);173 assert.deepEqual(centreOf(shape("area", [square(0, 0, 10), square(20, 0, 10)])), [15, 5]);174 assert.equal(centreOf(shape("area", [])), null);175});176177test("containment asks every part, and every part's own holes", () => {178 assert.ok(shapeContains(TWO_PIECES, [5, 1]), "inside the first piece");179 assert.ok(shapeContains(TWO_PIECES, [25, 1]), "inside the second piece");180 assert.ok(!shapeContains(TWO_PIECES, [5, 5]), "inside the first piece's own hole");181 assert.ok(!shapeContains(TWO_PIECES, [25, 5]), "inside the second piece's own hole");182 assert.ok(!shapeContains(TWO_PIECES, [15, 5]), "between the two pieces");183 // One unit of grace: a pin dropped on a boundary meant the ground it is on.184 assert.ok(shapeContains(TWO_PIECES, [10.5, 5]), "half a unit outside a boundary");185 assert.ok(!shapeContains(TWO_PIECES, [12, 5]), "two units outside a boundary");186});187188// ---- the fixtures' own features ----------------------------------------189190/** The city world, built the way the chart builds it. */191function city(): WorldModel {192 const payload = payloads("bend-or").get("2026-08-02");193 assert.ok(payload, "the city fixture has a world payload");194 return new WorldModel("2026-08-02", payload, worldGrid(tileGrid("bend-or"), payload), null);195}196197/**198 * How the payload itself spells a feature's parts, before anything projects199 * them: one entry per part, holding that part's rings by their vertex counts200 * — or, for a path, its single run's.201 */202function payloadParts(shape: ShapeRecord): number[][] {203 const rings = (part: unknown) => (part as unknown[]).map((ring) => (ring as unknown[]).length);204 const counts: number[][] = [];205 for (const geometry of shape.feature.geometry ?? []) {206 const coordinates = geometry.coordinates as unknown[];207 if (geometry.type === "MultiPolygon") {208 for (const polygon of coordinates) counts.push(rings(polygon));209 } else if (geometry.type === "Polygon") {210 counts.push(rings(coordinates));211 } else if (geometry.type === "MultiLineString") {212 for (const line of coordinates) counts.push([(line as unknown[]).length]);213 } else if (geometry.type === "LineString") {214 counts.push([coordinates.length]);215 }216 }217 return counts;218}219220test("the city's zoning draws every piece of ground it owns", () => {221 const world = city();222 // `RS`: fifty-three separate pieces of ground, twenty-five holes among223 // them, and one piece carrying sixteen rings by itself.224 const zoning = world.shapeByID.get("247260267");225 assert.ok(zoning, "the city has its RS zoning");226 const geometry = shapeGeometry(zoning) as MultiPolygon;227 assert.equal(geometry.getType(), "MultiPolygon");228 const drawn = geometry.getPolygons().map(229 (polygon) => polygon.getLinearRings().map((ring) => ring.getCoordinates().length));230 assert.equal(drawn.length, 53, "fifty-three separate pieces of ground");231 assert.deepEqual(drawn, payloadParts(zoning), "every part, ring for ring, vertex for vertex");232 assert.equal(drawn.reduce((held, rings) => held + rings.length, 0), 78, "78 rings in all");233 // A flattening builder answers one polygon of seventy-eight rings here, of234 // which fifty-two are pieces of ground turned inside out.235 assert.notEqual(drawn.length, 1);236});237238test("the city's longest trail draws all hundred and forty-seven of its runs", () => {239 const world = city();240 const trail = world.shapeByID.get("1071970836");241 assert.ok(trail, "the city has the Deschutes River Trail");242 assert.equal(trail.title, "Deschutes River Trail");243 const geometry = shapeGeometry(trail) as MultiLineString;244 assert.equal(geometry.getType(), "MultiLineString");245 const drawn = geometry.getLineStrings().map((line) => line.getCoordinates().length);246 assert.deepEqual(drawn, payloadParts(trail).map((counts) => counts[0]));247 assert.equal(drawn.length, 147, "not one run of it, and not one run short");248});249250test("no shape in any volume, corpus or invented, loses a part", () => {251 // The corpus's two volumes, and the invented game plane beside them: mars252 // carries no shapes at all, so the multipart weight is the city's — its253 // zoning and trails alone are ninety-eight multipart features.254 const worlds: [string, WorldModel][] = [];255 for (const slug of volumes()) {256 for (const [name, payload] of payloads(slug)) {257 worlds.push(258 [`${slug}/${name}`, new WorldModel(name, payload, worldGrid(tileGrid(slug), payload), null)]);259 }260 }261 const city = gamePlane();262 for (const [name, payload] of city.worlds) {263 worlds.push(264 [`${city.slug}/${name}`, new WorldModel(name, payload, city.tileGrid, null)]);265 }266 let multipart = 0;267 for (const [label, world] of worlds) {268 for (const record of world.shapes) {269 const declared = payloadParts(record);270 if (declared.length > 1) multipart++;271 const where = `${label}: ${record.title} (${record.id})`;272 if (record.kind === "area") {273 const geometry = shapeGeometry(record) as MultiPolygon;274 assert.deepEqual(geometry.getPolygons().map(275 (polygon) => polygon.getLinearRings().map((ring) => ring.getCoordinates().length)),276 declared, where);277 } else {278 const geometry = shapeGeometry(record) as MultiLineString;279 assert.deepEqual(geometry.getLineStrings().map(280 (line) => [line.getCoordinates().length]), declared, where);281 }282 }283 }284 assert.ok(multipart > 90, `the volumes are multi-part enough to be a test (${multipart})`);285});