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";57import { legacyOpenWorld } from "./legacy.ts";5859// ---- the page a raster is composed on --------------------------------6061/** One image the seam has asked for and not yet been given. */62interface Parked {63 readonly url: string;64 readonly load: () => void;65}6667const parked: Parked[] = [];6869const host = globalThis as unknown as Record<string, unknown>;70host.document = {71 createElement: () => ({72 width: 0,73 height: 0,74 getContext: () => ({ drawImage: () => {}, fillRect: () => {} }),75 toDataURL: () => "data:image/png;base64,raster",76 }),77};78// OpenLayers asks whether an icon's image is an `HTMLImageElement` before it79// decides how to size it, so the stub has to be one.80host.HTMLImageElement = class {81 // The raster this stands for is 64 square, which is what the sizes below82 // are read against: an icon drawn at 31 is that raster at 31/64.83 width = 64;84 height = 64;85};86host.Image = class extends (host.HTMLImageElement as new () => object) {87 onload: (() => void) | null = null;88 onerror: (() => void) | null = null;89 crossOrigin = "";90 private asked = "";9192 get src(): string {93 return this.asked;94 }9596 set src(value: string) {97 this.asked = value;98 // A data URL is the composed raster coming back to be measured, not an99 // asset being fetched: it is not something a test hands over.100 if (value.startsWith("data:")) return;101 parked.push({ url: value, load: () => this.onload?.() });102 }103};104105/** Let the microtasks behind a delivered image run. */106function settle(): Promise<void> {107 return new Promise((resolve) => { setTimeout(resolve, 0); });108}109110const grid: TileGrid = { sourceZoom: 5, firstTile: 0, tileSize: 256, size: 8192 };111112/** Four point collections, one for each rung the ladder can start on. */113const PLAIN: Collection = { id: 1, title: "Settlements", kind: "point", visible: true };114const SPOKEN: Collection = {115 id: 2, title: "Cities", kind: "point", visible: true,116 attrs: { "atlas.label.policy": "always" },117};118const QUIETED: Collection = {119 id: 3, title: "Caves", kind: "point", visible: true,120 attrs: { "atlas.label.policy": "quiet" },121};122const TEXT: Collection = {123 id: 4, title: "Regions", kind: "point", visible: true,124 color: "#2c3f52",125 attrs: { "atlas.render.as": "text" },126};127128/**129 * Two collections drawn the same way, which is one raster.130 *131 * Shrine and Daedric Shrine: one asset in one colour, so the symbol is132 * composed once and only one of them ever asked for it.133 */134const SHRINES: Collection = {135 id: 5, title: "Shrines", kind: "point", visible: true,136 iconAsset: "shrine.svg", color: "#4fb3d5",137};138const DAEDRIC: Collection = {139 id: 6, title: "Daedric Shrines", kind: "point", visible: true,140 iconAsset: "shrine.svg", color: "#4fb3d5",141};142143/**144 * A text collection that was given artwork anyway, which really happens.145 *146 * The captured game volumes shipped region collections carrying a glyph147 * (`province.svg` and the like) *and* a text curation: the curation's word148 * about the form wins over the artwork it happens to have, or the change149 * would do nothing at all on the volumes that asked for it.150 */151const LETTERED: Collection = {152 id: 7, title: "Provinces", kind: "point", visible: true,153 iconAsset: "province.svg", color: "#2c3f52",154 attrs: { "atlas.render.as": "text" },155};156const COLLECTIONS = [PLAIN, SPOKEN, QUIETED, TEXT, SHRINES, DAEDRIC, LETTERED];157158/** One pin, standing wherever; only its title, id and collection matter here. */159function pin(collection: Collection, id = "7", title = "Goodsprings"): PointRecord {160 return {161 id, index: 0, title, collection,162 coordinate: [0, 0], member: "", shard: 0, priority: 42,163 feature: {164 id, title, subtitle: "", description: "", center: null, shard: 0,165 geometry: { kind: 1, parts: [{ rings: [[[0, 0]]] }] },166 properties: [], relationships: [], provenance: [],167 },168 };169}170171function overrides(collection: Collection, policy?: LabelPolicy) {172 const map = new Map<string, LabelPolicy>();173 if (policy) map.set(String(collection.id), policy);174 return map;175}176177/** The chart's styles over an empty world, with the scene the case describes. */178function styles(179 scene: Partial<Scene>,180 labelsHeld = false,181 hovered: string | null = null,182 outset = "dark",183 repaint?: () => void,184): Styles {185 const model = new WorldModel(legacyOpenWorld(186 "world", { lenses: [], collections: COLLECTIONS }, grid, null));187 const full: Scene = { ...EMPTY_SCENE, ...scene };188 const built = new Styles({189 visibility: new Visibility(model, full, 0, null, hovered),190 scene: full,191 labelsHeld,192 hovered,193 outset,194 iconURL: (asset: string) => `/icons/${asset}`,195 ...(repaint ? { repaint } : {}),196 });197 built.learn(COLLECTIONS);198 return built;199}200201/** A world whose symbols have not arrived, and the ask that stands parked. */202function waiting(203 scene: Partial<Scene> = {}, outset = "dark", repaint?: () => void,204): Styles {205 forgetMarkerRasters();206 parked.length = 0;207 return styles(scene, false, null, outset, repaint);208}209210// ---- the ladder -------------------------------------------------------211212interface LadderCase {213 readonly name: string;214 readonly collection: Collection;215 readonly override?: LabelPolicy;216 readonly held?: boolean;217 readonly hovered?: boolean;218 readonly selected?: boolean;219 readonly promoted?: boolean;220 readonly labelled: boolean;221}222223const LADDER: readonly LadderCase[] = [224 // The producer's word, with nobody touching anything.225 { name: "a curated-always collection speaks unasked", collection: SPOKEN, labelled: true },226 { name: "a plain pin collection waits to be asked", collection: PLAIN, labelled: false },227 { name: "a curated-quiet collection waits too", collection: QUIETED, labelled: false },228 {229 name: "a collection curated as text speaks unasked",230 collection: TEXT, labelled: true,231 },232233 // Z reveals what is optional.234 { name: "Z reveals a plain collection's names", collection: PLAIN, held: true, labelled: true },235 {236 name: "Z reveals a curated-quiet collection's names",237 collection: QUIETED, held: true, labelled: true,238 },239240 // The reader's override wins, and Z never overrules a silencing.241 {242 name: "an override of quiet silences a collection curated always",243 collection: SPOKEN, override: "quiet", labelled: false,244 },245 {246 name: "Z does not revive what the reader silenced by hand",247 collection: QUIETED, override: "quiet", held: true, labelled: false,248 },249 {250 name: "Z does not revive a silenced text collection either",251 collection: TEXT, override: "quiet", held: true, labelled: false,252 },253 {254 name: "an override of always speaks with no key held",255 collection: PLAIN, override: "always", labelled: true,256 },257258 // Promotion places a label; it never asks for one.259 {260 name: "a searched pin is promoted and not labelled",261 collection: PLAIN, promoted: true, labelled: false,262 },263 {264 name: "a searched pin of a silenced collection is still not labelled",265 collection: SPOKEN, override: "quiet", promoted: true, labelled: false,266 },267 {268 name: "a promoted pin of a speaking collection keeps its name",269 collection: SPOKEN, promoted: true, labelled: true,270 },271272 // Pointing at a pin, and opening its card, speak over everything.273 { name: "the hovered pin is named", collection: PLAIN, hovered: true, labelled: true },274 { name: "the selected pin is named", collection: PLAIN, selected: true, labelled: true },275 {276 name: "the hovered pin is named even where the reader silenced its collection",277 collection: QUIETED, override: "quiet", hovered: true, labelled: true,278 },279 {280 name: "the selected pin is named even where the reader silenced its collection",281 collection: QUIETED, override: "quiet", selected: true, labelled: true,282 },283];284285for (const item of LADDER) {286 test(`pin label: ${item.name}`, () => {287 const id = "7";288 const style = styles(289 {290 selected: item.selected ? id : "",291 overrides: overrides(item.collection, item.override),292 },293 item.held ?? false,294 item.hovered ? id : null,295 ).pinLabel(pin(item.collection, id), item.promoted ?? false);296 assert.equal(style !== null, item.labelled);297 if (style) assert.equal(style.getText()?.getText(), "Goodsprings");298 });299}300301test("a pin with no name draws none, however loudly its collection speaks", () => {302 const built = styles({ selected: "7" }, true);303 assert.equal(built.pinLabel(pin(SPOKEN, "7", ""), true), null);304});305306test("a search that matches ten places labels none of them", () => {307 // The defect as it was seen: ten pins matching one word, every one of them308 // promoted past the declutterer, and a pile of names over a single town.309 const built = styles({ search: "goodsprings", overrides: overrides(PLAIN) });310 const drawn = Array.from({ length: 10 }, (_, i) => pin(PLAIN, String(i)))311 .map((record) => built.pinLabel(record, true))312 .filter((style) => style !== null);313 assert.equal(drawn.length, 0);314});315316test("promotion still decides where a label is drawn and whether it declutters", () => {317 const built = styles({ overrides: overrides(SPOKEN) });318 const plain = built.pinLabel(pin(SPOKEN), false);319 const promoted = built.pinLabel(pin(SPOKEN), true);320 assert.equal(plain?.getZIndex(), 42);321 assert.equal(plain?.getText()?.getDeclutterMode(), "declutter");322 assert.equal(promoted?.getZIndex(), 9_100_000);323 assert.equal(promoted?.getText()?.getDeclutterMode(), "none");324});325326327test("the curated default of a point collection is silence unless it is text", () => {328 assert.equal(curatedPointPolicy(PLAIN), "quiet");329 assert.equal(curatedPointPolicy(SPOKEN), "always");330 assert.equal(curatedPointPolicy(QUIETED), "quiet");331 assert.equal(curatedPointPolicy(TEXT), "always");332});333334// ---- the outset -------------------------------------------------------335336test("an outset token names a colour, and anything but dark reads light", () => {337 assert.equal(outsetColor("dark"), OUTSET_COLORS.dark);338 assert.equal(outsetColor("light"), OUTSET_COLORS.light);339 assert.equal(outsetColor(""), OUTSET_COLORS.light);340 assert.equal(outsetColor("sepia"), OUTSET_COLORS.light);341});342343test("a marker is rimmed with the outset's colour, never with its token", () => {344 for (const [token, color] of [["dark", OUTSET_COLORS.dark], ["light", OUTSET_COLORS.light],345 ["", OUTSET_COLORS.light]] as const) {346 const built = waiting({}, token);347 const text = built.pin(pin(PLAIN), false)[0]?.getText();348 assert.equal(text?.getStroke()?.getColor(), color);349 }350});351352// ---- the mark ---------------------------------------------------------353354test("a marker is the symbol itself: no disc, no second style", async () => {355 // THE DEFECT, as the reader saw it: "icons are rendered as coloured bubbles356 // with an outset and a black symbol inside the bubble". A pin was a filled357 // circle plus an untinted 15px icon over it; it is one style now, and that358 // style is the symbol.359 const built = waiting();360 parked[0]?.load();361 await settle();362 const marks = built.pin(pin(SHRINES), false);363 assert.equal(marks.length, 1, "one style, not a disc and a glyph over it");364 const icon = marks[0]?.getImage() as Icon | undefined;365 assert.ok(icon, "and the style's image is the composed raster");366 assert.equal(icon?.getSrc(), "data:image/png;base64,raster");367 assert.equal(marks[0]?.getText(), null, "with no separate glyph beside it");368});369370test("one symbol is composed for every collection drawn that way", async () => {371 const built = waiting();372 assert.equal(parked.filter((one) => one.url.endsWith("shrine.svg")).length, 1,373 "Shrine and Daedric Shrine are one asset in one colour, so one raster");374 parked[0]?.load();375 await settle();376 const shrine = built.pin(pin(SHRINES), false)[0]?.getImage() as Icon | undefined;377 const daedric = built.pin(pin(DAEDRIC), false)[0]?.getImage() as Icon | undefined;378 assert.equal(shrine?.getSrc(), daedric?.getSrc());379});380381test("the chosen pin is the larger mark, and the one drawn over the rest", async () => {382 const built = waiting({ selected: "7" });383 parked[0]?.load();384 await settle();385 const chosen = built.pin(pin(SHRINES, "7"), true)[0];386 const beside = built.pin(pin(SHRINES, "8"), false)[0];387 // The raster is 64 square, so an icon drawn at 31 is that raster scaled by388 // 31/64: the scale is the size, said the way OpenLayers says it before the389 // image it will measure has loaded.390 assert.deepEqual((chosen?.getImage() as Icon).getScale(), [36 / 64, 36 / 64]);391 assert.deepEqual((beside?.getImage() as Icon).getScale(), [31 / 64, 31 / 64]);392 assert.equal(chosen?.getZIndex(), 20_000_000, "the open card's pin is over everything");393 assert.equal(beside?.getZIndex(), 42, "and every other pin keeps its place in the crowd");394});395396test("a promoted pin that is not the chosen one is not grown", async () => {397 // A search promotes every pin it matched so the declutterer cannot drop398 // one; growing all of those would say a hundred places had been chosen.399 const built = waiting({ selected: "7" });400 parked[0]?.load();401 await settle();402 const promoted = built.pin(pin(SHRINES, "9"), true)[0];403 assert.deepEqual((promoted?.getImage() as Icon).getScale(), [31 / 64, 31 / 64]);404 assert.equal(promoted?.getZIndex(), 42);405});406407test("until the symbol arrives the pin wears its collection's initials", () => {408 const built = waiting();409 const text = built.pin(pin(SHRINES), false)[0]?.getText();410 assert.equal(text?.getText(), "S", "the first letters of the first two words");411 assert.equal(text?.getFont(), "900 13px Inter, ui-sans-serif, system-ui, sans-serif");412 assert.equal(text?.getFill()?.getColor(), "#4fb3d5", "in the collection's own colour");413 assert.equal(text?.getStroke()?.getColor(), OUTSET_COLORS.dark);414 assert.equal(text?.getStroke()?.getWidth(), 3);415 assert.equal(built.pin(pin(SHRINES), false)[0]?.getImage(), null, "and no disc under it");416});417418test("the chosen pin's initials are the larger of the two as well", () => {419 const built = waiting({ selected: "7" });420 const text = built.pin(pin(SHRINES, "7"), true)[0]?.getText();421 assert.equal(text?.getFont(), "900 15px Inter, ui-sans-serif, system-ui, sans-serif");422 assert.equal(text?.getStroke()?.getWidth(), 4);423});424425test("a collection with no artwork wears its initials for good", async () => {426 const built = waiting();427 parked[0]?.load();428 await settle();429 const text = built.pin(pin(PLAIN), false)[0]?.getText();430 assert.equal(text?.getText(), "S", "Settlements");431 assert.equal(built.pin(pin(QUIETED), false)[0]?.getText()?.getText(), "C", "Caves");432});433434test("a colour that would vanish into the rim is taken off it", () => {435 // The ladder is `markers.ts`'s and is tested there; what is checked here is436 // that the chart climbs it — a near-black collection on a world with dark437 // rims is drawn in a lifted shade of itself rather than in the dark.438 const DIM: Collection = {439 id: 9, title: "Caverns", kind: "point", visible: true, color: "#0d1014",440 };441 const model = new WorldModel(legacyOpenWorld(442 "world", { lenses: [], collections: [DIM] }, grid, null));443 const scene: Scene = { ...EMPTY_SCENE };444 const built = new Styles({445 visibility: new Visibility(model, scene, 0, null, null),446 scene,447 labelsHeld: false,448 hovered: null,449 outset: "dark",450 iconURL: (asset: string) => asset,451 });452 built.learn([DIM]);453 assert.notEqual(built.pin(pin(DIM), false)[0]?.getText()?.getFill()?.getColor(), "#0d1014");454 assert.equal(built.color(DIM), "#0d1014",455 "and everything else the collection draws keeps the colour it declared");456});457458test("a symbol arriving drops every mark and asks the pane to draw again", async () => {459 let pokes = 0;460 const built = waiting({}, "dark", () => { pokes++; });461 const shrine = built.pin(pin(SHRINES), false)[0];462 const daedric = built.pin(pin(DAEDRIC), false)[0];463 assert.ok(shrine?.getText() && daedric?.getText(), "both cached their initials");464 parked[0]?.load();465 await settle();466 assert.equal(pokes, 1, "once per raster, not once per collection wearing it");467 assert.ok(built.pin(pin(SHRINES), false)[0]?.getImage(), "the collection that asked is redrawn");468 assert.ok(built.pin(pin(DAEDRIC), false)[0]?.getImage(),469 "and so is the one that cached its initials while the raster was loading");470});471472test("a mark is built once per collection and state, not once per pin", () => {473 const built = waiting();474 const first = built.pin(pin(SHRINES, "1"), false)[0];475 assert.equal(built.pin(pin(SHRINES, "2"), false)[0], first,476 "two thousand pins of twenty collections are forty styles");477 assert.notEqual(built.pin(pin(PLAIN, "3"), false)[0], first);478});479480// ---- the text form -----------------------------------------------------481482/** The colour a text mark is written in: the collection's, off the rim. */483const TEXT_COLOR = legibleIconColor("#2c3f52", "dark");484485test("a collection curated as text draws its name and nothing else", () => {486 // THE RULE, in the reader's words: "the map will display text as just the487 // text by default rather than 'A - Wellspring of Wisdom'". The initials and488 // the always-on name were two drawings of one place.489 const built = waiting();490 assert.deepEqual(built.pin(pin(TEXT), false), [], "no mark of any kind under the name");491 const mark = built.pinLabel(pin(TEXT, "7", "Wellspring of Wisdom"), false);492 assert.ok(mark, "and the name is what there is");493 assert.equal(mark?.getText()?.getText(), "Wellspring of Wisdom");494 assert.equal(mark?.getImage(), null, "no disc, no icon");495});496497test("artwork does not buy a text collection a marker back", async () => {498 // A region collection carrying a glyph and curated as text: the curation499 // decides the form, or the rule changes nothing where it was asked for.500 // The raster still composes -- the reader may yet ask for markers -- and501 // it is still not drawn.502 const built = waiting();503 for (const one of parked) one.load();504 await settle();505 assert.deepEqual(built.pin(pin(LETTERED), false), []);506 assert.ok(built.pinLabel(pin(LETTERED, "7", "Necluda"), false));507});508509test("the text mark is the text-symbol's own face, in the collection's colour", () => {510 const built = waiting();511 const text = built.pinLabel(pin(TEXT, "7", "Necluda"), false)?.getText();512 assert.equal(text?.getFont(),513 '900 11px "Arial Narrow", "Roboto Condensed", ui-sans-serif, system-ui, sans-serif');514 assert.equal(text?.getFill()?.getColor(), TEXT_COLOR,515 "the collection's own colour, taken off the rim it is drawn against");516 assert.equal(text?.getStroke()?.getColor(), OUTSET_COLORS.dark);517 assert.equal(text?.getStroke()?.getWidth(), 3);518});519520test("a text mark stands on its place rather than beside it", () => {521 // The label's sixteen pixels are the room a marker takes up. With no marker522 // there is nothing to stand off from, and a province written below the523 // point it names is a province in the wrong place.524 const built = waiting();525 assert.equal(built.pinLabel(pin(TEXT), false)?.getText()?.getOffsetY(), 0);526});527528test("an ordinary label still stands off from the marker it belongs to", () => {529 const built = styles({ overrides: overrides(SPOKEN) });530 assert.equal(built.pinLabel(pin(SPOKEN), false)?.getText()?.getOffsetY(), 16);531});532533for (const attended of ["hovered", "selected"] as const) {534 test(`a ${attended} text mark grows a step, as a marker does`, () => {535 const id = "7";536 const built = attended === "hovered"537 ? styles({}, false, id)538 : styles({ selected: id });539 const text = built.pinLabel(pin(TEXT, id), attended === "selected")?.getText();540 assert.equal(text?.getFont(),541 '900 13px "Arial Narrow", "Roboto Condensed", ui-sans-serif, system-ui, sans-serif');542 assert.equal(text?.getStroke()?.getWidth(), 4);543 });544}545546test("the text mark of the open card is drawn over everything", () => {547 const built = styles({ selected: "7" });548 assert.equal(built.pinLabel(pin(TEXT, "7"), true)?.getZIndex(), 20_000_000);549 const beside = built.pinLabel(pin(TEXT, "8"), true);550 assert.equal(beside?.getZIndex(), 9_100_000, "a promoted one is placed, not chosen");551 assert.equal(beside?.getText()?.getDeclutterMode(), "none");552 assert.equal(built.pinLabel(pin(TEXT, "9"), false)?.getZIndex(), 42);553 assert.equal(built.pinLabel(pin(TEXT, "9"), false)?.getText()?.getDeclutterMode(), "declutter",554 "and one in the crowd gives way to a rarer collection's, as any name does");555});556557test("a reader who silences a text collection gets its markers back", () => {558 // THE ONE JUDGEMENT THE RULE NEEDS. Silencing a collection silences its559 // names, and for a collection whose name is its only form that would erase560 // the feature outright: a box still ticked, a place still counted, and561 // nothing on the map. So the override chooses between the two forms a point562 // collection can take -- which is what the reference's own key called that563 // toggle -- rather than between a form and nothing.564 const built = waiting({ overrides: overrides(TEXT, "quiet") });565 assert.equal(built.pinLabel(pin(TEXT), false), null, "the names are silenced");566 const mark = built.pin(pin(TEXT), false)[0];567 assert.equal(mark?.getText()?.getText(), "R", "and the ordinary marker stands in their place");568 assert.equal(built.pin(pin(TEXT), false).length, 1);569});570571test("a place with no name to be written as keeps its marker", () => {572 // The other half of the same rule: there is no text to draw a nameless573 // feature as, and a feature drawn as nothing is a feature the reader can574 // neither see nor point at.575 const built = waiting();576 const nameless = pin(TEXT, "7", "");577 assert.equal(built.pinLabel(nameless, false), null);578 assert.equal(built.pin(nameless, false)[0]?.getText()?.getText(), "R");579});580581test("a silenced text collection still answers the pointer, and with a marker beside", () => {582 // Hover and selection speak over the silencing for every other collection;583 // the marker is the form either way, so the name comes back beside it.584 const id = "7";585 const built = waiting({ overrides: overrides(TEXT, "quiet") }, "dark");586 assert.equal(built.pin(pin(TEXT, id), false).length, 1);587 const hovering = styles({ overrides: overrides(TEXT, "quiet") }, false, id);588 const label = hovering.pinLabel(pin(TEXT, id), false);589 assert.equal(label?.getText()?.getOffsetY(), 16, "an ordinary name, beside an ordinary mark");590});591592test("everything else keeps the mark it had", async () => {593 const built = waiting();594 parked[0]?.load();595 await settle();596 assert.equal(built.pin(pin(SHRINES), false).length, 1);597 assert.ok(built.pin(pin(SHRINES), false)[0]?.getImage(), "artwork collections are unchanged");598 assert.equal(built.pin(pin(PLAIN), false).length, 1);599 assert.equal(built.pin(pin(SPOKEN), false)[0]?.getText()?.getText(), "C", "Cities");600});601602// ---- the shapes' own colours ------------------------------------------603604/** One zone, standing wherever; only its id and collection matter here. */605function zone(collection: Collection, id: string): ShapeRecord {606 return {607 id, title: "", subtitle: "", collection, kind: "area", shard: 0,608 lines: [], holes: [], center: null, feature: { id } as never,609 };610}611612test("a shape wears its feature's colour, which is the index's own", () => {613 // Native identities are arbitrary stable strings. They keep their colour614 // without being parsed into a compatibility integer first.615 assert.equal(featureColor("world/feature/1496244488"), featureColor("world/feature/1496244488"));616 assert.notEqual(featureColor("world/feature/1496244488"), featureColor("world/feature/39191589"));617 assert.notEqual(featureColor("zoning-b1"), featureColor("zoning-o1"));618619 // A zoning collection is one row with one declared colour, but B-1 and620 // O-1 are different grounds: the map draws each in its feature's colour,621 // never the collection's.622 const zoning: Collection = {623 id: 9, title: "Zoning", kind: "area", visible: true, color: "#00ff00",624 };625 const built = styles({});626 const first = built.area(zone(zoning, "1496244488"), false);627 const second = built.area(zone(zoning, "39191589"), false);628 assert.equal(first.getStroke()?.getColor(), featureColor("1496244488"));629 assert.equal(second.getStroke()?.getColor(), featureColor("39191589"));630 assert.notEqual(first.getStroke()?.getColor(), zoning.color);631632 // A path is the same rule at a different width.633 const trails: Collection = { id: 4, title: "Trails", kind: "path", visible: true };634 const [, ink] = built.path({ ...zone(trails, "1496244488"), kind: "path" }, 2, false);635 assert.equal(ink?.getStroke()?.getColor(), featureColor("1496244488"));636});