clone

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

archive/golden-reference
atlas / frontend / src / grid.js
grid.js 14.0K
1// The grid controller: the telescoping interactions -- open, descend,2// ascend, divide, hold pins to the chosen cell -- spoken against whichever3// cell system divides this map. The systems themselves live in4// cellsystems/, each a pure object keeping one contract; this file owns5// the state, the navigator, and the plan both renderers draw from, and it6// no longer knows what a geohash is.7import Feature from "ol/Feature.js";8import MultiPolygon from "ol/geom/MultiPolygon.js";9import Polygon from "ol/geom/Polygon.js";1011import {12 activeSystem,13 applicableSystems,14 clipRingX,15 equivalentCell,16 surfaceExtent,17} from "./cellsystems/index.js";18import { gridTheme, palette } from "./constants.js";19import { closeDetail } from "./detail.js";20import { elements } from "./dom.js";21import { releaseCameraReturn, viewMaxZoom } from "./navigation.js";22import { refreshPinRendering } from "./features.js";23import { state } from "./state.js";2425export function handleGridKey(event) {26 if (event.key.toLocaleLowerCase() === "g") {27 event.preventDefault();28 toggleGrid();29 // The grid is opened to go somewhere, so the field that takes a hash is30 // ready for the rest of what is being typed: "gm6" arrives at m6. Closing31 // hands the keyboard back to the map, where the shortcuts live.32 if (state.gridEnabled) {33 elements.gridInput.focus();34 elements.gridInput.select();35 } else {36 elements.viewport.focus({ preventScroll: true });37 }38 return true;39 }40 // Space is about what the chosen cell is divided into, not where the reader41 // is: the cell stays chosen and the pins outside it stay put away, while the42 // subdivision inside it gets out of the way of the map underneath.43 // The button answers its own space bar, and would otherwise be pressed twice.44 if (event.key === " " && state.gridEnabled && event.target !== elements.subgridToggle) {45 event.preventDefault();46 setSubgridVisible(!state.subgridVisible);47 return true;48 }49 if (event.key === "Escape" && state.gridEnabled) {50 event.preventDefault();51 ascendGrid();52 return true;53 }54 return false;55}5657export function toggleGrid(enabled = !state.gridEnabled) {58 state.gridEnabled = enabled;59 // Opening the grid divides the cell again. A reader who put the subgrid away60 // and then closed the grid altogether is starting over, not resuming.61 if (enabled) setSubgridVisible(true);62 renderGrid();63 // A cell already chosen starts holding the map to itself again the moment64 // the grid is opened, so the footer and the dock have to hear about it too.65 refreshPinRendering();66}6768// Dividing the chosen cell and being held to it are two questions. The69// subdivision is how a reader picks the next place; once picked, the mesh over70// the map is often the last thing they want to look at, and until now the only71// way to be rid of it was to give up the scope it was holding. Nothing is72// really hidden: the cell keeps its boundary and the ancestors keep theirs.73export function setSubgridVisible(visible) {74 state.subgridVisible = visible;75 state.layers.grid.changed();76 state.layers.gridContext.changed();77 elements.subgridToggle.setAttribute("aria-pressed", String(visible));78 elements.subgridToggle.setAttribute("aria-label",79 visible ? "Hide the subgrid" : "Show the subgrid");80 updateGridHint();81 document.dispatchEvent(new Event("atlas:grid"));82}8384export function updateGridHint() {85 if (!state.gridEnabled) {86 elements.gridHint.textContent = "G · grid off";87 return;88 }89 // Compact while it is carrying a hash: the mode, the place and the state of90 // the subdivision are one reading, not three separate ones.91 elements.gridHint.textContent = `G-${state.gridCell || "root"}` +92 (state.subgridVisible ? "" : " no subgrid");93}9495export function selectGridCell(raw) {96 const system = activeSystem();97 const id = system.parseInput(system.normalizeInput(raw));98 if (id === null) return;99 const changed = id !== state.gridCell;100 state.gridCell = id;101 if (!state.gridEnabled) state.gridEnabled = true;102 renderGrid();103 refreshPinRendering();104 if (!changed) return;105 // Descending is the reader saying where they want to be, so the view a106 // panel's jump was holding is spent -- and the card closing must not hand107 // it back over the top of the cell being flown to.108 releaseCameraReturn();109 closeDetail();110 state.engine.getView().fit(currentGridExtent(), {111 size: state.engine.getSize(),112 padding: [52, 52, 52, 52],113 maxZoom: viewMaxZoom(state.lens),114 duration: 180,115 });116}117118export function ascendGrid() {119 if (!state.gridEnabled) return;120 if (!state.gridCell) {121 toggleGrid(false);122 return;123 }124 selectGridCell(activeSystem().parent(state.gridCell));125}126127export function renderGrid() {128 if (!state.sources) return;129 state.sources.grid.clear();130 state.sources.gridContext.clear();131 elements.gridNavigator.hidden = !state.gridEnabled;132 updateGridHint();133 syncGridSystemControl();134 if (state.gridEnabled && state.lens) {135 const system = activeSystem();136 elements.gridInput.maxLength = system.inputLength(state.world);137 elements.gridInput.value = state.gridCell;138 elements.gridBack.title = state.gridCell139 ? `Back one ${system.slug} level`140 : `Close ${system.slug} grid`;141 for (const cell of gridCellPlan()) {142 addGridFeature(cell);143 }144 }145 // Anything else drawing the grid -- the globe -- redraws from the same146 // plan the same moment the chart does.147 document.dispatchEvent(new Event("atlas:grid"));148}149150// syncGridSystemControl keeps the navigator honest about which systems can151// divide this map: one small button wearing the current system's mark,152// cycling to the next on click -- and nothing at all when geohash is the153// only voice. The full name lives on the field label beside it.154function syncGridSystemControl() {155 const systems = applicableSystems(state.world);156 const active = activeSystem();157 elements.gridSystemName.textContent = active.name;158 elements.gridSystem.hidden = systems.length < 2;159 if (systems.length < 2) return;160 const next = systems[(systems.indexOf(active) + 1) % systems.length];161 elements.gridSystem.textContent = active.short;162 elements.gridSystem.title = `Dividing by ${active.name} · ⌘G for ${next.name}`;163 elements.gridSystem.setAttribute("aria-label",164 `Cell system: ${active.name}. Switch to ${next.name}`);165}166167// cycleGridSystem steps to the next system that can divide this map.168export function cycleGridSystem() {169 const systems = applicableSystems(state.world);170 if (systems.length < 2) return;171 const at = systems.indexOf(activeSystem());172 setGridSystem(systems[(at + 1) % systems.length].slug);173}174175// setGridSystem changes which system divides the map. A chosen place176// survives the change: the new hierarchy's cell over the old cell's center177// at the nearest precision, so switching dividers re-addresses the ground178// under the reader rather than sending them back to the root. The view179// stays put -- the new cell covers roughly the ground already on screen.180export function setGridSystem(slug) {181 if (slug === state.gridSystem) return;182 const from = activeSystem();183 const held = state.gridCell;184 state.gridSystem = slug;185 state.gridCell = held ? equivalentCell(from, activeSystem(), held, state.world) : "";186 renderGrid();187 // The carried cell covers slightly different ground, so which features it188 // holds the map to has moved with it.189 refreshPinRendering();190}191192// gridCellColor is the accent a cell wears everywhere it is drawn, chosen193// by the system so siblings differ and a cell keeps its color at every194// depth it appears.195export function gridCellColor(id) {196 return palette[activeSystem().colorKey(id)];197}198199// gridCellVisual is the one styling of a grid cell, as pure tokens: what200// its boundary weighs, what fills it, and what its corner label says. The201// chart adapts these into ol/style and the globe into materials and202// sprites; neither holds a color or width of its own, so the projections203// read as one instrument. A child cell too small for its label returns204// nothing at all -- the subdivision appears at the size it can be read,205// on both panes at the same visual moment.206export function gridCellVisual(cell, { subgridVisible, labelled }) {207 const { role, contextDistance } = cell;208 if (role === "child" && (!labelled || !subgridVisible)) return null;209 const color = gridCellColor(cell.hash);210 const chosen = role === "leaf" || role === "scope";211 const bare = role === "scope" && subgridVisible;212213 const line = {214 color: chosen ? gridTheme.lineWhite : color,215 opacity: role === "neighbor"216 ? gridTheme.neighborLineAlpha217 : role === "child" ? gridTheme.childLineAlpha : 1,218 widthPx: bare219 ? gridTheme.widths.scopeBare220 : gridTheme.widths[role],221 };222223 let fill = null;224 if (role === "neighbor") {225 fill = {226 color: gridTheme.dimColor,227 opacity: Math.min(gridTheme.dimCap, gridTheme.dimBase + contextDistance * gridTheme.dimStep),228 };229 } else if (role === "leaf") {230 fill = { color, opacity: gridTheme.leafFillAlpha };231 } else if (role === "child") {232 fill = { color, opacity: gridTheme.childFillAlpha };233 }234235 let label = null;236 if (labelled && !bare) {237 const { context, principal } = activeSystem().label(cell.hash);238 label = {239 prefix: context,240 final: principal,241 color: chosen ? gridTheme.lineWhite : color,242 textAlpha: role === "neighbor" ? gridTheme.neighborTextAlpha : 1,243 chip: role === "neighbor" ? gridTheme.neighborChip : gridTheme.chip,244 sizePx: role === "neighbor" ? gridTheme.neighborLabelSizePx : gridTheme.labelSizePx,245 };246 }247 return { line, fill, label, bare };248}249250// gridCellPlan is the one account of which cells the grid shows: the chosen251// cell outlined, its subdivision, and the dimmed neighbors of every252// ancestor. The chart tiles it as polygons and the globe drapes it as253// boundaries, both reading this. Emission order is part of the contract --254// the parity harness compares it positionally.255export function gridCellPlan() {256 const system = activeSystem();257 const cells = [];258 const chain = ancestorChain(system, state.gridCell);259 for (let depth = 0; depth < chain.length - 1; depth++) {260 const selected = chain[depth + 1];261 for (const id of system.children(chain[depth])) {262 if (id === selected) continue;263 cells.push(planCell(system, id, "neighbor", chain.length - 1 - depth));264 }265 }266 if (state.gridCell && system.level(state.gridCell) >= system.maxLevel(state.world)) {267 cells.push(planCell(system, state.gridCell, "leaf", 0));268 return cells;269 }270 // The cell the reader is inside, outlined rather than tiled. It is the one271 // part of the grid that survives putting the grid away: what is on screen is272 // still a chosen place, and a boundary says so where a bare map does not.273 if (state.gridCell) {274 cells.push(planCell(system, state.gridCell, "scope", 0));275 }276 for (const id of system.children(state.gridCell)) {277 cells.push(planCell(system, id, "child", 0));278 }279 return cells;280}281282// ancestorChain walks from the root down to the chosen cell, root first.283function ancestorChain(system, id) {284 const chain = [id];285 let held = id;286 while (held) {287 held = system.parent(held);288 chain.unshift(held);289 }290 return chain;291}292293function planCell(system, id, role, contextDistance) {294 return {295 hash: id,296 extent: system.bbox(id),297 ring: system.ring(id),298 pole: system.poleContained(id),299 childIndex: system.childIndex(id),300 role,301 contextDistance,302 };303}304305export function addGridFeature(cell) {306 const system = activeSystem();307 const count = state.features.filter((pin) =>308 !pin.filteredHidden && system.contains(cell.hash, pin.coordinate)).length;309 const feature = new Feature({310 geometry: gridGeometry(cell),311 // Exactly these five keys: the parity harness serializes this object,312 // and the id keeps the field name `hash` whatever system minted it.313 gridCell: {314 hash: cell.hash,315 extent: cell.extent,316 role: cell.role,317 count,318 contextDistance: cell.contextDistance,319 },320 priority: cell.role === "neighbor"321 ? -cell.contextDistance * 100 + cell.childIndex322 : cell.childIndex,323 });324 const source = cell.role === "neighbor" ? state.sources.gridContext : state.sources.grid;325 source.addFeature(feature);326}327328// gridGeometry tiles one plan cell for the chart. A system's ring is a329// closed, continuous loop; most rings lie within the surface and become a330// plain polygon -- every geohash ring does, byte for byte as before. A331// cell holding a pole circles it, so its loop closes along the pole's own332// edge of the picture; and a ring that stayed continuous across the333// antimeridian is drawn twice, clipped to the surface as it lies and334// shifted a world over, so the one cell appears as its two pieces.335function gridGeometry(cell) {336 const surface = surfaceExtent();337 let ring = cell.ring;338 if (cell.pole) {339 // The whole loop, closing point included: a pole cell's walk ends a340 // world over from where it began, and the closure is what spans the341 // last tessellation step. Dropping it left a sliver of ground the342 // polygon never covered, one step wide, at the walk's own longitude.343 const poleY = cell.pole === "north" ? surface[3] : surface[1];344 ring = [345 ...ring,346 [ring[ring.length - 1][0], poleY],347 [ring[0][0], poleY],348 ring[0],349 ];350 }351 const inside = ring.every(([x]) => x >= surface[0] && x <= surface[2]);352 if (inside) return new Polygon([ring]);353 const width = surface[2] - surface[0];354 const pieces = [];355 for (const shift of [0, -width, width]) {356 const clipped = clipRingX(357 ring.map(([x, y]) => [x + shift, y]),358 surface[0],359 surface[2],360 );361 if (clipped.length >= 4) pieces.push([clipped]);362 }363 return new MultiPolygon(pieces);364}365366export function currentGridExtent() {367 return activeSystem().bbox(state.gridCell);368}369370export function gridMaxLevel() {371 return activeSystem().maxLevel(state.world);372}373374export function pinInGridCell(pin) {375 if (!state.gridEnabled || !state.gridCell) return false;376 return activeSystem().contains(state.gridCell, pin.coordinate);377}