1// What a pointer resolves to, and where the answer goes.2//3// Two surfaces can be pointed at and, once a grid is up, two different things4// can be under the pointer. The rules below are the reference implementation's5// and every one of them was a defect in this seam before it: the canvas6// resolved features only, so a grid could be drawn and never descended, and7// the sphere answered a press with the nearest pin however deep the reader was8// telescoping.9//10// A CELL ANSWERS FIRST. While the grid is up a click is a request to go one11// level in, and the pins standing on the cell are not consulted at all.12// Reversed, the grid would be undescendable anywhere a marker happened to13// be, which is everywhere worth descending to.14// AND ONLY A NEIGHBOUR OR A CHILD IS AN ANSWER. The cell the reader is15// already in lies over its own children -- as an outline, or as the whole16// grid at the floor of the telescope -- so a plan that answered for `scope`17// or `leaf` would hand back the address already held on every click.18// THE TOLERANCES DIFFER, AND SAY DIFFERENT THINGS. A marker is a point and19// is given four pixels of slack; a cell is a region the reader is pointing20// into and is given one, because slack around a boundary is a boundary that21// cannot be told from the cell beyond it.22// AND THE TWO ANSWERS TAKE DIFFERENT ROADS. A feature is an identity the23// session selects; a cell is an address the session holds. Two forms, two24// routes, and a click that resolved to one must never post the other.25//26// The map here is a stub of OpenLayers' own hit-test contract -- walk the27// stack the layer filter allows, in z-order, stop at the first truthy answer28// -- so what is checked is the seam's use of it: which layers it asks, with29// what tolerance, in what order, and what it does with the answer.3031import test from "node:test";32import { strict as assert } from "node:assert";33import { setLevel } from "../log.ts";3435setLevel("error");3637// ---- the page ---------------------------------------------------------3839interface Field { value: string }4041const fields = new Map<string, Field>();42const nodes = new Map<string, unknown>();43const events: CustomEvent[] = [];4445/** A canvas-ish node, enough of one for `globe.gl` to load over. */46function stubElement(): unknown {47 return {48 width: 0, height: 0, style: {}, classList: { add: () => {} },49 getContext: () => ({ measureText: () => ({ width: 0 }), fillRect: () => {} }),50 appendChild: () => {}, insertBefore: () => {}, setAttribute: () => {},51 addEventListener: () => {},52 };53}5455const host = globalThis as unknown as Record<string, unknown>;56host.HTMLElement = class { setAttribute(): void { /* the hover writes one */ } };57host.requestAnimationFrame = (): number => 0;58host.cancelAnimationFrame = (): void => {};59host.Image = class { src = ""; crossOrigin = ""; };60host.document = {61 querySelector: (selector: string) => nodes.get(selector) ?? null,62 createElement: stubElement,63 createElementNS: stubElement,64 createTextNode: stubElement,65 getElementsByTagName: () => [stubElement()],66 querySelectorAll: () => [],67 addEventListener: () => {},68 head: stubElement(),69 body: stubElement(),70};71// The window is the real global -- `globe.gl` reaches for its animation frames72// as it loads -- with the one method the reports use recording instead.73host.window = globalThis;74host.dispatchEvent = (event: Event) => {75 events.push(event as CustomEvent);76 return true;77};7879/** The two hidden forms the shell renders, empty again. */80function page(): void {81 fields.clear();82 nodes.clear();83 events.length = 0;84 for (const id of ["#atlas-pick-feature", "#atlas-grid-pick-cell"]) {85 const field: Field = { value: "" };86 fields.set(id, field);87 nodes.set(id, field);88 }89 nodes.set("#atlas-pick", {});90 nodes.set("#atlas-grid-pick", {});91}9293// Imported after the page exists: `globe.gl` reads the document as it loads.94const { AtlasChart } = await import("../chart/element.ts");95const { AtlasGlobe } = await import("../globe/element.ts");9697// ---- the map the chart asks -------------------------------------------9899/** One feature as a hit test sees it: a bag of properties on some layer. */100function feature(values: Record<string, unknown>): { get(key: string): unknown } {101 return { get: (key: string) => values[key] };102}103104interface Probe {105 readonly tolerance: number | undefined;106 readonly layers: readonly unknown[];107}108109interface Drawn {110 readonly feature: { get(key: string): unknown };111 readonly layer: unknown;112}113114/**115 * OpenLayers' hit-test contract, in fifteen lines: the drawn stack in z-order116 * from the top, narrowed by the caller's layer filter, stopping at the first117 * truthy answer the caller's own callback gives.118 */119class StubMap {120 readonly probes: Probe[] = [];121 readonly handlers = new Map<string, (event: unknown) => void>();122 private readonly stack: readonly Drawn[];123 constructor(stack: readonly Drawn[]) { this.stack = stack; }124125 on(name: string, handler: (event: unknown) => void): void {126 this.handlers.set(name, handler);127 }128129 forEachFeatureAtPixel(130 _pixel: number[],131 callback: (feature: unknown, layer: unknown) => unknown,132 options?: { hitTolerance?: number; layerFilter?: (layer: unknown) => boolean },133 ): unknown {134 const asked = this.stack.filter((drawn) => options?.layerFilter?.(drawn.layer) ?? true);135 this.probes.push({136 tolerance: options?.hitTolerance,137 layers: [...new Set(asked.map((drawn) => drawn.layer))],138 });139 for (const drawn of asked) {140 const answer = callback(drawn.feature, drawn.layer);141 if (answer) return answer;142 }143 return undefined;144 }145}146147const GRID = { name: "grid" };148const GRID_CONTEXT = { name: "gridContext" };149const PINS = { name: "pins" };150const ZONES = { name: "zones" };151/**152 * The layer the names are drawn in.153 *154 * It carries the same features the pin layers do, and for a collection155 * curated as text it is the only layer that draws them at all: the name is156 * the whole of the mark, so the written place is hit-tested through its text157 * (chart/styles.ts).158 */159const LABELS = { name: "pinLabels" };160161/** A grid feature as `chart/grid.ts` mints one. */162function cell(hash: string, role: string): { get(key: string): unknown } {163 return feature({ gridCell: { hash, extent: [0, 0, 1, 1], role, count: 0, contextDistance: 0 } });164}165166const PIN = feature({ record: { id: "1849", index: 0 } });167const ZONE = feature({ record: { id: "RS", kind: "area" } });168/** A place its collection writes on the map rather than pinning to it. */169const TEXT_PIN = feature({ record: { id: "8988", index: 0 } });170171/** A world where one pin stands and one piece of ground is drawn. */172function context(gridOn: boolean): unknown {173 return {174 scene: { gridSystem: gridOn ? "geohash" : "", volume: "mars", world: "surface" },175 system: gridOn ? { slug: "geohash" } : null,176 cell: "",177 visibility: {178 at: () => ({ hidden: false, promoted: false, passesHighlights: false }),179 shapesShown: [ZONE.get("record")],180 },181 };182}183184interface Seam { [key: string]: unknown }185186/** A chart standing over a drawn stack, with nothing else wired. */187function chartOver(stack: readonly Drawn[], gridOn = true): { seam: Seam; map: StubMap } {188 page();189 const chart = new AtlasChart();190 const seam = chart as unknown as Seam;191 const map = new StubMap(stack);192 seam.map = map;193 seam.context = context(gridOn);194 seam.gridLayers = [GRID, GRID_CONTEXT];195 return { seam, map };196}197198function hit(seam: Seam): unknown {199 return (seam.hit as (pixel: number[]) => unknown).call(seam, [10, 10]);200}201202/** The stack a descendable grid draws: a child under the pins, a neighbour over. */203const DESCENDABLE: Drawn[] = [204 { feature: cell("9qb", "neighbor"), layer: GRID_CONTEXT },205 { feature: PIN, layer: PINS },206 { feature: cell("9qa", "child"), layer: GRID },207 { feature: ZONE, layer: ZONES },208];209210// ---- the order --------------------------------------------------------211212test("a cell answers before the pin standing on it", () => {213 const { seam } = chartOver(DESCENDABLE);214 assert.deepEqual(hit(seam), { kind: "cell", cell: "9qb" });215});216217test("a cell under the pins answers too, because depth is not the question", () => {218 const { seam } = chartOver([219 { feature: PIN, layer: PINS },220 { feature: cell("9qa", "child"), layer: GRID },221 ]);222 assert.deepEqual(hit(seam), { kind: "cell", cell: "9qa" });223});224225test("with no cell under the pointer the pin answers, and beats the ground", () => {226 const { seam } = chartOver([227 { feature: PIN, layer: PINS },228 { feature: ZONE, layer: ZONES },229 ]);230 assert.deepEqual(hit(seam), { kind: "point", id: "1849" });231});232233test("with neither, the ground answers", () => {234 const { seam } = chartOver([{ feature: ZONE, layer: ZONES }]);235 assert.deepEqual(hit(seam), { kind: "area", id: "RS" });236});237238test("a click on nothing at all is a miss", () => {239 const { seam } = chartOver([]);240 assert.equal(hit(seam), null);241});242243// ---- which cells are answers ------------------------------------------244245test("the cell the reader is already in is not an answer, and the pin under it is", () => {246 for (const role of ["scope", "leaf"]) {247 const { seam } = chartOver([248 { feature: cell("9q", role), layer: GRID },249 { feature: PIN, layer: PINS },250 ]);251 assert.deepEqual(hit(seam), { kind: "point", id: "1849" },252 `a ${role} cell answered, so the grid could not be descended`);253 }254});255256test("a scope over its own child hands back the child", () => {257 const { seam } = chartOver([258 { feature: cell("9q", "scope"), layer: GRID },259 { feature: cell("9qa", "child"), layer: GRID },260 ]);261 assert.deepEqual(hit(seam), { kind: "cell", cell: "9qa" });262});263264test("with the grid off no cell is looked for at all", () => {265 const { seam, map } = chartOver(DESCENDABLE, false);266 assert.deepEqual(hit(seam), { kind: "point", id: "1849" });267 assert.equal(map.probes.length, 1, "the grid was asked about while it was off");268});269270// ---- what is asked, and how closely ------------------------------------271272test("the cell is asked of the grid's own layers, at one pixel", () => {273 const { seam, map } = chartOver(DESCENDABLE);274 hit(seam);275 const cells = map.probes[0];276 assert.equal(cells?.tolerance, 1);277 assert.deepEqual(cells?.layers, [GRID_CONTEXT, GRID],278 "the cell hit test was offered a layer that is not the grid's");279});280281test("a feature is asked of everything, at four", () => {282 const { seam, map } = chartOver([{ feature: ZONE, layer: ZONES }]);283 hit(seam);284 const features = map.probes[1];285 assert.equal(features?.tolerance, 4);286 assert.deepEqual(features?.layers, [ZONES]);287});288289test("a hidden pin is not a hit, and the ground under it is", () => {290 const { seam } = chartOver([291 { feature: PIN, layer: PINS },292 { feature: ZONE, layer: ZONES },293 ]);294 (seam.context as { visibility: { at: () => unknown } }).visibility.at =295 () => ({ hidden: true, promoted: false, passesHighlights: false });296 assert.deepEqual(hit(seam), { kind: "area", id: "RS" });297});298299// ---- where the answer goes ---------------------------------------------300301function click(seam: Seam, map: StubMap): void {302 (seam.wire as () => void).call(seam);303 map.handlers.get("singleclick")?.({ pixel: [10, 10] });304}305306test("a cell posts the address, and never the selection", () => {307 const { seam, map } = chartOver(DESCENDABLE);308 click(seam, map);309 assert.equal(fields.get("#atlas-grid-pick-cell")?.value, "9qb");310 assert.equal(fields.get("#atlas-pick-feature")?.value, "",311 "descending a grid selected a feature as well");312 assert.deepEqual(events.map((event) => event.type), ["atlas:grid-pick"]);313});314315test("a feature posts the selection, and never an address", () => {316 const { seam, map } = chartOver([{ feature: PIN, layer: PINS }]);317 click(seam, map);318 assert.equal(fields.get("#atlas-pick-feature")?.value, "1849");319 assert.equal(fields.get("#atlas-grid-pick-cell")?.value, "",320 "picking a feature moved the grid as well");321 assert.deepEqual(events.map((event) => event.type), ["atlas:pick"]);322});323324test("a click on nothing posts neither", () => {325 const { seam, map } = chartOver([]);326 click(seam, map);327 assert.equal(events.length, 0);328});329330test("a place written on the map is picked like any other, and opens its card", () => {331 // A collection curated as text has no marker, so the only layer it is drawn332 // in is the one carrying the names -- and the feature hit test asks every333 // layer rather than the pin layers alone, which is what keeps a written334 // place pickable at all. The record it answers with is the same either way.335 const { seam, map } = chartOver([{ feature: TEXT_PIN, layer: LABELS }], false);336 assert.deepEqual(hit(seam), { kind: "point", id: "8988" });337 click(seam, map);338 assert.equal(fields.get("#atlas-pick-feature")?.value, "8988");339 assert.deepEqual(events.map((event) => event.type), ["atlas:pick"]);340});341342test("the hover is about features and never about cells", () => {343 const { seam, map } = chartOver(DESCENDABLE);344 (seam.wire as () => void).call(seam);345 map.handlers.get("pointermove")?.({ pixel: [10, 10], dragging: false });346 // The cell over the pin does not stop the pin lifting under the pointer,347 // and no cell was ever asked for: a cell has no hover reading.348 assert.equal((seam.context as { hovered: string | null }).hovered, "1849");349 assert.equal(map.probes.length, 1);350 assert.equal(map.probes[0]?.tolerance, 4);351});352353// ---- the sphere ---------------------------------------------------------354355/** A flattening that is its own inverse to the nearest degree. */356const MAPPING = {357 toWorld: (lat: number, lng: number) => [lng * 2, lat * 2] as const,358 toLatLng: (x: number, y: number) => [y / 2, x / 2] as const,359};360361interface Descent {362 readonly held: string;363 readonly at: readonly [number, number];364}365366/** A globe over a world a system divides, recording what it was asked. */367function globeOver(options: {368 gridOn: boolean;369 held?: string;370 answer?: string;371 pins?: { id: string; coordinate: [number, number] }[];372}): { seam: Seam; asked: Descent[] } {373 page();374 const asked: Descent[] = [];375 const globe = new AtlasGlobe();376 const seam = globe as unknown as Seam;377 seam.equirect = { mapping: MAPPING, px: [0, 0, 8192, 4096] };378 seam.context = {379 scene: { gridSystem: options.gridOn ? "geohash" : "" },380 system: options.gridOn381 ? {382 slug: "geohash",383 on: () => ({384 descendTarget: (held: string, at: readonly [number, number]) => {385 asked.push({ held, at });386 return options.answer ?? "";387 },388 }),389 }390 : null,391 ground: {},392 cell: options.held ?? "",393 visibility: { standing: () => options.pins ?? [] },394 };395 return { seam, asked };396}397398function press(seam: Seam, lat: number, lng: number): void {399 (seam.pick as (lat: number, lng: number) => void).call(seam, lat, lng);400}401402test("a press on the sphere descends one level below the cell being held", () => {403 const { seam, asked } = globeOver({ gridOn: true, held: "9q", answer: "9qb" });404 press(seam, 10, 20);405 // The point pressed, flattened back onto the picture: x east, y negative406 // down, which is the space every coordinate in this lane lives in.407 assert.deepEqual(asked, [{ held: "9q", at: [40, -20] }]);408 assert.equal(fields.get("#atlas-grid-pick-cell")?.value, "9qb");409 assert.deepEqual(events.map((event) => event.type), ["atlas:grid-pick"]);410});411412test("a press at the floor of the telescope goes nowhere", () => {413 const { seam, asked } = globeOver({ gridOn: true, held: "9qbxu4", answer: "" });414 press(seam, 10, 20);415 assert.equal(asked.length, 1, "the system was not asked");416 assert.equal(events.length, 0, "an empty address was posted as if it were the root");417});418419test("a press while a grid is up never picks the pin under it", () => {420 const { seam } = globeOver({421 gridOn: true, held: "9q", answer: "9qb",422 pins: [{ id: "1849", coordinate: [40, -20] }],423 });424 press(seam, 10, 20);425 assert.equal(fields.get("#atlas-pick-feature")?.value, "");426});427428test("with no grid up a press is a pick, as it has always been", () => {429 const { seam, asked } = globeOver({430 gridOn: false, pins: [{ id: "1849", coordinate: [40, -20] }],431 });432 press(seam, 10, 20);433 assert.equal(asked.length, 0);434 assert.equal(fields.get("#atlas-pick-feature")?.value, "1849");435 assert.deepEqual(events.map((event) => event.type), ["atlas:pick"]);436});