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 / render / viewport.ts
viewport.ts 30.5K
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 { demandWindowKey, replaceDemandWindow } from "./data/window.ts";27import { reportCamera } from "./data/report.ts";28import type { Lens } from "./data/payload.ts";29import { SceneWatcher } from "./scene/observe.ts";30import { sceneChange, type Scene, type SceneChange } from "./scene/read.ts";31import { WorldModel } from "./world/model.ts";32import { Visibility } from "./world/visibility.ts";33import type { WorldContext } from "./context.ts";34import { AtlasChart } from "./chart/element.ts";35import { AtlasGlobe } from "./globe/element.ts";36import { cellTest } from "./chart/grid.ts";3738const log = logger("viewport");3940export class AtlasViewport extends HTMLElement {41 private readonly plane = new DataPlane();42 private watcher: SceneWatcher | 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 const windowed: Scene = { ...scene, camera };66 void this.apply(windowed, sceneChange(scene, windowed));67 });68 this.unkey?.();69 this.unkey = wireKeyboard(this);70 this.hover?.stop();71 this.hover = new RowHover();72 this.hover.start();73 this.wireGlobeToggle();74 this.wireZoom();75 this.wireGridInput();76 // The sphere's camera, written where a camera can be read: the corner77 // locator's rectangle, which is the one form the globe's view ever takes78 // outside its own scene graph.79 const globe = this.globe;80 if (globe) globe.onCamera = (pov) => this.locate(pov);81 this.watcher.start();82 }8384 disconnectedCallback(): void {85 this.watcher?.stop();86 this.unkey?.();87 this.unkey = null;88 this.hover?.stop();89 this.hover = null;90 }9192 /** The one after-swap hook: re-resolve the scene node and re-read it. */93 rescan(): void {94 this.watcher?.rescan();95 this.wireGlobeToggle();96 this.wireZoom();97 // The navigator is a region and a swap replaces it whole, so the field the98 // last swap left behind is not the field on screen.99 this.wireGridInput();100 // The legend is a region too, and the one that moves most: every filtering101 // move re-renders it. The list on screen may be a new node with no102 // listener on it, or the same node carrying a mark from before the swap on103 // a row the pointer is no longer over -- one pass answers both.104 this.hover?.rescan();105 // The footer's sentence is half the application's and half this lane's,106 // and a swap that re-rendered the legend has just put the application's107 // half back on screen. A concern that touches the legend and not the108 // scene node -- folding a section, unfolding every one of them -- moves109 // nothing this seam watches, so nothing would otherwise tell it to110 // finish the sentence again.111 // ...unless there is no window to count over, which is the chart's case112 // behind the sphere: the sentence on screen is then the last true thing it113 // said, and that is what the reader is left reading.114 if (!this.sphereUp) this.chart?.writeCount();115 // And the same duty for the corner: the shelf hides itself when the whole116 // map is on screen, and a swap that re-rendered it has just un-hidden it.117 this.chart?.redrawOverview();118 // The open card is the same shape of duty as the footer's sentence: the119 // application renders the row and leaves it empty, and only this lane can120 // say what a point's address is.121 this.writeCell();122 }123124 /** The panes, looked up rather than held: a morph may not touch them, but125 * an application that renders one and not the other is still a page. */126 get chart(): AtlasChart | null {127 return this.querySelector("atlas-chart");128 }129130 get globe(): AtlasGlobe | null {131 return this.querySelector("atlas-globe");132 }133134 /** What the seam publishes about itself. Read by the diagnostics module. */135 get current(): WorldContext | null {136 return this.context;137 }138139 get sphereUp(): boolean {140 return this.globeUp;141 }142143 // ---- reconciling ----------------------------------------------------144145 private async apply(scene: Scene, change: SceneChange): Promise<void> {146 if (!scene.volume || !scene.base || !scene.world) return;147 // BEFORE ANYTHING IS FETCHED, because a sphere left standing over a world148 // that has none is the whole of the defect: the reader switches volumes,149 // the page comes back a plane, and the planet on screen goes on wearing150 // the skin of the world they left. The application says which surface this151 // world declares in the scene itself (`data-surface`), so the pane can be152 // put right in the same tick the scene moved rather than a payload later153 // -- and if the payload never lands, the sphere is still down.154 if (scene.surface !== "sphere") this.dropSphere();155 const mine = ++this.generation;156 try {157 const model = await this.model(scene);158 // A scene that moved again while a payload was in flight wins: the last159 // thing the reader asked for is the thing to draw.160 if (mine !== this.generation) return;161162 const lens = model.lenses[this.lensIndex(model, scene)] ?? null;163 const ground = model.ground(lens);164 const system = this.system(scene, ground);165 const test = system ? cellTest(ground, system, scene.gridCell) : null;166 const held = this.context;167 const context: WorldContext = {168 scene,169 base: scene.base,170 grid: model.grid,171 model,172 worldTitle: model.title,173 lens,174 lensIndex: this.lensIndex(model, scene),175 outset: outsetOf(model),176 ground,177 system,178 cell: scene.gridCell,179 subgridVisible: scene.subgrid > 0,180 visibility: new Visibility(model, scene, lens?.shard ?? 0, test,181 change.world ? null : held?.hovered ?? null),182 labelsHeld: change.world ? false : held?.labelsHeld ?? false,183 hovered: change.world ? null : held?.hovered ?? null,184 };185 this.context = context;186 // The world's own answer, which is the one that decides: a world may187 // declare a sphere and still flatten in a way no globe can invert, and188 // only the payload knows. Asked before the chart is shown, so a chart189 // coming back up is measured against a pane that is already on screen.190 const sphere = AtlasGlobe.offers(model.attrs);191 if (!sphere) this.dropSphere();192 this.chart?.show(context);193 if (sphere) this.globe?.show(context);194 // The card's rows are written from the model, so they cannot be written195 // before there is one: a rescan runs the moment the swap lands, and the196 // payload it is about may still be in flight. This is the same duty197 // asked again on the other side of the wait.198 this.writeCell();199 } catch (error) {200 log.error("the scene could not be drawn", {201 op: "render", volume: scene.volume, world: scene.world, error: String(error),202 });203 }204 }205206 /**207 * Recompute what stands and repaint both panes, without touching the208 * payloads.209 *210 * `recount` is what tells a filter from a pane flip. A filter changed what211 * is drawn and the footer owes a new sentence; flipping panes changed212 * nothing about the world and the sentence on screen is still the last true213 * thing the chart said about it -- writing it again would only ask a pane214 * with no window how much of the map it can see.215 */216 private refresh({ recount = true } = {}): void {217 const context = this.context;218 if (!context) return;219 // WRAPPED, because everything inside it asks a cell system a question and220 // a cell system is allowed to refuse. S2 throws outright on a ground with221 // no invertible flattening rather than shrugging -- `appliesTo` is the222 // question a caller was supposed to ask first (analysis/cellsystems/s2.ts)223 // -- and the guard in `system` below is the asking. This is the second224 // fence: a refusal that got past it costs one repaint and a line in the225 // log, and never the map. A reader whose grid is wrong can still read226 // their world; a reader whose repaint threw has a page that answers227 // nothing at all.228 try {229 const test = context.system230 ? cellTest(context.ground, context.system, context.cell)231 : null;232 context.visibility = new Visibility(233 context.model, context.scene, context.lens?.shard ?? 0, test, context.hovered);234 this.chart?.restyle();235 if (recount) this.chart?.writeCount();236 this.globe?.update();237 } catch (error) {238 log.error("the arrangement could not be repainted", {239 op: "render", volume: context.scene.volume, world: context.scene.world,240 system: context.system?.slug ?? "", cell: context.cell, error: String(error),241 });242 }243 }244245 private lensIndex(model: WorldModel, scene: Scene): number {246 const named = model.lenses.findIndex((lens: Lens) => lens.name === scene.lens);247 if (named >= 0) return named;248 return Math.min(Math.max(scene.lensIndex, 0), Math.max(0, model.lenses.length - 1));249 }250251 /**252 * The system dividing this world, and only if it can.253 *254 * THE FALLBACK WAS THE BUG. A system named in the session that this ground255 * does not offer used to be looked up in the registry and handed back256 * anyway, which is the one answer that cannot be given: the systems are257 * exact rather than approximate, and one asked about a ground it refused258 * throws on the first question the grid puts to it. So the offered set is259 * the whole of the answer -- `applicableSystems` is `appliesTo` over the260 * registry -- and a name outside it draws no grid and says so once.261 *262 * The server refuses such a value too (internal/app/session.go, applyGrid),263 * and that is not a reason to trust it here: a record written by an older264 * build, or a hand-edited session, arrives at this lane all the same, and265 * the pane that would throw is this one.266 */267 private system(scene: Scene, ground: ReturnType<WorldModel["ground"]>): CellSystem | null {268 if (!scene.gridSystem) return null;269 const offered = applicableSystems(ground);270 const found = offered.find((candidate) => candidate.slug === scene.gridSystem);271 if (found) return found;272 log.warn("the session names a system this world cannot be divided by", {273 op: "render", volume: scene.volume, world: scene.world, system: scene.gridSystem,274 offered: offered.map((candidate) => candidate.slug).join(","),275 known: cellSystems.get(scene.gridSystem) !== undefined,276 });277 return null;278 }279280 /**281 * The open card's cell addresses.282 *283 * WHOSE HALF THIS IS. Where a point stands is the application's -- the card284 * renders the coordinates out of the payload -- and *what that place is285 * called* is the analysis lane's, which the server has no access to. So the286 * card is rendered with the row present and empty, hidden, and this fills287 * it: the same division `writeCount` makes over the footer's sentence, and288 * the same lifecycle, because the card is an outerMorph region and every289 * swap replaces it whole. There is nothing to keep in step -- a swap takes290 * the last answer away with the node it was written on, and the rescan that291 * follows writes this one.292 *293 * GEOHASH IS THE FIRST ROW AND IT IS FIXED. The address shown is the one at294 * depth 3, not at whatever depth the grid currently stands: the row says295 * where this point *is*, and a reader descending the navigator did not ask296 * for every open card to be re-spelled behind them. Every other system this297 * ground offers adds a row of its own, in registry order, out of the one298 * question the contract has for a point (`locate`).299 *300 * The rows are inert text. Nothing here is a control, and the reference's301 * were not either.302 */303 private writeCell(): void {304 const context = this.context;305 const field = document.querySelector<HTMLElement>("#detail-cell-field");306 const value = document.querySelector<HTMLElement>("#detail-cell");307 // The strays go first and unconditionally. Every other row on the card is308 // the server's and leaves with the swap that replaced it; these are this309 // lane's, and a card that has just become a shape's -- or nobody's -- must310 // not keep the last point's second address under it.311 for (const row of [...document.querySelectorAll("[data-cell-system-row]")]) row.remove();312 // No card on the page at all, which is most of the application's states.313 if (!field || !value) return;314 // THE LIVE SCENE, not the reconciled one. A rescan runs the moment the315 // swap lands and the reconcile it starts is a payload away; the context316 // still names the selection *before* this swap, and writing that here317 // would put the last point's address on the card now open about another.318 // The model is the same model either way -- a selection does not change319 // the world -- and a selection this one has never heard of resolves to320 // nothing, which is a hidden row until the reconcile fills it.321 const selected = this.watcher?.scene.selected ?? context?.scene.selected ?? "";322 const point = context?.model.pointByID.get(selected) ?? null;323 if (!context || !point) {324 value.textContent = "";325 field.hidden = true;326 return;327 }328 const address = geohashCellAt(context.ground, point.coordinate);329 value.textContent = address;330 field.hidden = address === "";331 if (!address) return;332 let after: Element = field;333 for (const system of applicableSystems(context.ground)) {334 if (system.slug === "geohash") continue;335 const located = this.addressOf(system, context.ground, point.coordinate);336 if (!located) continue;337 const row = cellRow(system.slug, located.label, located.value);338 after.after(row);339 after = row;340 }341 }342343 /**344 * One system's name for a point, or nothing.345 *346 * Fenced for the reason every call into a system on this element is fenced:347 * the systems are exact rather than approximate, and S2 asked about a ground348 * with no invertible flattening throws outright. `applicableSystems` above349 * is the asking that should make this unreachable; this is the second fence,350 * and it costs a row and a line on the stream rather than the card.351 */352 private addressOf(353 system: CellSystem, ground: Ground, at: Coordinate,354 ): LocatedCell | null {355 try {356 return system.on(ground).locate(at);357 } catch (error) {358 log.warn("the system cannot name the place this card is about", {359 op: "render", system: system.slug, error: String(error),360 });361 return null;362 }363 }364365 /**366 * The navigator's field, kept spellable.367 *368 * THE SERVER STAYS AUTHORITATIVE. What a cell address is, and whether the369 * text has become one, is decided where the session is written; this is370 * display assist and nothing more -- the field shows the reader only what371 * their system could keep of what they typed, so a capital or a stray372 * character does not sit in the box looking like part of an address.373 *374 * IN THE CAPTURE PHASE, so the value is normalized before the route bound to375 * this same field reads it: one keystroke, one posted value, and the two376 * halves of the page agree about what was typed. It never stops the event --377 * the field's own `input` trigger is the application's and must fire exactly378 * as it did -- and it rewrites nothing when there is nothing to rewrite,379 * which is every keystroke that was already an address.380 */381 private wireGridInput(): void {382 const field = document.querySelector<HTMLInputElement>("#grid-input");383 if (!field || wired.has(field)) return;384 wired.add(field);385 // The live viewport, not this one: a swap can carry a control across while386 // the element it was wired to is no longer the element on the page.387 field.addEventListener("input", () => {388 (document.querySelector<AtlasViewport>("atlas-viewport") ?? this).normalizeCell(field);389 }, { capture: true });390 }391392 /**393 * Rewrite what is in the field as the active system spells it, keeping the394 * caret where the reader left it.395 *396 * The caret is counted rather than kept: how many characters of what stands397 * *before* it survive normalization is where it belongs afterwards, which is398 * the one arithmetic that holds however many characters were dropped and399 * wherever in the text they were. Typing at the end -- which is nearly all400 * typing -- lands it at the end, as it would have anyway.401 *402 * Public because the listener above resolves the live element and calls it.403 */404 normalizeCell(field: HTMLInputElement): void {405 const context = this.context;406 if (!context?.system) return;407 const raw = field.value;408 let on;409 try {410 on = context.system.on(context.ground);411 } catch (error) {412 log.warn("the active system cannot spell an address on this ground", {413 op: "render", system: context.system.slug, error: String(error),414 });415 return;416 }417 const shown = on.normalizeInput(raw);418 // Nothing to say. This is the path the recorded tours take -- "m" is419 // already what geohash keeps of "m" -- so the field, the event and the420 // caret are all left exactly as they were found.421 if (shown === raw) return;422 const caret = field.selectionStart ?? raw.length;423 const before = on.normalizeInput(raw.slice(0, caret)).length;424 field.value = shown;425 field.setSelectionRange?.(before, before);426 }427428 private model(scene: Scene): Promise<WorldModel> {429 const prefix = `${scene.base}/${scene.world}/`;430 const key = `${prefix}${demandWindowKey(scene.camera)}`;431 const held = this.worlds.get(key);432 if (held) return held;433 const building = (async () => {434 return new WorldModel(await this.plane.world(scene.base, scene.world, scene.camera));435 })();436 replaceDemandWindow(this.worlds, prefix, key, building);437 return building;438 }439440 // ---- the ephemeral half ---------------------------------------------441442 /**443 * Z is held, not switched.444 *445 * The reader wants every name for as long as they are looking for one, and446 * then wants the map back. Holding says both in one gesture: there is no447 * state left on by mistake and no control to find first.448 *449 * Which key does this, when a repeat counts and what happens when the window450 * loses focus mid-hold all belong to `keys.ts`; what is here is the half only451 * this element can do -- the flag both panes read, and the sentence in the452 * corner that says the flag is up.453 */454 holdLabels(down: boolean): void {455 const context = this.context;456 if (!context || context.labelsHeld === down) return;457 context.labelsHeld = down;458 this.chart?.restyle();459 this.globe?.update();460 const hint = document.querySelector("#labels-hint");461 if (hint) hint.textContent = down ? "Z · labels shown" : "Z · hold for labels";462 }463464 /**465 * One step of zoom, wherever the ask came from.466 *467 * The buttons beside the map and the +/- keys are the same interaction, and468 * which pane is up decides what a step means: a zoom level on the chart, a469 * halving of distance on the sphere. Public because both callers are470 * outside -- the button handlers may outlive an instance, and the keyboard471 * is a module.472 */473 zoomBy(delta: number): void {474 if (this.globeUp) this.globe?.changeZoom(delta);475 else this.chart?.nudgeZoom(delta);476 }477478 /**479 * Which pane is up is seam-local.480 *481 * The application renders the toggle — it knows whether the world declares482 * a sphere — and the seam wires it, because flipping panes moves no483 * discrete state: it is the same world, the same filters and the same484 * camera, seen from a different distance.485 */486 private wireGlobeToggle(): void {487 const toggle = document.querySelector<HTMLButtonElement>("#globe-toggle");488 if (!toggle) return;489 // Which pane is up is this lane's state, and the application renders the490 // button that says so -- with `aria-pressed="false"`, because the server491 // has no way of knowing. So a swap that re-renders the topbar puts the492 // button back to "chart" while the sphere is still on screen, and the493 // next press reads as a first press. The state is re-asserted onto the494 // control after every swap, which is the price of the control belonging495 // to one side and the state to the other.496 toggle.setAttribute("aria-pressed", String(this.globeUp));497 if (wired.has(toggle)) return;498 wired.add(toggle);499 // The handler resolves the live viewport rather than closing over this500 // one. A morph swap can carry a control across intact while the element it501 // was wired to is no longer the element on the page, and a toggle bound to502 // a viewport nobody is looking at flips nothing and reports nothing.503 toggle.addEventListener("click", () => {504 (document.querySelector<AtlasViewport>("atlas-viewport") ?? this).flipPane();505 });506 }507508 /** Swap the panes. Public because the toggle's handler may outlive an509 * instance and has to be able to reach whichever one is live. */510 flipPane(): void {511 const globe = this.globe;512 const chart = this.chart;513 const toggle = document.querySelector<HTMLButtonElement>("#globe-toggle");514 if (!globe || !chart) return;515 const size = { width: this.clientWidth, height: this.clientHeight };516 if (this.globeUp) {517 const camera = globe.leave();518 if (camera) chart.goTo(camera.x, camera.y, camera.zoom, camera.rotation);519 chart.hidden = false;520 this.globeUp = false;521 // THE CAMERA IS CHECKED WHERE IT FRONTS, and this is the second half of522 // the boundary the sphere's write-back is the first half of. Behind the523 // sphere the chart went on being moved by things that do not need a524 // window -- a filter, a lens, a cell descended into -- and the fit a525 // descent makes there is made against a window of no size, so it lands526 // at the deepest level the lens has whatever the reader was looking at.527 // While the pane was away that was nobody's business. It becomes the528 // reader's here, and it is the *only* moment it can be asked without529 // moving what the recorded globe steps pin: a sphere handing back530 // nothing at all (`leave` refusing a camera that is not a place) leaves531 // the chart standing on that camera, and this is what looks at it.532 chart.front();533 // Over the chart the camera is in the snapshot proper, so the locator534 // goes back to reading the map rather than being told.535 chart.locate(null);536 } else {537 const camera = chart.camera();538 chart.hidden = true;539 // Which pane is up is set *before* the sphere comes up, because coming540 // up is the sphere's first camera event and the corner locator refuses541 // to follow a globe that is not up yet. Told afterwards, the mark was542 // right on a first entry -- where the tiles arriving raised more events543 // -- and stale on every entry after it.544 this.globeUp = true;545 // Retiring a sphere forgets its camera callback along with everything546 // else, so a globe going up re-learns where its camera reports -- a547 // wire asserted only at connect time is a wire a retire has cut.548 globe.onCamera = (pov) => this.locate(pov);549 if (camera) globe.enter(camera, size);550 }551 toggle?.setAttribute("aria-pressed", String(this.globeUp));552 this.refresh({ recount: false });553 }554555 /**556 * Put the sphere down, because this world has none.557 *558 * THE PANE IS SEAM STATE AND THE WORLD IS NOT. Which pane is up survives a559 * swap on purpose — it is continuous interaction state, and a filter must560 * not drop the reader back to the chart — but a world change is exactly the561 * moment that stops being right: the new world may be a game map, and the562 * sphere standing over it belongs to the world before. Left alone, that is563 * what the reader saw: Night City opened while a planet went on turning,564 * still wearing Mars's skin, with only the toggle quietly hiding itself to565 * say the offer had been withdrawn.566 *567 * So the chart comes back up, the toggle is put down, and the sphere is568 * retired — the same teardown a disconnect does, plus the context, because569 * this element is not about that world any more. No camera is carried back:570 * a camera is a place on a particular world, and the sphere's was a place on571 * the one being left (`AtlasGlobe.retire`).572 *573 * Idempotent, and it has to be: it is asked on every scene change a plane574 * world sees, and only the first one has anything to put down.575 */576 private dropSphere(): void {577 if (this.globeUp) {578 this.globeUp = false;579 const chart = this.chart;580 if (chart) {581 chart.hidden = false;582 // The same fronting duty as `flipPane`, and owed more here: no camera583 // is handed back at all on this path, so whatever the chart was left584 // standing on behind the sphere is what the reader is about to be585 // looking through.586 chart.front();587 // Over the chart the camera is in the snapshot proper, so the locator588 // goes back to reading the map rather than being told.589 chart.locate(null);590 }591 document.querySelector<HTMLButtonElement>("#globe-toggle")592 ?.setAttribute("aria-pressed", "false");593 }594 this.globe?.retire();595 }596597 /**598 * The globe's camera, marked on the chart's own surface.599 *600 * A point, not an extent. On the sphere half the world is always out of601 * sight, so the honest thing for the corner to say is where the camera is602 * looking; the locator draws a fixed mark there, which is why every603 * recorded globe step carries a 22-pixel box that never changes size604 * however close the camera comes.605 */606 private locate(pov: { lat: number; lng: number; altitude: number }): void {607 const globe = this.globe;608 if (!globe || !this.globeUp) return;609 const camera = globe.cameraOf(pov);610 if (!camera) return;611 this.chart?.locate([camera.x, camera.y]);612 }613614 /**615 * The zoom controls.616 *617 * Zoom is continuous interaction state, so it is the seam's (issue #5618 * §4.1), and the two buttons are the application's chrome the way the globe619 * toggle is: it renders them because they belong beside the map, and this620 * lane wires them because pressing one moves nothing discrete. Which pane621 * is up decides what a press means -- a zoom level on the chart, a halving622 * of distance on the sphere.623 */624 private wireZoom(): void {625 for (const [id, delta] of [["#zoom-in", 1], ["#zoom-out", -1]] as const) {626 const button = document.querySelector<HTMLButtonElement>(id);627 if (!button || wired.has(button)) continue;628 wired.add(button);629 button.addEventListener("click", () => {630 (document.querySelector<AtlasViewport>("atlas-viewport") ?? this).zoomBy(delta);631 });632 }633 }634}635636/**637 * The controls this lane has already wired.638 *639 * WHY A SET AND NOT A MARK ON THE ELEMENT. It was a `data-` attribute, and640 * that is the one place a mark cannot be kept: a morph rewrites an element's641 * attributes from the server's markup, which has never heard of it, so the642 * mark was rubbed off every swap while the listener it recorded stayed643 * attached. The next rescan wired the same button again, and again, until one644 * press of the globe toggle flipped the panes four times and left them where645 * they started — reachable by hand, unreachable at the end of a tour, and the646 * parity of a count deciding which. Held here, the fact belongs to the lane647 * that owns it and no swap can reach it.648 *649 * Weak, so a control that leaves the page leaves this with it.650 */651const wired = new WeakSet<Element>();652653/**654 * One system's row for the open card, in the card's own shape.655 *656 * A bare `<div><dt>…</dt><dd>…</dd></div>`, which is what every other row in657 * that list is: the carried assets/css/pin-detail.css lays the definition list658 * out from the divs and asks nothing of them, so a row this lane inserts and a659 * row the server rendered are the same row to the stylesheet. The mark is how660 * the next pass finds it again, and it is the only thing that distinguishes661 * them.662 *663 * Text, not markup: a cell address is a value, and `textContent` is the one664 * way to put a value on a page without asking what is in it.665 */666function cellRow(slug: string, label: string, value: string): HTMLElement {667 const row = document.createElement("div");668 row.setAttribute("data-cell-system-row", slug);669 const term = document.createElement("dt");670 term.textContent = label;671 const said = document.createElement("dd");672 said.textContent = value;673 row.append(term, said);674 return row;675}676677function outsetOf(model: WorldModel): string {678 return model.attrs[KEY_ICON_OUTSET] ?? "";679}