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 / markers.test.ts
markers.test.ts 13.9K
1// What a marker is made of.2//3// THE DEFECT THIS SUITE IS THE RECORD OF: markers were drawn as coloured4// bubbles — a filled disc in the collection's colour, rimmed with the world's5// outset, with a black symbol punched out of the middle. Every collection6// therefore said the same thing at a glance ("a coloured bubble"), and the7// one drawing that told a Shrine from a Landing Site was the small dark shape8// inside it. The mark is supposed to be the symbol itself: the colour is9// carried by the glyph, and what makes it legible over the art beneath is an10// outset cut to the glyph's own shape.11//12// So the composition is the contract, and it is three canvases:13//14// the TINTED glyph — the symbol, filled with the collection's colour, and15// only when it is a glyph: a picture already carries its own colours;16// the OUTLINE — the same symbol, filled with the world's rim colour;17// the RASTER — the outline stamped in a disc of 37 offsets, and the tinted18// glyph over the top of it, in that order. Stamping the other way round19// would leave every symbol looking eroded at its edges.20//21// The canvas is stubbed rather than mocked away: every operation is recorded22// with the state it was performed under, so the tests can say what was drawn,23// in what order, in what colour and under which composite mode. Images are24// parked until a test hands them over, which is the only way to say anything25// about the window before a raster exists.2627import test from "node:test";28import { strict as assert } from "node:assert";2930// ---- the page a raster is composed on --------------------------------3132/** One call made on a stub canvas, with the state it was made under. */33interface Op {34 readonly name: string;35 readonly args: readonly unknown[];36 readonly fill: string;37 readonly mode: string;38 readonly smoothing: boolean | undefined;39}4041/** Every canvas the module has minted, in the order it minted them. */42const canvases: StubCanvas[] = [];4344class StubCanvas {45 width = 0;46 height = 0;47 readonly ops: Op[] = [];48 readonly paper: Record<string, unknown>;4950 constructor() {51 const record = (name: string) => (...args: unknown[]): void => {52 this.ops.push({53 name,54 args,55 fill: String(this.paper.fillStyle),56 mode: String(this.paper.globalCompositeOperation),57 smoothing: this.paper.imageSmoothingEnabled as boolean | undefined,58 });59 };60 this.paper = {61 fillStyle: "",62 globalCompositeOperation: "source-over",63 imageSmoothingEnabled: undefined,64 drawImage: record("drawImage"),65 fillRect: record("fillRect"),66 };67 }6869 getContext(): Record<string, unknown> {70 return this.paper;71 }7273 toDataURL(): string {74 return `data:image/png;base64,canvas-${canvases.indexOf(this)}`;75 }7677 drawn(name: string): readonly Op[] {78 return this.ops.filter((op) => op.name === name);79 }80}8182/** One image the seam has asked for and not yet been given. */83interface Parked {84 readonly url: string;85 readonly load: () => void;86 readonly fail: () => void;87}8889const parked: Parked[] = [];9091class StubImage {92 crossOrigin = "";93 onload: (() => void) | null = null;94 onerror: (() => void) | null = null;95 private asked = "";9697 get src(): string {98 return this.asked;99 }100101 set src(value: string) {102 this.asked = value;103 parked.push({104 url: value,105 load: () => this.onload?.(),106 fail: () => this.onerror?.(),107 });108 }109}110111const host = globalThis as unknown as Record<string, unknown>;112host.document = {113 createElement: (): StubCanvas => {114 const canvas = new StubCanvas();115 canvases.push(canvas);116 return canvas;117 },118};119host.Image = StubImage;120121const {122 composeMarker, forgetMarkerRasters, initialsOf, legibleIconColor, markerKey,123 markerRaster, markerRasterReady, outsetColor, relativeLuminance, withLightness,124} = await import("../chart/markers.ts");125type MarkerFace = import("../chart/markers.ts").MarkerFace;126127const { iconURL } = await import("../data/plane.ts");128129const DARK_RIM = outsetColor("dark");130131function face(over: Partial<MarkerFace> = {}): MarkerFace {132 return {133 asset: "shrine.svg",134 url: "/data/v/skyrim/abc123abc123/icons/shrine.svg",135 picture: false,136 color: "#4fb3d5",137 outset: DARK_RIM,138 ...over,139 };140}141142/** Let the microtasks behind a delivered image run. */143function settle(): Promise<void> {144 return new Promise((resolve) => { setTimeout(resolve, 0); });145}146147/** Compose one raster from a fresh page, and hand back the three canvases. */148async function compose(over: Partial<MarkerFace> = {}): Promise<{149 raster: string | null;150 target: StubCanvas;151 tinted: StubCanvas;152 outline: StubCanvas;153}> {154 forgetMarkerRasters();155 canvases.length = 0;156 parked.length = 0;157 const asking = markerRasterReady(face(over));158 parked[0]?.load();159 const raster = await asking;160 const [target, tinted, outline] = canvases;161 assert.ok(target && tinted && outline, "a raster is composed on three canvases");162 return { raster, target, tinted, outline };163}164165// ---- the raster ------------------------------------------------------166167test("a raster is 64 square, and the glyph sits inset inside it", async () => {168 const { target, tinted } = await compose();169 for (const canvas of canvases) {170 assert.equal(canvas.width, 64);171 assert.equal(canvas.height, 64);172 }173 const [glyph] = tinted.drawn("drawImage");174 assert.deepEqual(glyph?.args.slice(1), [6, 6, 52, 52],175 "six of inset on every side, which is where the halo goes");176 assert.equal(target.width, 64);177});178179test("the halo goes down first and the symbol over it", async () => {180 const { target, tinted, outline } = await compose();181 const stamps = target.drawn("drawImage");182 assert.equal(stamps.at(-1)?.args[0], tinted,183 "the tinted glyph is the last thing drawn — a symbol over its rim, not under it");184 for (const stamp of stamps.slice(0, -1)) {185 assert.equal(stamp.args[0], outline, "everything before it is the rim");186 }187 assert.deepEqual(stamps.at(-1)?.args.slice(1), [0, 0], "and it is drawn square on");188});189190test("the halo is a disc: thirty-seven stamps, none further than three", async () => {191 const { target, outline } = await compose();192 const stamps = target.drawn("drawImage").filter((op) => op.args[0] === outline);193 assert.equal(stamps.length, 37, "a disc of radius √10, not a 7×7 square of 49");194 const offsets = stamps.map(195 (op): readonly [number, number] => [op.args[1] as number, op.args[2] as number]);196 for (const [x, y] of offsets) {197 assert.ok(x * x + y * y <= 10, `(${x}, ${y}) is inside the disc`);198 assert.ok(Math.abs(x) <= 3 && Math.abs(y) <= 3);199 }200 assert.equal(new Set(offsets.map(String)).size, 37, "every offset is stamped once");201 // The corners are what a square would have added: at three out in both202 // directions the rim would reach 4.2 pixels on the diagonal and 3 on the203 // straight, which reads as a lozenge rather than a halo.204 assert.equal(offsets.some(([x, y]) => Math.abs(x) === 3 && Math.abs(y) === 3), false);205});206207test("a glyph is filled with its collection's colour, and smoothed on the way", async () => {208 const { tinted } = await compose({ color: "#c9924b" });209 const [drawn] = tinted.drawn("drawImage");210 assert.equal(drawn?.smoothing, true, "a silhouette is scaled smoothly");211 const [filled] = tinted.drawn("fillRect");212 assert.equal(filled?.mode, "source-in", "the fill keeps the shape and replaces its pixels");213 assert.equal(filled?.fill, "#c9924b");214 assert.deepEqual(filled?.args, [0, 0, 64, 64], "over the whole raster");215});216217test("a picture keeps its own colours and its own pixels", async () => {218 const { tinted } = await compose({ picture: true });219 const [drawn] = tinted.drawn("drawImage");220 assert.equal(drawn?.smoothing, false, "a picture is scaled nearest-neighbour");221 assert.equal(tinted.drawn("fillRect").length, 0,222 "flattening a picture to one colour would leave nothing but its outline filled in");223});224225test("the rim is the world's outset, whatever the symbol is", async () => {226 for (const picture of [false, true]) {227 const { outline } = await compose({ picture, outset: DARK_RIM });228 const [filled] = outline.drawn("fillRect");229 assert.equal(filled?.mode, "source-in");230 assert.equal(filled?.fill, DARK_RIM, "a picture wears a halo too");231 }232});233234test("a composed raster is a data URL of the raster canvas, not of a part of it", async () => {235 const { raster, target } = await compose();236 assert.equal(raster, target.toDataURL());237});238239test("a page with no canvas composes nothing rather than throwing", () => {240 const page = host.document;241 host.document = { createElement: () => ({ getContext: () => null }) };242 try {243 assert.equal(composeMarker({} as CanvasImageSource, face()), "");244 } finally {245 host.document = page;246 }247});248249// ---- the cache -------------------------------------------------------250251test("a raster is keyed by asset, colour and rim — not by collection", () => {252 const key = markerKey(face());253 assert.equal(markerKey(face()), key, "two collections drawn the same way share one raster");254 assert.notEqual(markerKey(face({ color: "#c9924b" })), key);255 assert.notEqual(markerKey(face({ outset: outsetColor("light") })), key);256 assert.notEqual(markerKey(face({ asset: "cave.svg" })), key);257});258259test("one asset is fetched once, however many collections ask for it", async () => {260 forgetMarkerRasters();261 parked.length = 0;262 const first = markerRasterReady(face());263 const second = markerRasterReady(face());264 assert.equal(parked.length, 1, "the second ask joins the first rather than fetching again");265 parked[0]?.load();266 assert.equal(await first, await second, "and they are handed the same raster");267 assert.equal(markerRaster(face()), await first, "which is there for a style function to read");268});269270test("until the symbol arrives there is no raster to draw", () => {271 forgetMarkerRasters();272 parked.length = 0;273 void markerRasterReady(face());274 assert.equal(markerRaster(face()), null, "the pin wears its initials in the meantime");275});276277test("a symbol that never arrives is not asked for again", async () => {278 forgetMarkerRasters();279 parked.length = 0;280 const asking = markerRasterReady(face());281 parked[0]?.fail();282 assert.equal(await asking, null);283 assert.equal(markerRaster(face()), null, "the initials become permanent");284 await markerRasterReady(face());285 assert.equal(parked.length, 1, "a 404 is remembered as firmly as a picture");286});287288test("a collection with no artwork asks for nothing at all", async () => {289 forgetMarkerRasters();290 parked.length = 0;291 assert.equal(await markerRasterReady(face({ asset: "", url: "" })), null);292 assert.equal(markerRaster(face({ asset: "", url: "" })), null);293 assert.equal(parked.length, 0);294});295296test("the symbol is asked for over CORS, because the raster is exported", async () => {297 forgetMarkerRasters();298 parked.length = 0;299 void markerRasterReady(face());300 // A canvas tainted by an image fetched without CORS cannot be exported at301 // all, and exporting it is the whole of what this module does.302 assert.equal(parked[0]?.url, face().url);303 await settle();304});305306// ---- the colour ladder -----------------------------------------------307308test("brightness is the reference's own ungamma'd weights", () => {309 assert.equal(relativeLuminance("#000000"), 0);310 assert.equal(relativeLuminance("#ffffff"), 1);311 assert.ok(Math.abs(relativeLuminance("#00ff00") - 0.7152) < 1e-9);312 assert.equal(relativeLuminance("rgb(0, 0, 0)"), 0.5, "a colour we cannot take apart is a shrug");313});314315interface LadderCase {316 readonly name: string;317 readonly color: string;318 readonly outset: string;319 readonly kept: boolean;320}321322const LADDER: readonly LadderCase[] = [323 {324 name: "a mid colour stands against a dark rim",325 color: "#4fb3d5", outset: "dark", kept: true,326 },327 {328 name: "a near-black colour is lifted off a dark rim",329 color: "#101418", outset: "dark", kept: false,330 },331 {332 name: "a mid colour stands against a light rim",333 color: "#4fb3d5", outset: "light", kept: true,334 },335 {336 name: "a near-white colour is dropped against a light rim",337 color: "#fafafa", outset: "light", kept: false,338 },339 {340 name: "an unknown outset reads light, like everywhere else",341 color: "#fafafa", outset: "sepia", kept: false,342 },343 {344 name: "and a mid colour survives an unknown outset too",345 color: "#4fb3d5", outset: "", kept: true,346 },347];348349for (const item of LADDER) {350 test(`legible colour: ${item.name}`, () => {351 const drawn = legibleIconColor(item.color, item.outset);352 assert.equal(drawn === item.color, item.kept);353 if (item.kept) return;354 const lifted = item.outset === "dark";355 assert.ok(lifted356 ? relativeLuminance(drawn) > relativeLuminance(item.color)357 : relativeLuminance(drawn) < relativeLuminance(item.color),358 "the colour moved the way the rim needed it to");359 });360}361362test("a lifted colour keeps its hue, so the legend still matches the map", () => {363 // 0.74 and 0.42 are the two lightnesses the reference lifts and drops to.364 assert.equal(withLightness("#0a3d5c", 0.74), "#87caf2");365 assert.equal(withLightness("#0a3d5c", 0.42), "#1580c1");366 assert.equal(withLightness("#808080", 0.74), "#bdbdbd", "grey has no hue to keep");367 assert.equal(withLightness("rgb(1,2,3)", 0.5), "rgb(1,2,3)", "and what cannot be read is kept");368});369370// ---- the fallback and the URL ----------------------------------------371372test("initials are the first letters of the first two words", () => {373 assert.equal(initialsOf("Impact Craters"), "IC");374 assert.equal(initialsOf("Landing Sites and Rovers"), "LS");375 assert.equal(initialsOf("Volcanoes"), "V");376 assert.equal(initialsOf(""), "");377});378379test("an asset path is encoded segment by segment", () => {380 const base = "/data/v/neon-harbor/abc123abc123";381 assert.equal(iconURL(base, "assets/ward.svg"), `${base}/assets/ward.svg`);382 assert.equal(iconURL(base, "assets/signs/Ward 101 (Ext).png"),383 `${base}/assets/signs/Ward%20101%20(Ext).png`,384 "the separators stay separators and everything else goes on the wire encoded");385});