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 / docs / analysis.md
analysis.md 22.8K rendered
1# The analysis lane23**Status: normative for `analysis/cellsystems`.** This document specifies the4cell-system contract — the eighteen methods, the coordinate and continuity5rules, the `Ground` descriptor, and the two contractual outputs — and it is6written to be sufficient on its own. An implementer who has never seen Atlas7should be able to write a conforming system, register it, and pass the8conformance suite from this document and nothing else.910The reference implementation is `analysis/cellsystems`. The judge is the11executable conformance suite in `analysis/test/conformance.ts`, plus the 21212shared contract vectors in `analysis/testdata/cells/` — compared as JSON text,13positionally, and read by the Go twin (`tests/cells`) as well, so the two14copies of the arithmetic answer to one set of numbers. Where this document and15the implementation disagree, take it as a defect in one of them and say so.1617---1819## 1. What the lane is2021Issue #5 §5.4: **an analysis is a client-side, render-time transformation22applied to a volume — the `.atlas` file is blind to it and is never mutated by23it.** The volume is one input; an analysis may declare others (user markup, a24live external feed, a hook into a running game), and how those are supplied is25the consumer's adapter concern, never the lane's.2627Three rules follow, and they are enforced rather than encouraged:2829- **Pure.** Every function is a function of its declared inputs. No DOM, no30 fetch, no application imports, no shared client state. The repository-root31 `eslint.config.mjs` refuses the imports and the globals; `tsc` with32 `lib: ["es2023"]` refuses the DOM types before a lint rule gets the chance.33- **Renderer-neutral.** Outputs are plans and pure style tokens. Renderers34 adapt them. No renderer owns a cell rule.35- **Session-free.** Which system is active, which cell is held, and whether the36 subdivision is showing are session state (§4.1). They arrive as arguments.3738**Cell systems are the founding family** and set the conformance bar: a second39family joins by exporting from `analysis/index.ts` beside them, and by bringing40its own property suite.4142---4344## 2. Coordinates4546Two coordinate conventions meet in this lane, and confusing them is the one47mistake that produces plausible wrong answers.4849| Space | Used by | Sense |50|---|---|---|51| **OL world coordinates** | everything in `analysis/cellsystems` | x east, y **negative-down**: the top edge of the world square is `y = 0` (or `-0`), the bottom is `y = -height`. This is the space a pin's `coordinate` lives in. |52| **y-down world pixels** | a `Rect` in a `Ground`, and `atlas.geometry.*.px` | x east, y **positive-down**, from the world square's top-left. This is the space a bundle declares itself in. |5354The conversion happens in exactly three places — `surfaceExtent`, which flips55the sign of a declared `Rect`, and the `GeoMapping` calls in `s2.ts` and in56`geohash.ts`'s real mode (§4.3), which flip it again on the way to degrees.57Nothing else in the lane converts, and nothing outside it should have to.5859A degree pair is always `[lat, lng]`, latitude first, degrees, north and east60positive.6162---6364## 3. The `Ground` descriptor6566A system is handed a `Ground` instead of reaching into a running application.67The descriptor is exactly the implicit state the pre-rewrite systems read, and68nothing else:6970```ts71interface Rect { x: number; y: number; width: number; height: number } // y-down pixels72interface Lens { surface?: Rect | null; bounds?: Rect | null }73interface World { attrs: Readonly<Record<string, string>> }7475interface Ground {76 tileGridSize: number | null; // the volume's world square; null with no volume open77 lens: Lens | null; // null with no lens open78 world: World; // the semconv geometry keys79}80```8182`analysis/testdata/cells/grounds.json` records six of these, each with its two83derived values — `surfaceExtent` and the systems that divide it — checked by84both lanes' vector suites.8586### 3.1 `surfaceExtent(ground): [minX, minY, maxX, maxY]`8788The declared surface, resolved. Three branches, in order:89901. the lens's declared **surface**, when it has one;912. failing that, the lens's raster **bounds**;923. failing that, the whole **world square** (`tileGridSize`, or `0`).9394Each branch flips the sign: `[x, -(y + height), x + width, -y]`. A lens95anchored at `y = 0` therefore produces a `maxY` of `-0`, which is a real96double. JSON cannot carry it, which is why the shared vectors are compared as97JSON text on both sides — the serialization forces the negative zero to be98normalized away rather than the comparison loosened.99100S2, the ring clip, and every ground question read this ladder. **Geohash does101not always**: on a plane with no earth anchoring it divides the whole world102square `[0, -size, size, 0]` whenever the volume declares one103(`geohashExtent`, in `geohash.ts`), so a game map's grid covers the full map104and stays put across lens switches; on an earth-anchored plane it divides no105pixel rectangle at all (§4.3). Spheres keep the ladder.106107### 3.2 What is deliberately not in it108109The active system, the held cell, and whether the subgrid is showing. Those are110session state, and they arrive as arguments — `cellPlan(ground, system,111cellID)`. That is what lets one ground be divided two ways at once, by two112callers who never meet, and it is what the plan tests exercise by passing the113session values per call rather than folding them into the ground.114115### 3.3 The attribute vocabulary it reads116117`analysis/semconv/geometry.ts` is the lane's reader for four keys, and118`geohash.ts` reads one more:119120| Key | Read by | Meaning |121|---|---|---|122| `atlas.geometry.surface` | `worldSurface` | `sphere`, or a plane when absent |123| `atlas.geometry.projection` | `geoMapping` | `equirect` or `mercator` |124| `atlas.geometry.<p>.px` | `geoMapping` | `x,y,w,h` — the raster window, y-down pixels |125| `atlas.geometry.<p>.deg` | `geoMapping` | `west,north,east,south` — what it pictures |126| `atlas.geometry.body` | geohash's real mode (§4.3) | the body pictured |127128A declaration that cannot be read — wrong arity, a non-number, a zero-sized129window, a degenerate degree range — produces `null`, never a plausible NaN.130131The projection key gates the equirect pair outright — the registry requires132it of a sphere. The **Mercator pair alone is also readable when no projection133is declared at all**: the registry's projection enum names only `equirect`,134so a plane georeferenced by a real-world tile window (bend-or) carries135`atlas.geometry.mercator.*` with no projection key, and the keys themselves136say what the numbers are. `mercatorWindow(world)` answers the window's px/deg137pair whole, for the one consumer that needs more than the two functions.138139The reader is the lane's own; the **keys it reads are not**. They come from140`analysis/semconv/keys.ts`, generated from `spec/registry.yaml` — the one141machine-readable source `format/semconv` and142[`docs/semconv/REGISTRY.md`](semconv/REGISTRY.md) are cut from too (issue #5143§8). The seam imports the same module, as `@atlas/analysis/semconv/keys`, so144no lane spells a convention key for itself.145146---147148## 4. The eighteen methods149150A cell system divides a ground into an exact hierarchy a reader can telescope151through. The contract splits in two, and the split *is* the de-globalization.152153**Three** answer about a ground before any cell is named, and live on the154system:155156```ts157appliesTo(ground): boolean // will this system divide this ground at all158maxLevel(ground): number // how deep the telescope goes159inputLength(ground): number // how many characters the navigator's field takes160```161162**Fifteen** answer about a cell on a ground, and are reached through163`system.on(ground)`, which binds the ground once:164165```ts166level(id): number // depth; the root "" is level 0167parent(id): CellID // one level up; "" from a level-1 cell and from the root168children(id): CellID[] // next level down, in a STABLE order169childIndex(id): number // ordinal among siblings170contains(id, at): boolean // boundaries INCLUSIVE171descendTarget(id, at): CellID // the child of id under the point, or ""172bbox(id): Extent // [minX, minY, maxX, maxY], OL world coordinates173center(id): Coordinate // a point inside the cell174ring(id): Ring // closed, pre-tessellated, CONTINUOUS across the seam175poleContained(id): Pole | null // "north" | "south" | null, for ring closure176label(id): CellLabel // { context, principal } — the chip's two cuts177colorKey(id): number // palette index; siblings must differ178normalizeInput(text): string // what the navigator's field keeps of a keystroke179parseInput(text): CellID | null // a canonical id, or null while it is not yet a place180locate(at): LocatedCell | null // { label, value } for the pin card181```182183Plus three identity fields: `slug` (stable, persisted in sessions), `name` (how184the navigator names it), `short` (the mark its cycle button wears).185186### 4.1 The rules187188**Opaque ids.** A cell id is an opaque string and `""` is the root of every189system. Nothing outside a system may take one apart. Geohash refines by190appending a character; S2 does not — the children of `47a1cb` are `47a1ca4`191and its siblings — and a third system may do neither.192193**Stable child order.** `children(id)` answers in one order, forever. The plan194emits in it, the palette indexes into it, and the vectors gate compares195positionally. `childIndex(children(id)[n]) === n`.196197**Boundary-inclusive containment.** `contains` is true on the boundary. A point198on a shared edge is in both cells, and a corner is in all four; nothing in the199lane breaks the tie, because the caller asking is drawing, not partitioning.200201**Descent agrees with containment.** `descendTarget(id, at)` names a child that202`contains` the point.203204**Whole-or-nothing parsing.** `parseInput` answers a canonical id or `null`. A205draft is not a place: the field holds it and the session stays put. Every id a206system mints at or above `maxLevel` must survive `normalizeInput` and207`parseInput` unchanged.208209**Ring closure.** `ring(id)` is a loop whose last point is its first — *some210whole number of worlds over in x, on the same row*. See continuity.211212**Antimeridian continuity.** A ring never jumps the seam: consecutive points213stay within half a world of each other in x, **even where that runs the loop214off the surface**. An honest ring may sit at x = 13312 on an 8192-wide picture.215The system does not wrap or split; `cellRings(ground, cell)` does, and it is216where every renderer should go. Half a world exactly is a bound rather than a217strict one: a cell touching a pole sweeps exactly that much longitude in one218tessellation step, and that is ground, not a seam.219220**Poles.** `poleContained(id)` says which pole a cell circles, because a loop221around a pole closes along the picture's own top or bottom edge and only the222system knows it needs to. A cell that circles a pole has a parent that circles223the same one.224225### 4.2 What the contract deliberately does not promise226227Three silences, each of which a property suite would otherwise be tempted to228assert falsely.229230**Winding.** A ring's signed area may be either sign. The antimeridian unwrap231reverses it for some loops that circle a pole, and nothing downstream reads the232direction. Non-degeneracy is a rule; orientation is not.233234**The floor asymmetry.** `descendTarget` at `maxLevel` answers `""` in S2 and235keeps halving in geohash. Nothing reaches it — every caller checks `maxLevel`236first, in `cellPlan` and in `equivalentCell` — and both behaviours are pinned237by the shared vectors, so the contract asserts only that descent *terminates*: the telescope238reaches `maxLevel` in exactly `maxLevel` hops, one level per hop.239240**The whole-world assumption.** The seam unwrap treats the ground's width as241one whole world. That is true of the equirectangular whole-planet lenses S2 is242offered on in practice, and false of, say, a Web-Mercator city window — where243`appliesTo` still answers yes and the coarse cells' rings are nonsense.244`analysis/test/s2-limits.test.ts` pins the limit rather than papering over it;245narrowing `appliesTo` is a curation decision for the app or a future `Ground`246field, not this lane's to make unilaterally.247248### 4.3 Geohash's two modes249250Geohash still divides any ground, but WHAT it divides is the ground's own251question, answered once in `geohash.ts` and mirrored in the Go twin:252253**Synthetic** — every sphere, and every plane with no earth anchoring. The254recursive halving in world pixels, over `geohashExtent(ground)` (§3.1):255thirty-two children to a cell, `level(id) = id.length`, the floor at three256characters.257258**Real** — a plane whose world declares `atlas.geometry.body: earth` AND an259invertible Mercator window (`mercatorMapping`). The picture is OF somewhere,260so cell ids are genuine WGS84 geohashes: the classic arithmetic — longitude261first, five bits per character, the same alphabet in the same order — run in262degrees, with each cell put on the picture through the flattening. Mercator263keeps latitude lines horizontal, so cells stay world-pixel rectangles.264265Real mode moves four things and nothing else:266267- **Depths.** The window is city-sized, so the three layers start at268 `baseDepth(window)`: the smallest depth ≥ 1 whose cell **longitude span269 fits the window's or whose latitude span does** (spans halve from270 360°/180° per the 5-bit alternation), capped at 9. The floor is271 `baseDepth + 2`; `inputLength(ground)` answers it (6 for bend-or, whose272 window derives base 4) and `maxLevel` stays 3 — **level counts layers**,273 so the root's children are base-depth cells at level 1.274- **The plan is the window's.** `children("")` are the base-depth cells that275 intersect the window with real area (strict on every edge), in reading276 order — north to south, west to east; a held cell's children are likewise277 filtered. `childIndex` is the position among those siblings. Cells are278 drawn at their **full real extents** — they may overhang the window, which279 is honest and never clipped.280- **Addresses are the planet's.** `cellAt`/`locate`/`descendTarget` answer a281 real geohash for ANY point — outside the window is still real ground — and282 `contains` is the cell's own degree rectangle, boundaries inclusive.283- **Parsing gains geohash's one refusal.** An address shorter than284 `baseDepth` names a cell the window's grid never shows, so it is a draft;285 the root is still a place, and a base-depth cell's `parent` is the root.286287S2 on these grounds stays refused exactly as before: the surface says plane.288Mars — a sphere with an equirect window and no earth body — never enters real289mode at all.290291---292293## 5. The registry, and carrying a place between systems294295```ts296const cellSystems: CellSystemRegistry; // geohash, then s2297applicableSystems(ground, registry?): readonly CellSystem[]; // those that will divide it298equivalentCell(ground, from, to, id): CellID; // the cross-system carry299```300301A registry is **immutable**: `registry.with(system)` answers a new one. A302mutable module-level registry would be the same shared client state §5.4 just303removed from the systems, and would make "which systems exist" depend on import304order. Two systems may not share a slug.305306`equivalentCell` carries a place across systems: the cell in the new hierarchy307holding the old cell's centre, taken to the depth whose cells cover closest to308the same ground. The two hierarchies share no boundaries, so what survives is309the point and the precision — area compared on a **log scale**, since levels310grow geometrically and "closest" should mean closest in kind, not in pixels.311The root carries to the root, which is the only place two hierarchies agree312exactly.313314---315316## 6. The plan, and its frozen order317318```ts319cellPlan(ground, system, cellID): PlanCell[]320321interface PlanCell {322 hash: CellID; // the id; the field keeps this name whatever minted it323 extent: Extent;324 ring: Ring;325 pole: Pole | null;326 childIndex: number;327 role: "neighbor" | "scope" | "child" | "leaf";328 contextDistance: number;329}330```331332**Emission order is the contract.** Every check of a plan — the conformance333suite, the property suite — compares it positionally, cell for cell. A334reordering is a failure, not a detail. The order is:3353361. For each ancestor from the root down, that ancestor's children **except the337 one on the path**, as `neighbor`s carrying their `contextDistance` — how many338 levels above the held cell that ancestor's children sit. The root's339 neighbours come first, so `contextDistance` descends across the run.3402. Then either:341 - the held cell as a **`leaf`**, if it sits at `maxLevel` — and **nothing342 follows**, because a leaf has nothing to divide; or343 - the held cell as a **`scope`**, followed by every one of its children as344 `child`s in the system's stable child order.345346The root plan — no held cell — is the second case with no ancestors and no347scope: the root's children, and nothing else.348349`contextDistance` is `0` for every role but `neighbor`.350351---352353## 7. The style tokens354355```ts356gridCellVisual(ground, system, cell, { subgridVisible, labelled }): CellVisual | null357```358359Pure tokens: colours, opacities, widths in px, the label's two cuts and its360chip. The chart adapts them into `ol/style` and the globe into materials and361sprites; neither holds a colour or a width of its own, which is why the two362projections read as one instrument.363364Two questions come from the renderer's side rather than the plan's:365`subgridVisible` (session state) and `labelled` (does the label fit where it366would be drawn — a measurement only a renderer can make).367368- A **`child`** with `labelled: false` or `subgridVisible: false` draws369 **nothing at all**: `null`. The subdivision appears at the size it can be370 read, on both panes at the same visual moment.371- A **`scope`** with the subdivision showing is **bare**: an outline at372 `widths.scopeBare`, no fill, no label. Put the subdivision away and it373 thickens to `widths.scope` and gets its label back.374- A **`leaf`** is filled in its own accent; a **`neighbor`** is dimmed by375 `dimBase + contextDistance × dimStep`, capped at `dimCap`.376377`palette` and `gridTheme` are exported: a renderer reads them, it does not378restate them.379380---381382## 8. Rings on the picture383384```ts385cellRings(ground, cell): Coordinate[][]386clipRingX(ring, minX, maxX): Coordinate[]387ringClosesOn(ground, ring): boolean388```389390`cellRings` is the whole of what a renderer would otherwise have to work out,391and it is four rules in one place:392393- Only a **sphere has a seam**. On a plane every ring comes back as a single394 piece untouched, even where it runs past the surface's x-range — a real395 geohash overhanging bend-or's window, a world-square cell beside a windowed396 lens — because an overhang is not a wrap, and cutting it into shifted397 pieces would tile ghost cells over the map.398- Most rings lie within the surface and come back as a **single piece,399 untouched** — every synthetic geohash ring does.400- A cell circling a **pole** has its loop closed along the picture's own top or401 bottom edge, *closing point included*: a pole cell's walk ends a world over402 from where it began, and that closure spans the last tessellation step.403 Dropping it leaves a sliver of ground the fill never covers, one step wide,404 at the walk's own longitude.405- A ring that stayed continuous across the **antimeridian** is clipped as it406 lies and clipped again shifted a world each way, so the one cell arrives as407 its two pieces.408409`clipRingX` is the Sutherland–Hodgman walk that does the cutting; it may start410its result at a different vertex than the input, because a clip has no opinion411about where a loop begins.412413---414415## 9. Adding a third system416417A third system — H3, MGRS, a game's district grid — is **a registry entry plus418a green conformance run**. `analysis/test/districts.ts` is a worked example419that exists only to prove it: dash-joined ids, nine-way subdivision, a label420that is not "the last character and everything before it", and a `parseInput`421with something to refuse.422423The checklist:4244251. **Write the system.** One file exporting a `CellSystem`: the three identity426 fields, the three ground questions, and `on(ground)` returning the fifteen.427 Read §4.1 first — every rule there is a property the suite checks.4282. **Decide what ground you divide.** `appliesTo` is where a system says no.429 Geohash says yes to everything; S2 asks for a declared sphere and an430 invertible flattening; a district grid asks for a curation attribute. Say no431 loudly rather than dividing a ground you do not understand.4323. **Pick the floor.** `maxLevel` is a reading decision, not a mathematical433 one: deep enough for the pins on the map, shallow enough that every level434 stays a place rather than a coordinate.4354. **Register it.** `cellSystems.with(yourSystem)` — or add it to the shipped436 list in `registry.ts` if it ships. The registry order is the navigator's437 order.4385. **Add a conformance subject.** In `analysis/test/conformance.test.ts`:439440 ```ts441 describeConformance("your system on its ground", {442 system: yourSystem,443 ground: aGroundItApplies,444 probe: [x, y], // a point well inside, used to drive descent445 boundaryProbes: [ ... ], // [cellID, point] pairs your geometry puts exactly446 // on a boundary; declare [] and say why if your447 // containment is decided on a finer lattice448 });449 ```4504516. **Run the gates.** `make analysis-lane` for the boundary rules, the type452 checker and the conformance suite; `make test` for everything, including453 the shared vectors, which your system does not appear in and must not454 disturb.4557. **Do not touch the shared vectors.** `analysis/testdata/cells/` describes456 geohash and S2. A new system adds nothing to it; if adding one turns a457 vector red, the new system reached something shared that it should not458 have.459460---461462## 10. The root cases463464`""` is the root of every system, and S2's token vocabulary has no spelling465for it — `fromToken("")` answers a cell id that is neither the sphere nor an466error — so the root's answers are defined by this contract rather than467borrowed from the token library. The root is never a plan cell, so nothing468downstream reaches these answers through a plan; they exist so the fifteen469methods stay total. The contract: the root is the whole ground, its bbox and470ring are the picture's own rectangle, its centre is the picture's centre, it471contains every point the ground can name, its ordinal is 0, and it circles no472single pole because it circles both.473474---475476## 11. Running the lane477478```sh479make analysis-lane # boundary rules + tsc --strict + the lane's suite480make test # the whole required surface, this lane included481npm run --silent test # the lane's suite alone, shared vectors included482```483484The lane needs a node that strips types (22.18+, or 24+) and one `npm ci` at485the repository root, which installs its single dependency: `s2js`, the Go S2486library ported, test vectors and all. Nothing else is bundled, mocked, or487built.