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 / viewport.ts
viewport.ts 31.2K
1// `<atlas-viewport>` — the host of the two panes, and the seam's whole edge.2//3// It does four things and delegates everything else:4//5// READS THE SCENE. One watcher over `#atlas-viewport-state`, surviving the6// morph swaps that replace it (`scene/observe.ts`).7// LOADS. The world payload, the packed locations, through the one data8// module. Payloads are kept per build, so switching worlds back and forth9// costs nothing after the first visit.10// RECONCILES. Model, standing set, ground, cell system: computed once and11// handed to both panes, so the chart and the sphere cannot disagree.12// HOLDS THE EPHEMERAL. Z, the hover, which pane is up, and the camera —13// the continuous half of issue #5 §4.1, none of which is the session's.14//15// Three things leave, all of them as DOM events the page's glue submits: the16// pick, the cell a surface was pointed at, and the settled camera, debounced.17// Nothing else, ever.1819import { KEY_ICON_OUTSET } from "@atlas/analysis/semconv/keys";20import { applicableSystems, cellSystems, geohashCellAt } from "@atlas/analysis";21import type { CellSystem, Coordinate, Ground, LocatedCell } from "@atlas/analysis";22import { logger } from "./log.ts";23import { wireKeyboard } from "./keys.ts";24import { RowHover } from "./hover.ts";25import { DataPlane } from "./data/plane.ts";26import { reportCamera } from "./data/report.ts";27import type { Catalog, Lens, TileGrid } from "./data/payload.ts";28import { SceneWatcher } from "./scene/observe.ts";29import type { Scene, SceneChange } from "./scene/read.ts";30import { WorldModel, worldGrid } from "./world/model.ts";31import { Visibility } from "./world/visibility.ts";32import type { WorldContext } from "./context.ts";33import { AtlasChart } from "./chart/element.ts";34import { AtlasGlobe } from "./globe/element.ts";35import { cellTest } from "./chart/grid.ts";3637const log = logger("viewport");3839export class AtlasViewport extends HTMLElement {40 private readonly plane = new DataPlane();41 private watcher: SceneWatcher | null = null;42 private catalog: Catalog | null = null;43 private readonly worlds = new Map<string, Promise<WorldModel>>();44 private context: WorldContext | null = null;45 private generation = 0;46 private globeUp = false;47 // How to take the keyboard off again. Held rather than forgotten because a48 // custom element can be disconnected and reconnected -- a swap that moved49 // it, a test that reused it -- and window listeners do not leave with it.50 private unkey: (() => void) | null = null;51 // And the same for the legend's pointer: which row it is over is continuous52 // state nobody posts, and the tracker's listeners live and die with this53 // element (`hover.ts`).54 private hover: RowHover | null = null;5556 connectedCallback(): void {57 const selector = this.getAttribute("state-src") || "#atlas-viewport-state";58 this.watcher = new SceneWatcher(selector, (scene, change) => {59 void this.apply(scene, change);60 });61 this.chart?.attach(this.plane, (camera) => {62 const scene = this.watcher?.scene;63 if (!scene?.volume) return;64 reportCamera({ volume: scene.volume, world: scene.world, ...camera });65 });66 this.unkey?.();67 this.unkey = wireKeyboard(this);68 this.hover?.stop();69 this.hover = new RowHover();70 this.hover.start();71 this.wireGlobeToggle();72 this.wireZoom();73 this.wireGridInput();74 // The sphere's camera, written where a camera can be read: the corner75 // locator's rectangle, which is the one form the globe's view ever takes76 // outside its own scene graph.77 const globe = this.globe;78 if (globe) globe.onCamera = (pov) => this.locate(pov);79 this.watcher.start();80 }8182 disconnectedCallback(): void {83 this.watcher?.stop();84 this.unkey?.();85 this.unkey = null;86 this.hover?.stop();87 this.hover = null;88 }8990 /** The one after-swap hook: re-resolve the scene node and re-read it. */91 rescan(): void {92 this.watcher?.rescan();93 this.wireGlobeToggle();94 this.wireZoom();95 // The navigator is a region and a swap replaces it whole, so the field the96 // last swap left behind is not the field on screen.97 this.wireGridInput();98 // The legend is a region too, and the one that moves most: every filtering99 // move re-renders it. The list on screen may be a new node with no100 // listener on it, or the same node carrying a mark from before the swap on101 // a row the pointer is no longer over -- one pass answers both.102 this.hover?.rescan();103 // The footer's sentence is half the application's and half this lane's,104 // and a swap that re-rendered the legend has just put the application's105 // half back on screen. A concern that touches the legend and not the106 // scene node -- folding a section, unfolding every one of them -- moves107 // nothing this seam watches, so nothing would otherwise tell it to108 // finish the sentence again.109 // ...unless there is no window to count over, which is the chart's case110 // behind the sphere: the sentence on screen is then the last true thing it111 // said, and that is what the reader is left reading.112 if (!this.sphereUp) this.chart?.writeCount();113 // And the same duty for the corner: the shelf hides itself when the whole114 // map is on screen, and a swap that re-rendered it has just un-hidden it.115 this.chart?.redrawOverview();116 // The open card is the same shape of duty as the footer's sentence: the117 // application renders the row and leaves it empty, and only this lane can118 // say what a point's address is.119 this.writeCell();120 }121122 /** The panes, looked up rather than held: a morph may not touch them, but123 * an application that renders one and not the other is still a page. */124 get chart(): AtlasChart | null {125 return this.querySelector("atlas-chart");126 }127128 get globe(): AtlasGlobe | null {129 return this.querySelector("atlas-globe");130 }131132 /** What the seam publishes about itself. Read by the diagnostics module. */133 get current(): WorldContext | null {134 return this.context;135 }136137 get sphereUp(): boolean {138 return this.globeUp;139 }140141 // ---- reconciling ----------------------------------------------------142143 private async apply(scene: Scene, change: SceneChange): Promise<void> {144 if (!scene.volume || !scene.base || !scene.world) return;145 // BEFORE ANYTHING IS FETCHED, because a sphere left standing over a world146 // that has none is the whole of the defect: the reader switches volumes,147 // the page comes back a plane, and the planet on screen goes on wearing148 // the skin of the world they left. The application says which surface this149 // world declares in the scene itself (`data-surface`), so the pane can be150 // put right in the same tick the scene moved rather than a payload later151 // -- and if the payload never lands, the sphere is still down.152 if (scene.surface !== "sphere") this.dropSphere();153 const mine = ++this.generation;154 try {155 const grid = await this.tileGrid(scene);156 if (!grid) return;157 const worldTitle = this.catalog?.volumes158 .find((entry) => entry.slug === scene.volume)?.worlds159 .find((world) => world.slug === scene.world)?.title ?? scene.world;160 const model = await this.model(scene, grid);161 // A scene that moved again while a payload was in flight wins: the last162 // thing the reader asked for is the thing to draw.163 if (mine !== this.generation) return;164165 const lens = model.payload.lenses[this.lensIndex(model, scene)] ?? null;166 const ground = model.ground(lens);167 const system = this.system(scene, ground);168 const test = system ? cellTest(ground, system, scene.gridCell) : null;169 const held = this.context;170 const context: WorldContext = {171 scene,172 base: scene.base,173 grid: worldGrid(grid, model.payload),174 model,175 worldTitle,176 lens,177 lensIndex: this.lensIndex(model, scene),178 outset: outsetOf(model),179 ground,180 system,181 cell: scene.gridCell,182 subgridVisible: scene.subgrid > 0,183 visibility: new Visibility(model, scene, lens?.shard ?? 0, test,184 change.world ? null : held?.hovered ?? null),185 labelsHeld: change.world ? false : held?.labelsHeld ?? false,186 hovered: change.world ? null : held?.hovered ?? null,187 };188 this.context = context;189 // The world's own answer, which is the one that decides: a world may190 // declare a sphere and still flatten in a way no globe can invert, and191 // only the payload knows. Asked before the chart is shown, so a chart192 // coming back up is measured against a pane that is already on screen.193 const sphere = AtlasGlobe.offers(model.payload.attrs ?? {});194 if (!sphere) this.dropSphere();195 this.chart?.show(context);196 if (sphere) this.globe?.show(context);197 // The card's rows are written from the model, so they cannot be written198 // before there is one: a rescan runs the moment the swap lands, and the199 // payload it is about may still be in flight. This is the same duty200 // asked again on the other side of the wait.201 this.writeCell();202 } catch (error) {203 log.error("the scene could not be drawn", {204 op: "render", volume: scene.volume, world: scene.world, error: String(error),205 });206 }207 }208209 /**210 * Recompute what stands and repaint both panes, without touching the211 * payloads.212 *213 * `recount` is what tells a filter from a pane flip. A filter changed what214 * is drawn and the footer owes a new sentence; flipping panes changed215 * nothing about the world and the sentence on screen is still the last true216 * thing the chart said about it -- writing it again would only ask a pane217 * with no window how much of the map it can see.218 */219 private refresh({ recount = true } = {}): void {220 const context = this.context;221 if (!context) return;222 // WRAPPED, because everything inside it asks a cell system a question and223 // a cell system is allowed to refuse. S2 throws outright on a ground with224 // no invertible flattening rather than shrugging -- `appliesTo` is the225 // question a caller was supposed to ask first (analysis/cellsystems/s2.ts)226 // -- and the guard in `system` below is the asking. This is the second227 // fence: a refusal that got past it costs one repaint and a line in the228 // log, and never the map. A reader whose grid is wrong can still read229 // their world; a reader whose repaint threw has a page that answers230 // nothing at all.231 try {232 const test = context.system233 ? cellTest(context.ground, context.system, context.cell)234 : null;235 context.visibility = new Visibility(236 context.model, context.scene, context.lens?.shard ?? 0, test, context.hovered);237 this.chart?.restyle();238 if (recount) this.chart?.writeCount();239 this.globe?.update();240 } catch (error) {241 log.error("the arrangement could not be repainted", {242 op: "render", volume: context.scene.volume, world: context.scene.world,243 system: context.system?.slug ?? "", cell: context.cell, error: String(error),244 });245 }246 }247248 private lensIndex(model: WorldModel, scene: Scene): number {249 const named = model.payload.lenses.findIndex((lens: Lens) => lens.name === scene.lens);250 if (named >= 0) return named;251 return Math.min(Math.max(scene.lensIndex, 0), Math.max(0, model.payload.lenses.length - 1));252 }253254 /**255 * The system dividing this world, and only if it can.256 *257 * THE FALLBACK WAS THE BUG. A system named in the session that this ground258 * does not offer used to be looked up in the registry and handed back259 * anyway, which is the one answer that cannot be given: the systems are260 * exact rather than approximate, and one asked about a ground it refused261 * throws on the first question the grid puts to it. So the offered set is262 * the whole of the answer -- `applicableSystems` is `appliesTo` over the263 * registry -- and a name outside it draws no grid and says so once.264 *265 * The server refuses such a value too (internal/app/session.go, applyGrid),266 * and that is not a reason to trust it here: a record written by an older267 * build, or a hand-edited session, arrives at this lane all the same, and268 * the pane that would throw is this one.269 */270 private system(scene: Scene, ground: ReturnType<WorldModel["ground"]>): CellSystem | null {271 if (!scene.gridSystem) return null;272 const offered = applicableSystems(ground);273 const found = offered.find((candidate) => candidate.slug === scene.gridSystem);274 if (found) return found;275 log.warn("the session names a system this world cannot be divided by", {276 op: "render", volume: scene.volume, world: scene.world, system: scene.gridSystem,277 offered: offered.map((candidate) => candidate.slug).join(","),278 known: cellSystems.get(scene.gridSystem) !== undefined,279 });280 return null;281 }282283 /**284 * The open card's cell addresses.285 *286 * WHOSE HALF THIS IS. Where a point stands is the application's -- the card287 * renders the coordinates out of the payload -- and *what that place is288 * called* is the analysis lane's, which the server has no access to. So the289 * card is rendered with the row present and empty, hidden, and this fills290 * it: the same division `writeCount` makes over the footer's sentence, and291 * the same lifecycle, because the card is an outerMorph region and every292 * swap replaces it whole. There is nothing to keep in step -- a swap takes293 * the last answer away with the node it was written on, and the rescan that294 * follows writes this one.295 *296 * GEOHASH IS THE FIRST ROW AND IT IS FIXED. The address shown is the one at297 * depth 3, not at whatever depth the grid currently stands: the row says298 * where this point *is*, and a reader descending the navigator did not ask299 * for every open card to be re-spelled behind them. Every other system this300 * ground offers adds a row of its own, in registry order, out of the one301 * question the contract has for a point (`locate`).302 *303 * The rows are inert text. Nothing here is a control, and the reference's304 * were not either.305 */306 private writeCell(): void {307 const context = this.context;308 const field = document.querySelector<HTMLElement>("#detail-cell-field");309 const value = document.querySelector<HTMLElement>("#detail-cell");310 // The strays go first and unconditionally. Every other row on the card is311 // the server's and leaves with the swap that replaced it; these are this312 // lane's, and a card that has just become a shape's -- or nobody's -- must313 // not keep the last point's second address under it.314 for (const row of [...document.querySelectorAll("[data-cell-system-row]")]) row.remove();315 // No card on the page at all, which is most of the application's states.316 if (!field || !value) return;317 // THE LIVE SCENE, not the reconciled one. A rescan runs the moment the318 // swap lands and the reconcile it starts is a payload away; the context319 // still names the selection *before* this swap, and writing that here320 // would put the last point's address on the card now open about another.321 // The model is the same model either way -- a selection does not change322 // the world -- and a selection this one has never heard of resolves to323 // nothing, which is a hidden row until the reconcile fills it.324 const selected = this.watcher?.scene.selected ?? context?.scene.selected ?? "";325 const point = context?.model.pointByID.get(selected) ?? null;326 if (!context || !point) {327 value.textContent = "";328 field.hidden = true;329 return;330 }331 const address = geohashCellAt(context.ground, point.coordinate);332 value.textContent = address;333 field.hidden = address === "";334 if (!address) return;335 let after: Element = field;336 for (const system of applicableSystems(context.ground)) {337 if (system.slug === "geohash") continue;338 const located = this.addressOf(system, context.ground, point.coordinate);339 if (!located) continue;340 const row = cellRow(system.slug, located.label, located.value);341 after.after(row);342 after = row;343 }344 }345346 /**347 * One system's name for a point, or nothing.348 *349 * Fenced for the reason every call into a system on this element is fenced:350 * the systems are exact rather than approximate, and S2 asked about a ground351 * with no invertible flattening throws outright. `applicableSystems` above352 * is the asking that should make this unreachable; this is the second fence,353 * and it costs a row and a line on the stream rather than the card.354 */355 private addressOf(356 system: CellSystem, ground: Ground, at: Coordinate,357 ): LocatedCell | null {358 try {359 return system.on(ground).locate(at);360 } catch (error) {361 log.warn("the system cannot name the place this card is about", {362 op: "render", system: system.slug, error: String(error),363 });364 return null;365 }366 }367368 /**369 * The navigator's field, kept spellable.370 *371 * THE SERVER STAYS AUTHORITATIVE. What a cell address is, and whether the372 * text has become one, is decided where the session is written; this is373 * display assist and nothing more -- the field shows the reader only what374 * their system could keep of what they typed, so a capital or a stray375 * character does not sit in the box looking like part of an address.376 *377 * IN THE CAPTURE PHASE, so the value is normalized before the route bound to378 * this same field reads it: one keystroke, one posted value, and the two379 * halves of the page agree about what was typed. It never stops the event --380 * the field's own `input` trigger is the application's and must fire exactly381 * as it did -- and it rewrites nothing when there is nothing to rewrite,382 * which is every keystroke that was already an address.383 */384 private wireGridInput(): void {385 const field = document.querySelector<HTMLInputElement>("#grid-input");386 if (!field || wired.has(field)) return;387 wired.add(field);388 // The live viewport, not this one: a swap can carry a control across while389 // the element it was wired to is no longer the element on the page.390 field.addEventListener("input", () => {391 (document.querySelector<AtlasViewport>("atlas-viewport") ?? this).normalizeCell(field);392 }, { capture: true });393 }394395 /**396 * Rewrite what is in the field as the active system spells it, keeping the397 * caret where the reader left it.398 *399 * The caret is counted rather than kept: how many characters of what stands400 * *before* it survive normalization is where it belongs afterwards, which is401 * the one arithmetic that holds however many characters were dropped and402 * wherever in the text they were. Typing at the end -- which is nearly all403 * typing -- lands it at the end, as it would have anyway.404 *405 * Public because the listener above resolves the live element and calls it.406 */407 normalizeCell(field: HTMLInputElement): void {408 const context = this.context;409 if (!context?.system) return;410 const raw = field.value;411 let on;412 try {413 on = context.system.on(context.ground);414 } catch (error) {415 log.warn("the active system cannot spell an address on this ground", {416 op: "render", system: context.system.slug, error: String(error),417 });418 return;419 }420 const shown = on.normalizeInput(raw);421 // Nothing to say. This is the path the recorded tours take -- "m" is422 // already what geohash keeps of "m" -- so the field, the event and the423 // caret are all left exactly as they were found.424 if (shown === raw) return;425 const caret = field.selectionStart ?? raw.length;426 const before = on.normalizeInput(raw.slice(0, caret)).length;427 field.value = shown;428 field.setSelectionRange?.(before, before);429 }430431 private async tileGrid(scene: Scene): Promise<TileGrid | null> {432 this.catalog ??= await this.plane.catalog();433 const volume = this.catalog.volumes.find((entry) => entry.slug === scene.volume);434 if (!volume) {435 log.warn("the catalog does not list the volume the page is about", {436 op: "render", volume: scene.volume,437 });438 return null;439 }440 return volume.tileGrid;441 }442443 private model(scene: Scene, grid: TileGrid): Promise<WorldModel> {444 const key = `${scene.base}/${scene.world}`;445 const held = this.worlds.get(key);446 if (held) return held;447 const building = (async () => {448 const [payload, table] = await Promise.all([449 this.plane.world(scene.base, scene.world),450 this.plane.locations(scene.base, scene.world),451 ]);452 return new WorldModel(scene.world, payload, worldGrid(grid, payload), table);453 })();454 this.worlds.set(key, building);455 return building;456 }457458 // ---- the ephemeral half ---------------------------------------------459460 /**461 * Z is held, not switched.462 *463 * The reader wants every name for as long as they are looking for one, and464 * then wants the map back. Holding says both in one gesture: there is no465 * state left on by mistake and no control to find first.466 *467 * Which key does this, when a repeat counts and what happens when the window468 * loses focus mid-hold all belong to `keys.ts`; what is here is the half only469 * this element can do -- the flag both panes read, and the sentence in the470 * corner that says the flag is up.471 */472 holdLabels(down: boolean): void {473 const context = this.context;474 if (!context || context.labelsHeld === down) return;475 context.labelsHeld = down;476 this.chart?.restyle();477 this.globe?.update();478 const hint = document.querySelector("#labels-hint");479 if (hint) hint.textContent = down ? "Z · labels shown" : "Z · hold for labels";480 }481482 /**483 * One step of zoom, wherever the ask came from.484 *485 * The buttons beside the map and the +/- keys are the same interaction, and486 * which pane is up decides what a step means: a zoom level on the chart, a487 * halving of distance on the sphere. Public because both callers are488 * outside -- the button handlers may outlive an instance, and the keyboard489 * is a module.490 */491 zoomBy(delta: number): void {492 if (this.globeUp) this.globe?.changeZoom(delta);493 else this.chart?.nudgeZoom(delta);494 }495496 /**497 * Which pane is up is seam-local.498 *499 * The application renders the toggle — it knows whether the world declares500 * a sphere — and the seam wires it, because flipping panes moves no501 * discrete state: it is the same world, the same filters and the same502 * camera, seen from a different distance.503 */504 private wireGlobeToggle(): void {505 const toggle = document.querySelector<HTMLButtonElement>("#globe-toggle");506 if (!toggle) return;507 // Which pane is up is this lane's state, and the application renders the508 // button that says so -- with `aria-pressed="false"`, because the server509 // has no way of knowing. So a swap that re-renders the topbar puts the510 // button back to "chart" while the sphere is still on screen, and the511 // next press reads as a first press. The state is re-asserted onto the512 // control after every swap, which is the price of the control belonging513 // to one side and the state to the other.514 toggle.setAttribute("aria-pressed", String(this.globeUp));515 if (wired.has(toggle)) return;516 wired.add(toggle);517 // The handler resolves the live viewport rather than closing over this518 // one. A morph swap can carry a control across intact while the element it519 // was wired to is no longer the element on the page, and a toggle bound to520 // a viewport nobody is looking at flips nothing and reports nothing.521 toggle.addEventListener("click", () => {522 (document.querySelector<AtlasViewport>("atlas-viewport") ?? this).flipPane();523 });524 }525526 /** Swap the panes. Public because the toggle's handler may outlive an527 * instance and has to be able to reach whichever one is live. */528 flipPane(): void {529 const globe = this.globe;530 const chart = this.chart;531 const toggle = document.querySelector<HTMLButtonElement>("#globe-toggle");532 if (!globe || !chart) return;533 const size = { width: this.clientWidth, height: this.clientHeight };534 if (this.globeUp) {535 const camera = globe.leave();536 if (camera) chart.goTo(camera.x, camera.y, camera.zoom, camera.rotation);537 chart.hidden = false;538 this.globeUp = false;539 // THE CAMERA IS CHECKED WHERE IT FRONTS, and this is the second half of540 // the boundary the sphere's write-back is the first half of. Behind the541 // sphere the chart went on being moved by things that do not need a542 // window -- a filter, a lens, a cell descended into -- and the fit a543 // descent makes there is made against a window of no size, so it lands544 // at the deepest level the lens has whatever the reader was looking at.545 // While the pane was away that was nobody's business. It becomes the546 // reader's here, and it is the *only* moment it can be asked without547 // moving what the recorded globe steps pin: a sphere handing back548 // nothing at all (`leave` refusing a camera that is not a place) leaves549 // the chart standing on that camera, and this is what looks at it.550 chart.front();551 // Over the chart the camera is in the snapshot proper, so the locator552 // goes back to reading the map rather than being told.553 chart.locate(null);554 } else {555 const camera = chart.camera();556 chart.hidden = true;557 // Which pane is up is set *before* the sphere comes up, because coming558 // up is the sphere's first camera event and the corner locator refuses559 // to follow a globe that is not up yet. Told afterwards, the mark was560 // right on a first entry -- where the tiles arriving raised more events561 // -- and stale on every entry after it.562 this.globeUp = true;563 // Retiring a sphere forgets its camera callback along with everything564 // else, so a globe going up re-learns where its camera reports -- a565 // wire asserted only at connect time is a wire a retire has cut.566 globe.onCamera = (pov) => this.locate(pov);567 if (camera) globe.enter(camera, size);568 }569 toggle?.setAttribute("aria-pressed", String(this.globeUp));570 this.refresh({ recount: false });571 }572573 /**574 * Put the sphere down, because this world has none.575 *576 * THE PANE IS SEAM STATE AND THE WORLD IS NOT. Which pane is up survives a577 * swap on purpose — it is continuous interaction state, and a filter must578 * not drop the reader back to the chart — but a world change is exactly the579 * moment that stops being right: the new world may be a game map, and the580 * sphere standing over it belongs to the world before. Left alone, that is581 * what the reader saw: Night City opened while a planet went on turning,582 * still wearing Mars's skin, with only the toggle quietly hiding itself to583 * say the offer had been withdrawn.584 *585 * So the chart comes back up, the toggle is put down, and the sphere is586 * retired — the same teardown a disconnect does, plus the context, because587 * this element is not about that world any more. No camera is carried back:588 * a camera is a place on a particular world, and the sphere's was a place on589 * the one being left (`AtlasGlobe.retire`).590 *591 * Idempotent, and it has to be: it is asked on every scene change a plane592 * world sees, and only the first one has anything to put down.593 */594 private dropSphere(): void {595 if (this.globeUp) {596 this.globeUp = false;597 const chart = this.chart;598 if (chart) {599 chart.hidden = false;600 // The same fronting duty as `flipPane`, and owed more here: no camera601 // is handed back at all on this path, so whatever the chart was left602 // standing on behind the sphere is what the reader is about to be603 // looking through.604 chart.front();605 // Over the chart the camera is in the snapshot proper, so the locator606 // goes back to reading the map rather than being told.607 chart.locate(null);608 }609 document.querySelector<HTMLButtonElement>("#globe-toggle")610 ?.setAttribute("aria-pressed", "false");611 }612 this.globe?.retire();613 }614615 /**616 * The globe's camera, marked on the chart's own surface.617 *618 * A point, not an extent. On the sphere half the world is always out of619 * sight, so the honest thing for the corner to say is where the camera is620 * looking; the locator draws a fixed mark there, which is why every621 * recorded globe step carries a 22-pixel box that never changes size622 * however close the camera comes.623 */624 private locate(pov: { lat: number; lng: number; altitude: number }): void {625 const globe = this.globe;626 if (!globe || !this.globeUp) return;627 const camera = globe.cameraOf(pov);628 if (!camera) return;629 this.chart?.locate([camera.x, camera.y]);630 }631632 /**633 * The zoom controls.634 *635 * Zoom is continuous interaction state, so it is the seam's (issue #5636 * §4.1), and the two buttons are the application's chrome the way the globe637 * toggle is: it renders them because they belong beside the map, and this638 * lane wires them because pressing one moves nothing discrete. Which pane639 * is up decides what a press means -- a zoom level on the chart, a halving640 * of distance on the sphere.641 */642 private wireZoom(): void {643 for (const [id, delta] of [["#zoom-in", 1], ["#zoom-out", -1]] as const) {644 const button = document.querySelector<HTMLButtonElement>(id);645 if (!button || wired.has(button)) continue;646 wired.add(button);647 button.addEventListener("click", () => {648 (document.querySelector<AtlasViewport>("atlas-viewport") ?? this).zoomBy(delta);649 });650 }651 }652}653654/**655 * The controls this lane has already wired.656 *657 * WHY A SET AND NOT A MARK ON THE ELEMENT. It was a `data-` attribute, and658 * that is the one place a mark cannot be kept: a morph rewrites an element's659 * attributes from the server's markup, which has never heard of it, so the660 * mark was rubbed off every swap while the listener it recorded stayed661 * attached. The next rescan wired the same button again, and again, until one662 * press of the globe toggle flipped the panes four times and left them where663 * they started — reachable by hand, unreachable at the end of a tour, and the664 * parity of a count deciding which. Held here, the fact belongs to the lane665 * that owns it and no swap can reach it.666 *667 * Weak, so a control that leaves the page leaves this with it.668 */669const wired = new WeakSet<Element>();670671/**672 * One system's row for the open card, in the card's own shape.673 *674 * A bare `<div><dt>…</dt><dd>…</dd></div>`, which is what every other row in675 * that list is: the carried assets/css/pin-detail.css lays the definition list676 * out from the divs and asks nothing of them, so a row this lane inserts and a677 * row the server rendered are the same row to the stylesheet. The mark is how678 * the next pass finds it again, and it is the only thing that distinguishes679 * them.680 *681 * Text, not markup: a cell address is a value, and `textContent` is the one682 * way to put a value on a page without asking what is in it.683 */684function cellRow(slug: string, label: string, value: string): HTMLElement {685 const row = document.createElement("div");686 row.setAttribute("data-cell-system-row", slug);687 const term = document.createElement("dt");688 term.textContent = label;689 const said = document.createElement("dd");690 said.textContent = value;691 row.append(term, said);692 return row;693}694695function outsetOf(model: WorldModel): string {696 return model.payload.attrs?.[KEY_ICON_OUTSET] ?? "";697}