1// The grid the chart draws, judged against the documented halving.2//3// Geohash divides a ground by alternating halvings, five bits to a4// character, most significant bit first, x first — and the axis does NOT5// reset between characters, so an odd character splits x three times and y6// twice, and an even character does the opposite. That is the whole of the7// arithmetic, so every extent below is computed from it by hand and inlined:8// nothing here reads a recording, and a change to the halving moves these9// numbers before it moves anything a reader sees.10//11// THE HAND DERIVATION, used throughout and spot-checked against literals:12// for an odd-position character of ordinal v (bits b16 b8 b4 b2 b1), the13// splits land x, y, x, y, x — so column = 4·b16 + 2·b4 + b1 of eight columns14// and row = 2·b8 + b2 of four rows, rows counted from the extent's bottom15// (a set bit keeps the upper half, and "up" in OL world coordinates is16// toward 0). An even-position character starts on y: column = 2·b8 + b2 of17// four, row = 4·b16 + 2·b4 + b1 of eight.18//19// The ground is real: mars, whose lens declares an 8192 × 4096 surface, so20// its level-1 cells are 1024 × 1024 — and bend-or, a plane city anchored to21// real earth by its Mercator window, whose grid is therefore REAL geohash:22// base-depth-4 WGS84 cells put on the picture through the declared23// flattening (its own derivation, at the bend-or section below). The pin24// counts are the corpus's own locations, recounted here by an independent25// sweep against the hand-derived boxes.2627import test from "node:test";28import { strict as assert } from "node:assert";29import VectorSource from "ol/source/Vector.js";30import type { FeatureLike } from "ol/Feature.js";31import { cellSystems, gridCellVisual } from "@atlas/analysis";32import type { Extent, PlanCell } from "@atlas/analysis";33import { cellMoved, drawGrid, planCells } from "../chart/grid.ts";34import { labelFitsCell } from "../chart/styles.ts";35import type { DrawnCell } from "../chart/grid.ts";36import { WorldModel, worldGrid } from "../world/model.ts";37import { legacyOpenWorld } from "./legacy.ts";38import type { PointRecord } from "../world/model.ts";39import { Visibility } from "../world/visibility.ts";40import { EMPTY_SCENE } from "../scene/read.ts";41import { LocationTable } from "../data/atlasloc.ts";42import { locations, payloads, tileGrid } from "./fixtures.ts";43import type { LocationsFixture } from "./fixtures.ts";4445/** The alphabet, spelled out by hand: the child order and the plan order. */46const ALPHABET = "0123456789bcdefghjkmnpqrstuvwxyz";4748/**49 * A page with a ruler on it.50 *51 * The fit gate measures an address in the pitch it will be drawn in, so it52 * needs a canvas; a monospace stub is enough, and it is the honest shape of53 * the thing — every hash at a level is the same length, so a level keeps or54 * drops its labels as one.55 */56(globalThis as unknown as Record<string, unknown>).document = {57 createElement: () => ({58 getContext: () => ({59 font: "",60 measureText(text: string) {61 const size = Number(/(\d+)px/.exec(String(this.font))?.[1] ?? 10);62 return { width: text.length * size * 0.6 };63 },64 }),65 }),66};6768/** Pack a fixture's records so the model can be built from them. */69function table(fixture: LocationsFixture): LocationTable {70 const encoder = new TextEncoder();71 const n = fixture.locations.length;72 const titles = fixture.locations.map((record) => encoder.encode(record.title));73 const total = titles.reduce((held, run) => held + run.length, 0);74 const buffer = new ArrayBuffer(20 + 26 * n + total);75 const bytes = new Uint8Array(buffer);76 bytes.set(encoder.encode("ATLASLOC"), 0);77 const header = new DataView(buffer);78 header.setUint16(8, 3, true);79 header.setUint32(10, n, true);80 const id = new Int32Array(buffer, 16, n);81 const lat = new Float32Array(buffer, 16 + 4 * n, n);82 const lng = new Float32Array(buffer, 16 + 8 * n, n);83 const member = new Int32Array(buffer, 16 + 12 * n, n);84 const shard = new Int32Array(buffer, 16 + 16 * n, n);85 const offsets = new Uint32Array(buffer, 16 + 20 * n, n + 1);86 const owner = new Uint16Array(buffer, 20 + 24 * n, n);87 let at = 0;88 fixture.locations.forEach((record, i) => {89 id[i] = record.id; lat[i] = record.lat; lng[i] = record.lng;90 member[i] = record.member; shard[i] = record.shard; owner[i] = record.owner;91 offsets[i] = at;92 bytes.set(titles[i] ?? new Uint8Array(), 20 + 26 * n + at);93 at += (titles[i] ?? new Uint8Array()).length;94 });95 offsets[n] = at;96 return LocationTable.over(buffer);97}9899function world(slug: string, name: string): WorldModel {100 const payload = payloads(slug).get(name);101 assert.ok(payload, `${slug}/${name} has a payload`);102 // The world's own window overrides the manifest's, which is the whole103 // difference between a coordinate on this world and one 850,000 pixels away.104 return new WorldModel(legacyOpenWorld(name, payload, worldGrid(tileGrid(slug), payload),105 table(locations(slug, name))));106}107108/**109 * As a diagnostics snapshot would carry it.110 *111 * A snapshot is JSON, and JSON has one zero: the analysis lane's surface112 * extent legitimately produces `-0` for the top edge of a lens anchored at113 * y = 0 (docs/analysis.md), which serializes as `0`. Comparing through the114 * serializer is comparing what a reader of the snapshot would actually see.115 */116function asRecorded<T>(value: T): T {117 return JSON.parse(JSON.stringify(value)) as T;118}119120/** The hand derivation from the header, one character at a time. */121function handCell(surface: Extent, hash: string): Extent {122 let [minX, minY, maxX, maxY] = surface;123 for (let depth = 0; depth < hash.length; depth++) {124 const v = ALPHABET.indexOf(hash[depth] ?? "");125 const odd = depth % 2 === 0;126 const column = odd ? ((v >> 4) & 1) * 4 + ((v >> 2) & 1) * 2 + (v & 1) : ((v >> 3) & 1) * 2 + ((v >> 1) & 1);127 const row = odd ? ((v >> 3) & 1) * 2 + ((v >> 1) & 1) : ((v >> 4) & 1) * 4 + ((v >> 2) & 1) * 2 + (v & 1);128 const columns = odd ? 8 : 4;129 const rows = odd ? 4 : 8;130 const width = (maxX - minX) / columns;131 const height = (maxY - minY) / rows;132 minX += width * column;133 maxX = minX + width;134 minY += height * row;135 maxY = minY + height;136 }137 return [minX, minY, maxX, maxY];138}139140test("the hand derivation lands on the numbers worked out on paper", () => {141 const mars: Extent = [0, -4096, 8192, 0];142 // "0" is every bit clear: the bottom-left corner. "m" is ordinal 19,143 // binary 10011 — column 4+0+1 = 5, row 0+1 = 1. "z" is 31: the top-right.144 assert.deepEqual(handCell(mars, "0"), [0, -4096, 1024, -3072]);145 assert.deepEqual(handCell(mars, "m"), [5120, -3072, 6144, -2048]);146 assert.deepEqual(handCell(mars, "z"), [7168, -1024, 8192, 0]);147 // "6" in second position starts on y: ordinal 6, binary 00110 — column148 // 2·0 + 1 = 1 of four 256-wide columns, row 0 + 2·1 + 0 = 2 of eight149 // 128-tall rows. A depth-2 cell is 256 × 128, not 128 × 256.150 assert.deepEqual(handCell(mars, "m6"), [5376, -2816, 5632, -2688]);151 // And the third character is back on x: 32 × 32.152 assert.deepEqual(handCell(mars, "m60"), [5376, -2816, 5408, -2784]);153 assert.deepEqual(handCell(mars, "m6s"), [5504, -2752, 5536, -2720]);154});155156/** The independent recount: standing pins inside a hand-derived box. */157function sweep(points: readonly PointRecord[], extent: Extent): number {158 // Closed intervals on every edge, exactly as the containment rule reads: a159 // pin on a boundary belongs to both cells it touches.160 return points.filter(({ coordinate: [x, y] }) =>161 x >= extent[0] && x <= extent[2] && y >= extent[1] && y <= extent[3]).length;162}163164/** Draw one grid the way the chart does, and answer what it held. */165function draw(model: WorldModel, cell: string): {166 cells: DrawnCell[]; extent: readonly number[] | null; standing: PointRecord[];167} {168 const lens = model.lenses[0] ?? null;169 const ground = model.ground(lens);170 const system = cellSystems.require("geohash");171 const standing = [...new Visibility(model, EMPTY_SCENE, lens?.shard ?? 0, null).standing()];172 const chosen = new VectorSource({ wrapX: false });173 const context = new VectorSource({ wrapX: false });174 const drawn = drawGrid(ground, system, cell, standing, chosen, context);175 // `drawn.cells` is the plan's own order; the sources hand features back in176 // whatever order their index likes, so the two are checked against each177 // other as sets and the ordering claims are made of the draw's account.178 const held = [...chosen.getFeatures(), ...context.getFeatures()]179 .map((feature) => (feature.get("gridCell") as DrawnCell).hash).sort();180 assert.deepEqual(held, drawn.cells.map((cell) => cell.hash).sort(),181 "every planned cell became a feature on one of the two sources");182 return { cells: drawn.cells, extent: drawn.extent, standing };183}184185test("the root grid over Mars is the thirty-two children of the lens's surface", () => {186 const surface: Extent = [0, -4096, 8192, 0];187 const { cells, extent, standing } = draw(world("mars", "global"), "");188 assert.deepEqual(asRecorded(extent), [...surface], "the ground the system divides");189 assert.equal(standing.length, 2048, "every pin the corpus world holds is standing");190 assert.deepEqual(191 cells.map((cell) => [cell.hash, cell.role, cell.contextDistance]),192 [...ALPHABET].map((character) => [character, "child", 0]),193 "one child per character, in the alphabet's own order, and nothing else");194 for (const cell of cells) {195 assert.deepEqual(asRecorded([...cell.extent]), asRecorded([...handCell(surface, cell.hash)]),196 `cell ${cell.hash}: the hand-derived box`);197 assert.equal(cell.count, sweep(standing, handCell(surface, cell.hash)),198 `cell ${cell.hash}: the pins it holds, recounted independently`);199 }200 // Two anchors read straight off the corpus, so the sweep itself is held to201 // something: of the 2,048 Trek locations, 26 stand in "0" and 95 in "m".202 assert.equal(cells.find((cell) => cell.hash === "0")?.count, 26);203 assert.equal(cells.find((cell) => cell.hash === "m")?.count, 95);204});205206test("descending into a cell holds its neighbours as context", () => {207 const surface: Extent = [0, -4096, 8192, 0];208 const { cells, extent, standing } = draw(world("mars", "global"), "m");209 assert.deepEqual(asRecorded(extent), [5120, -3072, 6144, -2048],210 "the held cell is the ground now");211 const roles: Record<string, number> = {};212 for (const cell of cells) roles[cell.role] = (roles[cell.role] ?? 0) + 1;213 assert.deepEqual(roles, { child: 32, scope: 1, neighbor: 31 },214 "the subdivision, the held boundary, and every sibling dimmed around it");215 for (const cell of cells) {216 assert.equal(cell.contextDistance, cell.role === "neighbor" ? 1 : 0,217 `cell ${cell.hash}: one level of ancestors means one distance`);218 assert.deepEqual(asRecorded([...cell.extent]), asRecorded([...handCell(surface, cell.hash)]),219 `cell ${cell.hash}: the hand-derived box`);220 assert.equal(cell.count, sweep(standing, handCell(surface, cell.hash)),221 `cell ${cell.hash}: the pins it holds, recounted independently`);222 }223 assert.deepEqual(224 cells.filter((cell) => cell.role === "child").map((cell) => cell.hash),225 [...ALPHABET].map((character) => `m${character}`),226 "the children refine the held address by one character");227});228229// ---- the earth-anchored city: REAL geohash --------------------------------230//231// bend-or declares a plane, `atlas.geometry.body: earth` and a Mercator232// window — px 0,0,8192,8192 picturing -121.48204667177453 … -121.1529533282255233// east-west and 44.17572950664809 … 43.93922950850393 north-south — so its234// grid is genuine WGS84 geohash. THE HAND DERIVATION: cell spans halve from235// 360°/180° per the 5-bit alternation, so depth 4 is 0.3515625° × 0.17578125°;236// depth 3 (1.40625° both ways) fits neither of the window's spans (0.32909°237// × 0.23650°) and depth 4's latitude does, so the base depth is 4. The window238// crosses columns 166–167 and rows 761–763 of the depth-4 grid counted from239// (-180°, -90°) — 58.51795/0.3515625 = 166.45 to 58.84705/0.3515625 = 167.39240// in longitude, 133.93923/0.17578125 = 761.98 to 134.17573/0.17578125 =241// 763.31 in latitude — six cells, whose ids read north to south then west to242// east: 9rce 9rcg / 9rcd 9rcf / 9rc9 9rcc (encoded with the textbook243// algorithm from each cell's centre, cross-checked in python).244245/** bend-or's declared window, transcribed from the corpus payload. */246const BEND_WINDOW = {247 west: -121.48204667177453, north: 44.17572950664809,248 east: -121.1529533282255, south: 43.93922950850393,249} as const;250251/**252 * A depth-4 cell's box from its grid indices, through the window's own253 * formulas: x = (lng − west) / (east − west) · 8192, y = (asinh(tan latN) −254 * asinh(tan lat)) / (asinh(tan latN) − asinh(tan latS)) · 8192, then the255 * sign flip — independent of the analysis lane, which must land on the same256 * doubles.257 */258function realCell(column: number, row: number): Extent {259 const { west, north, east, south } = BEND_WINDOW;260 const project = (lat: number) => Math.asinh(Math.tan((lat * Math.PI) / 180));261 const top = project(north);262 const bottom = project(south);263 const x = (lng: number) => ((lng - west) / (east - west)) * 8192;264 const y = (lat: number) => ((top - project(lat)) / (top - bottom)) * 8192;265 const cellWest = -180 + column * 0.3515625;266 const cellSouth = -90 + row * 0.17578125;267 return [268 x(cellWest), -y(cellSouth),269 x(cellWest + 0.3515625), -y(cellSouth + 0.17578125),270 ];271}272273test("an earth-anchored city's root grid is the window's own real cells", () => {274 const { cells, extent, standing } = draw(world("bend-or", "2026-08-02"), "");275 // The camera's root extent is still the lens's surface: the grid may276 // overhang the picture, but the view opens on the picture.277 assert.deepEqual(asRecorded(extent), [0, -8192, 8192, 0]);278 assert.equal(standing.length, 65, "the city corpus stands 65 pins");279 // Six cells — not thirty-two — in reading order, every one a root child.280 const placed: [string, number, number][] = [281 ["9rce", 166, 763], ["9rcg", 167, 763],282 ["9rcd", 166, 762], ["9rcf", 167, 762],283 ["9rc9", 166, 761], ["9rcc", 167, 761],284 ];285 assert.deepEqual(286 cells.map((cell) => [cell.hash, cell.role, cell.contextDistance]),287 placed.map(([hash]) => [hash, "child", 0]),288 "the base-depth cells the window intersects, north to south, west to east");289 for (const [hash, column, row] of placed) {290 const box = realCell(column, row);291 const drawn = cells.find((cell) => cell.hash === hash);292 assert.ok(drawn);293 assert.deepEqual(asRecorded([...drawn.extent]), asRecorded([...box]),294 `cell ${hash}: the hand-derived Mercator box`);295 assert.equal(drawn.count, sweep(standing, box),296 `cell ${hash}: the pins it holds, recounted independently`);297 }298 // The cells overhang the picture, honestly: the window's west edge sits299 // inside column 166, so the west column starts west of the raster.300 const west = realCell(166, 762);301 assert.ok(west[0] < 0, `column 166 starts at x = ${west[0]}, west of the raster`);302 // Two anchors read straight off the corpus, so the sweep itself is held to303 // something: of the 65 city pins, 63 stand in 9rcd and 2 in 9rcf.304 assert.equal(cells.find((cell) => cell.hash === "9rcd")?.count, 63);305 assert.equal(cells.find((cell) => cell.hash === "9rcf")?.count, 2);306});307308test("descending into a real cell holds the window's own context", () => {309 const { cells } = draw(world("bend-or", "2026-08-02"), "9rcd");310 const roles: Record<string, number> = {};311 for (const cell of cells) roles[cell.role] = (roles[cell.role] ?? 0) + 1;312 // Five window neighbours dimmed around the held cell — not thirty-one —313 // and twenty children of the alphabet's thirty-two: 9rcd overhangs the314 // window's west edge (-121.48204667° sits inside it), and a depth-5 child315 // column is 0.3515625/8 = 0.0439453125° wide, so the three westmost316 // columns end at -121.59668°, -121.55273° and -121.50879° — all west of317 // the window — and their twelve cells picture none of it.318 assert.deepEqual(roles, { neighbor: 5, scope: 1, child: 20 });319 assert.deepEqual(320 cells.filter((cell) => cell.role === "neighbor").map((cell) => cell.hash),321 ["9rce", "9rcg", "9rcf", "9rc9", "9rcc"],322 "the root's other five, in the plan's reading order");323 assert.deepEqual(asRecorded([...(cells.find((cell) => cell.role === "scope")?.extent ?? [])]),324 asRecorded([...realCell(166, 762)]), "the held cell is drawn at its real box");325});326327test("the chosen path draws under the pins and the context over them", () => {328 const model = world("mars", "global");329 const lens = model.lenses[0] ?? null;330 const ground = model.ground(lens);331 const system = cellSystems.require("geohash");332 const chosen = new VectorSource({ wrapX: false });333 const context = new VectorSource({ wrapX: false });334 drawGrid(ground, system, "m", [], chosen, context);335 const roleOf = (source: VectorSource) => new Set(336 source.getFeatures().map((feature) => (feature.get("gridCell") as DrawnCell).role));337 assert.deepEqual([...roleOf(chosen)].sort(), ["child", "scope"]);338 assert.deepEqual([...roleOf(context)], ["neighbor"]);339});340341// ---- the subgrid at the depth cap --------------------------------------342//343// THE DEFECT, as the reader met it: hold a geohash cell two levels down with344// the subdivision on, and the chart draws no children at all — though the cap345// is three, the plan holds all thirty-two of them, and typing a depth-three346// address works.347//348// The plan was never the problem: held at "m6", it carries thirty-two349// children, in the frozen emission order the first test below spells out by350// hand. What was wrong is *when* the question "can this cell carry its351// address?" was asked. A child too small for its label draws **nothing**352// (analysis/cellsystems/visual.ts), so that one boolean decides whether the353// subdivision exists — and it was answered once, while the grid was being354// built, against the resolution the camera was *leaving*. Descending flies the355// camera in afterwards; nothing asked again. The deeper the held cell, the356// smaller its children are against the zoom they were judged at, and at two357// levels down every one of them failed.358//359// So the fit gate belongs to the style function, where OpenLayers hands the360// resolution being drawn at. These tests are that, in numbers derived from361// the halving and from the ruler stub above.362363/** Every feature one draw built, chosen path first, in the sources' own order. */364function features(model: WorldModel, cell: string): FeatureLike[] {365 const lens = model.lenses[0] ?? null;366 const chosen = new VectorSource({ wrapX: false });367 const context = new VectorSource({ wrapX: false });368 drawGrid(model.ground(lens), cellSystems.require("geohash"), cell, [], chosen, context);369 return [...chosen.getFeatures(), ...context.getFeatures()];370}371372/** Every cell one draw planned, with the plan cell the style will read. */373function planned(model: WorldModel, cell: string): PlanCell[] {374 return features(model, cell).map((feature) => feature.get("gridPlan") as PlanCell);375}376377/** What one cell paints at one resolution: null is "nothing at all". */378function paints(cell: PlanCell, resolution: number, subgridVisible: boolean): boolean {379 const model = world("mars", "global");380 const ground = model.ground(model.lenses[0] ?? null);381 return gridCellVisual(ground, cellSystems.require("geohash"), cell, {382 subgridVisible,383 labelled: labelFitsCell(cell.hash, cell.role, cell.extent, resolution),384 }) !== null;385}386387test("a cell held two levels down plans every one of its children, in the frozen order", () => {388 // The emission order, spelled out by hand from the contract: each389 // ancestor's children root-first, skipping the one on the path — thirty-one390 // at distance two, thirty-one at distance one — then the held cell as the391 // scope, then its thirty-two children.392 const expected: [string, string, number][] = [393 ...[...ALPHABET].filter((c) => c !== "m").map((c): [string, string, number] => [c, "neighbor", 2]),394 ...[...ALPHABET].filter((c) => c !== "6").map((c): [string, string, number] => [`m${c}`, "neighbor", 1]),395 ["m6", "scope", 0],396 ...[...ALPHABET].map((c): [string, string, number] => [`m6${c}`, "child", 0]),397 ];398 const model = world("mars", "global");399 const plan = planCells(400 model.ground(model.lenses[0] ?? null), cellSystems.require("geohash"), "m6");401 assert.deepEqual(402 plan.map((cell) => [cell.hash, cell.role, cell.contextDistance]), expected,403 "ninety-five cells, position for position");404 assert.equal(plan.filter((cell) => cell.role === "child").length, 32,405 "thirty-two children, one level above the cap");406 assert.deepEqual(407 planned(model, "m6").map((cell) => cell.hash).sort(),408 plan.map((cell) => cell.hash).sort(),409 "and the drawn features carry that same plan, cell for cell");410 // AND NOTHING ABOUT HOW THEY LOOK IS DECIDED HERE. A visual baked onto the411 // feature is a visual decided at the wrong moment: the camera moves between412 // a grid being built and a reader seeing it, and the answer never changed413 // afterwards.414 for (const feature of features(world("mars", "global"), "m6")) {415 assert.equal(feature.get("gridVisual"), undefined,416 "the tokens are the style function's answer, at the resolution it is handed");417 }418});419420test("and draws them, once the camera has landed on the cell it was flown to", () => {421 const cells = planned(world("mars", "global"), "m6");422 const children = cells.filter((cell) => cell.role === "child");423 // The gate's own arithmetic, with the ruler stub above: a three-character424 // chip at 11 px is 3 · 11 · 0.6 + 10 of padding = 29.8 screen pixels, and a425 // child of "m6" is 32 world pixels across — so it carries its address only426 // below 32 / 29.8 ≈ 1.07 world pixels per screen pixel.427 const leaving = 2; // reading "m" from far enough out that a child is 16 px428 assert.equal(children.some((cell) => paints(cell, leaving, true)), false,429 "no child fits its address at the zoom the camera is leaving — which is what was drawn");430 // Where the camera actually lands: a rung of the pyramid's own ladder well431 // under the gate, where the same child is 128 screen pixels across.432 const landed = 0.25;433 for (const child of children) {434 assert.equal(paints(child, landed, true), true,435 `child ${child.hash} draws once the camera is over it`);436 }437});438439test("the subdivision still answers to the reader's own switch", () => {440 const cells = planned(world("mars", "global"), "m6");441 const child = cells.find((cell) => cell.role === "child");442 const scope = cells.find((cell) => cell.role === "scope");443 assert.ok(child && scope);444 assert.equal(paints(child, 0.25, false), false, "the subgrid put away draws no children");445 assert.equal(paints(scope, 0.25, false), true, "and the held cell keeps its own boundary");446});447448test("a cell at the cap is a leaf with nothing under it", () => {449 const cells = planned(world("mars", "global"), "m6s");450 assert.equal(cells.filter((cell) => cell.role === "leaf").length, 1);451 assert.equal(cells.filter((cell) => cell.role === "child").length, 0,452 "the floor of the telescope divides no further");453 // A leaf is drawn whether or not its address fits: it is the ground the454 // reader asked for, not a subdivision offered to them.455 const leaf = cells.find((cell) => cell.role === "leaf");456 assert.ok(leaf);457 assert.deepEqual(asRecorded([...leaf.extent]), [5504, -2752, 5536, -2720],458 "a 32-pixel square, three characters down");459 assert.equal(paints(leaf, 64, true), true, "even from far enough out to lose its chip");460});461462test("a chip is measured as it will be drawn, not against a round number", () => {463 // Two hashes of different lengths in the same cell: a flat pixel threshold464 // keeps or drops both, and one of them is twice as wide as the other.465 const extent: [number, number, number, number] = [0, 0, 32, 32];466 assert.equal(labelFitsCell("m", "child", extent, 1.2), true);467 assert.equal(labelFitsCell("m6sz1", "child", extent, 1.2), false,468 "a label wider than its cell names the neighbours instead");469});470471// ---- cycling the system is not a descent -------------------------------472//473// THE DEFECT: press the cell-system key with a cell held and both panes moved474// the camera. The chart fitted its view and the sphere turned the planet — to475// ground the reader was already looking at, because cycling carries the held476// cell across to the cell of the next system covering the same place. "m" in477// geohash becomes "24" in S2, and read as a string that is a descent.478479test("a cell that only changed its spelling is not a cell that moved", () => {480 assert.equal(cellMoved({ system: "geohash", cell: "m" }, { system: "s2", cell: "24" }), false,481 "the same ground under a new name: the camera is already there");482 assert.equal(cellMoved({ system: "s2", cell: "24" }, { system: "geohash", cell: "m" }), false,483 "and back again");484});485486test("a descent within one system still moves the camera", () => {487 assert.equal(cellMoved({ system: "geohash", cell: "m" }, { system: "geohash", cell: "m6" }), true);488 assert.equal(cellMoved({ system: "geohash", cell: "m6" }, { system: "geohash", cell: "m" }), true,489 "ascending is a move too: the reader asked for the ground one level out");490 assert.equal(cellMoved({ system: "s2", cell: "24" }, { system: "s2", cell: "241" }), true,491 "including the descent made straight after a cycle");492});493494test("redrawing the same grid moves nothing", () => {495 assert.equal(cellMoved({ system: "geohash", cell: "m" }, { system: "geohash", cell: "m" }), false,496 "a filter moving must not drag the reader back to the cell they are in");497 assert.equal(cellMoved({ system: "", cell: "" }, { system: "geohash", cell: "" }), false,498 "and opening a grid over the ground already on screen is not a request to go anywhere");499});