1// `<atlas-globe>` — the sphere, for a world that declares one.2//3// A world is a ground and a lens is a picture of it; where the ground is a4// sphere and the picture is equirectangular, the picture can be draped back5// onto the shape it came off. That is the whole of this pane. It is offered6// only where `atlas.geometry.surface` says `sphere` and the flattening7// inverts, and everything it draws it reads from the same model and the same8// standing set the chart does — one filter, both panes, the same moment.9//10// FOUR BUDGETS, each of them a decision rather than a limit:11//12// The base skin is one texture, composited once per lens.13// The detail is a mesh per tile, draped under the camera only — never into14// the skin, which is level four across the whole window and would throw15// three quarters of a deeper capture away before drawing it.16// Names are raised only while Z is held, and at most 180 of them: past that17// a sphere is a word cloud with a planet behind it. WHICH 180 is the18// camera's answer and not the legend's — the nearest names to what is being19// looked at, out to the rim and no further.20// Sprites are built once per pin and afterwards only shown, hidden or21// re-dressed, so a filter costs a boolean per pin rather than a rebuild —22// and the dressing itself is one shared material per collection.23//24// THE HORIZON is enforced by hand, and for the pins as much as for the cards.25// Everything this pane raises off the ground turns the depth test off, because26// a screen-sized sprite anchored on a curve loses whatever half of itself the27// ground in front of it reaches over — which is what buried the limb pins to28// the waist while the reader's rule was the plain one, that an icon is drawn29// on top of the raster. `cull` then puts the silhouette back by hand, so30// nothing on the far side shines through.31//32// WHAT A PIN IS CULLED BY IS NOT `visible`. That boolean is the filters'33// answer and the diagnostics count it (`visibleSprites`), so a pin over the34// horizon is taken off the camera's layer35// instead and the standing count stays a reading of what the legend left36// rather than of where the camera happens to be pointing.37//38// THE CAMERA ROUND TRIP is the pane's one contract with the chart. A flip to39// the sphere and straight back must land the chart's camera exactly where it40// was: the pairing below is invertible arithmetic, and the view the globe was41// handed is handed back unchanged unless the reader actually moved it.4243import * as THREE from "three";44import { Line2 } from "three/examples/jsm/lines/Line2.js";45import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";46import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";47import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";48import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js";49import Globe from "globe.gl";50import type { GlobeInstance } from "globe.gl";51import {52 KEY_GEOMETRY_EQUIRECT_DEG,53 KEY_GEOMETRY_EQUIRECT_PX,54 KEY_GEOMETRY_SURFACE,55} from "@atlas/analysis/semconv/keys";56import {57 cellPlan, cellSystems, equirectMapping, gridCellVisual, gridTheme,58} from "@atlas/analysis";59import type { GeoMapping, PlanCell, Ring } from "@atlas/analysis";60import { logger } from "../log.ts";61import type { WorldContext } from "../context.ts";62import { tilePath } from "../data/pyramid.ts";63import type { Line as ShapeLine, ShapeRecord } from "../world/model.ts";64import type { Attrs, Collection, Lens } from "../data/payload.ts";65import { iconURL } from "../data/plane.ts";66import { LensCoverage } from "../data/pyramid.ts";67import { reportGridPick, reportPick } from "../data/report.ts";68import { cellMoved } from "../chart/grid.ts";69import { viewMaxZoom } from "../chart/projection.ts";70import { saneCamera } from "../chart/element.ts";71import { collectionColor, featureColor } from "../chart/styles.ts";72import {73 iconIsPicture, initialsOf, legibleIconColor, markerKey, markerRasterReady, outsetColor,74} from "../chart/markers.ts";75import type { MarkerFace } from "../chart/markers.ts";76import { Skin } from "./texture.ts";7778// The sphere's markers are the chart's: `markers.ts` composes them and both79// panes draw the same raster, so a collection cannot look like one thing on80// the sheet and another on the globe. `initialsOf` is re-exported because it81// is the sphere's fallback as much as the chart's.82export { initialsOf } from "../chart/markers.ts";8384const log = logger("globe");8586// THE PAIRING, calibrated rather than guessed.87//88// The two cameras answer to one another as a power law anchored at one point:89// the whole disc at altitude 2.5 reads like the whole chart at zoom 2, and90// each halving of altitude reads like one more zoom. It is deliberately *not*91// a field-of-view calculation -- an earlier draft of this seam derived the92// altitude from the resolution, the viewport height and the declared degree93// span, which is defensible arithmetic that pairs the cameras somewhere else94// entirely. The constants are calibrated against the reference's own recorded95// cameras (archived on the golden-reference tag): pushed out to the farthest96// distance, the chart reads a zoom of 1.3219 = 2 + log2(2.5 / 4), and two97// presses of the zoom-out button read an altitude of98// 0.68 = 2.5 / 2^(1.8826 - 2) / 4. Both fall out of the four constants below99// and nothing else does.100const WHOLE_DISC_ALTITUDE = 2.5;101const WHOLE_CHART_ZOOM = 2;102103// The camera keeps a respectful distance: never through the skin -- a camera104// inside the sphere sees the world inside out -- and never so far the planet105// is a dot. The clamps are load-bearing, not hygiene: the farthest is the106// altitude the calibration's 1.3219 zoom above is a reading of.107const NEAREST_ALTITUDE = 0.08;108const FARTHEST_ALTITUDE = 4;109110/** The level the base skin is woven at; detail is only ever deeper. */111const TEXTURE_ZOOM = 4;112113/**114 * The sphere globe.gl draws, in its own units. Only one thing reads it: the115 * ground-per-pixel a chip's fit is measured against, which is the sphere's116 * answer to the chart's resolution.117 */118const GLOBE_RADIUS = 100;119120/** Tiles the neighbourhood under the camera may hold at once. */121const DETAIL_TILE_BUDGET = 96;122123/**124 * Where the pyramid's own tiles are draped, and on how fine a grid.125 *126 * Just off the skin — under everything the grid draws and well under the pins127 * — and on an eight-by-eight grid per tile, because a flat quad laid over a128 * curve stands off the ground at its middle and cuts into it at its corners.129 */130const DETAIL_RADIUS = GLOBE_RADIUS + 0.2;131const DETAIL_SEGMENTS = 8;132133/**134 * The three radii the grid is drawn at, in the order it is painted.135 *136 * They are a hair apart on purpose: the sheet, the boundary over it, and the137 * chip over that, each far enough off the one below to be free of it and all138 * of them just off the tiles. Render order settles what the depth buffer139 * cannot — the fill writes no depth, so without it a dim cell drawn after its140 * neighbour's boundary would paint over the boundary.141 */142const FILL_RADIUS = DETAIL_RADIUS + 0.12;143const LINE_RADIUS = DETAIL_RADIUS + 0.25;144const CHIP_RADIUS = DETAIL_RADIUS + 0.35;145146/**147 * The zones' own steps on the same ladder: the wash, then the outline over148 * it, both over the grid's sheet and under its boundary.149 *150 * A country's border is ground the reader is looking at; a grid cell is151 * scaffolding laid over whatever ground is open. So a zone rides above a152 * cell's tint and below its edge, and a held cell still reads over the153 * borders inside it.154 */155const ZONE_FILL_RADIUS = DETAIL_RADIUS + 0.15;156const ZONE_RADIUS = DETAIL_RADIUS + 0.18;157158/**159 * The widest a fill triangle's edge may span, in degrees, before it is160 * subdivided. The same arithmetic as the rings': a straight edge between two161 * far corners is a chord through the planet, and a triangle whose middle sags162 * under the crust is a fill that renders perfectly, buried.163 */164const ZONE_TRIANGLE_SPAN = 3;165166/**167 * The order the sphere paints in, the top of the ladder last.168 *169 * A depth buffer cannot settle this pane's layering, so the layering is170 * stated: above the tiles, everything either writes no depth (the fills, the171 * boundaries) or refuses to test it (every sprite), for the reason the header172 * gives — a mark anchored on a curve loses half of itself to the ground in173 * front of it.174 *175 * PINS OVER THE GRID AND UNDER THE WRITING. A pin is what the reader came176 * for and a grid is the scaffolding it is read against, so a dim cell's wash177 * must not settle over the marks inside it; a cell's address and a feature's178 * name are writing *about* what is under them, so they go on top. That is the179 * reference's own order (`globe.js`: chips at 3 and cards at 4 drawn after180 * pins that tested depth against the skin and won), said in one place.181 */182const ORDER = { tile: 0, fill: 1, line: 2, pin: 3, chip: 4, card: 5 } as const;183184/**185 * The two layers a pin can stand on: the one the camera draws, and the one186 * over the horizon.187 *188 * A camera draws layer 0 and nothing else, so moving a sprite off it takes it189 * out of the frame as completely as hiding it would — and without touching190 * `visible`, which belongs to the filters and is what the diagnostics191 * count. The other two ways of hiding a pin are both closed: opacity lives on192 * a material shared by every pin of a collection, so dimming one dims twenty,193 * and scale is what a selection speaks with.194 */195const DRAWN_LAYER = 0;196const BEYOND_HORIZON_LAYER = 1;197198/** Names raised over the sphere at once. */199const LABEL_BUDGET = 180;200201/**202 * How far from what the camera faces a name may still be raised, in degrees.203 *204 * The budget says how many names; this says *which*. Past 85° a pin is on the205 * rim or behind it, and a card raised there is either edge-on or on ground206 * nobody can see -- so it takes a place in the budget away from a name the207 * reader is actually looking at. Collection priority decided this before, and208 * priority knows nothing about where the camera is standing.209 */210const LABEL_REACH_DEG = 85;211212/** Where a name's card floats: just off the skin, as a fraction of the radius. */213const LABEL_ALTITUDE = 0.006;214215/** A card's height on screen, and the canvas it is written on. */216const LABEL_HEIGHT = 0.028;217const CARD_FONT = "600 26px Inter, system-ui, sans-serif";218const CARD_TALL = 40;219const CARD_PAD = 24;220221/** A marker's canvas, and the pitch its initials are cut at. */222const MARKER_SIZE = 80;223const MARKER_FONT = "900 26px Inter, system-ui, sans-serif";224225/** How big a pin stands on screen, plain and chosen, and how far off the skin. */226const PIN_SIZE = 0.045;227const PIN_SELECTED_SIZE = 0.08;228const PIN_ALTITUDE = 0.005;229230function clamp(value: number, low: number, high: number): number {231 return Math.min(Math.max(value, low), high);232}233234/** How far out a chart zoom stands the camera. */235export function altitudeForZoom(zoom: number): number {236 const safe = Number.isFinite(zoom) ? zoom : WHOLE_CHART_ZOOM;237 return clamp(238 WHOLE_DISC_ALTITUDE / 2 ** (safe - WHOLE_CHART_ZOOM),239 NEAREST_ALTITUDE, FARTHEST_ALTITUDE);240}241242/** How close in a distance reads on the chart. The inverse, with its ceiling. */243export function zoomForAltitude(altitude: number, ceiling: number): number {244 const safe = Number.isFinite(altitude)245 ? Math.max(altitude, NEAREST_ALTITUDE / 2)246 : WHOLE_DISC_ALTITUDE;247 return clamp(WHOLE_CHART_ZOOM + Math.log2(WHOLE_DISC_ALTITUDE / safe), 0, ceiling);248}249250/**251 * Hold globe.gl's own controls to the pairing's clamps: the wheel is a door252 * too. Left alone, the controls dolly to a hair above the skin — under the253 * detail shell at `GLOBE_RADIUS + 0.2` and under the pins, where every face254 * is a back face and the pane goes black — and out to a hundred radii, where255 * the planet is a dot. Every other way of moving the camera (`steer`,256 * `changeZoom`, the flip) already clamps to the same two altitudes.257 */258export function holdControls(controls: { minDistance: number; maxDistance: number }): void {259 controls.minDistance = GLOBE_RADIUS * (1 + NEAREST_ALTITUDE);260 controls.maxDistance = GLOBE_RADIUS * (1 + FARTHEST_ALTITUDE);261}262263/** The camera as the chart speaks it. */264export interface ChartCamera {265 x: number;266 y: number;267 zoom: number;268 rotation: number;269}270271/** Where the camera stands on the sphere, in the units globe.gl speaks. */272export interface PointOfView {273 readonly lat: number;274 readonly lng: number;275 readonly altitude: number;276}277278/**279 * A point of view fit to be placed, or nothing.280 *281 * The sphere's half of the boundary guard, and the more dangerous half. A map282 * given a `NaN` centre draws nothing and can still be told a good one283 * afterwards; a *three.js* camera given a `NaN` position is a matrix of them284 * from the next frame on, and every reading taken off it after that --285 * including the one the sphere hands back to the chart when the reader flips286 * down -- is `NaN` too. That is the whole of how one broken number blacks out287 * both panes: it does not stay on the pane it landed on.288 *289 * So nothing that is not a place is ever placed, in either direction:290 * `povOf`, `cameraOf` and every steer through it.291 */292export function placeable(pov: PointOfView): PointOfView | null {293 return Number.isFinite(pov.lat) && Number.isFinite(pov.lng) &&294 Number.isFinite(pov.altitude) ? pov : null;295}296297/** The equirect window a world declares, in world pixels and degrees. */298export interface Equirect {299 readonly px: readonly [number, number, number, number];300 readonly deg: readonly [number, number, number, number];301 readonly mapping: GeoMapping;302}303304/** The declared flattening, or null when this world is not a sphere. */305export function equirectOf(attrs: Attrs): Equirect | null {306 const mapping = equirectMapping({ attrs });307 const px = quad(attrs[KEY_GEOMETRY_EQUIRECT_PX]);308 const deg = quad(attrs[KEY_GEOMETRY_EQUIRECT_DEG]);309 if (!mapping || !px || !deg) return null;310 return { px, deg, mapping };311}312313function quad(value: string | undefined): readonly [number, number, number, number] | null {314 const parts = (value ?? "").split(",").map(Number);315 if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) return null;316 return parts as [number, number, number, number];317}318319/** What the harness reads off the sphere: counts, never handles. */320export interface GlobeSeam {321 detail: { lens: string; tiles: Map<string, true> };322 grid: { group: THREE.Group | null; cell: string | null; fitKey: string };323 labels: { key: string; group: THREE.Group | null };324 sprites: Map<string, THREE.Object3D>;325}326327export class AtlasGlobe extends HTMLElement {328 private globe: GlobeInstance | null = null;329 private skin: Skin | null = null;330 private equirect: Equirect | null = null;331 private context: WorldContext | null = null;332 private texture: THREE.CanvasTexture | null = null;333 private readonly pins = new THREE.Group();334 private readonly labels = new THREE.Group();335 private readonly cells = new THREE.Group();336 private readonly zones = new THREE.Group();337 private readonly tiles = new THREE.Group();338 /** The zone geometry last built: the world and the legend's filter. */339 private zoneKey = "";340 /** The dress the zones last wore: the selection and the highlights. */341 private zoneStyleKey = "";342 private readonly sprites = new Map<string, THREE.Object3D>();343 /** The tiles draped under the camera, by `z/x/y`, and what each one is. */344 private readonly draped = new Map<string, THREE.Mesh>();345 /** The neighbourhood currently draped, so a camera that moved a hair redraws nothing. */346 private detailKey = "";347 private worldKey = "";348 private lensKey = "";349 private given: { lat: number; lng: number; altitude: number } | null = null;350 private handed: ChartCamera | null = null;351 private labelKey = "";352 private fitKey = "";353 /** The cell the grid was last drawn over: null while no grid is open. */354 private heldCell: string | null = null;355 /**356 * The system that cell was held in.357 *358 * Held beside the cell because the two only mean anything together. Cycling359 * the system carries the held cell to its equivalent in the next one — the360 * same ground, spelled another way — and the *string* changes, so a memo on361 * the cell alone reads that as a descent and flies the camera somewhere the362 * reader never asked to go. A cell moved is a cell moved **within one363 * system**; anything else is the same place under a new name.364 */365 private heldSystem: string | null = null;366 /** The feature a card was last open about, so a change in it can turn the planet. */367 private selected = "";368 /**369 * The handler globe.gl's controls were given, held so it can be taken off.370 *371 * Written inline it was unremovable, and a closure over `this` is exactly372 * the thing an element leaving the page has to stop being reachable from.373 */374 private moving: (() => void) | null = null;375 /**376 * The pane's own size, watched: globe.gl measures once and has to be told.377 *378 * Held rather than forgotten for the same reason the chart holds its own379 * (`chart/element.ts`): a `ResizeObserver` belongs to the browser, not to380 * this element, so one left running outlives the pane it was watching.381 */382 private sizes: ResizeObserver | null = null;383 /** Told when the camera moves, so the corner locator can follow it. */384 onCamera: ((pov: { lat: number; lng: number; altitude: number }) => void) | null = null;385386 /** The seam the diagnostics read, in the report's published shape (docs/render-seam.md §8). */387 readonly seam: GlobeSeam = {388 detail: { lens: "", tiles: new Map() },389 grid: { group: null, cell: null, fitKey: "" },390 labels: { key: "", group: null },391 sprites: this.sprites,392 };393394 /** Whether this world can be a sphere at all. */395 static offers(attrs: Attrs): boolean {396 return attrs[KEY_GEOMETRY_SURFACE] === "sphere" && equirectOf(attrs) !== null;397 }398399 /** Whether the sphere exists yet: entered at least once this session. */400 get built(): boolean {401 return this.globe !== null;402 }403404 /**405 * Take the world.406 *407 * Nothing is built until the sphere is actually entered — a WebGL context,408 * a texture and two thousand meshes are not what a reader looking at a409 * chart asked for. `globeBuilt` in the baselines is exactly this410 * distinction, so it is a real one and not an optimisation.411 */412 show(context: WorldContext): void {413 this.context = context;414 this.equirect = equirectOf(context.model.attrs);415 if (!this.equirect || !this.globe) return;416 const worldKey = `${context.base}/${context.model.slug}`;417 if (worldKey !== this.worldKey) {418 this.worldKey = worldKey;419 this.lensKey = "";420 // A WORLD IS A NEW SKIN, and saying so is the whole of the stale-skin421 // guard on this side. The window of world pixels the composite is laid422 // into is the world's own declaration, and the picture in it is the423 // world's own ground: everything the texture holds belonged to the world424 // being left, and so does every tile draped over it. The pyramid a425 // second world names may even be spelled the same as the first's, which426 // is why the skin is told the world as well (`texture.ts`).427 const [x, y, width, height] = this.equirect.px;428 this.skin?.retarget({ x, y, width, height });429 this.clearDetail();430 this.buildSprites(context);431 }432 this.openLens(context);433 this.update();434 }435436 private openLens(context: WorldContext): void {437 const lensKey = `${this.worldKey}#${context.lens?.tiles ?? ""}`;438 if (lensKey === this.lensKey || !context.lens) return;439 this.lensKey = lensKey;440 // A different pyramid: the neighbourhood under the camera belonged to the441 // one being left and is dropped rather than re-drawn. It comes back when442 // the camera next moves.443 this.clearDetail();444 void this.skin?.base(445 context.base, context.model.slug, context.lens,446 (z, x, y) => this.tileURL(z, x, y),447 () => this.refresh());448 this.seam.detail.lens = context.lens.tiles;449 }450451 /** A filter moved, or the held key: show and hide, never rebuild. */452 update(): void {453 const context = this.context;454 if (!context) return;455 if (context.scene.selected !== this.selected) {456 this.selected = context.scene.selected;457 // The ring is worn whether or not anybody is looking: a selection made458 // over the chart is what the sphere comes up already dressed for.459 this.restyle();460 this.face(this.selected);461 }462 // A sphere nobody is looking at shows and hides nothing. Everything below463 // is caught up in one call the moment it comes back up, and until then a464 // filter moving on the chart is not the sphere's business.465 if (this.hidden) return;466 context.model.points.forEach((point, index) => {467 const sprite = this.sprites.get(point.id);468 if (sprite) sprite.visible = !context.visibility.at(index).hidden;469 });470 // A pin a filter has just let back in stands wherever it stands, which may471 // be behind the planet: born on the camera's layer, it would shine through472 // until the reader next moved. `drawLabels` and `drawGrid` cull too, and473 // the horizon is cheap to answer twice.474 this.cull();475 this.drawLabels();476 this.drawGrid();477 this.drawZones();478 }479480 /** Come up, taking the chart's camera with you. Builds on first entry. */481 enter(camera: ChartCamera, viewport: { width: number; height: number }): void {482 this.hidden = false;483 const context = this.context;484 if (!this.globe && context) {485 this.build(context);486 this.worldKey = `${context.base}/${context.model.slug}`;487 this.buildSprites(context);488 this.openLens(context);489 this.update();490 }491 this.globe?.width(this.clientWidth || viewport.width);492 this.globe?.height(this.clientHeight || viewport.height);493 // The chart's camera, checked before it becomes a distance. The chart is494 // where this number has just come from, so it is nearly always already495 // sane -- and "nearly always" is exactly the word that made the reference496 // put its one guard on the other end of the same wire.497 const clean = context ? saneCamera(camera, context.lens, context.grid) : camera;498 const pov = clean && this.povOf(clean);499 if (!clean || !pov) {500 log.warn("the sphere refused a camera that is not a place", {501 op: "render", world: context?.model.slug, camera: JSON.stringify(camera),502 });503 return;504 }505 this.handed = clean;506 this.given = pov;507 this.steer(pov, 0);508 // Everything a filter moved while the sphere was down, caught up in one509 // call now that there is somebody looking.510 this.update();511 this.refreshDetail();512 this.onCamera?.(pov);513 log.info("the sphere is up", { op: "render", world: this.context?.model.slug, ...pov });514 }515516 /**517 * Go away, and hand the chart a camera back.518 *519 * If the reader never moved the sphere, the camera handed back is the one520 * it was given — the same numbers, not numbers that round-trip to within a521 * float of them. If they did move it, the pairing below is inverted522 * honestly and the chart lands where the sphere was looking.523 *524 * The pane's size is not asked for: the pairing between a distance and a525 * zoom is a property of the two cameras, not of the window they are seen526 * through (docs/render-seam.md §7, and THE PAIRING above).527 */528 leave(): ChartCamera | null {529 this.hidden = true;530 const pov = this.globe?.pointOfView();531 // A globe nobody is looking at holds no pyramid tiles: the skin under it532 // stays, because it is one texture and recompositing it is the expensive533 // half of coming back.534 this.clearDetail();535 // The name of the pyramid stays. It is the record of what the sphere read536 // while it was up, not a live handle on anything, and the baselines carry537 // it through the pane being put away.538 if (!pov || !this.given || !this.handed) return null;539 const unmoved = Math.abs(pov.lat - this.given.lat) < 1e-9 &&540 Math.abs(pov.lng - this.given.lng) < 1e-9 &&541 Math.abs(pov.altitude - this.given.altitude) < 1e-9;542 // The write-back, and the last place a number is looked at before the543 // other pane is standing on it (`saneCamera`, and `globe.js:182` before544 // it). `cameraOf` already refuses one it cannot invert; this holds the545 // one it can to the lens the chart is about to draw it under, and answers546 // nothing at all rather than a place that is not one -- which the host547 // reads as "leave the chart where it is" (`AtlasViewport.flipPane`).548 const back = unmoved ? this.handed : this.cameraOf(pov);549 const context = this.context;550 return context ? saneCamera(back, context.lens, context.grid) : back;551 }552553 /**554 * Leave the page, and give back everything a sphere holds.555 *556 * `leave` is the reader putting the pane away, and a keystroke undoes it;557 * this is the element itself going, and nothing survives it. A detached558 * globe keeps a live WebGL context, an animation frame asking for the next559 * one forever, and two callbacks closing over both — so a morph that560 * replaces the viewport on a volume navigation would leave a dead planet561 * rendering behind the new one, once per navigation.562 *563 * WHAT IS GIVEN BACK, and what deliberately is not. The names and the grid564 * are released, because every card in them is a canvas and a texture this565 * element minted. The pins are only *cleared*: a sprite's material is its566 * collection's, shared with every pin wearing the same mark and cached567 * across worlds, and its geometry is three's own quad shared by every sprite568 * in the scene — freeing those would take the marks out from under the next569 * sphere. It is also why the three groups come out of the scene before570 * `_destructor` empties it: globe.gl deallocates whatever it finds there,571 * and what it would find here is not its to free.572 *573 * AND THE ELEMENT STAYS RE-CONNECTABLE. A morph can take an element out and574 * put the same one back, so every memo that guards a rebuild is put back to575 * what a freshly constructed element carries — the lens key above all, which576 * would otherwise let `openLens` skip compositing a skin that no longer577 * exists and leave the sphere black. The world it was showing is *not*578 * forgotten: the context is what the next `enter` builds from.579 */580 disconnectedCallback(): void {581 this.dismantle();582 }583584 /**585 * Come back to the page, and start measuring it again.586 *587 * Only a *re*-connect has anything to rewire, exactly as on the chart: on588 * the first connect there is no globe yet -- nothing is built until the589 * sphere is entered -- and an observer wired before there is a planet to590 * resize would push dimensions at nothing.591 */592 connectedCallback(): void {593 if (!this.globe) return;594 this.watchSize();595 }596597 /**598 * Keep the sphere the size of its pane.599 *600 * globe.gl is told its dimensions once, at entry, out of the pane it was601 * given -- and on this page the pane moves without the window doing602 * anything: the dock folds under a keystroke, the panel beside the map603 * comes out the first time a search has something to say. A sphere that did604 * not hear about it went on drawing at the old size until something else605 * poked it, which is the defect this exists for.606 *607 * THREE THINGS MOVE WITH THE BOX, and only the first is globe.gl's:608 *609 * THE RENDERER, which is the picture itself and the camera's aspect.610 * THE FAT LINES. A `Line2` carries its width in *pixels*, which it can611 * only do by being told the size of the window it is measured against612 * (`cellBoundary`). Every boundary standing was told at the size the pane613 * had when the grid was drawn, so a resize that did not refresh them614 * leaves the whole hierarchy of line weights mis-scaled.615 * THE HORIZON. The cull is a question about where the camera is and how616 * much of the planet it can see, and a new aspect is a new answer.617 */618 private watchSize(): void {619 if (this.sizes || typeof ResizeObserver === "undefined") return;620 this.sizes = new ResizeObserver(() => this.resize());621 this.sizes.observe(this);622 }623624 /** One box change, pushed everywhere a box is remembered. */625 private resize(): void {626 const globe = this.globe;627 if (!globe) return;628 // A pane put away behind the chart is zero across, and so is one measured629 // mid-transition. Neither is a size to draw a planet at: a globe told it630 // is zero pixels wide throws its dimensions away, and the reader coming631 // back to the sphere would find nothing on it.632 const width = this.clientWidth;633 const height = this.clientHeight;634 if (!width || !height) return;635 globe.width(width);636 globe.height(height);637 for (const child of [...this.cells.children, ...this.zones.children]) {638 const material = (child as { material?: unknown }).material;639 if (material instanceof LineMaterial) material.resolution.set(width, height);640 }641 this.cull();642 }643644 /**645 * The world stopped declaring a sphere.646 *647 * A volume or a world change can land on ground no globe can be draped648 * over, and a pane that is not offered any more is not a pane to leave649 * standing: the planet on screen would go on wearing the last world's skin650 * with nothing but a hidden toggle to say otherwise. That is the defect this651 * exists for, and it is the reference's own contract — `syncGlobe` destroys652 * the instance, forgets what it was textured for, and empties the container653 * (frontend/src/globe.js).654 *655 * The camera is *not* handed back the way `leave` hands it back. A camera is656 * a place on a particular world, and the world it named is the one the657 * reader has just left; the chart opens the new world where the session658 * says, which is the same answer the reference gives by letting its mapping659 * come back null.660 */661 retire(): void {662 this.hidden = true;663 // Asked once per scene change while a plane is open, and only the first664 // one has anything to answer.665 if (!this.globe && !this.context) return;666 this.dismantle();667 // Unlike a disconnect, this is not a pane that might come back to the same668 // world: the next `enter` must build from whatever the reader opens next,669 // and until then this element is about nothing at all.670 this.context = null;671 this.equirect = null;672 }673674 private dismantle(): void {675 // Stopped, not merely forgotten: the callback closes over this element,676 // and through it over the scene, the skin and the world context.677 this.sizes?.disconnect();678 this.sizes = null;679 const globe = this.globe;680 if (globe && this.moving) globe.controls().removeEventListener("change", this.moving);681 this.moving = null;682 this.onCamera = null;683 release(this.labels);684 release(this.cells);685 release(this.zones);686 release(this.tiles);687 this.draped.clear();688 this.detailKey = "";689 this.zoneKey = "";690 this.zoneStyleKey = "";691 this.pins.clear();692 this.sprites.clear();693 if (globe) {694 globe.scene().remove(this.pins, this.labels, this.cells, this.zones, this.tiles);695 globe._destructor?.();696 // globe.gl's destructor gives back the renderer and empties the scene697 // and leaves its canvas exactly where it put it, holding the last frame698 // it drew. So the container is emptied by hand, as the reference does699 // (`elements.globe.replaceChildren()`): otherwise a retired sphere is700 // still a picture of a planet on screen, and the next build hangs a701 // second canvas behind the dead one.702 this.replaceChildren?.();703 }704 this.texture?.dispose();705 this.texture = null;706 this.skin = null;707 this.globe = null;708 this.worldKey = "";709 this.lensKey = "";710 this.labelKey = "";711 this.fitKey = "";712 this.heldCell = null;713 this.heldSystem = null;714 this.selected = "";715 this.given = null;716 this.handed = null;717 this.seam.detail = { lens: "", tiles: new Map() };718 this.seam.grid = { group: null, cell: null, fitKey: "" };719 this.seam.labels = { key: "", group: null };720 }721722 /** The globe's own rounding of its camera, non-empty only while Z is down. */723 diagnostics(): GlobeSeam {724 return this.seam;725 }726727 // ---- the pairing ----------------------------------------------------728729 /** The chart's camera as a point of view. */730 povOf(camera: ChartCamera): PointOfView | null {731 const equirect = this.equirect;732 if (!equirect) return null;733 const [lat, lng] = equirect.mapping.toLatLng(camera.x, -camera.y);734 return placeable({ lat, lng, altitude: altitudeForZoom(camera.zoom) });735 }736737 /** A point of view as the chart's camera. The inverse of `povOf`, exactly. */738 cameraOf(pov: PointOfView): ChartCamera | null {739 const equirect = this.equirect;740 const context = this.context;741 if (!equirect || !context) return null;742 const [x, y] = equirect.mapping.toWorld(pov.lat, pov.lng);743 // THE REFERENCE'S OWN GUARD, at the reference's own place: both744 // coordinates, checked before the map is told (`globe.js:182`). A745 // flattening is arithmetic over a point of view, so a point of view that746 // is not a place comes out of it as a coordinate that is not one.747 if (!Number.isFinite(x) || !Number.isFinite(y)) return null;748 return { x, y: -y, zoom: zoomForAltitude(pov.altitude, this.ceiling()), rotation: 0 };749 }750751 /**752 * Move the camera, and never to somewhere that is not a place.753 *754 * Every steer this element makes goes through here — coming up, descending755 * into a cell, following a selection — because they all end in the same756 * call and the call is one-way: a `NaN` handed to globe.gl is a `NaN` in757 * three's own camera, and there is no reading afterwards that says so.758 */759 private steer(pov: PointOfView, ms: number): void {760 const placed = placeable(pov);761 if (!placed) {762 log.warn("the sphere refused a point of view that is not a place", {763 op: "render", world: this.context?.model.slug,764 lat: String(pov.lat), lng: String(pov.lng), altitude: String(pov.altitude),765 });766 return;767 }768 this.globe?.pointOfView(placed, ms);769 }770771 /** How deep the chart is willing to go, which is the pairing's own ceiling. */772 private ceiling(): number {773 const lens = this.context?.lens;774 return lens ? viewMaxZoom(lens) : WHOLE_CHART_ZOOM;775 }776777 /**778 * One press of a zoom control, on the sphere.779 *780 * A press is one halving or doubling of the distance, read off the camera781 * where it stands rather than off a target this pane remembers -- two782 * presses in one tick therefore move the camera once.783 */784 changeZoom(delta: number): void {785 const globe = this.globe;786 if (!globe) return;787 const pov = globe.pointOfView();788 const standing = Number.isFinite(pov.altitude) ? pov.altitude : WHOLE_DISC_ALTITUDE;789 globe.pointOfView(790 { altitude: clamp(standing / 2 ** delta, NEAREST_ALTITUDE, FARTHEST_ALTITUDE) }, 180);791 }792793 /** Where the camera is looking, for the corner locator. */794 facing(): { lat: number; lng: number } | null {795 const pov = this.globe?.pointOfView();796 return pov ? { lat: pov.lat, lng: pov.lng } : null;797 }798799 // ---- building -------------------------------------------------------800801 private build(context: WorldContext): void {802 const equirect = this.equirect;803 if (!equirect) return;804 const [x, y, width, height] = equirect.px;805 this.skin = new Skin({ x, y, width, height }, context.grid);806 this.texture = new THREE.CanvasTexture(this.skin.canvas);807 this.texture.colorSpace = THREE.SRGBColorSpace;808809 this.globe = new Globe(this, { animateIn: false })810 .backgroundColor("#0b0e12")811 .showAtmosphere(true)812 .atmosphereColor("#7ea6c8")813 .atmosphereAltitude(0.12);814 wearSkin(this.globe.globeMaterial() as THREE.MeshPhongMaterial, this.texture);815816 holdControls(this.globe.controls());817818 const scene = this.globe.scene();819 scene.add(this.tiles);820 scene.add(this.zones);821 scene.add(this.pins);822 scene.add(this.labels);823 scene.add(this.cells);824 this.seam.labels.group = this.labels;825 this.seam.grid.group = this.cells;826827 this.buildSprites(context);828 this.watchCamera(this.globe);829 // The observer arrives with the planet, from here, the way the chart's830 // arrives with its world: before this there is no globe to be resized.831 this.watchSize();832 this.globe.onGlobeClick(({ lat, lng }) => this.pick(lat, lng));833 }834835 /**836 * Follow the camera, holding the handler that does it.837 *838 * One listener per built globe, and `build` is the only caller — which is839 * what keeps a sphere that has been put away and brought back reporting one840 * camera per move rather than one per life it has had.841 */842 private watchCamera(globe: GlobeInstance): void {843 this.moving = () => this.moved();844 globe.controls().addEventListener("change", this.moving);845 }846847 /**848 * One sprite per pin, built once and afterwards only shown, hidden or849 * re-dressed.850 *851 * A pin on the sphere wears what the same pin wears on the chart: its852 * collection's mark in its collection's colour, rimmed with the world's853 * declared outset. Two thousand identical cyan beads say only "something is854 * here", which is the one thing a reader can already see.855 *856 * The materials are shared and cached per collection, so this mints two857 * thousand *sprites* and a handful of textures -- and nothing here is858 * disposed on a rebuild, because none of it belongs to a pin.859 */860 private buildSprites(context: WorldContext): void {861 this.pins.clear();862 this.sprites.clear();863 const equirect = this.equirect;864 const globe = this.globe;865 if (!equirect || !globe) return;866 const marks = markersOf(context);867 for (const point of context.model.points) {868 const [lat, lng] = equirect.mapping.toLatLng(point.coordinate[0], -point.coordinate[1]);869 const chosen = point.id === context.scene.selected;870 const mark = marks.get(point.collection.id) ?? BARE_MARKER;871 const sprite = new THREE.Sprite(markerMaterial(mark, chosen));872 const size = chosen ? PIN_SELECTED_SIZE : PIN_SIZE;873 sprite.scale.set(size, size, 1);874 // Over the grid, under the writing, and on the camera's own layer until875 // `cull` says the pin has gone over the rim.876 sprite.renderOrder = ORDER.pin;877 sprite.layers.set(DRAWN_LAYER);878 const at = globe.getCoords(lat, lng, PIN_ALTITUDE);879 sprite.position.set(at.x, at.y, at.z);880 sprite.userData = {881 id: point.id, lat, lng, title: point.title, owner: point.collection.id,882 };883 this.pins.add(sprite);884 this.sprites.set(point.id, sprite);885 }886 }887888 /**889 * Dress the standing sprites for the selection.890 *891 * The chosen pin grows and takes its white ring; the one it replaced settles892 * back among the rest. Both materials come out of the same cache the build893 * drew from, so a selection costs two lookups rather than two thousand894 * canvases.895 */896 private restyle(): void {897 const context = this.context;898 if (!context) return;899 const marks = markersOf(context);900 for (const held of this.sprites.values()) {901 const sprite = held as THREE.Sprite;902 if (!sprite.isSprite) continue;903 const stood = held.userData as { id?: string; owner?: string | number };904 const mark = marks.get(stood.owner ?? "") ?? BARE_MARKER;905 const chosen = stood.id === context.scene.selected;906 sprite.material = markerMaterial(mark, chosen);907 const size = chosen ? PIN_SELECTED_SIZE : PIN_SIZE;908 sprite.scale.set(size, size, 1);909 }910 }911912 /**913 * The names, while Z is held.914 *915 * The key is the sphere's own rounding of its camera — latitude, longitude,916 * altitude, the system and the cell being held — and it is the one written917 * form of the globe's camera the harness can read. Letting Z go drops every918 * name, and the key with it.919 */920 private drawLabels(): void {921 const context = this.context;922 const globe = this.globe;923 if (!context || !globe) return;924 const pov = globe.pointOfView();925 // The rounding is the contract: whole degrees and two decimals of926 // altitude, which is what every recorded `globe-labels-held` is written927 // in ("0:0:0.68:geohash:"). Finer rounding would make the key move where928 // the baseline says it stands still.929 const key = context.labelsHeld ? [930 String(Math.round(pov.lat)), String(Math.round(pov.lng)), pov.altitude.toFixed(2),931 // The system, whether or not a grid is open: which system *would*932 // divide this world is a property of the world, and the key the933 // baselines record carries it over a closed grid ("0:0:0.68:geohash:").934 context.system?.slug ?? cellSystems.systems[0]?.slug ?? "", context.cell,935 ].join(":") : "";936 if (key === this.labelKey) return;937 this.labelKey = key;938 this.seam.labels.key = key;939 // Every rebuild mints a card canvas per name and a texture over it. The940 // group being emptied is not the texture being freed, and a camera settling941 // through a flight rebuilds these dozens of times.942 release(this.labels);943 if (!context.labelsHeld) return;944 // WHICH names, not which collections. The budget is what a sphere can945 // carry; the camera is what decides who spends it.946 for (const near of labelCandidates(pov, this.standing(), LABEL_BUDGET)) {947 const at = globe.getCoords(near.lat, near.lng, LABEL_ALTITUDE);948 this.labels.add(nameCard(near.title, at));949 }950 // Cards are born visible, and a rebuild with the camera already elsewhere951 // must not leave the far side's names shining through the planet.952 this.cull();953 }954955 /** The pins a name could be raised over: standing, placed and named. */956 private *standing(): Generator<Placed> {957 for (const held of this.sprites.values()) {958 if (!held.visible) continue;959 const stood = held.userData as Partial<Placed>;960 if (stood.title === undefined || stood.lat === undefined) continue;961 if (stood.lng === undefined) continue;962 yield { title: stood.title, lat: stood.lat, lng: stood.lng };963 }964 }965966 /**967 * The horizon, enforced by hand.968 *969 * Every sprite is drawn with the depth test off, because a screen-sized970 * sprite anchored on the ground loses whatever half of itself the planet's971 * own curve reaches over at a glancing angle -- the lower half of a card,972 * and the buried half of a pin at the limb. The price of that is the far973 * side of the world shining straight through, so the silhouette is applied974 * here instead: a point at the limb sits where the cosine of its angle from975 * the camera's axis equals the radius over the distance, and anything past976 * that is ground nobody can see.977 *978 * THE PINS ARE HELD TO IT THROUGH A DIFFERENT DOOR. `visible` is spoken979 * for: it is the filters' answer, and the diagnostics' `visibleSprites`980 * is a count of it. So a pin beyond the rim is moved off the layer the981 * camera draws, and back onto it when the planet turns -- the same982 * silhouette, and the standing count never moves.983 */984 private cull(): void {985 const camera = this.globe?.camera().position;986 if (!camera) return;987 for (const group of [this.cells, this.labels, this.zones]) {988 for (const child of group.children) {989 if (!(child as THREE.Sprite).isSprite) continue;990 child.visible = facesCamera(child.position, camera);991 }992 }993 for (const pin of this.pins.children) {994 pin.layers.set(facesCamera(pin.position, camera) ? DRAWN_LAYER : BEYOND_HORIZON_LAYER);995 }996 }997998 /** The grid, from the same plan and the same tokens the chart draws. */999 /**1000 * The zones, on the sphere, as the chart draws them.1001 *1002 * The chart's zone layer says what a world's ground *is*; until this the1003 * sphere drew pins over bare imagery and the first volume to carry areas on1004 * a sphere — the included Earth's countries — rendered on one pane and1005 * vanished on the other. The sphere draws what the chart draws, in the same1006 * per-feature accents its index rows wear: every shown shape's fill, faint1007 * until the shape is highlighted or selected, and its outlines over it,1008 * thicker and opaque when emphasized. A shape emphasized with a title wears1009 * a name card at its centre, which is the sphere's spelling of the chart's1010 * promoted area title. The scrim stays the chart's: dimming everything1011 * *outside* the highlights needs the even-odd geometry a flat sheet gives1012 * away and a sphere does not.1013 *1014 * Geometry is rebuilt only when the world or the legend's filter moves;1015 * a highlight or a selection restyles the meshes that already exist rather1016 * than tessellating a planet again.1017 */1018 private drawZones(): void {1019 const context = this.context;1020 const globe = this.globe;1021 const equirect = this.equirect;1022 if (!context || !globe || !equirect) return;1023 const shown = context.visibility.shapesShown;1024 const key = [this.worldKey, shown.map((shape) => shape.id).join(" ")].join("|");1025 if (key !== this.zoneKey) {1026 this.zoneKey = key;1027 this.zoneStyleKey = "";1028 release(this.zones);1029 const viewport = { width: this.clientWidth || 1, height: this.clientHeight || 1 };1030 for (const shape of shown) {1031 const fill = zoneFill(globe, shape, equirect.mapping);1032 if (fill) this.zones.add(fill);1033 const line = zoneMesh(globe, shape, equirect.mapping, {1034 color: featureColor(shape.id), opacity: 0.85, widthPx: 1.4,1035 }, viewport);1036 if (line) this.zones.add(line);1037 }1038 }1039 this.styleZones();1040 }10411042 /**1043 * Dress the zones for the scene: the chart's own weights and washes —1044 * stroke 1.4 and a tenth of the accent at rest, 2.4 and just over a quarter1045 * emphasized — and a name card over each emphasized shape that has one.1046 */1047 private styleZones(): void {1048 const context = this.context;1049 const globe = this.globe;1050 const equirect = this.equirect;1051 if (!context || !globe || !equirect) return;1052 const highlighted = new Set(context.visibility.highlightedShapes.map((shape) => shape.id));1053 const styleKey = [this.selected, ...highlighted].join(" ");1054 if (styleKey === this.zoneStyleKey) return;1055 this.zoneStyleKey = styleKey;1056 const emphasized = (id: unknown): boolean =>1057 typeof id === "string" && (highlighted.has(id) || id === this.selected);1058 for (const child of [...this.zones.children]) {1059 const worn = emphasized(child.userData.zone);1060 const material = (child as { material?: unknown }).material;1061 if (child.userData.role === "card") {1062 this.zones.remove(child);1063 dispose(child);1064 } else if (material instanceof LineMaterial) {1065 material.opacity = worn ? 1 : 0.85;1066 material.linewidth = worn ? 2.4 : 1.4;1067 } else if (material instanceof THREE.MeshBasicMaterial) {1068 material.opacity = worn ? 0.28 : 0.1;1069 }1070 }1071 for (const shape of context.visibility.shapesShown) {1072 if (!emphasized(shape.id) || !shape.title || !shape.center) continue;1073 const [lat, lng] = equirect.mapping.toLatLng(shape.center[0], -shape.center[1]);1074 const card = nameCard(shape.title, surfacePoint(globe, lat, lng, CHIP_RADIUS));1075 card.userData = { zone: shape.id, role: "card" };1076 this.zones.add(card);1077 }1078 this.cull();1079 }10801081 private drawGrid(): void {1082 const context = this.context;1083 const globe = this.globe;1084 const equirect = this.equirect;1085 if (!context || !globe || !equirect) return;1086 // Cleared *and released*, by the same call the names are released with.1087 release(this.cells);1088 // The fit key is not cleared when the grid closes. It is the last frame1089 // the camera was flown to hold a cell, and the baselines carry it through1090 // the grid closing, the pane being left, and the volume being reopened --1091 // a record of where the reader was taken, not a live flag.1092 if (!context.system || !context.scene.gridSystem) {1093 // No grid at all is *nothing held*, which is a different answer from a1094 // grid open over the whole ground and nothing chosen in it — and it is1095 // the answer that makes the next descent a change worth turning for.1096 this.seam.grid.cell = null;1097 return;1098 }1099 this.seam.grid.cell = context.cell;1100 // One cell, up to three objects, in the order the chart paints them: the1101 // tint or the dim on the ground, the boundary over it, and the address on1102 // a card in the corner. Which of the three a cell gets is the analysis1103 // lane's answer and not this pane's -- the same `gridCellVisual` the chart1104 // asks, handed the one thing only a renderer knows, which is whether the1105 // address would fit inside the cell as the camera currently sees it.1106 for (const cell of planOf(context)) {1107 // THE RING AS THE SYSTEM WALKED IT, not as the chart draws it.1108 // `cellRings` answers two questions about a flat sheet — where the1109 // antimeridian cuts a loop in two, and how a pole cell closes along the1110 // top edge of the picture — and neither is a question a sphere has. The1111 // trigonometry below is periodic, so a loop carrying its longitudes past1112 // 180 drapes exactly where it belongs, and a cut ring would be drawn as1113 // two pieces with a seam down the middle of a cell that has none.1114 const ring = ringLatLng(cell.ring, equirect.mapping);1115 const corners = boundsOf(ring);1116 if (!corners) continue;1117 const visual = gridCellVisual(context.ground, context.system, cell, {1118 subgridVisible: context.subgridVisible,1119 labelled: this.labelFits(cell, corners),1120 });1121 if (!visual) continue;1122 if (visual.fill) {1123 const [cx, cy] = context.system.on(context.ground).center(cell.hash);1124 const centre = equirect.mapping.toLatLng(cx, -cy);1125 const fill = ringFill(globe, ring, corners, centre, visual.fill);1126 if (fill) this.cells.add(fill);1127 }1128 const boundary = cellBoundary(globe, ring, visual.line,1129 { width: this.clientWidth || 1, height: this.clientHeight || 1 });1130 if (boundary) this.cells.add(boundary);1131 if (visual.label) this.cells.add(this.cellChip(visual.label, corners));1132 }1133 // The frame the camera was flown to hold the cell, which is what makes a1134 // held cell reproducible between runs rather than wherever the reader was1135 // when they chose it.1136 // The fit key is a reading of depth, not of place: the system, the cell,1137 // and the chart zoom the camera's distance reads as, in half steps. That1138 // is what the baselines record ("geohash:m:7"), and it is what decides1139 // when a chip could newly fit inside a cell's footprint.1140 // Descending holds a cell, and holding it turns the planet: a cell chosen1141 // on the far side of the world is a cell nobody can see. Only a *change*1142 // of held cell turns the camera — opening the grid over the ground the1143 // reader is already looking at moves nothing.1144 //1145 // AND ONLY WITHIN ONE SYSTEM. Cycling the system carries the held cell1146 // across to the cell of the next system that covers the same ground1147 // (`equivalentCell`), so the string changes while the place does not:1148 // "m" becomes "24" and the reader has not moved. Turning the planet for1149 // that is the camera answering a question about spelling. Both memos are1150 // written whatever happened, so the descent *after* a cycle is still read1151 // against the system it was made in.1152 const system = context.scene.gridSystem;1153 const held = this.heldCell;1154 if (held !== null &&1155 cellMoved({ system: this.heldSystem ?? "", cell: held }, { system, cell: context.cell })) {1156 this.frameCell();1157 }1158 this.heldCell = context.cell;1159 this.heldSystem = system;1160 // Chips are born visible, like the name cards, and are held to the same1161 // horizon for the same reason.1162 this.cull();1163 }11641165 /**1166 * Redraw the grid when the camera has moved far enough in depth that an1167 * address could newly fit inside a cell, or stop fitting.1168 *1169 * The fit key is that reading of depth -- the system, the cell, and the1170 * chart zoom the camera's distance reads as, in half steps -- and it is1171 * written here and nowhere else, because it is a fact about where the camera1172 * came to rest and a rebuild is not a camera move. It is never cleared: the1173 * baselines carry it through the grid closing and the pane being left, as a1174 * record of where the reader was taken.1175 */1176 private regridWhenFitChanges(): void {1177 const context = this.context;1178 const globe = this.globe;1179 if (!context || !globe || this.hidden || !context.scene.gridSystem) return;1180 const depth = Math.round(zoomForAltitude(globe.pointOfView().altitude, this.ceiling()) * 2);1181 const key = `${context.scene.gridSystem}:${context.cell}:${depth}`;1182 if (key === this.fitKey) return;1183 this.fitKey = key;1184 this.seam.grid.fitKey = key;1185 this.drawGrid();1186 }11871188 /**1189 * Whether a cell's address fits inside the cell as the camera sees it.1190 *1191 * The chart asks this of an extent over a resolution; the sphere has no1192 * resolution, so the ground-per-pixel is worked out from the camera's1193 * distance and the field of view, and the answer feeds the same gate.1194 */1195 private labelFits(cell: PlanCell, corners: Corners): boolean {1196 const globe = this.globe;1197 if (!globe) return false;1198 const size = cell.role === "neighbor"1199 ? gridTheme.neighborLabelSizePx1200 : gridTheme.labelSizePx;1201 const pov = globe.pointOfView();1202 const altitude = Math.max(pov.altitude, NEAREST_ALTITUDE / 2);1203 const field = (50 * Math.PI) / 180;1204 const groundPerPixel = (2 * altitude * GLOBE_RADIUS * Math.tan(field / 2)) /1205 Math.max(1, this.clientHeight || 1);1206 const degreePixels = ((GLOBE_RADIUS * Math.PI) / 180) / groundPerPixel;1207 const width = Math.abs(corners.east - corners.west) * degreePixels;1208 const height = Math.abs(corners.north - corners.south) * degreePixels;1209 return measure(cell.hash, `900 ${size}px ${gridTheme.labelFont}`) + 9 <= width &&1210 size + 6 <= height;1211 }12121213 /**1214 * A cell's address, on a card in its bottom-right corner.1215 *1216 * The prefix faint and the final character bright, exactly as the chart1217 * writes it, and anchored by its own corner so it stays put on screen while1218 * the planet turns under it.1219 */1220 private cellChip(1221 label: { prefix: string; final: string; color: string; textAlpha: number;1222 chip: string; sizePx: number },1223 corners: Corners,1224 ): THREE.Sprite {1225 const globe = this.globe;1226 const scale = 2;1227 const size = label.sizePx * scale;1228 const canvas = document.createElement("canvas");1229 const paper = canvas.getContext("2d");1230 const prefixFont = `500 ${size}px ${gridTheme.labelFont}`;1231 const finalFont = `900 ${size}px ${gridTheme.labelFont}`;1232 const pad = gridTheme.labelInsetPx * scale;1233 let prefixWidth = 0;1234 let finalWidth = size;1235 if (paper) {1236 paper.font = prefixFont;1237 prefixWidth = label.prefix ? paper.measureText(label.prefix).width : 0;1238 paper.font = finalFont;1239 finalWidth = paper.measureText(label.final).width;1240 }1241 canvas.width = Math.ceil(prefixWidth + finalWidth + pad * 2);1242 canvas.height = Math.ceil(size + pad * 1.4);1243 if (paper) {1244 paper.fillStyle = label.chip;1245 paper.fillRect(0, 0, canvas.width, canvas.height);1246 paper.textBaseline = "middle";1247 const middle = canvas.height / 2 + scale;1248 if (label.prefix) {1249 paper.font = prefixFont;1250 paper.fillStyle = label.color;1251 paper.globalAlpha = gridTheme.prefixAlpha * label.textAlpha;1252 paper.fillText(label.prefix, pad, middle);1253 }1254 paper.font = finalFont;1255 paper.globalAlpha = label.textAlpha;1256 paper.fillStyle = label.color;1257 paper.fillText(label.final, pad + prefixWidth, middle);1258 paper.globalAlpha = 1;1259 }1260 const texture = new THREE.CanvasTexture(canvas);1261 texture.colorSpace = THREE.SRGBColorSpace;1262 const sprite = new THREE.Sprite(new THREE.SpriteMaterial({1263 map: texture, depthTest: false, depthWrite: false,1264 transparent: true, sizeAttenuation: false,1265 }));1266 const insetLat = Math.abs(corners.north - corners.south) * 0.02;1267 const insetLng = Math.abs(corners.east - corners.west) * 0.02;1268 const at = globe1269 ? surfacePoint(globe, corners.south + insetLat, corners.east - insetLng, CHIP_RADIUS)1270 : null;1271 if (at) sprite.position.set(at.x, at.y, at.z);1272 const viewport = Math.max(1, this.clientHeight || 1);1273 const height = canvas.height / scale / viewport;1274 sprite.scale.set((height * canvas.width) / canvas.height, height, 1);1275 sprite.center.set(1, 0);1276 // The grid's own order: the sheet, the boundary, the chip -- and the chip1277 // over the pins as well, because an address is writing about the ground1278 // and not a thing standing on it (`ORDER`).1279 sprite.renderOrder = ORDER.chip;1280 return sprite;1281 }12821283 /**1284 * Turn the planet to hold the chosen cell, the way the chart fits its view1285 * when the navigator descends.1286 *1287 * The camera aims where the *system* says the cell's middle is — a pole1288 * cap's middle is the pole, which no corner of its frame would have named —1289 * and stands off by the cell's own span, so a cell fills the window whatever1290 * depth it was reached at.1291 */1292 private frameCell(): void {1293 const context = this.context;1294 const globe = this.globe;1295 const equirect = this.equirect;1296 if (!context?.system || !globe || !equirect) return;1297 const chosen = planOf(context)1298 .find((cell) => cell.role === "scope" || cell.role === "leaf");1299 if (!chosen) return;1300 const [x, y] = context.system.on(context.ground).center(chosen.hash);1301 const [lat, lng] = equirect.mapping.toLatLng(x, -y);1302 const corners = boundsOf(ringLatLng(chosen.ring, equirect.mapping));1303 if (!corners) return;1304 const span = Math.max(corners.east - corners.west, corners.north - corners.south);1305 const altitude = clamp(span / 45, NEAREST_ALTITUDE, FARTHEST_ALTITUDE);1306 this.steer({ lat, lng, altitude }, 400);1307 }13081309 /**1310 * Face the feature a card has just opened on.1311 *1312 * A ring drawn on the far side of a planet selects nothing anyone can see,1313 * so a selection made while the sphere is up turns it — keeping the reader's1314 * distance, because they chose a place and not a depth. A selection made1315 * over the chart is remembered and not acted on: coming up to the sphere1316 * afterwards lands on the chart's camera, which is where the reader was.1317 */1318 private face(id: string): void {1319 const globe = this.globe;1320 if (!globe || this.hidden || !id) return;1321 const stood = this.sprites.get(id)?.userData as1322 { lat?: number; lng?: number } | undefined;1323 if (stood?.lat !== undefined && stood.lng !== undefined) {1324 this.steer(1325 { lat: stood.lat, lng: stood.lng, altitude: globe.pointOfView().altitude }, 600);1326 return;1327 }1328 // Ground is an area, not a point, so the planet turns to show it whole —1329 // the sphere's spelling of the chart's fit — at the altitude its span1330 // asks for rather than whatever depth the reader happened to be at.1331 const shape = this.context?.model.shapeByID?.get(id);1332 const mapping = this.equirect?.mapping;1333 if (!shape || !mapping) return;1334 let corners: Corners | null = null;1335 for (const { ring } of zoneOutlines(shape)) {1336 const frame = boundsOf(ringLatLng(ring as Ring, mapping));1337 if (!frame) continue;1338 corners = corners ? {1339 north: Math.max(corners.north, frame.north),1340 south: Math.min(corners.south, frame.south),1341 east: Math.max(corners.east, frame.east),1342 west: Math.min(corners.west, frame.west),1343 } : frame;1344 }1345 if (!corners) return;1346 const centre = shape.center1347 ? mapping.toLatLng(shape.center[0], -shape.center[1])1348 : [(corners.north + corners.south) / 2, (corners.east + corners.west) / 2];1349 const span = Math.max(corners.east - corners.west, corners.north - corners.south);1350 const altitude = clamp(span / 45, NEAREST_ALTITUDE, FARTHEST_ALTITUDE);1351 this.steer({ lat: centre[0] ?? 0, lng: centre[1] ?? 0, altitude }, 600);1352 }13531354 private moved(): void {1355 this.refreshDetail();1356 if (this.context?.labelsHeld) this.drawLabels();1357 this.regridWhenFitChanges();1358 // The horizon moved with the camera even where nothing was rebuilt: the1359 // memo on the label key and the one on the fit key both stand still1360 // through a turn of the planet, and the silhouette does not.1361 this.cull();1362 const pov = this.globe?.pointOfView();1363 if (pov && this.onCamera) this.onCamera(pov);1364 }13651366 /**1367 * Drape the neighbourhood the camera is over.1368 *1369 * The level is the one whose tiles are about a quarter of what is on1370 * screen: shallower and the deep capture is wasted, deeper and the sphere1371 * asks for a hundred tiles to picture a continent.1372 *1373 * EACH TILE IS ITS OWN MESH, wearing its own texture at the size it was1374 * captured. That is the whole difference between the sphere reading as1375 * deeply as the chart and reading two levels shallower than it: the base1376 * skin is one four-thousand-pixel canvas of the entire equirect window,1377 * which is level four exactly, so a level-six tile composited into it is a1378 * 256-pixel square drawn 64 pixels wide and three quarters of the capture is1379 * thrown away before anything is drawn. Draped, the same tile covers the1380 * same ground at its own pitch, and the closer the camera comes the more of1381 * it there is to see.1382 */1383 private refreshDetail(): void {1384 const context = this.context;1385 const globe = this.globe;1386 const equirect = this.equirect;1387 if (!context?.lens || !globe || !equirect || !this.skin) return;1388 const pov = globe.pointOfView();1389 // The level the distance asks for, spoken through the pairing: one step1390 // deeper than the chart zoom the camera reads as. Nothing is draped until1391 // that is deeper than the skin already is -- a sphere seen whole is the1392 // base skin and nothing else, which is what makes "past the base skin's1393 // depth, tiles actually arrive" a statement a driver can check rather1394 // than a description of every frame.1395 const z = detailLevel(1396 zoomForAltitude(pov.altitude, this.ceiling()), context.lens.maxZoom);1397 if (z <= Math.max(TEXTURE_ZOOM, this.skin.baseLevel(context.lens))) {1398 this.clearDetail();1399 return;1400 }1401 const wanted = detailBlock(pov, z, DETAIL_TILE_BUDGET);1402 const key = `${context.lens.tiles}/${z}/` +1403 wanted.map(([x, y]) => `${x},${y}`).sort().join(" ");1404 if (key === this.detailKey) return;1405 this.detailKey = key;1406 this.seam.detail.lens = context.lens.tiles;1407 const coverage = new LensCoverage(context.lens);1408 const keep = new Set<string>();1409 for (const [x, y] of wanted) {1410 const name = `${z}/${x}/${y}`;1411 // Only where the capture says a tile was written: a lens is a picture of1412 // the ground it covered, and asking for the rest is a wall of 404s.1413 if (!coverage.has(context.grid, z, x, y)) continue;1414 keep.add(name);1415 if (this.draped.has(name)) continue;1416 const mesh = this.drapeTile(globe, context.lens, z, x, y);1417 if (!mesh) continue;1418 this.draped.set(name, mesh);1419 this.seam.detail.tiles.set(name, true);1420 this.tiles.add(mesh);1421 }1422 // What the camera has left behind. Dropped and disposed rather than kept1423 // against a return: a neighbourhood is a hundred textures, and the reader1424 // who flies back gets them from the browser's own cache.1425 for (const [name, mesh] of [...this.draped]) {1426 if (keep.has(name)) continue;1427 this.tiles.remove(mesh);1428 dispose(mesh);1429 this.draped.delete(name);1430 this.seam.detail.tiles.delete(name);1431 }1432 }14331434 /** One pyramid tile, draped at its place, invisible until its picture lands. */1435 private drapeTile(1436 globe: GlobeInstance, lens: Lens, z: number, x: number, y: number,1437 ): THREE.Mesh | null {1438 const url = this.tileURL(z, x, y);1439 if (!url) return null;1440 const span = 360 / 2 ** z;1441 const mesh = new THREE.Mesh(1442 tileGeometry(globe, -180 + x * span, 90 - y * span, span),1443 new THREE.MeshBasicMaterial());1444 // A black square teaches nothing: the tile appears when it has something1445 // to say, and the base skin carries the ground until then.1446 mesh.visible = false;1447 mesh.renderOrder = ORDER.tile;1448 new THREE.TextureLoader().load(url, (texture) => {1449 texture.colorSpace = THREE.SRGBColorSpace;1450 const material = mesh.material as THREE.MeshBasicMaterial;1451 material.map = texture;1452 material.needsUpdate = true;1453 mesh.visible = true;1454 });1455 return mesh;1456 }14571458 /** Put the neighbourhood away. A globe nobody is looking at holds none. */1459 private clearDetail(): void {1460 release(this.tiles);1461 this.draped.clear();1462 this.seam.detail.tiles.clear();1463 this.detailKey = "";1464 }14651466 private refresh(): void {1467 if (this.texture) this.texture.needsUpdate = true;1468 }14691470 private tileURL(z: number, x: number, y: number): string | null {1471 const context = this.context;1472 const lens = context?.lens;1473 if (!context || !lens) return null;1474 const extension = lens.formats[z - lens.minZoom];1475 if (!extension) return null;1476 const path = tilePath(lens, z, x, y);1477 return path ? `${context.base}/${path}` : null;1478 }14791480 private pick(lat: number, lng: number): void {1481 const context = this.context;1482 const equirect = this.equirect;1483 if (!context || !equirect) return;1484 if (this.descend(lat, lng)) return;1485 let best: { id: string; distance: number } | null = null;1486 for (const point of context.visibility.standing()) {1487 const [plat, plng] = equirect.mapping.toLatLng(point.coordinate[0], -point.coordinate[1]);1488 const distance = (plat - lat) ** 2 + (plng - lng) ** 2;1489 if (!best || distance < best.distance) best = { id: point.id, distance };1490 }1491 // Nothing near enough is a miss, and a miss says nothing at all.1492 if (!best || best.distance > 4) return;1493 // The same road the chart's picks take: the page renders the form, this1494 // fills it and says so (data/report.ts). The sphere draws pins and only1495 // pins, so the kind is settled here.1496 reportPick({ feature: best.id, kind: "point" });1497 }14981499 /**1500 * A press on the sphere while a grid is up: one level in, under the finger.1501 *1502 * PURE GEOMETRY, and the sphere has no other way of doing it. The chart can1503 * ask which cell it drew under a pixel because it drew them onto the same1504 * surface the pointer is over; here the cells are meshes on a globe and the1505 * press answers in degrees, so the point is flattened back onto the picture1506 * and the system is asked what lies below the held cell there. Which is the1507 * navigator's own arithmetic -- `descendTarget` is what typing one more1508 * character into the field means -- so the two roads meet at the same1509 * address, and both post the same form.1510 *1511 * `descendTarget` answers `""` at the floor of the telescope, which is1512 * "there is nothing below this" rather than "the root": the report refuses1513 * an empty address, and the click is a miss.1514 *1515 * Answers whether it claimed the press. With no grid up a press is a pick,1516 * as it has always been.1517 */1518 private descend(lat: number, lng: number): boolean {1519 const context = this.context;1520 const equirect = this.equirect;1521 if (!context || !equirect) return false;1522 if (!context.system || !context.scene.gridSystem) return false;1523 const [x, y] = equirect.mapping.toWorld(lat, lng);1524 const target = context.system.on(context.ground).descendTarget(context.cell, [x, -y]);1525 reportGridPick(target);1526 return true;1527 }1528}15291530/**1531 * The cells to draw: the analysis lane's plan, in its frozen order, exactly1532 * as the chart asks for it. Two panes, one plan, no second opinion.1533 */1534/** A cell's footprint in degrees, which is what a chip is placed by. */1535interface Corners {1536 readonly north: number;1537 readonly south: number;1538 readonly east: number;1539 readonly west: number;1540}15411542/**1543 * A system's ring, landed on the sphere.1544 *1545 * The ring is continuous by contract, so a loop that crossed the antimeridian1546 * simply carries its longitudes past 180 — the trigonometry underneath is1547 * periodic and drapes it where it belongs, which is why the globe never needs1548 * the splitting the chart does.1549 */1550export function ringLatLng(ring: Ring, mapping: GeoMapping): [number, number][] {1551 return ring.map(([x, y]) => {1552 const [lat, lng] = mapping.toLatLng(x, -y);1553 return [lat, lng];1554 });1555}15561557/** A ring's frame in degrees: the fit gate, the chip's anchor, the camera. */1558export function boundsOf(ring: readonly (readonly [number, number])[]): Corners | null {1559 let north = -Infinity;1560 let south = Infinity;1561 let east = -Infinity;1562 let west = Infinity;1563 for (const [lat, lng] of ring) {1564 north = Math.max(north, lat);1565 south = Math.min(south, lat);1566 east = Math.max(east, lng);1567 west = Math.min(west, lng);1568 }1569 return Number.isFinite(north) ? { north, south, east, west } : null;1570}15711572/**1573 * A ring, subdivided by the span of each of its own segments.1574 *1575 * THE ONE THING A SPHERE ASKS OF A RING. Two corners 45° apart joined by a1576 * straight line are joined by a chord *through the planet*: at a hundred units1577 * of radius the middle of that line sits seven units under the ground it is1578 * supposed to lie on, so the sheet a fill is made of is drawn inside the world1579 * and the boundary over it disappears into the crust. The large cells are1580 * exactly the ones this ruins — a dim neighbour at the root of a plan spans1581 * tens of degrees — which is why "the dim does not render" was a fair1582 * description of a mesh that was rendering perfectly, buried.1583 *1584 * Every two degrees of span, to a ceiling of 48 steps a segment. The floor is1585 * the caller's: a fill wants one step per segment at minimum, a boundary four,1586 * because a boundary is what a reader sees the curve *of*.1587 *1588 * The loop it answers is OPEN — the closing repeat of the first point is1589 * dropped — so a fan or a strip can close it by index arithmetic.1590 */1591export function densifyRing(1592 ring: readonly (readonly [number, number])[], floor: number,1593): [number, number][] {1594 const dense: [number, number][] = [];1595 for (let at = 0; at < ring.length - 1; at++) {1596 const from = ring[at];1597 const to = ring[at + 1];1598 if (!from || !to) continue;1599 const span = Math.max(Math.abs(to[0] - from[0]), Math.abs(to[1] - from[1]));1600 const steps = clamp(Math.ceil(span / 2), floor, 48);1601 for (let step = 0; step < steps; step++) {1602 const t = step / steps;1603 dense.push([from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t]);1604 }1605 }1606 return dense;1607}16081609/** How many rings of triangles a fill of this span is built from. */1610export function fillRows(span: number): number {1611 return clamp(Math.ceil(span / 6), 2, 24);1612}16131614/** A place on the sphere at an absolute radius, in globe.gl's own spelling. */1615function surfacePoint(1616 globe: GlobeInstance, lat: number, lng: number, radius: number,1617): { x: number; y: number; z: number } {1618 return globe.getCoords(lat, lng, radius / GLOBE_RADIUS - 1);1619}16201621/**1622 * A cell's tint or dim, laid on the ground.1623 *1624 * A fan of triangles from the cell's own middle out to its densified ring,1625 * ring by ring, each vertex pushed back out to the sphere it belongs on. The1626 * spokes are walked on the sphere itself rather than in degrees, where an1627 * unwrapped ring and a wrapped centre sit hundreds of degrees apart and a pole1628 * cell's middle has no honest longitude at all — but a straight walk from the1629 * middle to the rim is still a chord, so every vertex is lifted back to the1630 * radius before it is kept. Rows by span, for the same reason the ring is1631 * densified by span: one row of triangles across forty degrees sags as badly1632 * as one segment does.1633 */1634export function ringFill(1635 globe: GlobeInstance,1636 ring: readonly (readonly [number, number])[],1637 corners: Corners,1638 centre: readonly [number, number],1639 fill: { color: string; opacity: number },1640): THREE.Mesh | null {1641 const open = densifyRing(ring, 1);1642 if (open.length < 3) return null;1643 const middle = surfacePoint(globe, centre[0], centre[1], FILL_RADIUS);1644 const radius = Math.hypot(middle.x, middle.y, middle.z) || 1;1645 const edges = open.map(([lat, lng]) => surfacePoint(globe, lat, lng, FILL_RADIUS));1646 const rows = fillRows(Math.max(corners.east - corners.west, corners.north - corners.south));1647 const positions: number[] = [middle.x, middle.y, middle.z];1648 for (let row = 1; row <= rows; row++) {1649 const t = row / rows;1650 for (const edge of edges) {1651 const x = middle.x + (edge.x - middle.x) * t;1652 const y = middle.y + (edge.y - middle.y) * t;1653 const z = middle.z + (edge.z - middle.z) * t;1654 const lift = radius / (Math.hypot(x, y, z) || 1);1655 positions.push(x * lift, y * lift, z * lift);1656 }1657 }1658 const count = open.length;1659 const at = (row: number, index: number): number => 1 + (row - 1) * count + (index % count);1660 const indices: number[] = [];1661 for (let index = 0; index < count; index++) {1662 indices.push(0, at(1, index), at(1, index + 1));1663 }1664 for (let row = 1; row < rows; row++) {1665 for (let index = 0; index < count; index++) {1666 indices.push(at(row, index), at(row + 1, index), at(row, index + 1));1667 indices.push(at(row, index + 1), at(row + 1, index), at(row + 1, index + 1));1668 }1669 }1670 const geometry = new THREE.BufferGeometry();1671 geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));1672 geometry.setIndex(indices);1673 const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({1674 color: fill.color, transparent: true, opacity: fill.opacity,1675 side: THREE.DoubleSide, depthWrite: false,1676 }));1677 mesh.renderOrder = ORDER.fill;1678 return mesh;1679}16801681/**1682 * A cell's boundary, at the weight its role is drawn in.1683 *1684 * `Line2` rather than `THREE.Line`, and the whole role table is why: WebGL1685 * draws a native line one pixel wide and silently ignores the width it was1686 * asked for, so a leaf's 2.5, a bare scope's 1.8, a child's 1.4 and a1687 * neighbour's 1.0 all came out the same hairline and the hierarchy those1688 * weights encode was erased on the sphere while the chart drew it correctly.1689 * A line built out of quads carries its width honestly — in pixels, which is1690 * why the material has to be told the size of the window it is measured1691 * against.1692 *1693 * The segments are subdivided by their span for the same reason the fill is: a1694 * straight line between two corners 45° apart is a chord through the planet,1695 * and most of it is under the ground it is supposed to be drawn on.1696 */1697export function cellBoundary(1698 globe: GlobeInstance,1699 ring: readonly (readonly [number, number])[],1700 line: { color: string; opacity: number; widthPx: number },1701 viewport: { width: number; height: number },1702): Line2 | null {1703 const positions: number[] = [];1704 for (const [lat, lng] of densifyRing(ring, 4)) {1705 const at = surfacePoint(globe, lat, lng, LINE_RADIUS);1706 positions.push(at.x, at.y, at.z);1707 }1708 if (positions.length < 6) return null;1709 // Closed by hand: the walk above is the open loop, and a boundary with a gap1710 // in it is a cell that does not enclose anything.1711 positions.push(...positions.slice(0, 3));1712 const geometry = new LineGeometry();1713 geometry.setPositions(positions);1714 const material = new LineMaterial({1715 color: line.color, transparent: true, opacity: line.opacity,1716 linewidth: line.widthPx, depthWrite: false,1717 });1718 material.resolution.set(viewport.width || 1, viewport.height || 1);1719 const loop = new Line2(geometry, material);1720 loop.computeLineDistances();1721 loop.renderOrder = ORDER.line;1722 return loop;1723}17241725/**1726 * The outlines one shape draws: every outer ring, and every hole an area1727 * carries, each marked with whether it closes. An area's rings enclose ground1728 * and close; a path's lines lie as they lie.1729 */1730export function zoneOutlines(shape: ShapeRecord): { ring: ShapeLine; close: boolean }[] {1731 const close = shape.kind === "area";1732 const out: { ring: ShapeLine; close: boolean }[] = [];1733 shape.lines.forEach((line, index) => {1734 out.push({ ring: line, close });1735 for (const hole of shape.holes[index] ?? []) out.push({ ring: hole, close });1736 });1737 return out;1738}17391740/**1741 * One shape's outlines, landed on the sphere as a single object.1742 *1743 * `LineSegments2` rather than one `Line2` per ring: a country is often an1744 * archipelago — many rings, some of them holes — and segments are what let1745 * every disjoint piece ride one geometry and one draw call. Each segment is1746 * subdivided by its span exactly as the grid's boundaries are, and for the1747 * same reason: a straight chord between two far corners passes through the1748 * planet, not along it. Payload rings arrive split at the antimeridian by1749 * their producer, so no segment spans the seam and the trigonometry drapes1750 * each piece where it belongs.1751 */1752export function zoneMesh(1753 globe: GlobeInstance,1754 shape: ShapeRecord,1755 mapping: GeoMapping,1756 line: { color: string; opacity: number; widthPx: number },1757 viewport: { width: number; height: number },1758): LineSegments2 | null {1759 const positions: number[] = [];1760 for (const { ring, close } of zoneOutlines(shape)) {1761 const dense = densifyRing(ringLatLng(ring as Ring, mapping), 4);1762 if (dense.length < 2) continue;1763 const loop = close ? [...dense, dense[0]!] : dense;1764 let from = surfacePoint(globe, loop[0]![0], loop[0]![1], ZONE_RADIUS);1765 for (let at = 1; at < loop.length; at++) {1766 const to = surfacePoint(globe, loop[at]![0], loop[at]![1], ZONE_RADIUS);1767 positions.push(from.x, from.y, from.z, to.x, to.y, to.z);1768 from = to;1769 }1770 }1771 if (!positions.length) return null;1772 const geometry = new LineSegmentsGeometry();1773 geometry.setPositions(positions);1774 const material = new LineMaterial({1775 color: line.color, transparent: true, opacity: line.opacity,1776 linewidth: line.widthPx, depthWrite: false,1777 });1778 material.resolution.set(viewport.width || 1, viewport.height || 1);1779 const drawn = new LineSegments2(geometry, material);1780 drawn.computeLineDistances();1781 drawn.renderOrder = ORDER.line;1782 drawn.userData = { zone: shape.id, role: "line" };1783 return drawn;1784}17851786/**1787 * One shape's ground, tessellated onto the sphere.1788 *1789 * The cell fills fan from a centre and cannot honestly cover a concave,1790 * holed, many-part country, so the ground is triangulated the way a flat1791 * renderer would — each part's outer ring against its holes, in degree space1792 * — and then every triangle wider than a few degrees is cut down before its1793 * vertices are lifted to the sphere, for the reason every ring is densified:1794 * a triangle's middle is a chord, and a chord through a planet is a fill1795 * buried under its own ground. Payload rings arrive split at the1796 * antimeridian, so degree space is honest for exactly the shapes that reach1797 * here.1798 */1799export function zoneFill(1800 globe: GlobeInstance,1801 shape: ShapeRecord,1802 mapping: GeoMapping,1803): THREE.Mesh | null {1804 if (shape.kind !== "area") return null;1805 const positions: number[] = [];1806 shape.lines.forEach((outer, index) => {1807 const contour = openRing(ringLatLng(outer as Ring, mapping));1808 if (contour.length < 3) return;1809 const holes = (shape.holes[index] ?? [])1810 .map((hole) => openRing(ringLatLng(hole as Ring, mapping)))1811 .filter((hole) => hole.length >= 3);1812 const flat = [contour, ...holes].flat();1813 const triangles = THREE.ShapeUtils.triangulateShape(1814 contour.map(([lat, lng]) => new THREE.Vector2(lng, lat)),1815 holes.map((hole) => hole.map(([lat, lng]) => new THREE.Vector2(lng, lat))));1816 for (const [a, b, c] of triangles) {1817 const held = [flat[a!], flat[b!], flat[c!]];1818 if (held.every(Boolean)) sinkTriangle(globe, held as [number, number][], positions);1819 }1820 });1821 if (!positions.length) return null;1822 const geometry = new THREE.BufferGeometry();1823 geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));1824 const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({1825 color: featureColor(shape.id), transparent: true, opacity: 0.1,1826 side: THREE.DoubleSide, depthWrite: false,1827 }));1828 mesh.renderOrder = ORDER.fill;1829 mesh.userData = { zone: shape.id, role: "fill" };1830 return mesh;1831}18321833/** A payload ring without its closing repeat, which a triangulation refuses. */1834function openRing(ring: [number, number][]): [number, number][] {1835 const first = ring[0];1836 const last = ring[ring.length - 1];1837 if (ring.length > 1 && first && last && first[0] === last[0] && first[1] === last[1]) {1838 return ring.slice(0, -1);1839 }1840 return ring;1841}18421843/**1844 * One triangle of ground, subdivided by its span and landed on the sphere.1845 *1846 * Uniform barycentric rows: the longest edge decides how many, every corner1847 * of every piece is lifted to the fill radius, and the pieces are appended as1848 * plain position triples — an unindexed mesh, because sharing vertices across1849 * a subdivision saves less than it costs to book-keep.1850 */1851function sinkTriangle(1852 globe: GlobeInstance, corners: [number, number][], positions: number[],1853): void {1854 const [a, b, c] = corners as [[number, number], [number, number], [number, number]];1855 const span = Math.max(1856 Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]),1857 Math.abs(b[0] - c[0]), Math.abs(b[1] - c[1]),1858 Math.abs(c[0] - a[0]), Math.abs(c[1] - a[1]));1859 const rows = clamp(Math.ceil(span / ZONE_TRIANGLE_SPAN), 1, 32);1860 const at = (i: number, j: number): [number, number] => {1861 const u = i / rows;1862 const v = j / rows;1863 return [1864 a[0] + (b[0] - a[0]) * u + (c[0] - a[0]) * v,1865 a[1] + (b[1] - a[1]) * u + (c[1] - a[1]) * v,1866 ];1867 };1868 const push = (point: [number, number]): void => {1869 const landed = surfacePoint(globe, point[0], point[1], ZONE_FILL_RADIUS);1870 positions.push(landed.x, landed.y, landed.z);1871 };1872 for (let i = 0; i < rows; i++) {1873 for (let j = 0; j < rows - i; j++) {1874 push(at(i, j));1875 push(at(i + 1, j));1876 push(at(i, j + 1));1877 if (j < rows - i - 1) {1878 push(at(i + 1, j));1879 push(at(i + 1, j + 1));1880 push(at(i, j + 1));1881 }1882 }1883 }1884}18851886/**1887 * The level the camera's distance asks the pyramid for.1888 *1889 * One step deeper than the chart zoom the distance reads as, because a tile1890 * draped on a sphere is seen at a glancing angle over most of its own ground,1891 * and never deeper than the capture actually goes.1892 */1893export function detailLevel(zoom: number, maxZoom: number): number {1894 return Math.min(Math.round(zoom) + 1, maxZoom);1895}18961897/**1898 * The block of tiles under the camera, in the pyramid's own columns and rows.1899 *1900 * How much ground the camera can possibly see is the horizon angle at this1901 * altitude, padded by one tile so tiles arrive before their ground does, then1902 * pulled in until the neighbourhood fits its budget.1903 *1904 * The block is named in tiles rather than measured in pixels. A tile is a1905 * whole square of latitude and longitude at this level, so the camera faces1906 * exactly one of them and the neighbourhood is the (2·reach+1)² square around1907 * it: wrapped in longitude, because the sphere closes, and clipped in1908 * latitude, because it does not.1909 */1910export function detailBlock(1911 pov: { lat: number; lng: number; altitude: number }, z: number, budget: number,1912): [number, number][] {1913 const columns = 2 ** z;1914 const rows = columns / 2;1915 const span = 360 / columns;1916 const horizon = (Math.acos(1 / (1 + pov.altitude)) * 180) / Math.PI + span;1917 let reach = Math.ceil(horizon / span);1918 while ((2 * reach + 1) ** 2 > budget && reach > 1) reach -= 1;1919 const centreColumn = Math.floor(((((pov.lng + 180) % 360) + 360) % 360) / span);1920 const centreRow = Math.floor((90 - pov.lat) / span);1921 const wanted: [number, number][] = [];1922 for (let down = -reach; down <= reach; down++) {1923 const row = centreRow + down;1924 if (row < 0 || row >= rows) continue;1925 for (let across = -reach; across <= reach; across++) {1926 wanted.push([(((centreColumn + across) % columns) + columns) % columns, row]);1927 }1928 }1929 return wanted;1930}19311932/**1933 * The grid one tile is draped on.1934 *1935 * Through the same latitude-and-longitude spelling the pins stand by, so a1936 * tile lands on the pixel the base skin and the markers already agree on — and1937 * on a grid rather than a quad, because a flat square laid over a curve stands1938 * off the ground in the middle and cuts into it at the corners.1939 */1940export function tileGeometry(1941 globe: GlobeInstance, west: number, north: number, span: number,1942): THREE.BufferGeometry {1943 const positions: number[] = [];1944 const uvs: number[] = [];1945 const indices: number[] = [];1946 for (let down = 0; down <= DETAIL_SEGMENTS; down++) {1947 const lat = north - (down / DETAIL_SEGMENTS) * span;1948 for (let across = 0; across <= DETAIL_SEGMENTS; across++) {1949 const lng = west + (across / DETAIL_SEGMENTS) * span;1950 const at = surfacePoint(globe, lat, lng, DETAIL_RADIUS);1951 positions.push(at.x, at.y, at.z);1952 uvs.push(across / DETAIL_SEGMENTS, 1 - down / DETAIL_SEGMENTS);1953 }1954 }1955 const stride = DETAIL_SEGMENTS + 1;1956 for (let down = 0; down < DETAIL_SEGMENTS; down++) {1957 for (let across = 0; across < DETAIL_SEGMENTS; across++) {1958 const corner = down * stride + across;1959 indices.push(corner, corner + stride, corner + 1);1960 indices.push(corner + 1, corner + stride, corner + stride + 1);1961 }1962 }1963 const geometry = new THREE.BufferGeometry();1964 geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));1965 geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));1966 geometry.setIndex(indices);1967 return geometry;1968}19691970/** One shared canvas for measuring text: measuring is not drawing. */1971let ruler: CanvasRenderingContext2D | null | undefined;19721973function measure(text: string, font: string): number {1974 ruler ??= document.createElement("canvas").getContext("2d");1975 if (!ruler) return text.length * 8;1976 ruler.font = font;1977 return ruler.measureText(text).width;1978}19791980function planOf(context: WorldContext): PlanCell[] {1981 if (!context.system) return [];1982 return cellPlan(context.ground, context.system, context.cell);1983}19841985/** A pin the camera could raise a name over. */1986export interface Placed {1987 readonly title: string;1988 readonly lat: number;1989 readonly lng: number;1990}19911992/**1993 * Hang the composited skin on the sphere's own material.1994 *1995 * TWO LINES, AND THE SECOND ONE IS THE WHOLE OF `screen-globe`. globe.gl hands1996 * out a sphere with no picture on it, and it says so in the only way a Phong1997 * material can: it sets `color` to black. Its own `globeImageUrl` clears that1998 * colour when a texture finishes loading — this seam never calls it, because1999 * the skin is a live canvas recomposited under the camera and routing four2000 * megapixels through a data URL on every camera move is not a way to draw a2001 * map — so clearing the tint is this function's job.2002 *2003 * A Phong material multiplies its map by its colour. Leave the colour black2004 * and the skin is multiplied away: the texture is there, every count in the2005 * snapshot is right, every sprite draws, and the planet under them is black.2006 * That is the exact defect only a picture catches: every counting instrument2007 * in the diagnostics reads a healthy scene over a black planet.2008 *2009 * White rather than `null`, which is what globe.gl writes: three reads a null2010 * colour as "leave the shader's own default", and the shader's own default is2011 * white. Saying white says the same thing in a value the type admits.2012 */2013export function wearSkin(material: THREE.MeshPhongMaterial, skin: THREE.Texture): void {2014 material.map = skin;2015 material.color = new THREE.Color(0xffffff);2016 material.needsUpdate = true;2017}20182019/**2020 * The great-circle separation of two places, in degrees.2021 *2022 * Which is what "near what the camera is looking at" means on a sphere: not2023 * the difference of two latitudes and two longitudes, which calls the whole2024 * arctic a neighbourhood.2025 */2026export function angularDistance(2027 a: { lat: number; lng: number }, b: { lat: number; lng: number },2028): number {2029 const rad = Math.PI / 180;2030 const inner = Math.sin(a.lat * rad) * Math.sin(b.lat * rad) +2031 Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.cos((a.lng - b.lng) * rad);2032 return (Math.acos(clamp(inner, -1, 1)) * 180) / Math.PI;2033}20342035/**2036 * The names worth raising: the nearest ones to what the camera faces, within2037 * the reach and inside the budget.2038 *2039 * The budget is a decision about a sphere -- past a couple of hundred names a2040 * planet is a word cloud -- and the ordering is a decision about a *reader*.2041 * Spending it in collection order hands every card to whichever collection2042 * happens to be rarest, wherever in the world it stands, and leaves the ground2043 * under the camera bare.2044 */2045export function labelCandidates(2046 pov: { lat: number; lng: number },2047 standing: Iterable<Placed>,2048 budget: number,2049): Placed[] {2050 const near: { placed: Placed; distance: number }[] = [];2051 for (const placed of standing) {2052 const distance = angularDistance(pov, placed);2053 if (distance > LABEL_REACH_DEG) continue;2054 near.push({ placed, distance });2055 }2056 near.sort((a, b) => a.distance - b.distance);2057 return near.slice(0, budget).map((entry) => entry.placed);2058}20592060/**2061 * Whether a point on the sphere is on the side of it the camera can see.2062 *2063 * The limb is where the cosine of a point's angle from the camera's own axis2064 * equals the radius over the camera's distance: nearer the axis than that and2065 * the point is facing us, past it and the planet is in the way.2066 */2067export function facesCamera(2068 anchor: { x: number; y: number; z: number },2069 camera: { x: number; y: number; z: number },2070 radius = GLOBE_RADIUS,2071): boolean {2072 const distance = Math.hypot(camera.x, camera.y, camera.z) || 1;2073 const reach = Math.hypot(anchor.x, anchor.y, anchor.z) || 1;2074 const facing = (anchor.x * camera.x + anchor.y * camera.y + anchor.z * camera.z) /2075 (reach * distance);2076 return facing > radius / distance;2077}20782079/**2080 * One name, on a card floated above its pin.2081 *2082 * The card is cut to the name: the text is measured in the font it will be2083 * drawn in, and the canvas, the texture and the sprite's own aspect all follow2084 * that one number. A fixed canvas clips every name longer than it and pads2085 * every name shorter, and a fixed sprite scale then stretches whatever2086 * survived to the same width regardless -- so "Olympus Mons" and "Tharsis2087 * Tholus" came out the same size and neither was the size it asked for.2088 */2089export function nameCard(2090 title: string, at: { x: number; y: number; z: number },2091): THREE.Sprite {2092 const width = Math.ceil(measure(title, CARD_FONT)) + CARD_PAD;2093 const canvas = document.createElement("canvas");2094 canvas.width = width;2095 canvas.height = CARD_TALL;2096 const paper = canvas.getContext("2d");2097 if (paper) {2098 paper.font = CARD_FONT;2099 paper.textAlign = "center";2100 paper.textBaseline = "middle";2101 paper.fillStyle = "rgba(10, 13, 17, 0.78)";2102 paper.fillRect(0, 0, width, CARD_TALL);2103 paper.fillStyle = "#e6ebf0";2104 paper.fillText(title, width / 2, 21);2105 }2106 const texture = new THREE.CanvasTexture(canvas);2107 texture.colorSpace = THREE.SRGBColorSpace;2108 const sprite = new THREE.Sprite(new THREE.SpriteMaterial({2109 // No depth test: a screen-sized card anchored on the ground loses its2110 // lower half to the planet's curve at any glancing angle. `cull` puts the2111 // horizon back by hand.2112 map: texture, depthTest: false, depthWrite: false,2113 sizeAttenuation: false, transparent: true,2114 }));2115 sprite.position.set(at.x, at.y, at.z);2116 sprite.renderOrder = ORDER.card;2117 sprite.scale.set((LABEL_HEIGHT * width) / CARD_TALL, LABEL_HEIGHT, 1);2118 // Anchored at its bottom edge, so the card floats above the marker rather2119 // than covering it.2120 sprite.center.set(0.5, 0);2121 return sprite;2122}21232124/**2125 * What a collection's marker is, as the sphere has to draw it.2126 *2127 * A face and a name: everything about how the mark is composed is the face's,2128 * shared with the chart, and the name is only what the initials fall back to2129 * while the symbol is on its way.2130 */2131export interface Marker extends MarkerFace {2132 readonly title: string;2133}21342135/**2136 * What a pin wears when its collection is not in the world's own list.2137 *2138 * Which cannot happen through the model, and is a shared cache entry rather2139 * than a fresh material anyway: a marker nobody can account for is still not2140 * two thousand canvases nobody disposes.2141 */2142const BARE_MARKER: Marker = {2143 asset: "", url: "", picture: false, color: "#4fb3d5",2144 outset: outsetColor("light"), title: "",2145};21462147/** Every collection's marker, in payload order, which is palette order. */2148function markersOf(context: WorldContext): Map<string | number, Marker> {2149 const outset = outsetColor(context.outset);2150 const marker = (collection: Collection, ordinal: number): [string | number, Marker] => [2151 collection.id,2152 {2153 asset: collection.iconAsset ?? "",2154 url: collection.iconAsset ? iconURL(context.base, collection.iconAsset) : "",2155 picture: iconIsPicture(collection),2156 // The same ladder the chart climbs, because it is the same mark: a2157 // colour that collides with the rim is lifted or dropped, and the two2158 // panes must not disagree about which colour a collection is.2159 color: legibleIconColor(collectionColor(collection, ordinal), context.outset),2160 outset,2161 title: collection.title,2162 },2163 ];2164 return new Map(context.model.collections.map(marker));2165}21662167/**2168 * One sprite material per marker and selection state, cached and shared.2169 *2170 * A material is what a pin *is*, not what a pin *has*: two thousand pins of2171 * twenty collections are forty materials, and a filter or a selection swaps2172 * which of the forty a sprite points at. The cache is therefore never2173 * disposed -- entries outlive every sprite that wore them, which is the whole2174 * point of them.2175 */2176export function markerMaterial(marker: Marker, selected: boolean): THREE.SpriteMaterial {2177 const key = `${markerKey(marker)}:${selected ? "ringed" : "plain"}`;2178 const held = markers.get(key);2179 if (held) return held;2180 // Pins keep one size on screen however close the camera comes, the way the2181 // chart draws its markers: world-sized sprites become dinner plates from low2182 // altitude.2183 //2184 // AND THEY ARE NOT CLIPPED BY THE GROUND THEY STAND ON. A pin's sprite is2185 // screen-sized around a point half a unit off the skin, so at the limb most2186 // of that square is over ground *nearer* the camera than its own anchor:2187 // tested against the depth buffer, the planet swallowed the buried half and2188 // the reader saw icons sunk to the waist in the rim. The rule is the plain2189 // one -- an icon is drawn on top of the raster -- and the far side is kept2190 // out by `cull` rather than by the depth buffer.2191 const material = new THREE.SpriteMaterial({2192 depthTest: false, depthWrite: false, sizeAttenuation: false,2193 });2194 markers.set(key, material);2195 const dress = (image: HTMLImageElement | null): void => {2196 material.map = markerTexture(marker, selected, image);2197 material.needsUpdate = true;2198 };2199 // A collection with no artwork has nothing to wait for.2200 if (!marker.asset) {2201 dress(null);2202 return material;2203 }2204 // The chart's raster, not the raw artwork: the symbol is already tinted and2205 // already haloed, and composing it a second time here would be the sphere2206 // deciding for itself what a Shrine looks like.2207 void markerRasterReady(marker).then((raster) => {2208 if (!raster) {2209 // A collection with no artwork, or one whose artwork never arrived,2210 // wears its initials — which is what a build with no icons at all draws.2211 dress(null);2212 return;2213 }2214 const image = new Image();2215 image.onload = (): void => { dress(image); };2216 image.onerror = (): void => { dress(null); };2217 image.src = raster;2218 });2219 return material;2220}22212222const markers = new Map<string, THREE.SpriteMaterial>();22232224/**2225 * The 80×80 canvas a marker is drawn on: the chart's raster, or the initials.2226 *2227 * The raster arrives finished — the symbol already carries its collection's2228 * colour and already wears its halo — so all the sphere does with it is place2229 * it, inset by eight so the ring has somewhere to go.2230 */2231function markerTexture(2232 marker: Marker, selected: boolean, image: HTMLImageElement | null,2233): THREE.CanvasTexture {2234 const canvas = document.createElement("canvas");2235 canvas.width = MARKER_SIZE;2236 canvas.height = MARKER_SIZE;2237 const paper = canvas.getContext("2d");2238 if (paper && image) {2239 paper.drawImage(image, 8, 8, 64, 64);2240 } else if (paper) {2241 const short = initialsOf(marker.title);2242 paper.font = MARKER_FONT;2243 paper.textAlign = "center";2244 paper.textBaseline = "middle";2245 paper.lineWidth = 6;2246 paper.strokeStyle = marker.outset;2247 paper.strokeText(short, 40, 41);2248 paper.fillStyle = marker.color;2249 paper.fillText(short, 40, 41);2250 }2251 if (paper && selected) {2252 paper.beginPath();2253 paper.arc(40, 40, 36, 0, Math.PI * 2);2254 paper.lineWidth = 5;2255 paper.strokeStyle = "#ffffff";2256 paper.stroke();2257 }2258 const texture = new THREE.CanvasTexture(canvas);2259 texture.colorSpace = THREE.SRGBColorSpace;2260 return texture;2261}22622263/**2264 * Drop a group's children and give the GPU back what they were holding.2265 *2266 * Dropping an object out of a group is not freeing what it owns: a texture2267 * lives until something says so, and both the names and the grid mint one per2268 * card on every rebuild. The grid is redrawn whenever the camera moves far2269 * enough in depth to change what fits, and the names whenever it settles2270 * anywhere new -- over one flight to a cell that is dozens of rebuilds of a2271 * couple of hundred objects each.2272 *2273 * A sprite's geometry is three's own, shared by every sprite in the scene, so2274 * it is the one thing here that is not this group's to give back.2275 */2276export function release(group: THREE.Group): void {2277 for (const child of [...group.children]) {2278 group.remove(child);2279 dispose(child);2280 }2281}22822283/**2284 * Give back what one drawn object holds.2285 *2286 * Three kinds pass through here and each owns something different. A sprite's2287 * geometry is three's own quad, shared by every sprite in the scene, so it is2288 * the one thing not to free; its texture is a canvas this element minted, so2289 * it is. A fill mesh owns its geometry. A `Line2` owns both — the geometry it2290 * expanded its positions into and the `LineMaterial` carrying its width — and2291 * a boundary is rebuilt every time the camera changes what fits, so leaking2292 * one is leaking dozens over a single flight to a cell.2293 */2294export function dispose(child: THREE.Object3D): void {2295 const drawn = child as THREE.Mesh & {2296 isSprite?: boolean;2297 material?: THREE.Material | THREE.Material[];2298 };2299 if (!drawn.isSprite) drawn.geometry?.dispose?.();2300 const worn = drawn.material;2301 for (const material of Array.isArray(worn) ? worn : [worn]) {2302 const held = material as (THREE.Material & { map?: THREE.Texture | null }) | undefined;2303 held?.map?.dispose?.();2304 held?.dispose?.();2305 }2306}