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 / styles.test.ts
styles.test.ts 27.1K
1// Which names the chart says out loud, and what its markers are rimmed with.2//3// Two decisions in `chart/styles.ts` are contracts rather than taste, and4// both were quietly wrong before this suite existed.5//6// THE LABEL LADDER. A point collection's names are curated, overridden and7// revealed in that order — the reader's override wins, then the producer's8// `atlas.label.policy`, then the kind's own default, which for points is9// silence unless the curation asked for text. Over the policy sit exactly two10// states: the pin being pointed at and the pin whose card is open. Promotion11// is not one of them, and that is the whole of the defect this suite pins12// down: a search promotes every pin it matches so the declutterer cannot drop13// one, and a renderer that read promotion as "say its name" answered a search14// for a place with ten copies of the same word piled on one another.15//16// THE OUTSET. `atlas.icon.outset` arrives as a token — `light` or `dark` —17// and a marker is rimmed with the colour that token names, never with the18// word itself.19//20// THE MARK. A marker is the collection's symbol carrying the collection's21// colour, with an outset cut to the symbol's own shape: never a coloured22// bubble with a symbol punched out of it, which is what this pane drew until23// the reader said so. What that raster is made of is `markers.ts`'s and is24// tested there; what is here is what the chart does with it — the size, the25// stacking, and the initials it draws in the window before it exists.26//27// THE TEXT FORM. A point collection curated `atlas.render.as: text` is28// written on the map rather than pinned to it: one style, which is its name,29// and no marker under it. This departs from the reference on purpose — the30// reference drew a marker for every point collection, so a text collection31// wore its symbol and its name at once — and the cases below are the whole of32// the rule: no mark, a centred name in the collection's own colour and rim, a33// step of growth under the pointer, and the marker back again for the one34// reader who silences the collection by hand, so that nothing can be made to35// vanish from the map while its box is still ticked.36//37// The ladder is a pure function of a collection, a scene and a pin, so those38// cases are table-driven over a synthetic world: no fixtures, no map. The39// mark needs a canvas and an image, and both are stubbed by hand below —40// images are parked until a test hands them over, which is the only way to41// say anything about that window.4243import test from "node:test";44import { strict as assert } from "node:assert";45import type Icon from "ol/style/Icon.js";46import { EMPTY_SCENE } from "../scene/read.ts";47import type { LabelPolicy, Scene } from "../scene/read.ts";48import { WorldModel } from "../world/model.ts";49import type { PointRecord } from "../world/model.ts";50import { Visibility } from "../world/visibility.ts";51import type { Collection, TileGrid } from "../data/payload.ts";52import { Styles, curatedPointPolicy, featureColor } from "../chart/styles.ts";53import type { ShapeRecord } from "../world/model.ts";54import {55 OUTSET_COLORS, forgetMarkerRasters, legibleIconColor, outsetColor,56} from "../chart/markers.ts";5758// ---- the page a raster is composed on --------------------------------5960/** One image the seam has asked for and not yet been given. */61interface Parked {62 readonly url: string;63 readonly load: () => void;64}6566const parked: Parked[] = [];6768const host = globalThis as unknown as Record<string, unknown>;69host.document = {70 createElement: () => ({71 width: 0,72 height: 0,73 getContext: () => ({ drawImage: () => {}, fillRect: () => {} }),74 toDataURL: () => "data:image/png;base64,raster",75 }),76};77// OpenLayers asks whether an icon's image is an `HTMLImageElement` before it78// decides how to size it, so the stub has to be one.79host.HTMLImageElement = class {80 // The raster this stands for is 64 square, which is what the sizes below81 // are read against: an icon drawn at 31 is that raster at 31/64.82 width = 64;83 height = 64;84};85host.Image = class extends (host.HTMLImageElement as new () => object) {86 onload: (() => void) | null = null;87 onerror: (() => void) | null = null;88 crossOrigin = "";89 private asked = "";9091 get src(): string {92 return this.asked;93 }9495 set src(value: string) {96 this.asked = value;97 // A data URL is the composed raster coming back to be measured, not an98 // asset being fetched: it is not something a test hands over.99 if (value.startsWith("data:")) return;100 parked.push({ url: value, load: () => this.onload?.() });101 }102};103104/** Let the microtasks behind a delivered image run. */105function settle(): Promise<void> {106 return new Promise((resolve) => { setTimeout(resolve, 0); });107}108109const grid: TileGrid = { sourceZoom: 5, firstTile: 0, tileSize: 256, size: 8192 };110111/** Four point collections, one for each rung the ladder can start on. */112const PLAIN: Collection = { id: 1, title: "Settlements", kind: "point", visible: true };113const SPOKEN: Collection = {114 id: 2, title: "Cities", kind: "point", visible: true,115 attrs: { "atlas.label.policy": "always" },116};117const QUIETED: Collection = {118 id: 3, title: "Caves", kind: "point", visible: true,119 attrs: { "atlas.label.policy": "quiet" },120};121const TEXT: Collection = {122 id: 4, title: "Regions", kind: "point", visible: true,123 color: "#2c3f52",124 attrs: { "atlas.render.as": "text" },125};126127/**128 * Two collections drawn the same way, which is one raster.129 *130 * Shrine and Daedric Shrine: one asset in one colour, so the symbol is131 * composed once and only one of them ever asked for it.132 */133const SHRINES: Collection = {134 id: 5, title: "Shrines", kind: "point", visible: true,135 iconAsset: "shrine.svg", color: "#4fb3d5",136};137const DAEDRIC: Collection = {138 id: 6, title: "Daedric Shrines", kind: "point", visible: true,139 iconAsset: "shrine.svg", color: "#4fb3d5",140};141142/**143 * A text collection that was given artwork anyway, which really happens.144 *145 * The captured game volumes shipped region collections carrying a glyph146 * (`province.svg` and the like) *and* a text curation: the curation's word147 * about the form wins over the artwork it happens to have, or the change148 * would do nothing at all on the volumes that asked for it.149 */150const LETTERED: Collection = {151 id: 7, title: "Provinces", kind: "point", visible: true,152 iconAsset: "province.svg", color: "#2c3f52",153 attrs: { "atlas.render.as": "text" },154};155const COLLECTIONS = [PLAIN, SPOKEN, QUIETED, TEXT, SHRINES, DAEDRIC, LETTERED];156157/** One pin, standing wherever; only its title, id and collection matter here. */158function pin(collection: Collection, id = "7", title = "Goodsprings"): PointRecord {159 return {160 id, index: 0, title, collection,161 coordinate: [0, 0], member: 0, shard: 0, priority: 42,162 };163}164165function overrides(collection: Collection, policy?: LabelPolicy) {166 const map = new Map<string, LabelPolicy>();167 if (policy) map.set(String(collection.id), policy);168 return map;169}170171/** The chart's styles over an empty world, with the scene the case describes. */172function styles(173 scene: Partial<Scene>,174 labelsHeld = false,175 hovered: string | null = null,176 outset = "dark",177 repaint?: () => void,178): Styles {179 const model = new WorldModel(180 "world", { lenses: [], collections: COLLECTIONS }, grid, null);181 const full: Scene = { ...EMPTY_SCENE, ...scene };182 const built = new Styles({183 visibility: new Visibility(model, full, 0, null, hovered),184 scene: full,185 labelsHeld,186 hovered,187 outset,188 iconURL: (asset: string) => `/icons/${asset}`,189 ...(repaint ? { repaint } : {}),190 });191 built.learn(COLLECTIONS);192 return built;193}194195/** A world whose symbols have not arrived, and the ask that stands parked. */196function waiting(197 scene: Partial<Scene> = {}, outset = "dark", repaint?: () => void,198): Styles {199 forgetMarkerRasters();200 parked.length = 0;201 return styles(scene, false, null, outset, repaint);202}203204// ---- the ladder -------------------------------------------------------205206interface LadderCase {207 readonly name: string;208 readonly collection: Collection;209 readonly override?: LabelPolicy;210 readonly held?: boolean;211 readonly hovered?: boolean;212 readonly selected?: boolean;213 readonly promoted?: boolean;214 readonly labelled: boolean;215}216217const LADDER: readonly LadderCase[] = [218 // The producer's word, with nobody touching anything.219 { name: "a curated-always collection speaks unasked", collection: SPOKEN, labelled: true },220 { name: "a plain pin collection waits to be asked", collection: PLAIN, labelled: false },221 { name: "a curated-quiet collection waits too", collection: QUIETED, labelled: false },222 {223 name: "a collection curated as text speaks unasked",224 collection: TEXT, labelled: true,225 },226227 // Z reveals what is optional.228 { name: "Z reveals a plain collection's names", collection: PLAIN, held: true, labelled: true },229 {230 name: "Z reveals a curated-quiet collection's names",231 collection: QUIETED, held: true, labelled: true,232 },233234 // The reader's override wins, and Z never overrules a silencing.235 {236 name: "an override of quiet silences a collection curated always",237 collection: SPOKEN, override: "quiet", labelled: false,238 },239 {240 name: "Z does not revive what the reader silenced by hand",241 collection: QUIETED, override: "quiet", held: true, labelled: false,242 },243 {244 name: "Z does not revive a silenced text collection either",245 collection: TEXT, override: "quiet", held: true, labelled: false,246 },247 {248 name: "an override of always speaks with no key held",249 collection: PLAIN, override: "always", labelled: true,250 },251252 // Promotion places a label; it never asks for one.253 {254 name: "a searched pin is promoted and not labelled",255 collection: PLAIN, promoted: true, labelled: false,256 },257 {258 name: "a searched pin of a silenced collection is still not labelled",259 collection: SPOKEN, override: "quiet", promoted: true, labelled: false,260 },261 {262 name: "a promoted pin of a speaking collection keeps its name",263 collection: SPOKEN, promoted: true, labelled: true,264 },265266 // Pointing at a pin, and opening its card, speak over everything.267 { name: "the hovered pin is named", collection: PLAIN, hovered: true, labelled: true },268 { name: "the selected pin is named", collection: PLAIN, selected: true, labelled: true },269 {270 name: "the hovered pin is named even where the reader silenced its collection",271 collection: QUIETED, override: "quiet", hovered: true, labelled: true,272 },273 {274 name: "the selected pin is named even where the reader silenced its collection",275 collection: QUIETED, override: "quiet", selected: true, labelled: true,276 },277];278279for (const item of LADDER) {280 test(`pin label: ${item.name}`, () => {281 const id = "7";282 const style = styles(283 {284 selected: item.selected ? id : "",285 overrides: overrides(item.collection, item.override),286 },287 item.held ?? false,288 item.hovered ? id : null,289 ).pinLabel(pin(item.collection, id), item.promoted ?? false);290 assert.equal(style !== null, item.labelled);291 if (style) assert.equal(style.getText()?.getText(), "Goodsprings");292 });293}294295test("a pin with no name draws none, however loudly its collection speaks", () => {296 const built = styles({ selected: "7" }, true);297 assert.equal(built.pinLabel(pin(SPOKEN, "7", ""), true), null);298});299300test("a search that matches ten places labels none of them", () => {301 // The defect as it was seen: ten pins matching one word, every one of them302 // promoted past the declutterer, and a pile of names over a single town.303 const built = styles({ search: "goodsprings", overrides: overrides(PLAIN) });304 const drawn = Array.from({ length: 10 }, (_, i) => pin(PLAIN, String(i)))305 .map((record) => built.pinLabel(record, true))306 .filter((style) => style !== null);307 assert.equal(drawn.length, 0);308});309310test("promotion still decides where a label is drawn and whether it declutters", () => {311 const built = styles({ overrides: overrides(SPOKEN) });312 const plain = built.pinLabel(pin(SPOKEN), false);313 const promoted = built.pinLabel(pin(SPOKEN), true);314 assert.equal(plain?.getZIndex(), 42);315 assert.equal(plain?.getText()?.getDeclutterMode(), "declutter");316 assert.equal(promoted?.getZIndex(), 9_100_000);317 assert.equal(promoted?.getText()?.getDeclutterMode(), "none");318});319320321test("the curated default of a point collection is silence unless it is text", () => {322 assert.equal(curatedPointPolicy(PLAIN), "quiet");323 assert.equal(curatedPointPolicy(SPOKEN), "always");324 assert.equal(curatedPointPolicy(QUIETED), "quiet");325 assert.equal(curatedPointPolicy(TEXT), "always");326});327328// ---- the outset -------------------------------------------------------329330test("an outset token names a colour, and anything but dark reads light", () => {331 assert.equal(outsetColor("dark"), OUTSET_COLORS.dark);332 assert.equal(outsetColor("light"), OUTSET_COLORS.light);333 assert.equal(outsetColor(""), OUTSET_COLORS.light);334 assert.equal(outsetColor("sepia"), OUTSET_COLORS.light);335});336337test("a marker is rimmed with the outset's colour, never with its token", () => {338 for (const [token, color] of [["dark", OUTSET_COLORS.dark], ["light", OUTSET_COLORS.light],339 ["", OUTSET_COLORS.light]] as const) {340 const built = waiting({}, token);341 const text = built.pin(pin(PLAIN), false)[0]?.getText();342 assert.equal(text?.getStroke()?.getColor(), color);343 }344});345346// ---- the mark ---------------------------------------------------------347348test("a marker is the symbol itself: no disc, no second style", async () => {349 // THE DEFECT, as the reader saw it: "icons are rendered as coloured bubbles350 // with an outset and a black symbol inside the bubble". A pin was a filled351 // circle plus an untinted 15px icon over it; it is one style now, and that352 // style is the symbol.353 const built = waiting();354 parked[0]?.load();355 await settle();356 const marks = built.pin(pin(SHRINES), false);357 assert.equal(marks.length, 1, "one style, not a disc and a glyph over it");358 const icon = marks[0]?.getImage() as Icon | undefined;359 assert.ok(icon, "and the style's image is the composed raster");360 assert.equal(icon?.getSrc(), "data:image/png;base64,raster");361 assert.equal(marks[0]?.getText(), null, "with no separate glyph beside it");362});363364test("one symbol is composed for every collection drawn that way", async () => {365 const built = waiting();366 assert.equal(parked.filter((one) => one.url.endsWith("shrine.svg")).length, 1,367 "Shrine and Daedric Shrine are one asset in one colour, so one raster");368 parked[0]?.load();369 await settle();370 const shrine = built.pin(pin(SHRINES), false)[0]?.getImage() as Icon | undefined;371 const daedric = built.pin(pin(DAEDRIC), false)[0]?.getImage() as Icon | undefined;372 assert.equal(shrine?.getSrc(), daedric?.getSrc());373});374375test("the chosen pin is the larger mark, and the one drawn over the rest", async () => {376 const built = waiting({ selected: "7" });377 parked[0]?.load();378 await settle();379 const chosen = built.pin(pin(SHRINES, "7"), true)[0];380 const beside = built.pin(pin(SHRINES, "8"), false)[0];381 // The raster is 64 square, so an icon drawn at 31 is that raster scaled by382 // 31/64: the scale is the size, said the way OpenLayers says it before the383 // image it will measure has loaded.384 assert.deepEqual((chosen?.getImage() as Icon).getScale(), [36 / 64, 36 / 64]);385 assert.deepEqual((beside?.getImage() as Icon).getScale(), [31 / 64, 31 / 64]);386 assert.equal(chosen?.getZIndex(), 20_000_000, "the open card's pin is over everything");387 assert.equal(beside?.getZIndex(), 42, "and every other pin keeps its place in the crowd");388});389390test("a promoted pin that is not the chosen one is not grown", async () => {391 // A search promotes every pin it matched so the declutterer cannot drop392 // one; growing all of those would say a hundred places had been chosen.393 const built = waiting({ selected: "7" });394 parked[0]?.load();395 await settle();396 const promoted = built.pin(pin(SHRINES, "9"), true)[0];397 assert.deepEqual((promoted?.getImage() as Icon).getScale(), [31 / 64, 31 / 64]);398 assert.equal(promoted?.getZIndex(), 42);399});400401test("until the symbol arrives the pin wears its collection's initials", () => {402 const built = waiting();403 const text = built.pin(pin(SHRINES), false)[0]?.getText();404 assert.equal(text?.getText(), "S", "the first letters of the first two words");405 assert.equal(text?.getFont(), "900 13px Inter, ui-sans-serif, system-ui, sans-serif");406 assert.equal(text?.getFill()?.getColor(), "#4fb3d5", "in the collection's own colour");407 assert.equal(text?.getStroke()?.getColor(), OUTSET_COLORS.dark);408 assert.equal(text?.getStroke()?.getWidth(), 3);409 assert.equal(built.pin(pin(SHRINES), false)[0]?.getImage(), null, "and no disc under it");410});411412test("the chosen pin's initials are the larger of the two as well", () => {413 const built = waiting({ selected: "7" });414 const text = built.pin(pin(SHRINES, "7"), true)[0]?.getText();415 assert.equal(text?.getFont(), "900 15px Inter, ui-sans-serif, system-ui, sans-serif");416 assert.equal(text?.getStroke()?.getWidth(), 4);417});418419test("a collection with no artwork wears its initials for good", async () => {420 const built = waiting();421 parked[0]?.load();422 await settle();423 const text = built.pin(pin(PLAIN), false)[0]?.getText();424 assert.equal(text?.getText(), "S", "Settlements");425 assert.equal(built.pin(pin(QUIETED), false)[0]?.getText()?.getText(), "C", "Caves");426});427428test("a colour that would vanish into the rim is taken off it", () => {429 // The ladder is `markers.ts`'s and is tested there; what is checked here is430 // that the chart climbs it — a near-black collection on a world with dark431 // rims is drawn in a lifted shade of itself rather than in the dark.432 const DIM: Collection = {433 id: 9, title: "Caverns", kind: "point", visible: true, color: "#0d1014",434 };435 const model = new WorldModel(436 "world", { lenses: [], collections: [DIM] }, grid, null);437 const scene: Scene = { ...EMPTY_SCENE };438 const built = new Styles({439 visibility: new Visibility(model, scene, 0, null, null),440 scene,441 labelsHeld: false,442 hovered: null,443 outset: "dark",444 iconURL: (asset: string) => asset,445 });446 built.learn([DIM]);447 assert.notEqual(built.pin(pin(DIM), false)[0]?.getText()?.getFill()?.getColor(), "#0d1014");448 assert.equal(built.color(DIM), "#0d1014",449 "and everything else the collection draws keeps the colour it declared");450});451452test("a symbol arriving drops every mark and asks the pane to draw again", async () => {453 let pokes = 0;454 const built = waiting({}, "dark", () => { pokes++; });455 const shrine = built.pin(pin(SHRINES), false)[0];456 const daedric = built.pin(pin(DAEDRIC), false)[0];457 assert.ok(shrine?.getText() && daedric?.getText(), "both cached their initials");458 parked[0]?.load();459 await settle();460 assert.equal(pokes, 1, "once per raster, not once per collection wearing it");461 assert.ok(built.pin(pin(SHRINES), false)[0]?.getImage(), "the collection that asked is redrawn");462 assert.ok(built.pin(pin(DAEDRIC), false)[0]?.getImage(),463 "and so is the one that cached its initials while the raster was loading");464});465466test("a mark is built once per collection and state, not once per pin", () => {467 const built = waiting();468 const first = built.pin(pin(SHRINES, "1"), false)[0];469 assert.equal(built.pin(pin(SHRINES, "2"), false)[0], first,470 "two thousand pins of twenty collections are forty styles");471 assert.notEqual(built.pin(pin(PLAIN, "3"), false)[0], first);472});473474// ---- the text form -----------------------------------------------------475476/** The colour a text mark is written in: the collection's, off the rim. */477const TEXT_COLOR = legibleIconColor("#2c3f52", "dark");478479test("a collection curated as text draws its name and nothing else", () => {480 // THE RULE, in the reader's words: "the map will display text as just the481 // text by default rather than 'A - Wellspring of Wisdom'". The initials and482 // the always-on name were two drawings of one place.483 const built = waiting();484 assert.deepEqual(built.pin(pin(TEXT), false), [], "no mark of any kind under the name");485 const mark = built.pinLabel(pin(TEXT, "7", "Wellspring of Wisdom"), false);486 assert.ok(mark, "and the name is what there is");487 assert.equal(mark?.getText()?.getText(), "Wellspring of Wisdom");488 assert.equal(mark?.getImage(), null, "no disc, no icon");489});490491test("artwork does not buy a text collection a marker back", async () => {492 // A region collection carrying a glyph and curated as text: the curation493 // decides the form, or the rule changes nothing where it was asked for.494 // The raster still composes -- the reader may yet ask for markers -- and495 // it is still not drawn.496 const built = waiting();497 for (const one of parked) one.load();498 await settle();499 assert.deepEqual(built.pin(pin(LETTERED), false), []);500 assert.ok(built.pinLabel(pin(LETTERED, "7", "Necluda"), false));501});502503test("the text mark is the text-symbol's own face, in the collection's colour", () => {504 const built = waiting();505 const text = built.pinLabel(pin(TEXT, "7", "Necluda"), false)?.getText();506 assert.equal(text?.getFont(),507 '900 11px "Arial Narrow", "Roboto Condensed", ui-sans-serif, system-ui, sans-serif');508 assert.equal(text?.getFill()?.getColor(), TEXT_COLOR,509 "the collection's own colour, taken off the rim it is drawn against");510 assert.equal(text?.getStroke()?.getColor(), OUTSET_COLORS.dark);511 assert.equal(text?.getStroke()?.getWidth(), 3);512});513514test("a text mark stands on its place rather than beside it", () => {515 // The label's sixteen pixels are the room a marker takes up. With no marker516 // there is nothing to stand off from, and a province written below the517 // point it names is a province in the wrong place.518 const built = waiting();519 assert.equal(built.pinLabel(pin(TEXT), false)?.getText()?.getOffsetY(), 0);520});521522test("an ordinary label still stands off from the marker it belongs to", () => {523 const built = styles({ overrides: overrides(SPOKEN) });524 assert.equal(built.pinLabel(pin(SPOKEN), false)?.getText()?.getOffsetY(), 16);525});526527for (const attended of ["hovered", "selected"] as const) {528 test(`a ${attended} text mark grows a step, as a marker does`, () => {529 const id = "7";530 const built = attended === "hovered"531 ? styles({}, false, id)532 : styles({ selected: id });533 const text = built.pinLabel(pin(TEXT, id), attended === "selected")?.getText();534 assert.equal(text?.getFont(),535 '900 13px "Arial Narrow", "Roboto Condensed", ui-sans-serif, system-ui, sans-serif');536 assert.equal(text?.getStroke()?.getWidth(), 4);537 });538}539540test("the text mark of the open card is drawn over everything", () => {541 const built = styles({ selected: "7" });542 assert.equal(built.pinLabel(pin(TEXT, "7"), true)?.getZIndex(), 20_000_000);543 const beside = built.pinLabel(pin(TEXT, "8"), true);544 assert.equal(beside?.getZIndex(), 9_100_000, "a promoted one is placed, not chosen");545 assert.equal(beside?.getText()?.getDeclutterMode(), "none");546 assert.equal(built.pinLabel(pin(TEXT, "9"), false)?.getZIndex(), 42);547 assert.equal(built.pinLabel(pin(TEXT, "9"), false)?.getText()?.getDeclutterMode(), "declutter",548 "and one in the crowd gives way to a rarer collection's, as any name does");549});550551test("a reader who silences a text collection gets its markers back", () => {552 // THE ONE JUDGEMENT THE RULE NEEDS. Silencing a collection silences its553 // names, and for a collection whose name is its only form that would erase554 // the feature outright: a box still ticked, a place still counted, and555 // nothing on the map. So the override chooses between the two forms a point556 // collection can take -- which is what the reference's own key called that557 // toggle -- rather than between a form and nothing.558 const built = waiting({ overrides: overrides(TEXT, "quiet") });559 assert.equal(built.pinLabel(pin(TEXT), false), null, "the names are silenced");560 const mark = built.pin(pin(TEXT), false)[0];561 assert.equal(mark?.getText()?.getText(), "R", "and the ordinary marker stands in their place");562 assert.equal(built.pin(pin(TEXT), false).length, 1);563});564565test("a place with no name to be written as keeps its marker", () => {566 // The other half of the same rule: there is no text to draw a nameless567 // feature as, and a feature drawn as nothing is a feature the reader can568 // neither see nor point at.569 const built = waiting();570 const nameless = pin(TEXT, "7", "");571 assert.equal(built.pinLabel(nameless, false), null);572 assert.equal(built.pin(nameless, false)[0]?.getText()?.getText(), "R");573});574575test("a silenced text collection still answers the pointer, and with a marker beside", () => {576 // Hover and selection speak over the silencing for every other collection;577 // the marker is the form either way, so the name comes back beside it.578 const id = "7";579 const built = waiting({ overrides: overrides(TEXT, "quiet") }, "dark");580 assert.equal(built.pin(pin(TEXT, id), false).length, 1);581 const hovering = styles({ overrides: overrides(TEXT, "quiet") }, false, id);582 const label = hovering.pinLabel(pin(TEXT, id), false);583 assert.equal(label?.getText()?.getOffsetY(), 16, "an ordinary name, beside an ordinary mark");584});585586test("everything else keeps the mark it had", async () => {587 const built = waiting();588 parked[0]?.load();589 await settle();590 assert.equal(built.pin(pin(SHRINES), false).length, 1);591 assert.ok(built.pin(pin(SHRINES), false)[0]?.getImage(), "artwork collections are unchanged");592 assert.equal(built.pin(pin(PLAIN), false).length, 1);593 assert.equal(built.pin(pin(SPOKEN), false)[0]?.getText()?.getText(), "C", "Cities");594});595596// ---- the shapes' own colours ------------------------------------------597598/** One zone, standing wherever; only its id and collection matter here. */599function zone(collection: Collection, id: string): ShapeRecord {600 return {601 id, title: "", subtitle: "", collection, kind: "area", shard: 0,602 lines: [], holes: [], center: null, feature: { id } as never,603 };604}605606test("a shape wears its feature's colour, which is the index's own", () => {607 // The server's colorFor (internal/app/legend.go): Knuth's multiplicative608 // hash over the id, mod the ten-colour wheel. By hand: 1496244488 ·609 // 2654435761 mod 2^32 mod 10 = 2, and 39191589 lands on 1 -- so the two610 // zones wear the wheel's third and second colours, exactly as their index611 // rows do, and an id that is not a number wears the wheel's first.612 assert.equal(featureColor("1496244488"), "#82b56a");613 assert.equal(featureColor("39191589"), "#c9924b");614 assert.equal(featureColor("zoning-b1"), "#4fb3d5");615616 // A zoning collection is one row with one declared colour, but B-1 and617 // O-1 are different grounds: the map draws each in its feature's colour,618 // never the collection's.619 const zoning: Collection = {620 id: 9, title: "Zoning", kind: "area", visible: true, color: "#00ff00",621 };622 const built = styles({});623 const first = built.area(zone(zoning, "1496244488"), false);624 const second = built.area(zone(zoning, "39191589"), false);625 assert.equal(first.getStroke()?.getColor(), "#82b56a");626 assert.equal(second.getStroke()?.getColor(), "#c9924b");627 assert.notEqual(first.getStroke()?.getColor(), zoning.color);628629 // A path is the same rule at a different width.630 const trails: Collection = { id: 4, title: "Trails", kind: "path", visible: true };631 const [, ink] = built.path({ ...zone(trails, "1496244488"), kind: "path" }, 2, false);632 assert.equal(ink?.getStroke()?.getColor(), "#82b56a");633});