1// `<atlas-chart>` — the plane, drawn.2//3// Twelve layers, in one z-order that is a set of arguments rather than a4// convention:5//6// 0 raster the complete pyramid7// 1 rasterDetail the patchy levels above it, riding on top8// 5 grid the chosen cell and its subdivision, UNDER the pins9// 6 zoneScrim the dimming, seen THROUGH, not painted over10// 10 zones paths and areas11// 20 zoneTitles their names, at the curated policy12// 40 pins the markers13// 42 zonePins the markers a highlight claimed, over the ground14// 44 zoneTitleDetail a name a highlight or a hold revealed, over the pins15// 45 pinLabels the names beside the markers, decluttered16// 48 gridContext the dimmed neighbours, OVER the pins they dim17// 50 priority selected, hovered, searched, in-cell — over everything18//19// The element owns no discrete state. Everything it draws it was told: the20// scene says what is hidden, highlighted, searched, selected and held, and21// the model says what exists. What it owns is the camera, the hover, and the22// bookkeeping of tiles — the continuous half of issue #5 §4.1 — and the23// camera it reports upward once per settle.2425import OLMap from "ol/Map.js";26import View from "ol/View.js";27import Feature from "ol/Feature.js";28import Point from "ol/geom/Point.js";29import Polygon from "ol/geom/Polygon.js";30import MultiPolygon from "ol/geom/MultiPolygon.js";31import MultiLineString from "ol/geom/MultiLineString.js";32import VectorLayer from "ol/layer/Vector.js";33import VectorSource from "ol/source/Vector.js";34import Style from "ol/style/Style.js";35import { defaults as defaultControls } from "ol/control/defaults.js";36import type { FeatureLike } from "ol/Feature.js";37import type Projection from "ol/proj/Projection.js";38import { gridCellVisual } from "@atlas/analysis";39import type { PlanCell } from "@atlas/analysis";4041import { logger } from "../log.ts";42import type { DataPlane } from "../data/plane.ts";43import { reportGridPick, reportPick } from "../data/report.ts";44import type { WorldContext } from "../context.ts";45import type { Lens, TileGrid } from "../data/payload.ts";46import type { PointRecord, ShapeRecord } from "../world/model.ts";47import { COORDINATE_SYSTEM, atlasProjection, lensExtent, viewMaxZoom } from "./projection.ts";48import { TileCounter, buildRaster } from "./raster.ts";49import type { Raster } from "./raster.ts";50import { Styles, labelFitsCell } from "./styles.ts";51import type { StyleContext } from "./styles.ts";52import { cellMoved, drawGrid } from "./grid.ts";53import type { DrawnCell } from "./grid.ts";54import { Overview } from "./overview.ts";5556const log = logger("chart");5758/**59 * How close a jump gets. A reader who reached for a feature by name wants to60 * see what is around it; a reader already deeper than this keeps their depth,61 * because they were reading something and the jump is a detour rather than a62 * reset.63 */64const FOCUS_ZOOM = 4;6566/**67 * The room a held cell is given inside the window. A cell fitted edge to edge68 * has its own boundary drawn off the screen, and the boundary is the whole69 * point of holding it.70 */71const GRID_FIT_PADDING = 52;7273/**74 * The room a piece of ground is given when a jump fits the camera to it. A75 * boundary drawn hard against the window edge reads as ground running off the76 * screen rather than as a shape with a size.77 */78const ZONE_FIT_PADDING = 54;7980/** A camera, in the one shape both panes and the session speak it in. */81export interface Camera {82 readonly x: number;83 readonly y: number;84 readonly zoom: number;85 readonly rotation: number;86}8788/**89 * A camera fit to be put on a view, or nothing.90 *91 * THE BOUNDARY GUARD, and the reference has exactly one of it: the sphere's92 * write-back checked that both coordinates were numbers before handing them93 * to the map, because — its own words — "a broken number here once blacked94 * out both panes with no way back" (frontend/src/globe.js). That is the95 * defect this whole function is a widening of, and the widening is the point:96 * a camera crosses between the two panes in both directions, is carried97 * across a lens rebuild, and arrives out of a session record written by an98 * older build, and every one of those is a place a number can be wrong.99 *100 * THREE WAYS TO BE WRONG, and only the first is about `NaN`.101 *102 * NOT A NUMBER. `NaN` in a centre is terminal and silent: OpenLayers keeps103 * it, every constraint passes it through, and no control a reader has can104 * move it — a pan adds to it, a zoom leaves it alone. The pane is blank105 * until the page is thrown away. So a camera with one of these in it is not106 * repaired, it is refused.107 *108 * DEEPER THAN THE LENS. A perfectly finite zoom past `viewMaxZoom` is a109 * camera the pyramid has no level for. It is reachable: a camera fitted110 * while its pane had no window lands at whatever the deepest level is, and111 * the lens it lands on may not be the lens it fronts under.112 *113 * OFF THE WORLD. A finite centre outside the lens's own square is ground114 * that was never drawn, so the raster serves nothing and the view spends115 * its time being pushed back by its own extent constraint.116 *117 * The last two are clamped rather than refused, because a reader who is one118 * level too deep asked to be nearly there. Clamping is exact — `Math.min` and119 * `Math.max` of a value already inside its range answer the value itself, bit120 * for bit — so a healthy camera comes back out of here unmoved, which is what121 * lets this stand on every boundary without moving a recorded step.122 */123export function saneCamera(124 camera: Camera | null | undefined, lens: Lens | null, grid: TileGrid,125): Camera | null {126 if (!camera) return null;127 const { x, y, zoom, rotation } = camera;128 if (!Number.isFinite(x) || !Number.isFinite(y) ||129 !Number.isFinite(zoom) || !Number.isFinite(rotation)) return null;130 const [minX = 0, minY = 0, maxX = 0, maxY = 0] = lensExtent(lens, grid);131 return {132 x: Math.min(Math.max(x, minX), maxX),133 y: Math.min(Math.max(y, minY), maxY),134 zoom: Math.min(Math.max(zoom, 0), viewMaxZoom(lens)),135 rotation,136 };137}138139/** Whether two cameras are the same one, to the last bit of every field. */140export function sameCamera(a: Camera | null, b: Camera | null): boolean {141 if (!a || !b) return a === b;142 return a.x === b.x && a.y === b.y && a.zoom === b.zoom && a.rotation === b.rotation;143}144145/**146 * The rectangle a shape occupies, or nothing when it carries no drawable147 * geometry. Outer rings and lines are enough: a hole is inside its own ring.148 *149 * Cached against the record, because it is asked once per shape every time the150 * footer recounts and a district can carry thousands of vertices. The records151 * are rebuilt with the world, so the cache empties with them.152 */153const shapeExtents = new WeakMap<ShapeRecord, [number, number, number, number] | null>();154155function shapeExtent(shape: ShapeRecord): [number, number, number, number] | null {156 const held = shapeExtents.get(shape);157 if (held !== undefined) return held;158 let minimumX = Infinity;159 let minimumY = Infinity;160 let maximumX = -Infinity;161 let maximumY = -Infinity;162 for (const line of shape.lines) {163 for (const [x, y] of line) {164 if (x < minimumX) minimumX = x;165 if (y < minimumY) minimumY = y;166 if (x > maximumX) maximumX = x;167 if (y > maximumY) maximumY = y;168 }169 }170 const extent: [number, number, number, number] | null =171 Number.isFinite(minimumX) ? [minimumX, minimumY, maximumX, maximumY] : null;172 shapeExtents.set(shape, extent);173 return extent;174}175176177/**178 * Where a camera standing over one layer of a split sheet stands over another.179 *180 * By its position inside each layer's own box rather than by its coordinates:181 * the layers picture the same ground drawn in different places on the sheet,182 * so an absolute centre carried across lands somewhere else entirely. Only a183 * crossing between two *different* shards that both declare a box has anywhere184 * to be carried to; every other lens swap is a different picture of the ground185 * the reader is already looking at and moves nothing.186 */187function carryAcrossShards(188 previous: Lens | null,189 next: Lens | null,190 standing: { x: number; y: number } | null,191): [number, number] | null {192 if (!previous?.shard || !next?.shard || previous.shard === next.shard) return null;193 const from = previous.bounds;194 const to = next.bounds;195 if (!from || !to || !standing) return null;196 const clamp = (value: number) => Math.min(Math.max(value, 0), 1);197 const across = (standing.x - from.x) / from.width;198 const down = (-standing.y - from.y) / from.height;199 return [to.x + clamp(across) * to.width, -(to.y + clamp(down) * to.height)];200}201202/**203 * What a pixel on the canvas resolved to.204 *205 * Two things can be under a pointer and they are not the same kind of answer,206 * which is why this is a discriminated union rather than an id and a label. A207 * feature has an identity the session stores and the card, the dock and the208 * legend all mark; a cell has an *address*, which no feature has and which209 * belongs to a different concern entirely. Flattening the two into one string210 * would leave every caller guessing which route it was holding.211 */212export interface CellPick {213 readonly kind: "cell";214 readonly cell: string;215}216217/** A feature under the pointer: a pin, a piece of ground, or a run of one. */218export interface FeaturePick {219 readonly kind: "point" | "area" | "path";220 readonly id: string;221}222223export type Pick = CellPick | FeaturePick;224225/** What the chart publishes about itself, in the diagnostics report's key names (docs/render-seam.md §8). */226export interface ChartDiagnostics {227 coordinateSystem: string;228 zoom: number | null;229 center: [number, number] | null;230 resolution: number | null;231 fitZoom: number | null;232 grid: { extent: number[] | null; cells: DrawnCell[] };233}234235/**236 * The chart pane.237 *238 * Custom elements are progressive by construction: until this is defined the239 * tag renders nothing and the page is whole without it, which is the240 * deletability principle costing the application one script tag.241 */242export class AtlasChart extends HTMLElement {243 private map: OLMap | null = null;244 private view: View | null = null;245 private projection: Projection | null = null;246 private raster: Raster | null = null;247 private readonly counter = new TileCounter();248 private readonly sources = {249 grid: new VectorSource({ wrapX: false }),250 gridContext: new VectorSource({ wrapX: false }),251 zoneScrim: new VectorSource({ wrapX: false }),252 zones: new VectorSource({ wrapX: false }),253 zoneTitles: new VectorSource({ wrapX: false }),254 pins: new VectorSource({ wrapX: false }),255 priority: new VectorSource({ wrapX: false }),256 };257 /**258 * The two layers a cell is ever drawn on, held so a click can ask them and259 * only them. The chosen path lies under the pins and the dimmed context over260 * them, which is a z-order argument (chart/grid.ts) and is exactly why the261 * hit test cannot be a question about depth.262 */263 private gridLayers: VectorLayer<VectorSource>[] = [];264 private styles: Styles | null = null;265 private context: WorldContext | null = null;266 /** The feature the card is open about, so a change in it can be acted on. */267 private selected = "";268 /** The view a jump borrowed, and the view the jump landed on. */269 private borrowed: { x: number; y: number; zoom: number; rotation: number } | null = null;270 private landed: { x: number; y: number; zoom: number; rotation: number } | null = null;271 private sizes: ResizeObserver | null = null;272 /** The cell the camera was last flown to hold. */273 private heldCell = "";274 /**275 * The system that cell was held in — the other half of the memo above.276 *277 * A held cell is a string only its own system can read: geohash's "m" and278 * S2's "24" name the same ground here and nothing about the two spellings279 * says so. So "the cell moved" is only a question worth asking *within* one280 * system, and the fit asks it that way.281 */282 private heldSystem = "";283 private plane: DataPlane | null = null;284 private overview: Overview | null = null;285 private fitZoom: number | null = null;286 private drawnCells: DrawnCell[] = [];287 private gridExtent: number[] | null = null;288 private lensKey = "";289 /** The lens the chart was last drawn with, so a swap can be told from what. */290 private shownLens: Lens | null = null;291 private worldKey = "";292 private settle: number | undefined;293 private onSettled: ((report: { x: number; y: number; zoom: number; rotation: number }) => void) | null = null;294295 /** The host wires these once; the element never reaches for them itself. */296 attach(plane: DataPlane, settled: (report: { x: number; y: number; zoom: number; rotation: number }) => void): void {297 this.plane = plane;298 this.onSettled = settled;299 }300301 /** Draw a world. Idempotent: the same context redraws nothing. */302 show(context: WorldContext): void {303 this.context = context;304 const worldKey = `${context.base}/${context.model.slug}`;305 const lensKey = `${worldKey}#${context.lens?.tiles ?? ""}@${context.lensIndex}`;306 if (worldKey !== this.worldKey) {307 this.build(context);308 this.watchSize();309 this.worldKey = worldKey;310 this.lensKey = "";311 }312 if (lensKey !== this.lensKey) {313 this.openLens(context, this.lensKey === "");314 this.lensKey = lensKey;315 }316 // Before the selection is followed, because a descend spends the view a317 // jump was holding and the card closing must not hand it back over the top318 // of the cell being flown to. The reference releases the hold and then319 // closes the card, in that order, for exactly this reason (grid.js,320 // selectGridCell); here the two are a swap apart -- the server closes the321 // card, the scene comes back with both facts in it -- so the order is kept322 // by reading the cell first.323 this.releaseOnDescend(context);324 this.follow(context);325 this.restyle();326 // The footer's own sentence. It is written here and on a camera event and327 // on a filter, and deliberately *not* from `restyle` -- holding Z restyles328 // and the reference never recounted for it, which on a pane put away329 // behind the sphere is the difference between the count it recorded and a330 // count of nothing.331 this.writeCount();332 // The first camera a volume ever has is the one the fit produced, and a333 // fit without an animation raises no `moveend` -- so without this the334 // opening view is the one camera the server is never told about, and the335 // reader's own first arrangement is the only one that reopens wrong.336 this.report();337 }338339 /**340 * Keep the map the size of its pane.341 *342 * OpenLayers measures once and has to be told when the container moves, and343 * on this page the container moves without the window doing anything: the344 * panel beside the map comes out the first time a search has something to345 * say, and the sidebar folds under a keystroke. A map that did not hear346 * about it would draw at the old size and, worse, go on believing the whole347 * world still fits its window -- which is the question the corner locator348 * puts itself away over.349 */350 private watchSize(): void {351 if (this.sizes || typeof ResizeObserver === "undefined") return;352 this.sizes = new ResizeObserver(() => {353 // A pane measured mid-transition can be zero across, and telling354 // OpenLayers its map is zero pixels wide throws its size away. A pane355 // that is *put away* is a different case and its zero is the truth:356 // behind the sphere the chart has no window, and a chart with no window357 // has nothing in view and fits nothing -- which is what every recorded358 // globe step says once anything asks it again.359 if (!this.hidden && (!this.clientWidth || !this.clientHeight)) return;360 this.map?.updateSize();361 this.overview?.draw();362 // A window that changed size is over a different amount of what is363 // drawn -- unless there is no window, in which case the sentence on364 // screen is still the last true thing this pane said and the reader is365 // reading the sphere anyway.366 if (!this.hidden) this.writeCount();367 });368 this.sizes.observe(this);369 }370371 /**372 * A cell the reader moved to retires the view a jump was holding.373 *374 * Descending is the reader saying where they want to be. Whatever panel jump375 * was holding a view for them is spent by that, and the loan has to be376 * cancelled *before* the selection is read: the same swap that carries the377 * new cell may carry a closed card, and a card closing gives the borrowed378 * view back — over the top of the cell about to be fitted.379 *380 * It reads `heldCell`, which `drawGrid` owns and updates; between here and381 * there nothing else looks at it, so the two halves of one scene see the same382 * answer to "did the cell move".383 */384 private releaseOnDescend(context: WorldContext): void {385 if (context.cell === this.heldCell) return;386 this.borrowed = null;387 this.landed = null;388 }389390 /**391 * The camera's side of a selection, and the way back from it.392 *393 * A feature reached for from a panel is worth moving the camera for: the394 * reader asked for something they could not see. A feature clicked on the395 * canvas is already in front of them, and the pick goes through the same396 * route, so the test is whether the camera can already see it.397 *398 * The way back is the other half. A jump borrows the view, and closing the399 * card gives it back — unless the reader steered while the card was open,400 * in which case the view they steered to is the one they chose and the loan401 * is off. "Steered" is read off the camera rather than off the events: if402 * the camera is not where the jump put it, a hand moved it.403 */404 private follow(context: WorldContext): void {405 const selected = context.scene.selected;406 if (selected === this.selected) return;407 this.selected = selected;408 const view = this.view;409 if (!view) return;410 if (!selected) {411 const back = this.borrowed;412 this.borrowed = null;413 const standing = this.camera();414 // Where the jump left the camera, to a pixel: sub-pixel drift out of an415 // easing animation is not a reader's hand.416 const steered = !this.landed || !standing ||417 Math.abs(standing.zoom - this.landed.zoom) > 1e-3 ||418 Math.abs(standing.x - this.landed.x) > 0.5 ||419 Math.abs(standing.y - this.landed.y) > 0.5;420 this.landed = null;421 if (back && !steered) this.goTo(back.x, back.y, back.zoom, back.rotation);422 return;423 }424 const feature = context.model.feature(selected);425 if (!feature) return;426 const standing = this.camera();427 const lens = context.lens;428 if (!standing || !lens) return;429 // Each jump holds afresh, overwriting whatever the last one left: closing430 // a card undoes that card's own move and nothing older. Reading three rows431 // in turn steps back through them one view at a time, and none of the432 // three can hand back a place from before the reader went looking.433 this.borrowed = standing;434 if ("coordinate" in feature) {435 // A point is one place: go to it, and go no further in than the reader436 // already is.437 const at = feature.coordinate;438 const zoom = Math.min(viewMaxZoom(lens), Math.max(standing.zoom, FOCUS_ZOOM));439 view.animate({ center: [at[0], at[1]], zoom, duration: 220 });440 this.landed = { x: at[0], y: at[1], zoom, rotation: standing.rotation };441 return;442 }443 // Ground is an area, not a point, so the camera is fitted to it rather444 // than flown at its middle: a district reached for from an index is shown445 // whole, with room around it. A shape carrying no drawable geometry has no446 // extent to fit and the camera stays where it is.447 const extent = shapeExtent(feature);448 if (!extent) return;449 const landed = this.fitTo(extent, ZONE_FIT_PADDING, viewMaxZoom(lens));450 if (!landed) return;451 this.landed = { ...landed, rotation: standing.rotation };452 }453454 /**455 * Fly the camera to an extent, and answer where it will land.456 *457 * The destination is wanted before the flight, because closing a card gives458 * back the view the jump borrowed only if the camera is still where the jump459 * put it — and an eased animation cannot be asked where it is going. So the460 * fit is made instantly, read, undone, and then flown: no frame is drawn in461 * between, and the answer is OpenLayers' own rather than a second462 * implementation of its arithmetic.463 */464 private fitTo(465 extent: readonly [number, number, number, number],466 padding: number,467 maxZoom: number,468 ): { x: number; y: number; zoom: number } | null {469 const view = this.view;470 if (!view) return null;471 const standing = this.camera();472 const resolution = view.getResolution();473 view.fit(extent as [number, number, number, number], {474 size: this.map?.getSize(),475 padding: [padding, padding, padding, padding],476 maxZoom,477 duration: 0,478 });479 const target = view.getCenter();480 const targetResolution = view.getResolution();481 const targetZoom = view.getZoom();482 if (!target || targetResolution === undefined || targetZoom === undefined) return null;483 if (standing && resolution !== undefined) {484 view.setCenter([standing.x, standing.y]);485 view.setResolution(resolution);486 }487 view.animate({ center: target, resolution: targetResolution, duration: 220 });488 return { x: target[0] ?? 0, y: target[1] ?? 0, zoom: targetZoom };489 }490491 /** Restyle in place: a filter moved, or a selection, or the held key. */492 restyle(): void {493 const context = this.context;494 if (!context || !this.styles) return;495 this.styles.context.visibility = context.visibility;496 this.styles.context.scene = context.scene;497 this.styles.context.labelsHeld = context.labelsHeld;498 this.styles.context.hovered = context.hovered;499 this.styles.context.outset = context.outset;500 this.fillPriority(context);501 this.fillScrim(context);502 this.drawGrid(context);503 for (const source of Object.values(this.sources)) source.changed();504 this.map?.render();505 this.overview?.draw();506 }507508 /**509 * The footer's sentence.510 *511 * "N of M features in view": M is what the map is drawing and the server512 * renders it, N is how many of them the window is actually over and nobody513 * but this pane can say -- it is a question about the camera. The514 * application leaves the element carrying its own half and this completes515 * it once there is a camera to ask (docs/app.md §6.1).516 *517 * It is one set counted twice, said as one sentence, and the number after518 * "of" is the same number the dock puts at the top of its list.519 */520 writeCount(): void {521 const footer = document.querySelector("#visible-count");522 const context = this.context;523 if (!footer || !context) return;524 const drawn = context.visibility.drawn;525 if (drawn === 0) {526 footer.textContent = "No features shown";527 return;528 }529 footer.textContent = `${this.inView(context)} of ${count(drawn)} features` +530 ` in view`;531 }532533 /** How many of the standing features the window is over. */534 private inView(context: WorldContext): string {535 // The window, from whatever size the map has -- and a pane put away behind536 // the sphere has none at all. That is not a missing measurement to fall537 // back from: a chart nobody is looking through has nothing in view, which538 // is what every recorded globe step that recounts at all says.539 const extent = this.view?.calculateExtent(this.hidden ? [0, 0] : this.map?.getSize());540 if (!extent) return count(context.visibility.drawn);541 const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent;542 const inside = (x: number, y: number) =>543 x >= minX && x <= maxX && y >= minY && y <= maxY;544 let seen = 0;545 context.model.points.forEach((point, index) => {546 if (context.visibility.at(index).hidden) return;547 if (inside(point.coordinate[0], point.coordinate[1])) seen += 1;548 });549 // Ground is counted by whether the window is *over* it, not by whether a550 // corner of it happens to fall inside: a district big enough to fill the551 // screen has every one of its vertices off it, and a reader looking at552 // nothing but that district is looking at one feature rather than none.553 for (const shape of context.visibility.shapesShown) {554 const extent = shapeExtent(shape);555 if (!extent) continue;556 if (extent[0] <= maxX && extent[2] >= minX && extent[1] <= maxY && extent[3] >= minY) {557 seen += 1;558 }559 }560 return count(seen);561 }562563 /** The camera, as the diagnostics and the globe both read it. */564 camera(): { x: number; y: number; zoom: number; rotation: number } | null {565 const view = this.view;566 const centre = view?.getCenter();567 const zoom = view?.getZoom();568 if (!view || !centre || zoom === undefined) return null;569 return { x: centre[0] ?? 0, y: centre[1] ?? 0, zoom, rotation: view.getRotation() };570 }571572 /**573 * One step of a zoom control, held to the lens's own depth.574 *575 * A step, not a factor: the chart's zoom is a level and the buttons move it576 * by one, easing rather than cutting so a reader can see which way the577 * ground went. The floor and the ceiling are the lens's -- there is nothing578 * shallower than its first level and nothing deeper than two levels of579 * overzoom past its last.580 */581 nudgeZoom(delta: number): void {582 const view = this.view;583 const lens = this.context?.lens;584 if (!view || !lens) return;585 const standing = view.getZoom() ?? 0;586 // A step is taken from where the camera stands, and a camera standing587 // nowhere has no step to take from: `??` catches a view with no588 // resolution and not a view whose resolution is not a number, and adding589 // one to `NaN` is how a pane that went blank once stays blank through590 // every press afterwards. Putting the camera back is the more useful591 // answer to the press than ignoring it.592 if (!Number.isFinite(standing)) {593 this.front();594 return;595 }596 view.animate({597 zoom: Math.min(Math.max(standing + delta, lens.minZoom), viewMaxZoom(lens)),598 duration: 140,599 });600 }601602 /**603 * Put the camera somewhere — the globe handing a view back, a jump, a lens604 * rebuild carrying one across, or the session handing back where the reader605 * left off.606 *607 * THE ONE DOOR, which is why the guard is here and not at each of those608 * four. Every camera this pane is ever *told* comes through this call, so a609 * camera that cannot be put on a view is caught once, wherever it came610 * from, and answered with the world's own opening view rather than with a611 * blank pane (`saneCamera`).612 */613 goTo(x: number, y: number, zoom: number, rotation = 0): void {614 const context = this.context;615 if (!this.view || !context) return;616 const sane = saneCamera({ x, y, zoom, rotation }, context.lens, context.grid);617 if (!sane) {618 log.warn("a camera that is not a place was refused", {619 op: "render", world: context.model.slug, x: String(x), y: String(y),620 zoom: String(zoom), rotation: String(rotation),621 });622 this.fitWorld();623 return;624 }625 this.view.setCenter([sane.x, sane.y]);626 this.view.setZoom(sane.zoom);627 this.view.setRotation(sane.rotation);628 }629630 /**631 * Come back up, and stand somewhere real while doing it.632 *633 * A CAMERA THAT WAS NEVER LOOKED THROUGH. Behind the sphere this pane keeps634 * moving — a filter, a lens, above all a cell descent, whose fit is made635 * against a window of no size and therefore lands at the deepest level the636 * lens has (`drawGrid`, and every recorded globe step is a reading of it).637 * That is right while nobody is looking and it is the standing view the638 * moment somebody is, so the camera is asked whether it is a place at639 * exactly the moment it becomes one: fronting, rather than while hidden.640 * Checking it earlier would move what the baselines pin; checking it here641 * moves nothing that was already sane.642 *643 * Which is the other half: a healthy camera is left *untouched* rather than644 * rewritten with its own numbers. A `setCenter` here would raise a move on645 * every pane swap, and a move is a report, a recount and a redrawn corner.646 */647 front(): void {648 const context = this.context;649 if (!this.view || !context) return;650 const standing = this.camera();651 const sane = saneCamera(standing, context.lens, context.grid);652 if (sane && sameCamera(sane, standing)) return;653 if (!sane) {654 log.warn("the pane came up over a camera that is not a place", {655 op: "render", world: context.model.slug, camera: JSON.stringify(standing),656 });657 this.fitWorld();658 return;659 }660 this.goTo(sane.x, sane.y, sane.zoom, sane.rotation);661 }662663 /**664 * The world's own opening view: everything the lens drew, in the window665 * there is.666 *667 * It is the answer to a camera that could not be repaired, and it is the668 * same fit a volume opens on when the session has no camera to hand back —669 * one arithmetic, so a reader recovering from a broken camera lands exactly670 * where a reader arriving for the first time does.671 *672 * THE SIZE IS THE PANE'S WHEN THE MAP HAS NONE. A map behind the sphere673 * reports `[0, 0]`, and a fit into no window is the arithmetic that made674 * the camera suspect in the first place; falling back to what the element675 * measures keeps the recovery a recovery.676 */677 private fitWorld(): void {678 const context = this.context;679 if (!this.view || !context) return;680 const size = this.map?.getSize();681 const window: [number, number] = size && size[0] && size[1]682 ? [size[0], size[1]]683 : [this.clientWidth || 1, this.clientHeight || 1];684 this.view.fit(lensExtent(context.lens, context.grid) as [number, number, number, number],685 { size: window, nearest: false });686 }687688 /**689 * Redraw the corner locator.690 *691 * The shelf's `hidden` is this lane's answer written onto the application's692 * markup, so a swap that re-rendered the corner has just put a shelf back on693 * screen that the camera says has nothing to say. It is the same duty694 * `writeCount` does for the footer, and it is owed at the same moment.695 */696 redrawOverview(): void {697 this.overview?.draw();698 }699700 /** Tiles since the lens was chosen. */701 get tileStats() {702 return this.counter.stats;703 }704705 /** What the chart says about itself. */706 diagnostics(): ChartDiagnostics {707 const camera = this.camera();708 return {709 coordinateSystem: COORDINATE_SYSTEM,710 zoom: camera?.zoom ?? null,711 center: camera ? [camera.x, camera.y] : null,712 resolution: this.view?.getResolution() ?? null,713 fitZoom: this.fitZoom,714 grid: { extent: this.gridExtent, cells: this.drawnCells },715 };716 }717718 /**719 * Put the locator's rectangle where another pane's camera is.720 *721 * The sphere has no OpenLayers view to ask, and the corner locator is the722 * one place its camera is ever written down — in whole pixels, on a surface723 * the chart shares. So the globe hands its extent here rather than moving724 * the chart underneath it, which would fetch tiles for a pane nobody is725 * looking at.726 */727 locate(extent: readonly number[] | null): void {728 if (!extent) this.overview?.release();729 this.overview?.draw(extent ?? undefined);730 }731732 /** Force a synchronous frame, so a driver can read a settled camera. */733 renderSync(): void {734 // A map whose pane has no size has no frame to render, and asking for one735 // walks a null frame state. It happens for a moment whenever the chrome736 // moves -- a sidebar folding, a panel coming out -- and the harness's737 // settle loop forces a frame on every poll, so the moment is reachable.738 if (!this.map?.getSize()) return;739 this.map.renderSync();740 }741742 /**743 * Come back to the page.744 *745 * A morph can take an element out and put the same one back, and everything746 * the disconnect gave up has to go back on when it does — the observer, and747 * the map's own target, which `setTarget(undefined)` took away.748 *749 * Only a *re*-connect has anything to rewire. On the first one there is no750 * map yet: the observer is wired with the world, from `show`, and wiring it751 * here as well would start measuring a pane before there is anything in it752 * to measure.753 */754 connectedCallback(): void {755 if (!this.map) return;756 this.map.setTarget(this);757 this.watchSize();758 }759760 /**761 * Leave the page.762 *763 * The map lets go of its target, which is what stops OpenLayers drawing into764 * an element nobody can see. The observer has to be told separately: a765 * `ResizeObserver` is held by the browser, not by this element, so one left766 * running outlives the pane it was watching and keeps its callback — and the767 * map, the overview and the world context that callback closes over — alive768 * for as long as the page is open. The settle timer is the same fact in a769 * smaller form: a camera reported four hundred milliseconds after the pane770 * went away is a report about a world the reader has left.771 */772 disconnectedCallback(): void {773 this.sizes?.disconnect();774 this.sizes = null;775 if (this.settle !== undefined) clearTimeout(this.settle);776 this.settle = undefined;777 this.map?.setTarget(undefined);778 }779780 // ---- building -------------------------------------------------------781782 private build(context: WorldContext): void {783 this.map?.setTarget(undefined);784 this.projection = atlasProjection(context.grid);785 const styleContext: StyleContext = {786 visibility: context.visibility,787 scene: context.scene,788 labelsHeld: context.labelsHeld,789 hovered: context.hovered,790 outset: context.outset,791 iconURL: (asset) => this.plane?.iconURL(context.base, asset) ?? "",792 // A marker raster finishes composing after the first frame, and a style793 // function is evaluated once per layer revision rather than once per794 // frame: the pin layers have to be asked again or the symbol that just795 // arrived is not drawn until something else moves.796 repaint: () => this.restyle(),797 };798 this.styles = new Styles(styleContext);799 this.styles.learn(context.model.collections);800801 this.view = this.viewFor(context);802 this.map = new OLMap({803 target: this,804 view: this.view,805 controls: defaultControls({ attribution: false, rotate: false, zoom: false }),806 layers: this.layers(),807 });808 this.fillFeatures(context);809 this.wire();810 this.overview = new Overview(this.map, () => this.context);811 log.info("a world is on the chart", {812 op: "render", volume: context.scene.volume, world: context.model.slug,813 points: context.model.points.length, shapes: context.model.shapes.length,814 });815 }816817 private layers(): VectorLayer<VectorSource>[] {818 const eager = (819 source: VectorSource,820 zIndex: number,821 style: (feature: FeatureLike, resolution: number) => Style[] | Style | undefined,822 options: { declutter?: boolean; renderBuffer?: number } = {},823 ) => new VectorLayer({824 source,825 zIndex,826 style,827 updateWhileAnimating: true,828 updateWhileInteracting: true,829 renderBuffer: options.renderBuffer ?? 64,830 ...(options.declutter ? { declutter: true } : {}),831 });832 const grid = eager(this.sources.grid, 5, (f, r) => this.gridStyle(f, r));833 const gridContext = eager(this.sources.gridContext, 48, (f, r) => this.gridStyle(f, r));834 this.gridLayers = [grid, gridContext];835 return [836 grid,837 eager(this.sources.zoneScrim, 6, () => this.styles?.scrim()),838 eager(this.sources.zones, 10, (f, r) => this.shapeStyle(f, r)),839 eager(this.sources.zoneTitles, 20, (f) => this.titleStyle(f, false)),840 eager(this.sources.pins, 40, (f) => this.pinStyle(f, false)),841 eager(this.sources.pins, 42, (f) => this.pinStyle(f, true)),842 eager(this.sources.zoneTitles, 44, (f) => this.titleStyle(f, true)),843 eager(this.sources.pins, 45, (f) => this.labelStyle(f), { declutter: true, renderBuffer: 180 }),844 gridContext,845 eager(this.sources.priority, 50, (f) => this.priorityStyle(f), { renderBuffer: 220 }),846 ];847 }848849 /**850 * The camera, built for one lens.851 *852 * Three of these options are not decoration and were the source of a long853 * hunt. **`resolutions`** is the ladder the pyramid actually has -- the854 * world square over the tile size, halved per level -- and a view given the855 * ladder converts between a zoom and a resolution by walking it, where a856 * view left to derive one from a maximum and a factor arrives at answers857 * that differ in their last bit. The baselines compare a camera exactly, so858 * the last bit is the difference between agreeing and not. **`extent`** and859 * **`showFullExtent`** hold the camera over the ground the lens drew: widen860 * the window -- fold the sidebar away -- and the view slides back inside861 * its own picture rather than panning off the edge of it, which is what862 * `sidebar-collapsed` records. And **`smoothResolutionConstraint: false`**863 * is what makes the two ends of the ladder ends rather than springs.864 *865 * It is rebuilt per lens because none of the three can be changed866 * afterwards, and a lens is a different picture of the ground.867 */868 private viewFor(context: WorldContext): View {869 const extent = lensExtent(context.lens, context.grid);870 const maxZoom = viewMaxZoom(context.lens);871 const base = context.grid.size / context.grid.tileSize;872 return new View({873 projection: this.projection ?? undefined,874 center: [875 ((extent[0] ?? 0) + (extent[2] ?? 0)) / 2,876 ((extent[1] ?? 0) + (extent[3] ?? 0)) / 2,877 ],878 resolutions: Array.from({ length: maxZoom + 1 }, (_, zoom) => base / 2 ** zoom),879 minZoom: 0,880 maxZoom,881 extent: extent as [number, number, number, number],882 constrainResolution: false,883 smoothResolutionConstraint: false,884 showFullExtent: true,885 zoom: 0,886 enableRotation: false,887 });888 }889890 private openLens(context: WorldContext, fresh: boolean): void {891 if (!this.map || !this.view || !this.projection || !this.plane || !context.lens) return;892 if (this.raster) {893 this.map.removeLayer(this.raster.base);894 this.map.removeLayer(this.raster.detail);895 }896 this.counter.reset();897 this.raster = buildRaster(898 this.plane, context.base, context.lens, context.grid, this.projection, this.counter);899 this.map.addLayer(this.raster.base);900 this.map.addLayer(this.raster.detail);901 // A lens is a different picture of the ground, and three of the camera's902 // options cannot be changed after it is built, so it is rebuilt. The903 // camera itself is carried across by hand below.904 const standing = this.camera();905 const carried = fresh ? null : carryAcrossShards(this.shownLens, context.lens, standing);906 this.shownLens = context.lens;907 this.view = this.viewFor(context);908 this.map.setView(this.view);909 if (!fresh && standing) {910 this.goTo(standing.x, standing.y, standing.zoom, standing.rotation);911 // The layers of a split map are the same ground at different heights,912 // stacked down one sheet. Stepping between them leaves the reader over913 // the same place rather than at the same coordinates, which on a sheet914 // where each layer has its own box is a different point.915 if (carried) this.view.animate({ center: carried, duration: 200 });916 }917918 // Shard crossing: swapping to a lens that draws another layer of the same919 // split world keeps the camera exactly where it was. The reader stepped920 // between floors of one building, not into another world, and a refit921 // would throw away the place they had found.922 if (fresh) {923 // The session's camera, and it is a record rather than a reading: it was924 // written by whatever build the reader last opened this volume in, over925 // whatever lens they were on. `goTo` is what makes it safe to apply --926 // a zoom the lens they are opening under cannot reach is clamped to one927 // it can, and a record that is not a place opens the world at the fit,928 // which is where a volume with no record opens anyway.929 const camera = context.scene.camera;930 if (camera) this.goTo(camera.x, camera.y, camera.zoom, camera.rotation);931 else this.fitWorld();932 // `fitZoom` is what "the whole map fits" is measured against, and it is933 // decided once, when the world opens: the zoom the reader arrived at,934 // whether that came from a fit or from the camera they left behind.935 // Recomputing it later is what left the corner locator arguing with a936 // camera that had moved on.937 this.fitZoom = this.view.getZoom() ?? null;938 }939 this.overview?.forget();940 }941942 // ---- features -------------------------------------------------------943944 private fillFeatures(context: WorldContext): void {945 this.sources.pins.clear();946 this.sources.zones.clear();947 this.sources.zoneTitles.clear();948 for (const point of context.model.points) {949 this.sources.pins.addFeature(new Feature({950 geometry: new Point(point.coordinate),951 record: point,952 priority: point.priority,953 }));954 }955 for (const shape of context.model.shapes) {956 this.sources.zones.addFeature(new Feature({957 geometry: shapeGeometry(shape), record: shape,958 }));959 const centre = shape.center ?? centreOf(shape);960 if (centre) {961 this.sources.zoneTitles.addFeature(new Feature({962 geometry: new Point(centre), record: shape,963 }));964 }965 }966 }967968 private fillPriority(context: WorldContext): void {969 this.sources.priority.clear();970 context.model.points.forEach((point, index) => {971 if (!context.visibility.at(index).promoted) return;972 this.sources.priority.addFeature(new Feature({973 geometry: new Point(point.coordinate), record: point, priority: point.priority,974 }));975 });976 }977978 /**979 * The scrim: one polygon over the whole world with the highlighted shapes980 * cut out of it, even-odd.981 *982 * Rings alternate direction with their depth so the fill counts its way in983 * and out: the world, then each piece of the shape as a hole, then any hole984 * of that piece's own back to solid. A ring's winding in a payload is985 * nobody's promise, so it is normalised here — and the depth that decides986 * it is the ring's place *within its own part*, which is why the parts987 * cannot be flattened first. A path zone has no ring to cut and is skipped:988 * the highlighted line draws above the dimming at its own weight.989 */990 private fillScrim(context: WorldContext): void {991 this.sources.zoneScrim.clear();992 const highlighted = context.visibility.highlightedShapes993 .filter((shape) => shape.kind === "area");994 if (!highlighted.length) return;995 const size = context.grid.size;996 const outer: [number, number][] = [[0, 0], [size, 0], [size, -size], [0, -size], [0, 0]];997 this.sources.zoneScrim.addFeature(new Feature({998 geometry: scrimGeometry(outer, highlighted),999 }));1000 }10011002 private drawGrid(context: WorldContext): void {1003 this.sources.grid.clear();1004 this.sources.gridContext.clear();1005 this.drawnCells = [];1006 this.gridExtent = null;1007 if (!context.system || !context.scene.gridSystem) return;1008 const standing = [...context.visibility.withoutCell()];1009 const drawn = drawGrid(1010 context.ground, context.system, context.cell,1011 standing, this.sources.grid, this.sources.gridContext);1012 // The plan's own order is the contract; what is read back for the1013 // diagnostics is the sources' order, chosen path first and context after,1014 // which is the order the baselines record.1015 this.drawnCells = [1016 ...this.sources.grid.getFeatures(),1017 ...this.sources.gridContext.getFeatures(),1018 ].map((feature) => feature.get("gridCell") as DrawnCell);1019 this.gridExtent = drawn.extent ? [...drawn.extent] : null;1020 // Descending holds the cell: the camera is flown to the ground the1021 // address names, the same way the navigator's own field does it. Only a1022 // change of cell moves it -- redrawing the same grid because a filter1023 // moved must not drag the reader back.1024 //1025 // THIS IS THE ONE FIT, and every way into a cell arrives at it: the1026 // navigator's field, the back button, Escape, a click on the canvas and a1027 // press on the sphere. The reference fitted synchronously inside1028 // `selectGridCell` because the cell moved synchronously; here a cell moves1029 // by a round trip, so the fit belongs to the arrival rather than to the1030 // ask. A second fit fired off at the click would be the same arithmetic1031 // said twice, racing its own confirmation and answering for a cell the1032 // server had not yet agreed to -- so there is exactly one of it, here.1033 //1034 // AND A CELL MOVES WITHIN A SYSTEM. Cycling the system carries the held1035 // cell across to whichever cell of the next system covers the same ground1036 // (`equivalentCell`): "m" becomes "24", the string moves and the reader1037 // does not. Read against the cell alone that is a descent, and the camera1038 // flies to a place it is already standing over — the one thing a reader1039 // pressing a cycle key is not asking for. Both memos are written whatever1040 // happened, so the descent after a cycle is still read against the system1041 // it was made in.1042 const system = context.scene.gridSystem;1043 if (cellMoved({ system: this.heldSystem, cell: this.heldCell }, { system, cell: context.cell })) {1044 // Ascending is a move too: the reader asked for the ground one level1045 // out, and the camera goes there the same way it came in.1046 //1047 // The size is whatever the map has, and a map put away behind the sphere1048 // has none -- which OpenLayers reads as its own hundred-pixel default:1049 // a cell fitted into no window at all lands at the deepest zoom the1050 // lens allows, over the middle of the ground. That is still an answer;1051 // refusing to fit at all would leave the camera standing over ground1052 // the reader has already asked to leave.1053 if (this.gridExtent && this.view && context.lens) {1054 // THE LATEST ASK WINS, and saying so costs a line because OpenLayers1055 // says the opposite. Concurrent animation series are applied oldest1056 // *last* every frame (View.updateAnimations_ walks them backwards), so1057 // two fits begun inside one animation's 180 ms resolve to the earlier1058 // one's target and the later ask is overwritten frame by frame until1059 // both finish. Two Escapes in quick succession are exactly that: the1060 // camera ascended to the parent and stopped there, holding ground the1061 // reader had already left. Cancelling first makes the fit mean what a1062 // reader means by it -- go where I have just asked for -- whatever is1063 // still in the air.1064 this.view.cancelAnimations();1065 this.view.fit(this.gridExtent as [number, number, number, number], {1066 size: this.map?.getSize(),1067 padding: [GRID_FIT_PADDING, GRID_FIT_PADDING, GRID_FIT_PADDING, GRID_FIT_PADDING],1068 maxZoom: viewMaxZoom(context.lens), nearest: false, duration: 180,1069 });1070 }1071 }1072 this.heldCell = context.cell;1073 this.heldSystem = system;1074 }10751076 // ---- styles ---------------------------------------------------------10771078 private standingOf(feature: FeatureLike): { record: PointRecord; promoted: boolean } | null {1079 const record = feature.get("record") as PointRecord | undefined;1080 const context = this.context;1081 if (!record || !context) return null;1082 const standing = context.visibility.at(record.index);1083 if (standing.hidden) return null;1084 return { record, promoted: standing.promoted };1085 }10861087 private pinStyle(feature: FeatureLike, zoneLayer: boolean): Style[] | undefined {1088 const found = this.standingOf(feature);1089 if (!found || !this.styles || !this.context) return undefined;1090 const claimed = this.context.visibility.at(found.record.index).passesHighlights;1091 // The same markers twice: once under the ground, once over it for the1092 // ones a highlight claimed, so a claimed pin is never lost under the1093 // shape that claimed it.1094 if (zoneLayer !== claimed) return undefined;1095 if (found.promoted) return undefined;1096 if (this.context.scene.hidden.has(String(found.record.collection.id))) return undefined;1097 return this.styles.pin(found.record, false);1098 }10991100 private labelStyle(feature: FeatureLike): Style | undefined {1101 const found = this.standingOf(feature);1102 if (!found || !this.styles || found.promoted) return undefined;1103 return this.styles.pinLabel(found.record, false) ?? undefined;1104 }11051106 private priorityStyle(feature: FeatureLike): Style[] | undefined {1107 const found = this.standingOf(feature);1108 if (!found || !this.styles) return undefined;1109 const marks = this.styles.pin(found.record, true);1110 const label = this.styles.pinLabel(found.record, true);1111 return label ? [...marks, label] : marks;1112 }11131114 private shapeStyle(feature: FeatureLike, resolution: number): Style[] | undefined {1115 const record = feature.get("record") as ShapeRecord | undefined;1116 const context = this.context;1117 if (!record || !context || !this.styles) return undefined;1118 if (!context.visibility.shapesShown.includes(record)) return undefined;1119 const highlighted = context.scene.highlighted.has(record.id) ||1120 context.scene.selected === record.id;1121 return record.kind === "path"1122 ? this.styles.path(record, resolution, highlighted)1123 : [this.styles.area(record, highlighted)];1124 }11251126 private titleStyle(feature: FeatureLike, detail: boolean): Style | undefined {1127 const record = feature.get("record") as ShapeRecord | undefined;1128 const context = this.context;1129 if (!record || !context || !this.styles) return undefined;1130 if (!context.visibility.shapesShown.includes(record)) return undefined;1131 const promoted = context.scene.highlighted.has(record.id) ||1132 context.scene.selected === record.id;1133 if (promoted !== detail) return undefined;1134 return this.styles.areaTitle(record, promoted) ?? undefined;1135 }11361137 /**1138 * One cell, styled against the resolution it is actually being drawn at.1139 *1140 * THE RESOLUTION IS THE ARGUMENT, and it has to be: whether a cell can carry1141 * its address is a fact about the camera, and the camera moves between a1142 * grid being built and the reader looking at it. A descent builds its grid1143 * from the zoom it is leaving and then flies in — so a subdivision judged1144 * once, at build time, was judged at the wrong depth, and the deeper the1145 * held cell the worse it got. Held two levels down with the subdivision on,1146 * every child came out a third of the size the parent's zoom required, every1147 * one of them answered "no label", and a child with no label draws **nothing1148 * at all** (analysis/cellsystems/visual.ts) — a subgrid that was on, planned,1149 * counted, and invisible.1150 */1151 private gridStyle(feature: FeatureLike, resolution: number): Style[] | undefined {1152 const plan = feature.get("gridPlan") as PlanCell | undefined;1153 const context = this.context;1154 if (!plan || !context?.system || !this.styles) return undefined;1155 const visual = gridCellVisual(context.ground, context.system, plan, {1156 subgridVisible: context.subgridVisible,1157 labelled: labelFitsCell(plan.hash, plan.role, plan.extent, resolution),1158 });1159 if (!visual) return undefined;1160 // The chosen path draws over its own context wherever the two meet; every1161 // other cell carries the plan's own order, which the feature was built1162 // with (chart/grid.ts).1163 const chosen = plan.role === "leaf" || plan.role === "scope";1164 const zIndex = chosen ? 100 : Number(feature.get("priority") ?? 0);1165 return this.styles.grid(visual, zIndex, plan.role === "neighbor");1166 }11671168 // ---- interaction ----------------------------------------------------11691170 private wire(): void {1171 const map = this.map;1172 if (!map) return;1173 map.on("pointermove", (event) => {1174 if (event.dragging || !this.context) return;1175 // The hover is about features and never about cells: what it draws is a1176 // marker lifting under the pointer, and a cell has no such reading.1177 const found = this.hitFeature(event.pixel);1178 const id = found?.id ?? null;1179 if (id === this.context.hovered) return;1180 this.context.hovered = id;1181 this.restyle();1182 this.setAttribute("data-hovered", id ?? "");1183 });1184 map.on("singleclick", (event) => {1185 const found = this.hit(event.pixel);1186 // A click on nothing is not a pick. The hover above is untouched by it:1187 // leaving a feature still clears the cursor, because that is continuous1188 // state this pane owns, and a selection is not.1189 if (!found) return;1190 // The seam resolves the hit; what it resolved to is the application's to1191 // act on, and it acts on it through the forms the page renders for1192 // exactly this (data/report.ts, issue #5 §4.4). Two answers, two forms,1193 // two concerns -- a selection and a place.1194 if (found.kind === "cell") {1195 reportGridPick(found.cell);1196 return;1197 }1198 reportPick({ feature: found.id, kind: found.kind });1199 });1200 map.on("moveend", () => {1201 this.report();1202 // The corner locator answers two questions about the camera -- where it1203 // is, and whether it can see the whole map at once -- and both of them1204 // are this event's. Redrawing it only when a filter moved left the1205 // shelf put away across a whole zoom: the map no longer fitted, and1206 // nothing had told the corner so.1207 this.overview?.draw();1208 // The window moved, so how much of what is drawn it is over has moved1209 // with it. The count is the camera's answer and belongs to the camera's1210 // own event, not to a filter's -- and a pane with no window has no1211 // answer to give.1212 if (!this.hidden) this.writeCount();1213 });1214 }12151216 /**1217 * What is under a pixel: a cell first, then a feature.1218 *1219 * THE ORDER IS THE WHOLE OF IT. While a grid is up the reader is1220 * telescoping, and a click is a request to go one level in -- so a cell1221 * answers before the pins standing on it are consulted at all, and a cell1222 * that answered returns without asking. Reversed, the grid would be1223 * undescendable anywhere a pin happened to be, which is everywhere worth1224 * descending to.1225 */1226 private hit(pixel: number[]): Pick | null {1227 return this.hitCell(pixel) ?? this.hitFeature(pixel);1228 }12291230 /**1231 * The cell under a pixel, or nothing.1232 *1233 * TWO RULES, AND BOTH ARE THE REFERENCE'S OWN.1234 *1235 * ONLY THE GRID'S OWN LAYERS ARE ASKED, which is what a hit test over a1236 * dozen layers cannot say for itself: everything else on the map is a1237 * feature and answers the second question, not this one.1238 *1239 * AND ONLY A NEIGHBOUR OR A CHILD IS AN ANSWER. The cell the reader is1240 * already inside is drawn over its own children -- as an outline while the1241 * subdivision is up, and as the whole of the grid when the telescope has1242 * bottomed out -- so a plan that answered for `scope` or `leaf` would hand1243 * back the address already held on every click, and the grid could not be1244 * descended at all. A neighbour is a step sideways and a child is a step in;1245 * those are the two moves a click on a grid means.1246 *1247 * The tolerance is one pixel rather than the four a marker gets. A cell is a1248 * region and the reader is pointing *into* it; slack around a boundary would1249 * only make the boundary ambiguous, and a boundary that cannot be told apart1250 * from the cell beyond it is a grid nobody can steer by.1251 */1252 private hitCell(pixel: number[]): CellPick | null {1253 const map = this.map;1254 const context = this.context;1255 if (!map || !context?.system || !context.scene.gridSystem) return null;1256 let found: CellPick | null = null;1257 map.forEachFeatureAtPixel(pixel, (feature) => {1258 const cell = feature.get("gridCell") as DrawnCell | undefined;1259 if (!cell || (cell.role !== "neighbor" && cell.role !== "child")) return false;1260 found = { kind: "cell", cell: cell.hash };1261 return true;1262 }, {1263 hitTolerance: 1,1264 layerFilter: (layer) => this.gridLayers.some((one) => one === layer),1265 });1266 return found;1267 }12681269 private hitFeature(pixel: number[]): FeaturePick | null {1270 const map = this.map;1271 const context = this.context;1272 if (!map || !context) return null;1273 let found: FeaturePick | null = null;1274 map.forEachFeatureAtPixel(pixel, (feature) => {1275 const record = feature.get("record") as PointRecord | ShapeRecord | undefined;1276 if (!record) return false;1277 if ("index" in record) {1278 if (context.visibility.at(record.index).hidden) return false;1279 found = { id: record.id, kind: "point" };1280 return true;1281 }1282 if (!context.visibility.shapesShown.includes(record)) return false;1283 found = { id: record.id, kind: record.kind };1284 return true;1285 }, { hitTolerance: 4 });1286 return found;1287 }12881289 /**1290 * The camera's one whisper upward.1291 *1292 * Debounced, because a settling camera that posted every frame would fight1293 * the reader's own hand, and answered with `204` so nothing swaps under1294 * them (docs/app.md §4.3).1295 */1296 private report(): void {1297 // A pane put away reports nothing. Where the reader left off is where they1298 // were looking, and behind the sphere the chart is not it: its camera1299 // there is whatever a fit into a window of no size produced, and saving1300 // that would reopen the volume somewhere nobody ever stood.1301 if (this.hidden) return;1302 if (this.settle !== undefined) clearTimeout(this.settle);1303 this.settle = setTimeout(() => {1304 const camera = this.camera();1305 if (camera && this.onSettled) this.onSettled(camera);1306 }, 400) as unknown as number;1307 }1308}13091310/** A ring or a run, copied out of the model so nothing downstream edits it. */1311function copyLine(line: readonly (readonly [number, number])[]): [number, number][] {1312 return line.map((point) => [point[0], point[1]] as [number, number]);1313}13141315/**1316 * One shape's drawn geometry: every part, kept apart.1317 *1318 * A district is one feature and may be a dozen separate pieces of ground; a1319 * trail is one feature and may be fifty runs of it. Both are multi-part by1320 * nature, so both are drawn by a multi-part geometry — a single `Polygon`1321 * reads its first ring as the exterior and every later one as a hole, so the1322 * second piece of ground would be punched out of the first, and a single1323 * `LineString` has room for one run and silently drops the other forty-nine.1324 */1325export function shapeGeometry(shape: ShapeRecord): MultiPolygon | MultiLineString {1326 return shape.kind === "area"1327 ? new MultiPolygon(parts(shape))1328 : new MultiLineString(shape.lines.map(copyLine));1329}13301331/**1332 * An area's parts, nested the way a polygon is: each part its own exterior1333 * followed by that part's own interior rings, and nobody else's.1334 *1335 * The model already keeps them apart — `holes[i]` belongs to `lines[i]` — and1336 * this is where that survives into the drawing. Flattened into one ring list,1337 * a two-piece district reads as one piece with the other punched out of it.1338 */1339export function parts(shape: ShapeRecord): [number, number][][][] {1340 return shape.lines.map((line, index) => [1341 copyLine(line),1342 ...(shape.holes[index] ?? []).map(copyLine),1343 ]);1344}13451346/**1347 * The scrim: the world, then every highlighted area's parts cut out of it,1348 * each part's interior rings wound back to solid.1349 */1350export function scrimGeometry(1351 world: readonly (readonly [number, number])[],1352 highlighted: readonly ShapeRecord[],1353): Polygon {1354 const rings: [number, number][][] = [wind(world, true)];1355 for (const shape of highlighted) {1356 // A path zone has no ring to cut from the dimming.1357 if (shape.kind !== "area") continue;1358 for (const part of parts(shape)) {1359 part.forEach((ring, index) => rings.push(wind(ring, index > 0)));1360 }1361 }1362 return new Polygon(rings);1363}13641365/**1366 * Where a shape's name is anchored when the payload did not say.1367 *1368 * The middle of the union of every part's extent — outer rings are enough,1369 * because a hole is inside its own ring and cannot widen the box. It is a1370 * question about the model rather than about the drawing, so splitting the1371 * drawn geometry into parts does not move it by a bit.1372 */1373export function centreOf(shape: ShapeRecord): [number, number] | null {1374 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;1375 for (const line of shape.lines) {1376 for (const [x, y] of line) {1377 minX = Math.min(minX, x); maxX = Math.max(maxX, x);1378 minY = Math.min(minY, y); maxY = Math.max(maxY, y);1379 }1380 }1381 if (!Number.isFinite(minX)) return null;1382 return [(minX + maxX) / 2, (minY + maxY) / 2];1383}13841385/** A ring wound the way a fill rule needs it: counter-clockwise, or not. */1386function wind(line: readonly (readonly [number, number])[], counter: boolean): [number, number][] {1387 let area = 0;1388 for (let i = 1; i < line.length; i++) {1389 const a = line[i - 1];1390 const b = line[i];1391 if (!a || !b) continue;1392 area += (b[0] - a[0]) * (b[1] + a[1]);1393 }1394 const copy = line.map((point) => [point[0], point[1]] as [number, number]);1395 return (area < 0) === counter ? copy : copy.reverse();1396}13971398/** A count as the chrome writes it: thousands separated, the same words the server renders. */1399function count(value: number): string {1400 return value.toLocaleString("en-US");1401}