1// The S2 cell system: the sphere divided by the six faces of a cube2// projected onto it, each face a quadtree of exactly-nested quadrilateral3// cells addressed by Hilbert-curve tokens. It is the second system through4// the port and the proof the port holds nothing geohash-shaped: identities5// that do not refine by appending characters (the children of 47a1cb are6// 47a1ca4 and its siblings), cells whose edges are straight on neither7// projection, and an address that only means something on a map declaring8// what its picture is of. The math is s2js's -- the Go library ported,9// test vectors and all -- and this file only bridges it to world pixels10// through the map's declared flattening.11import { s2 } from "s2js";1213import { palette } from "../constants.js";14import { geoMapping, worldSurface } from "../semconv.js";15import { state } from "../state.js";1617import { surfaceExtent } from "./index.js";1819// The telescope stops at S2 level 10 -- cells a few kilometers across on a20// Mars-sized body, tokens six characters long -- which is depth enough for21// a couple thousand pins and shallow enough that every level stays a22// place, not a coordinate.23const deepestS2Level = 10;2425// Port levels count the root as 0 and the six faces as 1; S2 counts the26// faces as level 0. The two bookkeepings meet here and nowhere else.27const portLevel = (s2Level) => s2Level + 1;28const s2Level = (id) => s2.cellid.level(s2.cellid.fromToken(id));2930// The mapping bridge, remembered per map: world pixels are only meaningful31// through the flattening the map declares.32let bridge = { map: null, mapping: null };3334function mapping() {35 if (bridge.map !== state.world) {36 bridge = { map: state.world, mapping: geoMapping(state.world) };37 }38 return bridge.mapping;39}4041function latLngOf(coordinate) {42 const held = mapping();43 if (!held) return null;44 const [lat, lng] = held.toLatLng(coordinate[0], -coordinate[1]);45 return s2.LatLng.fromDegrees(lat, lng);46}4748function degreesOf(point) {49 const ll = s2.LatLng.fromPoint(point);50 return [(ll.lat * 180) / Math.PI, (ll.lng * 180) / Math.PI];51}5253// Leafs are looked up per pin every filter pass, so each coordinate's leaf54// is remembered against the mapping that produced it.55const leafMemo = new WeakMap();5657function leafOf(coordinate) {58 const held = mapping();59 if (!held) return 0n;60 const memo = leafMemo.get(coordinate);61 if (memo && memo.mapping === held) return memo.leaf;62 const ll = latLngOf(coordinate);63 const leaf = ll ? s2.cellid.fromLatLng(ll) : 0n;64 if (typeof coordinate === "object") leafMemo.set(coordinate, { mapping: held, leaf });65 return leaf;66}6768export const s2System = {69 slug: "s2",70 name: "S2",71 short: "S2",7273 // Only a map that declares what its picture is of -- a sphere, through74 // any invertible flattening -- can be divided geodesically.75 appliesTo(map) {76 return worldSurface(map) === "sphere" && geoMapping(map) !== null;77 },7879 maxLevel() {80 return portLevel(deepestS2Level);81 },8283 // A level-10 token is six characters; the field takes no more.84 inputLength() {85 return 6;86 },8788 level(id) {89 return id === "" ? 0 : portLevel(s2Level(id));90 },9192 parent(id) {93 const ci = s2.cellid.fromToken(id);94 const level = s2.cellid.level(ci);95 if (level === 0) return "";96 return s2.cellid.toToken(s2.cellid.parent(ci, level - 1));97 },9899 children(id) {100 if (id === "") {101 return [0, 1, 2, 3, 4, 5].map((face) => s2.cellid.toToken(s2.cellid.fromFace(face)));102 }103 return s2.cellid.children(s2.cellid.fromToken(id)).map(s2.cellid.toToken);104 },105106 childIndex(id) {107 const ci = s2.cellid.fromToken(id);108 const level = s2.cellid.level(ci);109 return level === 0 ? s2.cellid.face(ci) : s2.cellid.childPosition(ci, level);110 },111112 contains(id, coordinate) {113 return s2.cellid.contains(s2.cellid.fromToken(id), leafOf(coordinate));114 },115116 descendTarget(id, coordinate) {117 const leaf = leafOf(coordinate);118 if (leaf === 0n) return "";119 const target = id === "" ? 0 : s2Level(id) + 1;120 if (target > deepestS2Level) return "";121 return s2.cellid.toToken(s2.cellid.parent(leaf, target));122 },123124 bbox(id) {125 if (id === "") return surfaceExtent();126 const ring = this.ring(id);127 let minimumX = Infinity;128 let minimumY = Infinity;129 let maximumX = -Infinity;130 let maximumY = -Infinity;131 for (const [x, y] of ring) {132 minimumX = Math.min(minimumX, x);133 minimumY = Math.min(minimumY, y);134 maximumX = Math.max(maximumX, x);135 maximumY = Math.max(maximumY, y);136 }137 return [minimumX, minimumY, maximumX, maximumY];138 },139140 center(id) {141 const held = mapping();142 const [lat, lng] = degreesOf(s2.Cell.fromCellID(s2.cellid.fromToken(id)).center());143 const [x, y] = held.toWorld(lat, lng);144 return [x, -y];145 },146147 // The boundary as world pixels: the four geodesic edges, each walked in148 // small great-circle steps on the sphere itself, then unwrapped so a149 // loop that crossed the antimeridian stays continuous -- the adapters150 // wrap or split it, each in its own way.151 ring(id) {152 const held = mapping();153 const cell = s2.Cell.fromCellID(s2.cellid.fromToken(id));154 const level = s2.cellid.level(s2.cellid.fromToken(id));155 const steps = Math.max(2, Math.min(16, 2 ** (4 - level) * 2));156 const surface = surfaceExtent();157 const width = surface[2] - surface[0];158 const points = [];159 for (let edge = 0; edge < 4; edge++) {160 const from = cell.vertex(edge).vector;161 const to = cell.vertex((edge + 1) % 4).vector;162 for (let step = 0; step < steps; step++) {163 const t = step / steps;164 const x = from.x + (to.x - from.x) * t;165 const y = from.y + (to.y - from.y) * t;166 const z = from.z + (to.z - from.z) * t;167 const norm = Math.hypot(x, y, z) || 1;168 const [lat, lng] = degreesOf(new s2.Point(x / norm, y / norm, z / norm));169 const [worldX, worldY] = held.toWorld(lat, lng);170 points.push([worldX, -worldY]);171 }172 }173 // Sequential unwrap: a jump of more than half the world is the seam,174 // not the ground.175 for (let at = 1; at < points.length; at++) {176 let delta = points[at][0] - points[at - 1][0];177 if (delta > width / 2) points[at][0] -= width;178 else if (delta < -width / 2) points[at][0] += width;179 }180 // The closing point joins the loop in the frame the walk ended in, not181 // the one it began in: a pole cell's loop accumulates a whole world of182 // longitude, and closing back to the original frame would hand the183 // adapters a segment sweeping the entire planet -- a phantom edge the184 // boundary draws and the fill fans spokes to.185 const closing = [...points[0]];186 const delta = closing[0] - points[points.length - 1][0];187 if (delta > width / 2) closing[0] -= width;188 else if (delta < -width / 2) closing[0] += width;189 points.push(closing);190 return points;191 },192193 poleContained(id) {194 const ci = s2.cellid.fromToken(id);195 if (s2.cellid.contains(ci, s2.cellid.fromLatLng(s2.LatLng.fromDegrees(90, 0)))) {196 return "north";197 }198 if (s2.cellid.contains(ci, s2.cellid.fromLatLng(s2.LatLng.fromDegrees(-90, 0)))) {199 return "south";200 }201 return null;202 },203204 label(id) {205 return { context: id.slice(0, -1), principal: id.slice(-1) };206 },207208 // Sibling tokens can share their final character, so the accent comes209 // from the sibling ordinal instead -- neighbors always differ.210 colorKey(id) {211 return this.childIndex(id) % palette.length;212 },213214 normalizeInput(text) {215 return [...text.toLocaleLowerCase()]216 .filter((character) => "0123456789abcdef".includes(character))217 .slice(0, 6)218 .join("");219 },220221 // A partial token is not yet a place: the field holds the draft and the222 // state stays put until the address parses whole. Deeper tokens clamp to223 // the telescope's floor, re-tokenized so the spelling is canonical.224 parseInput(text) {225 if (text === "") return "";226 const ci = s2.cellid.fromToken(text);227 if (ci === 0n || !s2.cellid.valid(ci)) return null;228 const level = s2.cellid.level(ci);229 if (level > deepestS2Level) {230 return s2.cellid.toToken(s2.cellid.parent(ci, deepestS2Level));231 }232 return s2.cellid.toToken(ci);233 },234235 locate(coordinate) {236 const leaf = leafOf(coordinate);237 if (leaf === 0n) return null;238 return {239 label: "S2",240 value: s2.cellid.toToken(s2.cellid.parent(leaf, deepestS2Level)),241 };242 },243};