clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / render / chart / grid.ts
grid.ts 6.2K
1// The grid, drawn from a plan.2//3// Everything about *which* cells there are, *where* they are and *what* they4// look like belongs to the analysis lane. This module is the adapter: it asks5// `cellPlan` for the cells in their frozen emission order, `cellRings` for the6// rings as they are actually drawn — the pole closure and the antimeridian cut7// are already resolved there, and no renderer should be working them out —8// and `gridCellVisual` for the tokens. It never sorts a plan, never dedupes9// one, and never invents a colour.10//11// Two sources, and the split is a z-order argument. The chosen path — the12// held cell and its subdivision — draws UNDER the pins, because the reader is13// looking at what stands inside it. The context — the neighbours, dimmed —14// draws OVER them, because dimming a neighbourhood has to dim what is in it.15// The promoted pins ride above both, which is how a searched-for name stays16// legible through a dimmed neighbour.1718import Feature from "ol/Feature.js";19import Polygon from "ol/geom/Polygon.js";20import type VectorSource from "ol/source/Vector.js";21import { cellPlan, cellRings, surfaceExtent } from "@atlas/analysis";22import type { CellSystem, Coordinate as Cell, Extent, Ground, PlanCell } from "@atlas/analysis";23import type { PointRecord } from "../world/model.ts";2425/** One cell as the diagnostics object reports it. */26export interface DrawnCell {27 readonly hash: string;28 readonly extent: Extent;29 readonly role: string;30 readonly count: number;31 readonly contextDistance: number;32}3334/** What one draw of the grid produced. */35export interface GridDraw {36 readonly cells: DrawnCell[];37 readonly extent: Extent | null;38}3940/**41 * Fill the two sources from one plan.42 *43 * `points` is the standing registry, and each cell is handed the number of44 * them it holds — the count the navigator and the diagnostics both read. It45 * is computed here rather than asked of the system twice, because a plan is46 * walked once and a containment test is the expensive half of drawing a grid.47 *48 * WHAT A CELL LOOKS LIKE IS NOT DECIDED HERE, and that is the whole shape of49 * this module. `gridCellVisual` needs to know whether the address fits inside50 * the cell *as the camera currently sees it*, which is a question about a51 * resolution — and the resolution at the moment a grid is built is the one the52 * camera is leaving, not the one it is flying to. Baked in here, a descent53 * drew its children against the parent's zoom, decided none of them could54 * carry a label, and drew nothing at all; the camera then landed and nothing55 * asked again. So the plan cell rides on the feature and the style function56 * answers, once per render, against the resolution it is handed.57 */58export function drawGrid(59 ground: Ground,60 system: CellSystem,61 cell: string,62 points: readonly PointRecord[],63 chosen: VectorSource,64 context: VectorSource,65): GridDraw {66 const on = system.on(ground);67 const plan = cellPlan(ground, system, cell);68 const cells: DrawnCell[] = [];69 for (const planned of plan) {70 const count = points.reduce(71 (held, point) => held + (on.contains(planned.hash, point.coordinate) ? 1 : 0), 0);72 const feature = new Feature({73 geometry: new Polygon(cellRings(ground, planned).map((ring) => ring.map(pair))),74 gridCell: { hash: planned.hash, extent: planned.extent, role: planned.role, count,75 contextDistance: planned.contextDistance } satisfies DrawnCell,76 gridPlan: planned,77 // The paint order within one layer. A dimmed neighbour is drawn under78 // the neighbours nearer the held cell — the dimming deepens inward, and79 // a farther cell's darker sheet painted last would flatten the ladder —80 // and a cell's own children are drawn in the system's child order so two81 // adjacent chips overlap the same way every time.82 priority: planned.role === "neighbor"83 ? -planned.contextDistance * 100 + planned.childIndex84 : planned.childIndex,85 });86 // Every planned cell becomes a feature, drawn or not: a cell whose label87 // does not fit answers `null` tokens and paints nothing, and the88 // diagnostics report carries it all the same. What the grid *holds* and89 // what it *paints* are two questions, and the plan answers the first.90 (planned.role === "neighbor" ? context : chosen).addFeature(feature);91 cells.push({ hash: planned.hash, extent: planned.extent, role: planned.role, count,92 contextDistance: planned.contextDistance });93 }94 return { cells, extent: cell ? on.bbox(cell) : surfaceExtent(ground) };95}9697/**98 * Whether the camera owes this grid a move.99 *100 * Descending holds a cell and holding it moves the camera: the chart fits the101 * ground the address names, and the sphere turns to face it. Both panes decide102 * that here, because it is one rule and they must not answer it differently.103 *104 * A CELL MOVES WITHIN A SYSTEM. Cycling the cell system carries the held cell105 * across to whichever cell of the next system covers the same ground106 * (`equivalentCell`), so geohash's "m" becomes S2's "24" and the reader has107 * not gone anywhere — the string moved, the place did not. Compared as strings108 * alone that reads as a descent, and both panes flew the camera to ground it109 * was already over, which is the one thing a reader pressing a cycle key is110 * not asking for.111 *112 * Nothing held yet is not a move either: opening a grid over the ground113 * already on screen is not a request to go anywhere.114 */115export function cellMoved(116 was: { system: string; cell: string },117 now: { system: string; cell: string },118): boolean {119 return was.system === now.system && was.cell !== now.cell;120}121122/** Whether a point stands inside the held cell. */123export function cellTest(124 ground: Ground,125 system: CellSystem,126 cell: string,127): ((at: readonly [number, number]) => boolean) | null {128 if (!cell) return null;129 const on = system.on(ground);130 return (at) => on.contains(cell, at);131}132133function pair(point: Cell): [number, number] {134 return [point[0], point[1]];135}136137/** The cells a plan holds, without drawing them — for the globe and for tests. */138export function planCells(ground: Ground, system: CellSystem, cell: string): PlanCell[] {139 return cellPlan(ground, system, cell);140}