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 / chart / element.ts
element.ts 62.0K
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}144145function finiteExtent(values: readonly number[]): [number, number, number, number] | null {146 if (values.length < 4 || values.some((value) => !Number.isFinite(value))) return null;147 return [values[0] ?? 0, values[1] ?? 0, values[2] ?? 0, values[3] ?? 0];148}149150/**151 * The rectangle a shape occupies, or nothing when it carries no drawable152 * geometry. Outer rings and lines are enough: a hole is inside its own ring.153 *154 * Cached against the record, because it is asked once per shape every time the155 * footer recounts and a district can carry thousands of vertices. The records156 * are rebuilt with the world, so the cache empties with them.157 */158const shapeExtents = new WeakMap<ShapeRecord, [number, number, number, number] | null>();159160function shapeExtent(shape: ShapeRecord): [number, number, number, number] | null {161 const held = shapeExtents.get(shape);162 if (held !== undefined) return held;163 let minimumX = Infinity;164 let minimumY = Infinity;165 let maximumX = -Infinity;166 let maximumY = -Infinity;167 for (const line of shape.lines) {168 for (const [x, y] of line) {169 if (x < minimumX) minimumX = x;170 if (y < minimumY) minimumY = y;171 if (x > maximumX) maximumX = x;172 if (y > maximumY) maximumY = y;173 }174 }175 const extent: [number, number, number, number] | null =176 Number.isFinite(minimumX) ? [minimumX, minimumY, maximumX, maximumY] : null;177 shapeExtents.set(shape, extent);178 return extent;179}180181182/**183 * Where a camera standing over one layer of a split sheet stands over another.184 *185 * By its position inside each layer's own box rather than by its coordinates:186 * the layers picture the same ground drawn in different places on the sheet,187 * so an absolute centre carried across lands somewhere else entirely. Only a188 * crossing between two *different* shards that both declare a box has anywhere189 * to be carried to; every other lens swap is a different picture of the ground190 * the reader is already looking at and moves nothing.191 */192function carryAcrossShards(193 previous: Lens | null,194 next: Lens | null,195 standing: { x: number; y: number } | null,196): [number, number] | null {197 if (!previous?.shard || !next?.shard || previous.shard === next.shard) return null;198 const from = previous.bounds;199 const to = next.bounds;200 if (!from || !to || !standing) return null;201 const clamp = (value: number) => Math.min(Math.max(value, 0), 1);202 const across = (standing.x - from.x) / from.width;203 const down = (-standing.y - from.y) / from.height;204 return [to.x + clamp(across) * to.width, -(to.y + clamp(down) * to.height)];205}206207/**208 * What a pixel on the canvas resolved to.209 *210 * Two things can be under a pointer and they are not the same kind of answer,211 * which is why this is a discriminated union rather than an id and a label. A212 * feature has an identity the session stores and the card, the dock and the213 * legend all mark; a cell has an *address*, which no feature has and which214 * belongs to a different concern entirely. Flattening the two into one string215 * would leave every caller guessing which route it was holding.216 */217export interface CellPick {218 readonly kind: "cell";219 readonly cell: string;220}221222/** A feature under the pointer: a pin, a piece of ground, or a run of one. */223export interface FeaturePick {224 readonly kind: "point" | "area" | "path";225 readonly id: string;226}227228export type Pick = CellPick | FeaturePick;229230/** What the chart publishes about itself, in the diagnostics report's key names (docs/render-seam.md §8). */231export interface ChartDiagnostics {232 coordinateSystem: string;233 zoom: number | null;234 center: [number, number] | null;235 resolution: number | null;236 fitZoom: number | null;237 grid: { extent: number[] | null; cells: DrawnCell[] };238}239240/**241 * The chart pane.242 *243 * Custom elements are progressive by construction: until this is defined the244 * tag renders nothing and the page is whole without it, which is the245 * deletability principle costing the application one script tag.246 */247export class AtlasChart extends HTMLElement {248 private map: OLMap | null = null;249 private view: View | null = null;250 private projection: Projection | null = null;251 private raster: Raster | null = null;252 private readonly counter = new TileCounter();253 private readonly sources = {254 grid: new VectorSource({ wrapX: false }),255 gridContext: new VectorSource({ wrapX: false }),256 zoneScrim: new VectorSource({ wrapX: false }),257 zones: new VectorSource({ wrapX: false }),258 zoneTitles: new VectorSource({ wrapX: false }),259 pins: new VectorSource({ wrapX: false }),260 priority: new VectorSource({ wrapX: false }),261 };262 /**263 * The two layers a cell is ever drawn on, held so a click can ask them and264 * only them. The chosen path lies under the pins and the dimmed context over265 * them, which is a z-order argument (chart/grid.ts) and is exactly why the266 * hit test cannot be a question about depth.267 */268 private gridLayers: VectorLayer<VectorSource>[] = [];269 private styles: Styles | null = null;270 private context: WorldContext | null = null;271 /** The feature the card is open about, so a change in it can be acted on. */272 private selected = "";273 /** The view a jump borrowed, and the view the jump landed on. */274 private borrowed: { x: number; y: number; zoom: number; rotation: number } | null = null;275 private landed: { x: number; y: number; zoom: number; rotation: number } | null = null;276 private sizes: ResizeObserver | null = null;277 /** The cell the camera was last flown to hold. */278 private heldCell = "";279 /**280 * The system that cell was held in — the other half of the memo above.281 *282 * A held cell is a string only its own system can read: geohash's "m" and283 * S2's "24" name the same ground here and nothing about the two spellings284 * says so. So "the cell moved" is only a question worth asking *within* one285 * system, and the fit asks it that way.286 */287 private heldSystem = "";288 private plane: DataPlane | null = null;289 private overview: Overview | null = null;290 private fitZoom: number | null = null;291 private drawnCells: DrawnCell[] = [];292 private gridExtent: number[] | null = null;293 private lensKey = "";294 /** The lens the chart was last drawn with, so a swap can be told from what. */295 private shownLens: Lens | null = null;296 private worldKey = "";297 private settle: number | undefined;298 private onSettled: ((report: { x: number; y: number; zoom: number; rotation: number }) => void) | null = null;299300 /** The host wires these once; the element never reaches for them itself. */301 attach(plane: DataPlane, settled: (report: { x: number; y: number; zoom: number; rotation: number }) => void): void {302 this.plane = plane;303 this.onSettled = settled;304 }305306 /** Draw a world. Idempotent: the same context redraws nothing. */307 show(context: WorldContext): void {308 this.context = context;309 const worldKey = `${context.base}/${context.model.slug}`;310 const lensKey = `${worldKey}#${context.lens?.tiles ?? ""}@${context.lensIndex}`;311 if (worldKey !== this.worldKey) {312 this.build(context);313 this.watchSize();314 this.worldKey = worldKey;315 this.lensKey = "";316 }317 if (lensKey !== this.lensKey) {318 this.openLens(context, this.lensKey === "");319 this.lensKey = lensKey;320 }321 // Before the selection is followed, because a descend spends the view a322 // jump was holding and the card closing must not hand it back over the top323 // of the cell being flown to. The reference releases the hold and then324 // closes the card, in that order, for exactly this reason (grid.js,325 // selectGridCell); here the two are a swap apart -- the server closes the326 // card, the scene comes back with both facts in it -- so the order is kept327 // by reading the cell first.328 this.releaseOnDescend(context);329 this.follow(context);330 this.restyle();331 // The footer's own sentence. It is written here and on a camera event and332 // on a filter, and deliberately *not* from `restyle` -- holding Z restyles333 // and the reference never recounted for it, which on a pane put away334 // behind the sphere is the difference between the count it recorded and a335 // count of nothing.336 this.writeCount();337 // The first camera a volume ever has is the one the fit produced, and a338 // fit without an animation raises no `moveend` -- so without this the339 // opening view is the one camera the server is never told about, and the340 // reader's own first arrangement is the only one that reopens wrong.341 this.report();342 }343344 /**345 * Keep the map the size of its pane.346 *347 * OpenLayers measures once and has to be told when the container moves, and348 * on this page the container moves without the window doing anything: the349 * panel beside the map comes out the first time a search has something to350 * say, and the sidebar folds under a keystroke. A map that did not hear351 * about it would draw at the old size and, worse, go on believing the whole352 * world still fits its window -- which is the question the corner locator353 * puts itself away over.354 */355 private watchSize(): void {356 if (this.sizes || typeof ResizeObserver === "undefined") return;357 this.sizes = new ResizeObserver(() => {358 // A pane measured mid-transition can be zero across, and telling359 // OpenLayers its map is zero pixels wide throws its size away. A pane360 // that is *put away* is a different case and its zero is the truth:361 // behind the sphere the chart has no window, and a chart with no window362 // has nothing in view and fits nothing -- which is what every recorded363 // globe step says once anything asks it again.364 if (!this.hidden && (!this.clientWidth || !this.clientHeight)) return;365 this.map?.updateSize();366 this.overview?.draw();367 // A window that changed size is over a different amount of what is368 // drawn -- unless there is no window, in which case the sentence on369 // screen is still the last true thing this pane said and the reader is370 // reading the sphere anyway.371 if (!this.hidden) this.writeCount();372 });373 this.sizes.observe(this);374 }375376 /**377 * A cell the reader moved to retires the view a jump was holding.378 *379 * Descending is the reader saying where they want to be. Whatever panel jump380 * was holding a view for them is spent by that, and the loan has to be381 * cancelled *before* the selection is read: the same swap that carries the382 * new cell may carry a closed card, and a card closing gives the borrowed383 * view back — over the top of the cell about to be fitted.384 *385 * It reads `heldCell`, which `drawGrid` owns and updates; between here and386 * there nothing else looks at it, so the two halves of one scene see the same387 * answer to "did the cell move".388 */389 private releaseOnDescend(context: WorldContext): void {390 if (context.cell === this.heldCell) return;391 this.borrowed = null;392 this.landed = null;393 }394395 /**396 * The camera's side of a selection, and the way back from it.397 *398 * A feature reached for from a panel is worth moving the camera for: the399 * reader asked for something they could not see. A feature clicked on the400 * canvas is already in front of them, and the pick goes through the same401 * route, so the test is whether the camera can already see it.402 *403 * The way back is the other half. A jump borrows the view, and closing the404 * card gives it back — unless the reader steered while the card was open,405 * in which case the view they steered to is the one they chose and the loan406 * is off. "Steered" is read off the camera rather than off the events: if407 * the camera is not where the jump put it, a hand moved it.408 */409 private follow(context: WorldContext): void {410 const selected = context.scene.selected;411 if (selected === this.selected) return;412 this.selected = selected;413 const view = this.view;414 if (!view) return;415 if (!selected) {416 const back = this.borrowed;417 this.borrowed = null;418 const standing = this.camera();419 // Where the jump left the camera, to a pixel: sub-pixel drift out of an420 // easing animation is not a reader's hand.421 const steered = !this.landed || !standing ||422 Math.abs(standing.zoom - this.landed.zoom) > 1e-3 ||423 Math.abs(standing.x - this.landed.x) > 0.5 ||424 Math.abs(standing.y - this.landed.y) > 0.5;425 this.landed = null;426 if (back && !steered) this.goTo(back.x, back.y, back.zoom, back.rotation);427 return;428 }429 const feature = context.model.feature(selected);430 if (!feature) return;431 const standing = this.camera();432 const lens = context.lens;433 if (!standing || !lens) return;434 // Each jump holds afresh, overwriting whatever the last one left: closing435 // a card undoes that card's own move and nothing older. Reading three rows436 // in turn steps back through them one view at a time, and none of the437 // three can hand back a place from before the reader went looking.438 this.borrowed = standing;439 if ("coordinate" in feature) {440 // A point is one place: go to it, and go no further in than the reader441 // already is.442 const at = feature.coordinate;443 const zoom = Math.min(viewMaxZoom(lens), Math.max(standing.zoom, FOCUS_ZOOM));444 view.animate({ center: [at[0], at[1]], zoom, duration: 220 });445 this.landed = { x: at[0], y: at[1], zoom, rotation: standing.rotation };446 return;447 }448 // Ground is an area, not a point, so the camera is fitted to it rather449 // than flown at its middle: a district reached for from an index is shown450 // whole, with room around it. A shape carrying no drawable geometry has no451 // extent to fit and the camera stays where it is.452 const extent = shapeExtent(feature);453 if (!extent) return;454 const landed = this.fitTo(extent, ZONE_FIT_PADDING, viewMaxZoom(lens));455 if (!landed) return;456 this.landed = { ...landed, rotation: standing.rotation };457 }458459 /**460 * Fly the camera to an extent, and answer where it will land.461 *462 * The destination is wanted before the flight, because closing a card gives463 * back the view the jump borrowed only if the camera is still where the jump464 * put it — and an eased animation cannot be asked where it is going. So the465 * fit is made instantly, read, undone, and then flown: no frame is drawn in466 * between, and the answer is OpenLayers' own rather than a second467 * implementation of its arithmetic.468 */469 private fitTo(470 extent: readonly [number, number, number, number],471 padding: number,472 maxZoom: number,473 ): { x: number; y: number; zoom: number } | null {474 const view = this.view;475 if (!view) return null;476 const standing = this.camera();477 const resolution = view.getResolution();478 view.fit(extent as [number, number, number, number], {479 size: this.map?.getSize(),480 padding: [padding, padding, padding, padding],481 maxZoom,482 duration: 0,483 });484 const target = view.getCenter();485 const targetResolution = view.getResolution();486 const targetZoom = view.getZoom();487 if (!target || targetResolution === undefined || targetZoom === undefined) return null;488 if (standing && resolution !== undefined) {489 view.setCenter([standing.x, standing.y]);490 view.setResolution(resolution);491 }492 view.animate({ center: target, resolution: targetResolution, duration: 220 });493 return { x: target[0] ?? 0, y: target[1] ?? 0, zoom: targetZoom };494 }495496 /** Restyle in place: a filter moved, or a selection, or the held key. */497 restyle(): void {498 const context = this.context;499 if (!context || !this.styles) return;500 this.styles.context.visibility = context.visibility;501 this.styles.context.scene = context.scene;502 this.styles.context.labelsHeld = context.labelsHeld;503 this.styles.context.hovered = context.hovered;504 this.styles.context.outset = context.outset;505 this.fillPriority(context);506 this.fillScrim(context);507 this.drawGrid(context);508 for (const source of Object.values(this.sources)) source.changed();509 this.map?.render();510 this.overview?.draw();511 }512513 /**514 * The footer's sentence.515 *516 * "N of M features in view": M is what the map is drawing and the server517 * renders it, N is how many of them the window is actually over and nobody518 * but this pane can say -- it is a question about the camera. The519 * application leaves the element carrying its own half and this completes520 * it once there is a camera to ask (docs/app.md §6.1).521 *522 * It is one set counted twice, said as one sentence, and the number after523 * "of" is the same number the dock puts at the top of its list.524 */525 writeCount(): void {526 const footer = document.querySelector("#visible-count");527 const context = this.context;528 if (!footer || !context) return;529 const drawn = context.visibility.drawn;530 if (drawn === 0) {531 footer.textContent = "No features shown";532 return;533 }534 footer.textContent = `${this.inView(context)} of ${count(drawn)} features` +535 ` in view`;536 }537538 /** How many of the standing features the window is over. */539 private inView(context: WorldContext): string {540 // The window, from whatever size the map has -- and a pane put away behind541 // the sphere has none at all. That is not a missing measurement to fall542 // back from: a chart nobody is looking through has nothing in view, which543 // is what every recorded globe step that recounts at all says.544 const extent = this.view?.calculateExtent(this.hidden ? [0, 0] : this.map?.getSize());545 if (!extent) return count(context.visibility.drawn);546 const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent;547 const inside = (x: number, y: number) =>548 x >= minX && x <= maxX && y >= minY && y <= maxY;549 let seen = 0;550 context.model.points.forEach((point, index) => {551 if (context.visibility.at(index).hidden) return;552 if (inside(point.coordinate[0], point.coordinate[1])) seen += 1;553 });554 // Ground is counted by whether the window is *over* it, not by whether a555 // corner of it happens to fall inside: a district big enough to fill the556 // screen has every one of its vertices off it, and a reader looking at557 // nothing but that district is looking at one feature rather than none.558 for (const shape of context.visibility.shapesShown) {559 const extent = shapeExtent(shape);560 if (!extent) continue;561 if (extent[0] <= maxX && extent[2] >= minX && extent[1] <= maxY && extent[3] >= minY) {562 seen += 1;563 }564 }565 return count(seen);566 }567568 /** The camera, as the diagnostics and the globe both read it. */569 camera(): { x: number; y: number; zoom: number; rotation: number } | null {570 const view = this.view;571 const centre = view?.getCenter();572 const zoom = view?.getZoom();573 if (!view || !centre || zoom === undefined) return null;574 return { x: centre[0] ?? 0, y: centre[1] ?? 0, zoom, rotation: view.getRotation() };575 }576577 /** The local world rectangle currently visible through this pane. */578 visibleExtent(): [number, number, number, number] | null {579 const size = this.map?.getSize();580 if (!this.view || !size || !size[0] || !size[1]) return null;581 return finiteExtent(this.view.calculateExtent(size));582 }583584 /** Resolve a screen rectangle, relative to this pane, into local world space. */585 extentForPixels(rect: readonly [number, number, number, number]): [number, number, number, number] | null {586 if (!this.map) return null;587 const first = this.map.getCoordinateFromPixel([rect[0], rect[1]]);588 const second = this.map.getCoordinateFromPixel([rect[2], rect[3]]);589 if (!first || !second) return null;590 return finiteExtent([591 Math.min(first[0] ?? NaN, second[0] ?? NaN),592 Math.min(first[1] ?? NaN, second[1] ?? NaN),593 Math.max(first[0] ?? NaN, second[0] ?? NaN),594 Math.max(first[1] ?? NaN, second[1] ?? NaN),595 ]);596 }597598 /**599 * One step of a zoom control, held to the lens's own depth.600 *601 * A step, not a factor: the chart's zoom is a level and the buttons move it602 * by one, easing rather than cutting so a reader can see which way the603 * ground went. The floor and the ceiling are the lens's -- there is nothing604 * shallower than its first level and nothing deeper than two levels of605 * overzoom past its last.606 */607 nudgeZoom(delta: number): void {608 const view = this.view;609 const lens = this.context?.lens;610 if (!view || !lens) return;611 const standing = view.getZoom() ?? 0;612 // A step is taken from where the camera stands, and a camera standing613 // nowhere has no step to take from: `??` catches a view with no614 // resolution and not a view whose resolution is not a number, and adding615 // one to `NaN` is how a pane that went blank once stays blank through616 // every press afterwards. Putting the camera back is the more useful617 // answer to the press than ignoring it.618 if (!Number.isFinite(standing)) {619 this.front();620 return;621 }622 view.animate({623 zoom: Math.min(Math.max(standing + delta, lens.minZoom), viewMaxZoom(lens)),624 duration: 140,625 });626 }627628 /**629 * Put the camera somewhere — the globe handing a view back, a jump, a lens630 * rebuild carrying one across, or the session handing back where the reader631 * left off.632 *633 * THE ONE DOOR, which is why the guard is here and not at each of those634 * four. Every camera this pane is ever *told* comes through this call, so a635 * camera that cannot be put on a view is caught once, wherever it came636 * from, and answered with the world's own opening view rather than with a637 * blank pane (`saneCamera`).638 */639 goTo(x: number, y: number, zoom: number, rotation = 0): void {640 const context = this.context;641 if (!this.view || !context) return;642 const sane = saneCamera({ x, y, zoom, rotation }, context.lens, context.grid);643 if (!sane) {644 log.warn("a camera that is not a place was refused", {645 op: "render", world: context.model.slug, x: String(x), y: String(y),646 zoom: String(zoom), rotation: String(rotation),647 });648 this.fitWorld();649 return;650 }651 this.view.setCenter([sane.x, sane.y]);652 this.view.setZoom(sane.zoom);653 this.view.setRotation(sane.rotation);654 }655656 /**657 * Come back up, and stand somewhere real while doing it.658 *659 * A CAMERA THAT WAS NEVER LOOKED THROUGH. Behind the sphere this pane keeps660 * moving — a filter, a lens, above all a cell descent, whose fit is made661 * against a window of no size and therefore lands at the deepest level the662 * lens has (`drawGrid`, and every recorded globe step is a reading of it).663 * That is right while nobody is looking and it is the standing view the664 * moment somebody is, so the camera is asked whether it is a place at665 * exactly the moment it becomes one: fronting, rather than while hidden.666 * Checking it earlier would move what the baselines pin; checking it here667 * moves nothing that was already sane.668 *669 * Which is the other half: a healthy camera is left *untouched* rather than670 * rewritten with its own numbers. A `setCenter` here would raise a move on671 * every pane swap, and a move is a report, a recount and a redrawn corner.672 */673 front(): void {674 const context = this.context;675 if (!this.view || !context) return;676 const standing = this.camera();677 const sane = saneCamera(standing, context.lens, context.grid);678 if (sane && sameCamera(sane, standing)) return;679 if (!sane) {680 log.warn("the pane came up over a camera that is not a place", {681 op: "render", world: context.model.slug, camera: JSON.stringify(standing),682 });683 this.fitWorld();684 return;685 }686 this.goTo(sane.x, sane.y, sane.zoom, sane.rotation);687 }688689 /**690 * The world's own opening view: everything the lens drew, in the window691 * there is.692 *693 * It is the answer to a camera that could not be repaired, and it is the694 * same fit a volume opens on when the session has no camera to hand back —695 * one arithmetic, so a reader recovering from a broken camera lands exactly696 * where a reader arriving for the first time does.697 *698 * THE SIZE IS THE PANE'S WHEN THE MAP HAS NONE. A map behind the sphere699 * reports `[0, 0]`, and a fit into no window is the arithmetic that made700 * the camera suspect in the first place; falling back to what the element701 * measures keeps the recovery a recovery.702 */703 private fitWorld(): void {704 const context = this.context;705 if (!this.view || !context) return;706 const size = this.map?.getSize();707 const window: [number, number] = size && size[0] && size[1]708 ? [size[0], size[1]]709 : [this.clientWidth || 1, this.clientHeight || 1];710 this.view.fit(lensExtent(context.lens, context.grid) as [number, number, number, number],711 { size: window, nearest: false });712 }713714 /**715 * Redraw the corner locator.716 *717 * The shelf's `hidden` is this lane's answer written onto the application's718 * markup, so a swap that re-rendered the corner has just put a shelf back on719 * screen that the camera says has nothing to say. It is the same duty720 * `writeCount` does for the footer, and it is owed at the same moment.721 */722 redrawOverview(): void {723 this.overview?.draw();724 }725726 /** Tiles since the lens was chosen. */727 get tileStats() {728 return this.counter.stats;729 }730731 /** What the chart says about itself. */732 diagnostics(): ChartDiagnostics {733 const camera = this.camera();734 return {735 coordinateSystem: COORDINATE_SYSTEM,736 zoom: camera?.zoom ?? null,737 center: camera ? [camera.x, camera.y] : null,738 resolution: this.view?.getResolution() ?? null,739 fitZoom: this.fitZoom,740 grid: { extent: this.gridExtent, cells: this.drawnCells },741 };742 }743744 /**745 * Put the locator's rectangle where another pane's camera is.746 *747 * The sphere has no OpenLayers view to ask, and the corner locator is the748 * one place its camera is ever written down — in whole pixels, on a surface749 * the chart shares. So the globe hands its extent here rather than moving750 * the chart underneath it, which would fetch tiles for a pane nobody is751 * looking at.752 */753 locate(extent: readonly number[] | null): void {754 if (!extent) this.overview?.release();755 this.overview?.draw(extent ?? undefined);756 }757758 /** Force a synchronous frame, so a driver can read a settled camera. */759 renderSync(): void {760 // A map whose pane has no size has no frame to render, and asking for one761 // walks a null frame state. It happens for a moment whenever the chrome762 // moves -- a sidebar folding, a panel coming out -- and the harness's763 // settle loop forces a frame on every poll, so the moment is reachable.764 if (!this.map?.getSize()) return;765 this.map.renderSync();766 }767768 /**769 * Come back to the page.770 *771 * A morph can take an element out and put the same one back, and everything772 * the disconnect gave up has to go back on when it does — the observer, and773 * the map's own target, which `setTarget(undefined)` took away.774 *775 * Only a *re*-connect has anything to rewire. On the first one there is no776 * map yet: the observer is wired with the world, from `show`, and wiring it777 * here as well would start measuring a pane before there is anything in it778 * to measure.779 */780 connectedCallback(): void {781 if (!this.map) return;782 this.map.setTarget(this);783 this.watchSize();784 }785786 /**787 * Leave the page.788 *789 * The map lets go of its target, which is what stops OpenLayers drawing into790 * an element nobody can see. The observer has to be told separately: a791 * `ResizeObserver` is held by the browser, not by this element, so one left792 * running outlives the pane it was watching and keeps its callback — and the793 * map, the overview and the world context that callback closes over — alive794 * for as long as the page is open. The settle timer is the same fact in a795 * smaller form: a camera reported four hundred milliseconds after the pane796 * went away is a report about a world the reader has left.797 */798 disconnectedCallback(): void {799 this.sizes?.disconnect();800 this.sizes = null;801 if (this.settle !== undefined) clearTimeout(this.settle);802 this.settle = undefined;803 this.map?.setTarget(undefined);804 }805806 // ---- building -------------------------------------------------------807808 private build(context: WorldContext): void {809 this.map?.setTarget(undefined);810 this.projection = atlasProjection(context.grid);811 const styleContext: StyleContext = {812 visibility: context.visibility,813 scene: context.scene,814 labelsHeld: context.labelsHeld,815 hovered: context.hovered,816 outset: context.outset,817 iconURL: (asset) => this.plane?.iconURL(context.base, asset) ?? "",818 // A marker raster finishes composing after the first frame, and a style819 // function is evaluated once per layer revision rather than once per820 // frame: the pin layers have to be asked again or the symbol that just821 // arrived is not drawn until something else moves.822 repaint: () => this.restyle(),823 };824 this.styles = new Styles(styleContext);825 this.styles.learn(context.model.collections);826827 this.view = this.viewFor(context);828 this.map = new OLMap({829 target: this,830 view: this.view,831 controls: defaultControls({ attribution: false, rotate: false, zoom: false }),832 layers: this.layers(),833 });834 this.fillFeatures(context);835 this.wire();836 this.overview = new Overview(this.map, () => this.context);837 log.info("a world is on the chart", {838 op: "render", volume: context.scene.volume, world: context.model.slug,839 points: context.model.points.length, shapes: context.model.shapes.length,840 });841 }842843 private layers(): VectorLayer<VectorSource>[] {844 const eager = (845 source: VectorSource,846 zIndex: number,847 style: (feature: FeatureLike, resolution: number) => Style[] | Style | undefined,848 options: { declutter?: boolean; renderBuffer?: number } = {},849 ) => new VectorLayer({850 source,851 zIndex,852 style,853 updateWhileAnimating: true,854 updateWhileInteracting: true,855 renderBuffer: options.renderBuffer ?? 64,856 ...(options.declutter ? { declutter: true } : {}),857 });858 const grid = eager(this.sources.grid, 5, (f, r) => this.gridStyle(f, r));859 const gridContext = eager(this.sources.gridContext, 48, (f, r) => this.gridStyle(f, r));860 this.gridLayers = [grid, gridContext];861 return [862 grid,863 eager(this.sources.zoneScrim, 6, () => this.styles?.scrim()),864 eager(this.sources.zones, 10, (f, r) => this.shapeStyle(f, r)),865 eager(this.sources.zoneTitles, 20, (f) => this.titleStyle(f, false)),866 eager(this.sources.pins, 40, (f) => this.pinStyle(f, false)),867 eager(this.sources.pins, 42, (f) => this.pinStyle(f, true)),868 eager(this.sources.zoneTitles, 44, (f) => this.titleStyle(f, true)),869 eager(this.sources.pins, 45, (f) => this.labelStyle(f), { declutter: true, renderBuffer: 180 }),870 gridContext,871 eager(this.sources.priority, 50, (f) => this.priorityStyle(f), { renderBuffer: 220 }),872 ];873 }874875 /**876 * The camera, built for one lens.877 *878 * Three of these options are not decoration and were the source of a long879 * hunt. **`resolutions`** is the ladder the pyramid actually has -- the880 * world square over the tile size, halved per level -- and a view given the881 * ladder converts between a zoom and a resolution by walking it, where a882 * view left to derive one from a maximum and a factor arrives at answers883 * that differ in their last bit. The baselines compare a camera exactly, so884 * the last bit is the difference between agreeing and not. **`extent`** and885 * **`showFullExtent`** hold the camera over the ground the lens drew: widen886 * the window -- fold the sidebar away -- and the view slides back inside887 * its own picture rather than panning off the edge of it, which is what888 * `sidebar-collapsed` records. And **`smoothResolutionConstraint: false`**889 * is what makes the two ends of the ladder ends rather than springs.890 *891 * It is rebuilt per lens because none of the three can be changed892 * afterwards, and a lens is a different picture of the ground.893 */894 private viewFor(context: WorldContext): View {895 const extent = lensExtent(context.lens, context.grid);896 const maxZoom = viewMaxZoom(context.lens);897 const base = context.grid.size / context.grid.tileSize;898 return new View({899 projection: this.projection ?? undefined,900 center: [901 ((extent[0] ?? 0) + (extent[2] ?? 0)) / 2,902 ((extent[1] ?? 0) + (extent[3] ?? 0)) / 2,903 ],904 resolutions: Array.from({ length: maxZoom + 1 }, (_, zoom) => base / 2 ** zoom),905 minZoom: 0,906 maxZoom,907 extent: extent as [number, number, number, number],908 constrainResolution: false,909 smoothResolutionConstraint: false,910 showFullExtent: true,911 zoom: 0,912 enableRotation: false,913 });914 }915916 private openLens(context: WorldContext, fresh: boolean): void {917 if (!this.map || !this.view || !this.projection || !this.plane) return;918 if (this.raster) {919 this.map.removeLayer(this.raster.base);920 this.map.removeLayer(this.raster.detail);921 this.raster = null;922 }923 this.counter.reset();924 if (context.lens) {925 this.raster = buildRaster(926 this.plane, context.base, context.lens, context.grid, this.projection, this.counter);927 this.map.addLayer(this.raster.base);928 this.map.addLayer(this.raster.detail);929 }930 // A lens is a different picture of the ground, and three of the camera's931 // options cannot be changed after it is built, so it is rebuilt. The932 // camera itself is carried across by hand below.933 const standing = this.camera();934 const carried = fresh ? null : carryAcrossShards(this.shownLens, context.lens, standing);935 this.shownLens = context.lens;936 this.view = this.viewFor(context);937 this.map.setView(this.view);938 if (!fresh && standing) {939 this.goTo(standing.x, standing.y, standing.zoom, standing.rotation);940 // The layers of a split map are the same ground at different heights,941 // stacked down one sheet. Stepping between them leaves the reader over942 // the same place rather than at the same coordinates, which on a sheet943 // where each layer has its own box is a different point.944 if (carried) this.view.animate({ center: carried, duration: 200 });945 }946947 // Shard crossing: swapping to a lens that draws another layer of the same948 // split world keeps the camera exactly where it was. The reader stepped949 // between floors of one building, not into another world, and a refit950 // would throw away the place they had found.951 if (fresh) {952 // The session's camera, and it is a record rather than a reading: it was953 // written by whatever build the reader last opened this volume in, over954 // whatever lens they were on. `goTo` is what makes it safe to apply --955 // a zoom the lens they are opening under cannot reach is clamped to one956 // it can, and a record that is not a place opens the world at the fit,957 // which is where a volume with no record opens anyway.958 const camera = context.scene.camera;959 if (camera) this.goTo(camera.x, camera.y, camera.zoom, camera.rotation);960 else this.fitWorld();961 // `fitZoom` is what "the whole map fits" is measured against, and it is962 // decided once, when the world opens: the zoom the reader arrived at,963 // whether that came from a fit or from the camera they left behind.964 // Recomputing it later is what left the corner locator arguing with a965 // camera that had moved on.966 this.fitZoom = this.view.getZoom() ?? null;967 }968 this.overview?.forget();969 }970971 // ---- features -------------------------------------------------------972973 private fillFeatures(context: WorldContext): void {974 this.sources.pins.clear();975 this.sources.zones.clear();976 this.sources.zoneTitles.clear();977 for (const point of context.model.points) {978 this.sources.pins.addFeature(new Feature({979 geometry: new Point(point.coordinate),980 record: point,981 priority: point.priority,982 }));983 }984 for (const shape of context.model.shapes) {985 this.sources.zones.addFeature(new Feature({986 geometry: shapeGeometry(shape), record: shape,987 }));988 const centre = shape.center ?? centreOf(shape);989 if (centre) {990 this.sources.zoneTitles.addFeature(new Feature({991 geometry: new Point(centre), record: shape,992 }));993 }994 }995 }996997 private fillPriority(context: WorldContext): void {998 this.sources.priority.clear();999 context.model.points.forEach((point, index) => {1000 if (!context.visibility.at(index).promoted) return;1001 this.sources.priority.addFeature(new Feature({1002 geometry: new Point(point.coordinate), record: point, priority: point.priority,1003 }));1004 });1005 }10061007 /**1008 * The scrim: one polygon over the whole world with the highlighted shapes1009 * cut out of it, even-odd.1010 *1011 * Rings alternate direction with their depth so the fill counts its way in1012 * and out: the world, then each piece of the shape as a hole, then any hole1013 * of that piece's own back to solid. A ring's winding in a payload is1014 * nobody's promise, so it is normalised here — and the depth that decides1015 * it is the ring's place *within its own part*, which is why the parts1016 * cannot be flattened first. A path zone has no ring to cut and is skipped:1017 * the highlighted line draws above the dimming at its own weight.1018 */1019 private fillScrim(context: WorldContext): void {1020 this.sources.zoneScrim.clear();1021 const highlighted = context.visibility.highlightedShapes1022 .filter((shape) => shape.kind === "area");1023 if (!highlighted.length) return;1024 const size = context.grid.size;1025 const outer: [number, number][] = [[0, 0], [size, 0], [size, -size], [0, -size], [0, 0]];1026 this.sources.zoneScrim.addFeature(new Feature({1027 geometry: scrimGeometry(outer, highlighted),1028 }));1029 }10301031 private drawGrid(context: WorldContext): void {1032 this.sources.grid.clear();1033 this.sources.gridContext.clear();1034 this.drawnCells = [];1035 this.gridExtent = null;1036 if (!context.system || !context.scene.gridSystem) return;1037 const standing = [...context.visibility.withoutCell()];1038 const drawn = drawGrid(1039 context.ground, context.system, context.cell,1040 standing, this.sources.grid, this.sources.gridContext);1041 // The plan's own order is the contract; what is read back for the1042 // diagnostics is the sources' order, chosen path first and context after,1043 // which is the order the baselines record.1044 this.drawnCells = [1045 ...this.sources.grid.getFeatures(),1046 ...this.sources.gridContext.getFeatures(),1047 ].map((feature) => feature.get("gridCell") as DrawnCell);1048 this.gridExtent = drawn.extent ? [...drawn.extent] : null;1049 // Descending holds the cell: the camera is flown to the ground the1050 // address names, the same way the navigator's own field does it. Only a1051 // change of cell moves it -- redrawing the same grid because a filter1052 // moved must not drag the reader back.1053 //1054 // THIS IS THE ONE FIT, and every way into a cell arrives at it: the1055 // navigator's field, the back button, Escape, a click on the canvas and a1056 // press on the sphere. The reference fitted synchronously inside1057 // `selectGridCell` because the cell moved synchronously; here a cell moves1058 // by a round trip, so the fit belongs to the arrival rather than to the1059 // ask. A second fit fired off at the click would be the same arithmetic1060 // said twice, racing its own confirmation and answering for a cell the1061 // server had not yet agreed to -- so there is exactly one of it, here.1062 //1063 // AND A CELL MOVES WITHIN A SYSTEM. Cycling the system carries the held1064 // cell across to whichever cell of the next system covers the same ground1065 // (`equivalentCell`): "m" becomes "24", the string moves and the reader1066 // does not. Read against the cell alone that is a descent, and the camera1067 // flies to a place it is already standing over — the one thing a reader1068 // pressing a cycle key is not asking for. Both memos are written whatever1069 // happened, so the descent after a cycle is still read against the system1070 // it was made in.1071 const system = context.scene.gridSystem;1072 if (cellMoved({ system: this.heldSystem, cell: this.heldCell }, { system, cell: context.cell })) {1073 // Ascending is a move too: the reader asked for the ground one level1074 // out, and the camera goes there the same way it came in.1075 //1076 // The size is whatever the map has, and a map put away behind the sphere1077 // has none -- which OpenLayers reads as its own hundred-pixel default:1078 // a cell fitted into no window at all lands at the deepest zoom the1079 // lens allows, over the middle of the ground. That is still an answer;1080 // refusing to fit at all would leave the camera standing over ground1081 // the reader has already asked to leave.1082 if (this.gridExtent && this.view && context.lens) {1083 // THE LATEST ASK WINS, and saying so costs a line because OpenLayers1084 // says the opposite. Concurrent animation series are applied oldest1085 // *last* every frame (View.updateAnimations_ walks them backwards), so1086 // two fits begun inside one animation's 180 ms resolve to the earlier1087 // one's target and the later ask is overwritten frame by frame until1088 // both finish. Two Escapes in quick succession are exactly that: the1089 // camera ascended to the parent and stopped there, holding ground the1090 // reader had already left. Cancelling first makes the fit mean what a1091 // reader means by it -- go where I have just asked for -- whatever is1092 // still in the air.1093 this.view.cancelAnimations();1094 this.view.fit(this.gridExtent as [number, number, number, number], {1095 size: this.map?.getSize(),1096 padding: [GRID_FIT_PADDING, GRID_FIT_PADDING, GRID_FIT_PADDING, GRID_FIT_PADDING],1097 maxZoom: viewMaxZoom(context.lens), nearest: false, duration: 180,1098 });1099 }1100 }1101 this.heldCell = context.cell;1102 this.heldSystem = system;1103 }11041105 // ---- styles ---------------------------------------------------------11061107 private standingOf(feature: FeatureLike): { record: PointRecord; promoted: boolean } | null {1108 const record = feature.get("record") as PointRecord | undefined;1109 const context = this.context;1110 if (!record || !context) return null;1111 const standing = context.visibility.at(record.index);1112 if (standing.hidden) return null;1113 return { record, promoted: standing.promoted };1114 }11151116 private pinStyle(feature: FeatureLike, zoneLayer: boolean): Style[] | undefined {1117 const found = this.standingOf(feature);1118 if (!found || !this.styles || !this.context) return undefined;1119 const claimed = this.context.visibility.at(found.record.index).passesHighlights;1120 // The same markers twice: once under the ground, once over it for the1121 // ones a highlight claimed, so a claimed pin is never lost under the1122 // shape that claimed it.1123 if (zoneLayer !== claimed) return undefined;1124 if (found.promoted) return undefined;1125 if (this.context.scene.hidden.has(String(found.record.collection.id))) return undefined;1126 return this.styles.pin(found.record, false);1127 }11281129 private labelStyle(feature: FeatureLike): Style | undefined {1130 const found = this.standingOf(feature);1131 if (!found || !this.styles || found.promoted) return undefined;1132 return this.styles.pinLabel(found.record, false) ?? undefined;1133 }11341135 private priorityStyle(feature: FeatureLike): Style[] | undefined {1136 const found = this.standingOf(feature);1137 if (!found || !this.styles) return undefined;1138 const marks = this.styles.pin(found.record, true);1139 const label = this.styles.pinLabel(found.record, true);1140 return label ? [...marks, label] : marks;1141 }11421143 private shapeStyle(feature: FeatureLike, resolution: number): Style[] | undefined {1144 const record = feature.get("record") as ShapeRecord | undefined;1145 const context = this.context;1146 if (!record || !context || !this.styles) return undefined;1147 if (!context.visibility.shapesShown.includes(record)) return undefined;1148 const highlighted = context.scene.highlighted.has(record.id) ||1149 context.scene.selected === record.id;1150 return record.kind === "path"1151 ? this.styles.path(record, resolution, highlighted)1152 : [this.styles.area(record, highlighted)];1153 }11541155 private titleStyle(feature: FeatureLike, detail: boolean): Style | undefined {1156 const record = feature.get("record") as ShapeRecord | undefined;1157 const context = this.context;1158 if (!record || !context || !this.styles) return undefined;1159 if (!context.visibility.shapesShown.includes(record)) return undefined;1160 const promoted = context.scene.highlighted.has(record.id) ||1161 context.scene.selected === record.id;1162 if (promoted !== detail) return undefined;1163 return this.styles.areaTitle(record, promoted) ?? undefined;1164 }11651166 /**1167 * One cell, styled against the resolution it is actually being drawn at.1168 *1169 * THE RESOLUTION IS THE ARGUMENT, and it has to be: whether a cell can carry1170 * its address is a fact about the camera, and the camera moves between a1171 * grid being built and the reader looking at it. A descent builds its grid1172 * from the zoom it is leaving and then flies in — so a subdivision judged1173 * once, at build time, was judged at the wrong depth, and the deeper the1174 * held cell the worse it got. Held two levels down with the subdivision on,1175 * every child came out a third of the size the parent's zoom required, every1176 * one of them answered "no label", and a child with no label draws **nothing1177 * at all** (analysis/cellsystems/visual.ts) — a subgrid that was on, planned,1178 * counted, and invisible.1179 */1180 private gridStyle(feature: FeatureLike, resolution: number): Style[] | undefined {1181 const plan = feature.get("gridPlan") as PlanCell | undefined;1182 const context = this.context;1183 if (!plan || !context?.system || !this.styles) return undefined;1184 const visual = gridCellVisual(context.ground, context.system, plan, {1185 subgridVisible: context.subgridVisible,1186 labelled: labelFitsCell(plan.hash, plan.role, plan.extent, resolution),1187 });1188 if (!visual) return undefined;1189 // The chosen path draws over its own context wherever the two meet; every1190 // other cell carries the plan's own order, which the feature was built1191 // with (chart/grid.ts).1192 const chosen = plan.role === "leaf" || plan.role === "scope";1193 const zIndex = chosen ? 100 : Number(feature.get("priority") ?? 0);1194 return this.styles.grid(visual, zIndex, plan.role === "neighbor");1195 }11961197 // ---- interaction ----------------------------------------------------11981199 private wire(): void {1200 const map = this.map;1201 if (!map) return;1202 map.on("pointermove", (event) => {1203 if (event.dragging || !this.context) return;1204 // The hover is about features and never about cells: what it draws is a1205 // marker lifting under the pointer, and a cell has no such reading.1206 const found = this.hitFeature(event.pixel);1207 const id = found?.id ?? null;1208 if (id === this.context.hovered) return;1209 this.context.hovered = id;1210 this.restyle();1211 this.setAttribute("data-hovered", id ?? "");1212 });1213 map.on("singleclick", (event) => {1214 const found = this.hit(event.pixel);1215 // A click on nothing is not a pick. The hover above is untouched by it:1216 // leaving a feature still clears the cursor, because that is continuous1217 // state this pane owns, and a selection is not.1218 if (!found) return;1219 // The seam resolves the hit; what it resolved to is the application's to1220 // act on, and it acts on it through the forms the page renders for1221 // exactly this (data/report.ts, issue #5 §4.4). Two answers, two forms,1222 // two concerns -- a selection and a place.1223 if (found.kind === "cell") {1224 reportGridPick(found.cell);1225 return;1226 }1227 reportPick({ feature: found.id, kind: found.kind });1228 });1229 map.on("moveend", () => {1230 this.report();1231 // The corner locator answers two questions about the camera -- where it1232 // is, and whether it can see the whole map at once -- and both of them1233 // are this event's. Redrawing it only when a filter moved left the1234 // shelf put away across a whole zoom: the map no longer fitted, and1235 // nothing had told the corner so.1236 this.overview?.draw();1237 // The window moved, so how much of what is drawn it is over has moved1238 // with it. The count is the camera's answer and belongs to the camera's1239 // own event, not to a filter's -- and a pane with no window has no1240 // answer to give.1241 if (!this.hidden) this.writeCount();1242 });1243 }12441245 /**1246 * What is under a pixel: a cell first, then a feature.1247 *1248 * THE ORDER IS THE WHOLE OF IT. While a grid is up the reader is1249 * telescoping, and a click is a request to go one level in -- so a cell1250 * answers before the pins standing on it are consulted at all, and a cell1251 * that answered returns without asking. Reversed, the grid would be1252 * undescendable anywhere a pin happened to be, which is everywhere worth1253 * descending to.1254 */1255 private hit(pixel: number[]): Pick | null {1256 return this.hitCell(pixel) ?? this.hitFeature(pixel);1257 }12581259 /**1260 * The cell under a pixel, or nothing.1261 *1262 * TWO RULES, AND BOTH ARE THE REFERENCE'S OWN.1263 *1264 * ONLY THE GRID'S OWN LAYERS ARE ASKED, which is what a hit test over a1265 * dozen layers cannot say for itself: everything else on the map is a1266 * feature and answers the second question, not this one.1267 *1268 * AND ONLY A NEIGHBOUR OR A CHILD IS AN ANSWER. The cell the reader is1269 * already inside is drawn over its own children -- as an outline while the1270 * subdivision is up, and as the whole of the grid when the telescope has1271 * bottomed out -- so a plan that answered for `scope` or `leaf` would hand1272 * back the address already held on every click, and the grid could not be1273 * descended at all. A neighbour is a step sideways and a child is a step in;1274 * those are the two moves a click on a grid means.1275 *1276 * The tolerance is one pixel rather than the four a marker gets. A cell is a1277 * region and the reader is pointing *into* it; slack around a boundary would1278 * only make the boundary ambiguous, and a boundary that cannot be told apart1279 * from the cell beyond it is a grid nobody can steer by.1280 */1281 private hitCell(pixel: number[]): CellPick | null {1282 const map = this.map;1283 const context = this.context;1284 if (!map || !context?.system || !context.scene.gridSystem) return null;1285 let found: CellPick | null = null;1286 map.forEachFeatureAtPixel(pixel, (feature) => {1287 const cell = feature.get("gridCell") as DrawnCell | undefined;1288 if (!cell || (cell.role !== "neighbor" && cell.role !== "child")) return false;1289 found = { kind: "cell", cell: cell.hash };1290 return true;1291 }, {1292 hitTolerance: 1,1293 layerFilter: (layer) => this.gridLayers.some((one) => one === layer),1294 });1295 return found;1296 }12971298 private hitFeature(pixel: number[]): FeaturePick | null {1299 const map = this.map;1300 const context = this.context;1301 if (!map || !context) return null;1302 let found: FeaturePick | null = null;1303 map.forEachFeatureAtPixel(pixel, (feature) => {1304 const record = feature.get("record") as PointRecord | ShapeRecord | undefined;1305 if (!record) return false;1306 if ("index" in record) {1307 if (context.visibility.at(record.index).hidden) return false;1308 found = { id: record.id, kind: "point" };1309 return true;1310 }1311 if (!context.visibility.shapesShown.includes(record)) return false;1312 found = { id: record.id, kind: record.kind };1313 return true;1314 }, { hitTolerance: 4 });1315 return found;1316 }13171318 /**1319 * The camera's one whisper upward.1320 *1321 * Debounced, because a settling camera that posted every frame would fight1322 * the reader's own hand, and answered with `204` so nothing swaps under1323 * them (docs/app.md §4.3).1324 */1325 private report(): void {1326 // A pane put away reports nothing. Where the reader left off is where they1327 // were looking, and behind the sphere the chart is not it: its camera1328 // there is whatever a fit into a window of no size produced, and saving1329 // that would reopen the volume somewhere nobody ever stood.1330 if (this.hidden) return;1331 if (this.settle !== undefined) clearTimeout(this.settle);1332 this.settle = setTimeout(() => {1333 const camera = this.camera();1334 if (camera && this.onSettled) this.onSettled(camera);1335 }, 400) as unknown as number;1336 }1337}13381339/** A ring or a run, copied out of the model so nothing downstream edits it. */1340function copyLine(line: readonly (readonly [number, number])[]): [number, number][] {1341 return line.map((point) => [point[0], point[1]] as [number, number]);1342}13431344/**1345 * One shape's drawn geometry: every part, kept apart.1346 *1347 * A district is one feature and may be a dozen separate pieces of ground; a1348 * trail is one feature and may be fifty runs of it. Both are multi-part by1349 * nature, so both are drawn by a multi-part geometry — a single `Polygon`1350 * reads its first ring as the exterior and every later one as a hole, so the1351 * second piece of ground would be punched out of the first, and a single1352 * `LineString` has room for one run and silently drops the other forty-nine.1353 */1354export function shapeGeometry(shape: ShapeRecord): MultiPolygon | MultiLineString {1355 return shape.kind === "area"1356 ? new MultiPolygon(parts(shape))1357 : new MultiLineString(shape.lines.map(copyLine));1358}13591360/**1361 * An area's parts, nested the way a polygon is: each part its own exterior1362 * followed by that part's own interior rings, and nobody else's.1363 *1364 * The model already keeps them apart — `holes[i]` belongs to `lines[i]` — and1365 * this is where that survives into the drawing. Flattened into one ring list,1366 * a two-piece district reads as one piece with the other punched out of it.1367 */1368export function parts(shape: ShapeRecord): [number, number][][][] {1369 return shape.lines.map((line, index) => [1370 copyLine(line),1371 ...(shape.holes[index] ?? []).map(copyLine),1372 ]);1373}13741375/**1376 * The scrim: the world, then every highlighted area's parts cut out of it,1377 * each part's interior rings wound back to solid.1378 */1379export function scrimGeometry(1380 world: readonly (readonly [number, number])[],1381 highlighted: readonly ShapeRecord[],1382): Polygon {1383 const rings: [number, number][][] = [wind(world, true)];1384 for (const shape of highlighted) {1385 // A path zone has no ring to cut from the dimming.1386 if (shape.kind !== "area") continue;1387 for (const part of parts(shape)) {1388 part.forEach((ring, index) => rings.push(wind(ring, index > 0)));1389 }1390 }1391 return new Polygon(rings);1392}13931394/**1395 * Where a shape's name is anchored when the payload did not say.1396 *1397 * The middle of the union of every part's extent — outer rings are enough,1398 * because a hole is inside its own ring and cannot widen the box. It is a1399 * question about the model rather than about the drawing, so splitting the1400 * drawn geometry into parts does not move it by a bit.1401 */1402export function centreOf(shape: ShapeRecord): [number, number] | null {1403 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;1404 for (const line of shape.lines) {1405 for (const [x, y] of line) {1406 minX = Math.min(minX, x); maxX = Math.max(maxX, x);1407 minY = Math.min(minY, y); maxY = Math.max(maxY, y);1408 }1409 }1410 if (!Number.isFinite(minX)) return null;1411 return [(minX + maxX) / 2, (minY + maxY) / 2];1412}14131414/** A ring wound the way a fill rule needs it: counter-clockwise, or not. */1415function wind(line: readonly (readonly [number, number])[], counter: boolean): [number, number][] {1416 let area = 0;1417 for (let i = 1; i < line.length; i++) {1418 const a = line[i - 1];1419 const b = line[i];1420 if (!a || !b) continue;1421 area += (b[0] - a[0]) * (b[1] + a[1]);1422 }1423 const copy = line.map((point) => [point[0], point[1]] as [number, number]);1424 return (area < 0) === counter ? copy : copy.reverse();1425}14261427/** A count as the chrome writes it: thousands separated, the same words the server renders. */1428function count(value: number): string {1429 return value.toLocaleString("en-US");1430}