clone

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

feat/atlas-next-horizon
atlas / render / test / element.test.ts
element.test.ts 12.6K
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";42import { legacyOpenWorld } from "./legacy.ts";4344// The seam's chart element extends `HTMLElement` at the moment it is defined,45// which is one browser global too many for `node --test`. Nothing else in the46// module reaches for the page outside a method, so a bare stand-in is enough47// to import it and ask its geometry builders what they build.48(globalThis as unknown as { HTMLElement: unknown }).HTMLElement = class {};49const { centreOf, parts, scrimGeometry, shapeGeometry } = await import("../chart/element.ts");5051const AREAS: Collection = { id: 1, title: "Districts", kind: "area", visible: true };52const PATHS: Collection = { id: 2, title: "Trails", kind: "path", visible: true };5354/** A square, closed, running counter-clockwise. */55function square(x: number, y: number, side: number): Line {56 return [[x, y], [x + side, y], [x + side, y + side], [x, y + side], [x, y]];57}5859function shape(60 kind: "area" | "path", lines: Line[], holes: Line[][] = [],61): ShapeRecord {62 return {63 id: "1", title: "Two Pieces", subtitle: "", collection: kind === "area" ? AREAS : PATHS,64 kind, shard: 0, lines, holes, center: null,65 feature: {66 id: "1", title: "Two Pieces", subtitle: "", description: "", center: null, shard: 0,67 geometry: { kind: kind === "path" ? 2 : 3, parts: [] },68 properties: [], relationships: [], provenance: [],69 },70 };71}7273/**74 * Two separate pieces of ground, each with a hole of its own.75 *76 * The holes are what tell a per-part builder from a flattening one: a77 * flattener produces four rings on one polygon, and there is no arrangement78 * of four rings on one polygon that means this.79 */80const TWO_PIECES = shape(81 "area",82 [square(0, 0, 10), square(20, 0, 10)],83 [[square(3, 3, 4)], [square(23, 3, 4)]],84);8586/** Twice the signed area of a ring: positive counter-clockwise. */87function signedArea(ring: readonly Coordinate[]): number {88 let area = 0;89 for (let i = 0, prior = ring.length - 1; i < ring.length; prior = i++) {90 const a = ring[prior];91 const b = ring[i];92 if (!a || !b) continue;93 area += a[0] * b[1] - b[0] * a[1];94 }95 return area;96}9798/** The same ring, whichever way round it is drawn. */99function sameRing(drawn: readonly Coordinate[], source: readonly Coordinate[]): boolean {100 const spell = (ring: readonly Coordinate[]) => JSON.stringify(ring);101 return spell(drawn) === spell(source) || spell(drawn) === spell([...source].reverse());102}103104test("an area of several pieces draws as several filled parts", () => {105 const geometry = shapeGeometry(TWO_PIECES) as MultiPolygon;106 assert.equal(geometry.getType(), "MultiPolygon");107 const polygons = geometry.getPolygons();108 assert.equal(polygons.length, 2, "two pieces of ground are two polygons");109 // The defect in one assertion: flattened, this is one polygon of four110 // rings, and the second piece of ground is the first piece's hole.111 for (const polygon of polygons) {112 assert.equal(polygon.getLinearRings().length, 2, "an exterior and its own hole");113 }114 assert.deepEqual(polygons[0]?.getLinearRing(0)?.getCoordinates(), square(0, 0, 10));115 assert.deepEqual(polygons[1]?.getLinearRing(0)?.getCoordinates(), square(20, 0, 10));116});117118test("a hole stays with the part that owns it", () => {119 assert.deepEqual(parts(TWO_PIECES), [120 [square(0, 0, 10), square(3, 3, 4)],121 [square(20, 0, 10), square(23, 3, 4)],122 ]);123 // And a part whose own hole list is missing is an exterior alone rather124 // than an exterior wearing the next part's holes.125 assert.deepEqual(parts(shape("area", [square(0, 0, 10), square(20, 0, 10)], [])), [126 [square(0, 0, 10)], [square(20, 0, 10)],127 ]);128});129130test("a path keeps every run of itself", () => {131 const runs: Line[] = [[[0, 0], [1, 1]], [[5, 5], [6, 6], [7, 7]], [[9, 9], [10, 10]]];132 const geometry = shapeGeometry(shape("path", runs)) as MultiLineString;133 assert.equal(geometry.getType(), "MultiLineString");134 assert.deepEqual(geometry.getCoordinates(), runs);135});136137test("the scrim cuts each part out and puts each part's holes back", () => {138 const world = square(0, -100, 100);139 const rings = scrimGeometry(world, [TWO_PIECES, shape("path", [[[0, 0], [1, 1]]])])140 .getLinearRings().map((ring) => ring.getCoordinates() as Coordinate[]);141 // The world, two exteriors and two interiors. A highlighted path has no142 // ring to cut from the dimming and contributes none.143 assert.equal(rings.length, 5);144 const worldRing = rings[0];145 assert.ok(worldRing);146 assert.ok(sameRing(worldRing, world), "the first ring is the world rectangle");147 const outward = Math.sign(signedArea(worldRing));148 assert.notEqual(outward, 0);149 const expected = [150 { source: square(0, 0, 10), winding: -outward },151 { source: square(3, 3, 4), winding: outward },152 { source: square(20, 0, 10), winding: -outward },153 { source: square(23, 3, 4), winding: outward },154 ];155 expected.forEach((want, index) => {156 const ring = rings[index + 1];157 assert.ok(ring);158 assert.ok(sameRing(ring, want.source), `ring ${index + 1} is its source ring`);159 assert.equal(160 Math.sign(signedArea(ring)), want.winding,161 `ring ${index + 1}: an exterior is wound against the world, an interior with it`);162 });163});164165test("a single-part shape's scrim is unchanged", () => {166 const one = shape("area", [square(0, 0, 10)]);167 const rings = scrimGeometry(square(0, -100, 100), [one])168 .getLinearRings().map((ring) => ring.getCoordinates() as Coordinate[]);169 assert.equal(rings.length, 2);170 const world = rings[0];171 const cut = rings[1];172 assert.ok(world && cut);173 assert.equal(Math.sign(signedArea(cut)), -Math.sign(signedArea(world)));174});175176test("the title anchors at the middle of every part, holes or not", () => {177 assert.deepEqual(centreOf(TWO_PIECES), [15, 5]);178 assert.deepEqual(centreOf(shape("area", [square(0, 0, 10), square(20, 0, 10)])), [15, 5]);179 assert.equal(centreOf(shape("area", [])), null);180});181182test("containment asks every part, and every part's own holes", () => {183 assert.ok(shapeContains(TWO_PIECES, [5, 1]), "inside the first piece");184 assert.ok(shapeContains(TWO_PIECES, [25, 1]), "inside the second piece");185 assert.ok(!shapeContains(TWO_PIECES, [5, 5]), "inside the first piece's own hole");186 assert.ok(!shapeContains(TWO_PIECES, [25, 5]), "inside the second piece's own hole");187 assert.ok(!shapeContains(TWO_PIECES, [15, 5]), "between the two pieces");188 // One unit of grace: a pin dropped on a boundary meant the ground it is on.189 assert.ok(shapeContains(TWO_PIECES, [10.5, 5]), "half a unit outside a boundary");190 assert.ok(!shapeContains(TWO_PIECES, [12, 5]), "two units outside a boundary");191});192193// ---- the fixtures' own features ----------------------------------------194195/** The city world, built the way the chart builds it. */196function city(): WorldModel {197 const payload = payloads("bend-or").get("2026-08-02");198 assert.ok(payload, "the city fixture has a world payload");199 return new WorldModel(legacyOpenWorld("2026-08-02", payload, worldGrid(tileGrid("bend-or"), payload), null));200}201202/**203 * How the payload itself spells a feature's parts, before anything projects204 * them: one entry per part, holding that part's rings by their vertex counts205 * — or, for a path, its single run's.206 */207function payloadParts(shape: ShapeRecord): number[][] {208 return shape.feature.geometry.parts.map((part) => part.rings.map((ring) => ring.length));209}210211test("the city's zoning draws every piece of ground it owns", () => {212 const world = city();213 // `RS`: fifty-three separate pieces of ground, twenty-five holes among214 // them, and one piece carrying sixteen rings by itself.215 const zoning = world.shapeByID.get("247260267");216 assert.ok(zoning, "the city has its RS zoning");217 const geometry = shapeGeometry(zoning) as MultiPolygon;218 assert.equal(geometry.getType(), "MultiPolygon");219 const drawn = geometry.getPolygons().map(220 (polygon) => polygon.getLinearRings().map((ring) => ring.getCoordinates().length));221 assert.equal(drawn.length, 53, "fifty-three separate pieces of ground");222 assert.deepEqual(drawn, payloadParts(zoning), "every part, ring for ring, vertex for vertex");223 assert.equal(drawn.reduce((held, rings) => held + rings.length, 0), 78, "78 rings in all");224 // A flattening builder answers one polygon of seventy-eight rings here, of225 // which fifty-two are pieces of ground turned inside out.226 assert.notEqual(drawn.length, 1);227});228229test("the city's longest trail draws all hundred and forty-seven of its runs", () => {230 const world = city();231 const trail = world.shapeByID.get("1071970836");232 assert.ok(trail, "the city has the Deschutes River Trail");233 assert.equal(trail.title, "Deschutes River Trail");234 const geometry = shapeGeometry(trail) as MultiLineString;235 assert.equal(geometry.getType(), "MultiLineString");236 const drawn = geometry.getLineStrings().map((line) => line.getCoordinates().length);237 assert.deepEqual(drawn, payloadParts(trail).map((counts) => counts[0]));238 assert.equal(drawn.length, 147, "not one run of it, and not one run short");239});240241test("no shape in any volume, corpus or invented, loses a part", () => {242 // The corpus's two volumes, and the invented game plane beside them: mars243 // carries no shapes at all, so the multipart weight is the city's — its244 // zoning and trails alone are ninety-eight multipart features.245 const worlds: [string, WorldModel][] = [];246 for (const slug of volumes()) {247 for (const [name, payload] of payloads(slug)) {248 worlds.push(249 [`${slug}/${name}`, new WorldModel(legacyOpenWorld(name, payload, worldGrid(tileGrid(slug), payload), null))]);250 }251 }252 const city = gamePlane();253 for (const [name, payload] of city.worlds) {254 worlds.push(255 [`${city.slug}/${name}`, new WorldModel(legacyOpenWorld(name, payload, city.tileGrid, null))]);256 }257 let multipart = 0;258 for (const [label, world] of worlds) {259 for (const record of world.shapes) {260 const declared = payloadParts(record);261 if (declared.length > 1) multipart++;262 const where = `${label}: ${record.title} (${record.id})`;263 if (record.kind === "area") {264 const geometry = shapeGeometry(record) as MultiPolygon;265 assert.deepEqual(geometry.getPolygons().map(266 (polygon) => polygon.getLinearRings().map((ring) => ring.getCoordinates().length)),267 declared, where);268 } else {269 const geometry = shapeGeometry(record) as MultiLineString;270 assert.deepEqual(geometry.getLineStrings().map(271 (line) => [line.getCoordinates().length]), declared, where);272 }273 }274 }275 assert.ok(multipart > 90, `the volumes are multi-part enough to be a test (${multipart})`);276});