1// A cell system divides a map's ground into an exact hierarchy the reader2// can telescope through. Geohash was the first and grew into the app's3// bones; this package is the port that pulls it back out, so a second4// system is one more file rather than another growth.5//6// The contract every system keeps:7//8// slug, name, short identity, how the navigator names it, and the9// mark its cycle button wears10// appliesTo(map) whether this system can divide this map11// maxLevel(map) how deep the telescope goes12// inputLength(map) how many characters the navigator accepts13// level(id) depth of a cell; the root "" is level 014// parent(id) one level up; "" from a level-1 cell15// children(idOrRoot) next level down, in a STABLE order16// childIndex(id) ordinal among siblings, for priority and color17// contains(id, olXY) whether a point is inside, boundaries INCLUSIVE18// descendTarget(id, olXY) the child of id under the point, or ""19// bbox(id) [minX, minY, maxX, maxY] in OL world coords20// center(id) a point inside the cell, OL world coords21// ring(id) closed boundary loop, pre-tessellated, OL22// coords, CONTINUOUS across the antimeridian --23// an unwrapped ring may run past the surface's24// x-range, and the adapters wrap or split it25// poleContained(id) "north" | "south" | null, for ring closure26// label(id) { context, principal } -- the chip's two cuts27// colorKey(id) palette index for the cell's accent28// normalizeInput(text) what the navigator field keeps of a keystroke29// parseInput(text) a canonical id, or null while the text is not30// yet a place31// locate(olXY, map) { label, value } naming the point in this32// system's own address, for the pin card --33// each layer owns the attribute it mints34//35// Identifiers are opaque strings and "" is the root. Coordinates are OL36// world coordinates -- the space pin.coordinate lives in, x east and y37// negative-down.38import { state } from "../state.js";3940import { geohashSystem } from "./geohash.js";41import { s2System } from "./s2.js";4243// surfaceExtent is the ground a cell system divides: the lens's surface44// where one is declared, its raster window otherwise, the whole world45// square when nothing narrower is known. The same numbers activeExtent46// produces, computed here without OpenLayers so the systems stay pure and47// node-testable.48export function surfaceExtent() {49 const surface = state.lens?.surface;50 if (surface) {51 return [52 surface.x,53 -(surface.y + surface.height),54 surface.x + surface.width,55 -surface.y,56 ];57 }58 const size = state.volume?.tileGrid.size ?? 0;59 const bounds = state.lens?.bounds || { x: 0, y: 0, width: size, height: size };60 return [bounds.x, -(bounds.y + bounds.height), bounds.x + bounds.width, -bounds.y];61}6263// The registry, in the order the navigator offers them.64export const cellSystems = [geohashSystem, s2System];6566export function applicableSystems(map) {67 return cellSystems.filter((system) => system.appliesTo(map));68}6970// activeSystem answers for whatever the reader chose, and for geohash when71// nothing chose otherwise -- a map that offers less than the state names72// must still divide sensibly.73export function activeSystem() {74 return cellSystems.find((system) => system.slug === state.gridSystem) || geohashSystem;75}7677// equivalentCell carries a place across systems: the cell in the new78// hierarchy holding the old cell's center, taken to the depth whose cells79// cover closest to the same ground. The two hierarchies share no80// boundaries, so what survives the translation is the point and the81// precision -- area compared on a log scale, since levels grow82// geometrically and "closest" should mean closest in kind, not in pixels.83export function equivalentCell(from, to, id, map) {84 if (!id) return "";85 const center = from.center(id);86 const area = extentArea(from.bbox(id));87 let held = "";88 let best = "";89 let bestFit = Infinity;90 while (to.level(held) < to.maxLevel(map)) {91 const next = to.descendTarget(held, center);92 if (!next) break;93 const nextArea = extentArea(to.bbox(next));94 const fit = Math.abs(Math.log(nextArea / area));95 if (fit < bestFit) {96 best = next;97 bestFit = fit;98 }99 // Areas only shrink on the way down: past the target, deeper is worse.100 if (nextArea <= area) break;101 held = next;102 }103 return best;104}105106function extentArea([minimumX, minimumY, maximumX, maximumY]) {107 return (maximumX - minimumX) * (maximumY - minimumY);108}109110// clipRingX cuts a closed ring against two vertical edges -- the standard111// Sutherland-Hodgman walk, one edge at a time. It exists for rings that112// left the surface's x-range by staying continuous across the antimeridian:113// the chart clips the unwrapped ring once as it lies and once shifted a114// world over, and draws the pieces. Returns a closed ring, or [] when115// nothing survives.116export function clipRingX(ring, minimumX, maximumX) {117 let points = ring[0] === ring[ring.length - 1] || samePoint(ring[0], ring[ring.length - 1])118 ? ring.slice(0, -1)119 : [...ring];120 for (const [edge, keep] of [121 [minimumX, ([x]) => x >= minimumX],122 [maximumX, ([x]) => x <= maximumX],123 ]) {124 const output = [];125 for (let at = 0; at < points.length; at++) {126 const current = points[at];127 const previous = points[(at + points.length - 1) % points.length];128 const currentIn = keep(current);129 const previousIn = keep(previous);130 if (currentIn !== previousIn) {131 const t = (edge - previous[0]) / (current[0] - previous[0]);132 output.push([edge, previous[1] + t * (current[1] - previous[1])]);133 }134 if (currentIn) output.push(current);135 }136 points = output;137 if (points.length === 0) return [];138 }139 return [...points, points[0]];140}141142function samePoint(a, b) {143 return a[0] === b[0] && a[1] === b[1];144}