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 type { Line as ShapeLine, ShapeRecord } from "../world/model.ts";63import type { Attrs, Collection, Lens } from "../data/payload.ts";64import { iconURL } from "../data/plane.ts";65import { LensCoverage } from "../data/pyramid.ts";66import { reportGridPick, reportPick } from "../data/report.ts";67import { cellMoved } from "../chart/grid.ts";68import { viewMaxZoom } from "../chart/projection.ts";69import { saneCamera } from "../chart/element.ts";70import { collectionColor, featureColor } from "../chart/styles.ts";71import {72 iconIsPicture, initialsOf, legibleIconColor, markerKey, markerRasterReady, outsetColor,73} from "../chart/markers.ts";74import type { MarkerFace } from "../chart/markers.ts";75import { Skin } from "./texture.ts";7677// The sphere's markers are the chart's: `markers.ts` composes them and both78// panes draw the same raster, so a collection cannot look like one thing on79// the sheet and another on the globe. `initialsOf` is re-exported because it80// is the sphere's fallback as much as the chart's.81export { initialsOf } from "../chart/markers.ts";8283const log = logger("globe");8485// THE PAIRING, calibrated rather than guessed.86//87// The two cameras answer to one another as a power law anchored at one point:88// the whole disc at altitude 2.5 reads like the whole chart at zoom 2, and89// each halving of altitude reads like one more zoom. It is deliberately *not*90// a field-of-view calculation -- an earlier draft of this seam derived the91// altitude from the resolution, the viewport height and the declared degree92// span, which is defensible arithmetic that pairs the cameras somewhere else93// entirely. The constants are calibrated against the reference's own recorded94// cameras (archived on the golden-reference tag): pushed out to the farthest95// distance, the chart reads a zoom of 1.3219 = 2 + log2(2.5 / 4), and two96// presses of the zoom-out button read an altitude of97// 0.68 = 2.5 / 2^(1.8826 - 2) / 4. Both fall out of the four constants below98// and nothing else does.99const WHOLE_DISC_ALTITUDE = 2.5;100const WHOLE_CHART_ZOOM = 2;101102// The camera keeps a respectful distance: never through the skin -- a camera103// inside the sphere sees the world inside out -- and never so far the planet104// is a dot. The clamps are load-bearing, not hygiene: the farthest is the105// altitude the calibration's 1.3219 zoom above is a reading of.106const NEAREST_ALTITUDE = 0.08;107const FARTHEST_ALTITUDE = 4;108109/** The level the base skin is woven at; detail is only ever deeper. */110const TEXTURE_ZOOM = 4;111112/**113 * The sphere globe.gl draws, in its own units. Only one thing reads it: the114 * ground-per-pixel a chip's fit is measured against, which is the sphere's115 * answer to the chart's resolution.116 */117const GLOBE_RADIUS = 100;118119/** Tiles the neighbourhood under the camera may hold at once. */120const DETAIL_TILE_BUDGET = 96;121122/**123 * Where the pyramid's own tiles are draped, and on how fine a grid.124 *125 * Just off the skin — under everything the grid draws and well under the pins126 * — and on an eight-by-eight grid per tile, because a flat quad laid over a127 * curve stands off the ground at its middle and cuts into it at its corners.128 */129const DETAIL_RADIUS = GLOBE_RADIUS + 0.2;130const DETAIL_SEGMENTS = 8;131132/**133 * The three radii the grid is drawn at, in the order it is painted.134 *135 * They are a hair apart on purpose: the sheet, the boundary over it, and the136 * chip over that, each far enough off the one below to be free of it and all137 * of them just off the tiles. Render order settles what the depth buffer138 * cannot — the fill writes no depth, so without it a dim cell drawn after its139 * neighbour's boundary would paint over the boundary.140 */141const FILL_RADIUS = DETAIL_RADIUS + 0.12;142const LINE_RADIUS = DETAIL_RADIUS + 0.25;143const CHIP_RADIUS = DETAIL_RADIUS + 0.35;144145/**146 * The zones' own steps on the same ladder: the wash, then the outline over147 * it, both over the grid's sheet and under its boundary.148 *149 * A country's border is ground the reader is looking at; a grid cell is150 * scaffolding laid over whatever ground is open. So a zone rides above a151 * cell's tint and below its edge, and a held cell still reads over the152 * borders inside it.153 */154const ZONE_FILL_RADIUS = DETAIL_RADIUS + 0.15;155const ZONE_RADIUS = DETAIL_RADIUS + 0.18;156157/**158 * The widest a fill triangle's edge may span, in degrees, before it is159 * subdivided. The same arithmetic as the rings': a straight edge between two160 * far corners is a chord through the planet, and a triangle whose middle sags161 * under the crust is a fill that renders perfectly, buried.162 */163const ZONE_TRIANGLE_SPAN = 3;164165/**166 * The order the sphere paints in, the top of the ladder last.167 *168 * A depth buffer cannot settle this pane's layering, so the layering is169 * stated: above the tiles, everything either writes no depth (the fills, the170 * boundaries) or refuses to test it (every sprite), for the reason the header171 * gives — a mark anchored on a curve loses half of itself to the ground in172 * front of it.173 *174 * PINS OVER THE GRID AND UNDER THE WRITING. A pin is what the reader came175 * for and a grid is the scaffolding it is read against, so a dim cell's wash176 * must not settle over the marks inside it; a cell's address and a feature's177 * name are writing *about* what is under them, so they go on top. That is the178 * reference's own order (`globe.js`: chips at 3 and cards at 4 drawn after179 * pins that tested depth against the skin and won), said in one place.180 */181const ORDER = { tile: 0, fill: 1, line: 2, pin: 3, chip: 4, card: 5 } as const;182183/**184 * The two layers a pin can stand on: the one the camera draws, and the one185 * over the horizon.186 *187 * A camera draws layer 0 and nothing else, so moving a sprite off it takes it188 * out of the frame as completely as hiding it would — and without touching189 * `visible`, which belongs to the filters and is what the diagnostics190 * count. The other two ways of hiding a pin are both closed: opacity lives on191 * a material shared by every pin of a collection, so dimming one dims twenty,192 * and scale is what a selection speaks with.193 */194const DRAWN_LAYER = 0;195const BEYOND_HORIZON_LAYER = 1;196197/** Names raised over the sphere at once. */198const LABEL_BUDGET = 180;199200/**201 * How far from what the camera faces a name may still be raised, in degrees.202 *203 * The budget says how many names; this says *which*. Past 85° a pin is on the204 * rim or behind it, and a card raised there is either edge-on or on ground205 * nobody can see -- so it takes a place in the budget away from a name the206 * reader is actually looking at. Collection priority decided this before, and207 * priority knows nothing about where the camera is standing.208 */209const LABEL_REACH_DEG = 85;210211/** Where a name's card floats: just off the skin, as a fraction of the radius. */212const LABEL_ALTITUDE = 0.006;213214/** A card's height on screen, and the canvas it is written on. */215const LABEL_HEIGHT = 0.028;216const CARD_FONT = "600 26px Inter, system-ui, sans-serif";217const CARD_TALL = 40;218const CARD_PAD = 24;219220/** A marker's canvas, and the pitch its initials are cut at. */221const MARKER_SIZE = 80;222const MARKER_FONT = "900 26px Inter, system-ui, sans-serif";223224/** How big a pin stands on screen, plain and chosen, and how far off the skin. */225const PIN_SIZE = 0.045;226const PIN_SELECTED_SIZE = 0.08;227const PIN_ALTITUDE = 0.005;228229function clamp(value: number, low: number, high: number): number {230 return Math.min(Math.max(value, low), high);231}232233/** How far out a chart zoom stands the camera. */234export function altitudeForZoom(zoom: number): number {235 const safe = Number.isFinite(zoom) ? zoom : WHOLE_CHART_ZOOM;236 return clamp(237 WHOLE_DISC_ALTITUDE / 2 ** (safe - WHOLE_CHART_ZOOM),238 NEAREST_ALTITUDE, FARTHEST_ALTITUDE);239}240241/** How close in a distance reads on the chart. The inverse, with its ceiling. */242export function zoomForAltitude(altitude: number, ceiling: number): number {243 const safe = Number.isFinite(altitude)244 ? Math.max(altitude, NEAREST_ALTITUDE / 2)245 : WHOLE_DISC_ALTITUDE;246 return clamp(WHOLE_CHART_ZOOM + Math.log2(WHOLE_DISC_ALTITUDE / safe), 0, ceiling);247}248249/**250 * Hold globe.gl's own controls to the pairing's clamps: the wheel is a door251 * too. Left alone, the controls dolly to a hair above the skin — under the252 * detail shell at `GLOBE_RADIUS + 0.2` and under the pins, where every face253 * is a back face and the pane goes black — and out to a hundred radii, where254 * the planet is a dot. Every other way of moving the camera (`steer`,255 * `changeZoom`, the flip) already clamps to the same two altitudes.256 */257export function holdControls(controls: { minDistance: number; maxDistance: number }): void {258 controls.minDistance = GLOBE_RADIUS * (1 + NEAREST_ALTITUDE);259 controls.maxDistance = GLOBE_RADIUS * (1 + FARTHEST_ALTITUDE);260}261262/** The camera as the chart speaks it. */263export interface ChartCamera {264 x: number;265 y: number;266 zoom: number;267 rotation: number;268}269270/** Where the camera stands on the sphere, in the units globe.gl speaks. */271export interface PointOfView {272 readonly lat: number;273 readonly lng: number;274 readonly altitude: number;275}276277/**278 * A point of view fit to be placed, or nothing.279 *280 * The sphere's half of the boundary guard, and the more dangerous half. A map281 * given a `NaN` centre draws nothing and can still be told a good one282 * afterwards; a *three.js* camera given a `NaN` position is a matrix of them283 * from the next frame on, and every reading taken off it after that --284 * including the one the sphere hands back to the chart when the reader flips285 * down -- is `NaN` too. That is the whole of how one broken number blacks out286 * both panes: it does not stay on the pane it landed on.287 *288 * So nothing that is not a place is ever placed, in either direction:289 * `povOf`, `cameraOf` and every steer through it.290 */291export function placeable(pov: PointOfView): PointOfView | null {292 return Number.isFinite(pov.lat) && Number.isFinite(pov.lng) &&293 Number.isFinite(pov.altitude) ? pov : null;294}295296/** The equirect window a world declares, in world pixels and degrees. */297export interface Equirect {298 readonly px: readonly [number, number, number, number];299 readonly deg: readonly [number, number, number, number];300 readonly mapping: GeoMapping;301}302303/** The declared flattening, or null when this world is not a sphere. */304export function equirectOf(attrs: Attrs): Equirect | null {305 const mapping = equirectMapping({ attrs });306 const px = quad(attrs[KEY_GEOMETRY_EQUIRECT_PX]);307 const deg = quad(attrs[KEY_GEOMETRY_EQUIRECT_DEG]);308 if (!mapping || !px || !deg) return null;309 return { px, deg, mapping };310}311312function quad(value: string | undefined): readonly [number, number, number, number] | null {313 const parts = (value ?? "").split(",").map(Number);314 if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) return null;315 return parts as [number, number, number, number];316}317318/** What the harness reads off the sphere: counts, never handles. */319export interface GlobeSeam {320 detail: { lens: string; tiles: Map<string, true> };321 grid: { group: THREE.Group | null; cell: string | null; fitKey: string };322 labels: { key: string; group: THREE.Group | null };323 sprites: Map<string, THREE.Object3D>;324}325326export class AtlasGlobe extends HTMLElement {327 private globe: GlobeInstance | null = null;328 private skin: Skin | null = null;329 private equirect: Equirect | null = null;330 private context: WorldContext | null = null;331 private texture: THREE.CanvasTexture | null = null;332 private readonly pins = new THREE.Group();333 private readonly labels = new THREE.Group();334 private readonly cells = new THREE.Group();335 private readonly zones = new THREE.Group();336 private readonly tiles = new THREE.Group();337 /** The zone geometry last built: the world and the legend's filter. */338 private zoneKey = "";339 /** The dress the zones last wore: the selection and the highlights. */340 private zoneStyleKey = "";341 private readonly sprites = new Map<string, THREE.Object3D>();342 /** The tiles draped under the camera, by `z/x/y`, and what each one is. */343 private readonly draped = new Map<string, THREE.Mesh>();344 /** The neighbourhood currently draped, so a camera that moved a hair redraws nothing. */345 private detailKey = "";346 private worldKey = "";347 private lensKey = "";348 private given: { lat: number; lng: number; altitude: number } | null = null;349 private handed: ChartCamera | null = null;350 private labelKey = "";351 private fitKey = "";352 /** The cell the grid was last drawn over: null while no grid is open. */353 private heldCell: string | null = null;354 /**355 * The system that cell was held in.356 *357 * Held beside the cell because the two only mean anything together. Cycling358 * the system carries the held cell to its equivalent in the next one — the359 * same ground, spelled another way — and the *string* changes, so a memo on360 * the cell alone reads that as a descent and flies the camera somewhere the361 * reader never asked to go. A cell moved is a cell moved **within one362 * system**; anything else is the same place under a new name.363 */364 private heldSystem: string | null = null;365 /** The feature a card was last open about, so a change in it can turn the planet. */366 private selected = "";367 /**368 * The handler globe.gl's controls were given, held so it can be taken off.369 *370 * Written inline it was unremovable, and a closure over `this` is exactly371 * the thing an element leaving the page has to stop being reachable from.372 */373 private moving: (() => void) | null = null;374 /**375 * The pane's own size, watched: globe.gl measures once and has to be told.376 *377 * Held rather than forgotten for the same reason the chart holds its own378 * (`chart/element.ts`): a `ResizeObserver` belongs to the browser, not to379 * this element, so one left running outlives the pane it was watching.380 */381 private sizes: ResizeObserver | null = null;382 /** Told when the camera moves, so the corner locator can follow it. */383 onCamera: ((pov: { lat: number; lng: number; altitude: number }) => void) | null = null;384385 /** The seam the diagnostics read, in the report's published shape (docs/render-seam.md §8). */386 readonly seam: GlobeSeam = {387 detail: { lens: "", tiles: new Map() },388 grid: { group: null, cell: null, fitKey: "" },389 labels: { key: "", group: null },390 sprites: this.sprites,391 };392393 /** Whether this world can be a sphere at all. */394 static offers(attrs: Attrs): boolean {395 return attrs[KEY_GEOMETRY_SURFACE] === "sphere" && equirectOf(attrs) !== null;396 }397398 /** Whether the sphere exists yet: entered at least once this session. */399 get built(): boolean {400 return this.globe !== null;401 }402403 /**404 * Take the world.405 *406 * Nothing is built until the sphere is actually entered — a WebGL context,407 * a texture and two thousand meshes are not what a reader looking at a408 * chart asked for. `globeBuilt` in the baselines is exactly this409 * distinction, so it is a real one and not an optimisation.410 */411 show(context: WorldContext): void {412 this.context = context;413 this.equirect = equirectOf(context.model.payload.attrs ?? {});414 if (!this.equirect || !this.globe) return;415 const worldKey = `${context.base}/${context.model.slug}`;416 if (worldKey !== this.worldKey) {417 this.worldKey = worldKey;418 this.lensKey = "";419 // A WORLD IS A NEW SKIN, and saying so is the whole of the stale-skin420 // guard on this side. The window of world pixels the composite is laid421 // into is the world's own declaration, and the picture in it is the422 // world's own ground: everything the texture holds belonged to the world423 // being left, and so does every tile draped over it. The pyramid a424 // second world names may even be spelled the same as the first's, which425 // is why the skin is told the world as well (`texture.ts`).426 const [x, y, width, height] = this.equirect.px;427 this.skin?.retarget({ x, y, width, height });428 this.clearDetail();429 this.buildSprites(context);430 }431 this.openLens(context);432 this.update();433 }434435 private openLens(context: WorldContext): void {436 const lensKey = `${this.worldKey}#${context.lens?.tiles ?? ""}`;437 if (lensKey === this.lensKey || !context.lens) return;438 this.lensKey = lensKey;439 // A different pyramid: the neighbourhood under the camera belonged to the440 // one being left and is dropped rather than re-drawn. It comes back when441 // the camera next moves.442 this.clearDetail();443 void this.skin?.base(444 context.base, context.model.slug, context.lens,445 (z, x, y) => this.tileURL(z, x, y),446 () => this.refresh());447 this.seam.detail.lens = context.lens.tiles;448 }449450 /** A filter moved, or the held key: show and hide, never rebuild. */451 update(): void {452 const context = this.context;453 if (!context) return;454 if (context.scene.selected !== this.selected) {455 this.selected = context.scene.selected;456 // The ring is worn whether or not anybody is looking: a selection made457 // over the chart is what the sphere comes up already dressed for.458 this.restyle();459 this.face(this.selected);460 }461 // A sphere nobody is looking at shows and hides nothing. Everything below462 // is caught up in one call the moment it comes back up, and until then a463 // filter moving on the chart is not the sphere's business.464 if (this.hidden) return;465 context.model.points.forEach((point, index) => {466 const sprite = this.sprites.get(point.id);467 if (sprite) sprite.visible = !context.visibility.at(index).hidden;468 });469 // A pin a filter has just let back in stands wherever it stands, which may470 // be behind the planet: born on the camera's layer, it would shine through471 // until the reader next moved. `drawLabels` and `drawGrid` cull too, and472 // the horizon is cheap to answer twice.473 this.cull();474 this.drawLabels();475 this.drawGrid();476 this.drawZones();477 }478479 /** Come up, taking the chart's camera with you. Builds on first entry. */480 enter(camera: ChartCamera, viewport: { width: number; height: number }): void {481 this.hidden = false;482 const context = this.context;483 if (!this.globe && context) {484 this.build(context);485 this.worldKey = `${context.base}/${context.model.slug}`;486 this.buildSprites(context);487 this.openLens(context);488 this.update();489 }490 this.globe?.width(this.clientWidth || viewport.width);491 this.globe?.height(this.clientHeight || viewport.height);492 // The chart's camera, checked before it becomes a distance. The chart is493 // where this number has just come from, so it is nearly always already494 // sane -- and "nearly always" is exactly the word that made the reference495 // put its one guard on the other end of the same wire.496 const clean = context ? saneCamera(camera, context.lens, context.grid) : camera;497 const pov = clean && this.povOf(clean);498 if (!clean || !pov) {499 log.warn("the sphere refused a camera that is not a place", {500 op: "render", world: context?.model.slug, camera: JSON.stringify(camera),501 });502 return;503 }504 this.handed = clean;505 this.given = pov;506 this.steer(pov, 0);507 // Everything a filter moved while the sphere was down, caught up in one508 // call now that there is somebody looking.509 this.update();510 this.refreshDetail();511 this.onCamera?.(pov);512 log.info("the sphere is up", { op: "render", world: this.context?.model.slug, ...pov });513 }514515 /**516 * Go away, and hand the chart a camera back.517 *518 * If the reader never moved the sphere, the camera handed back is the one519 * it was given — the same numbers, not numbers that round-trip to within a520 * float of them. If they did move it, the pairing below is inverted521 * honestly and the chart lands where the sphere was looking.522 *523 * The pane's size is not asked for: the pairing between a distance and a524 * zoom is a property of the two cameras, not of the window they are seen525 * through (docs/render-seam.md §7, and THE PAIRING above).526 */527 leave(): ChartCamera | null {528 this.hidden = true;529 const pov = this.globe?.pointOfView();530 // A globe nobody is looking at holds no pyramid tiles: the skin under it531 // stays, because it is one texture and recompositing it is the expensive532 // half of coming back.533 this.clearDetail();534 // The name of the pyramid stays. It is the record of what the sphere read535 // while it was up, not a live handle on anything, and the baselines carry536 // it through the pane being put away.537 if (!pov || !this.given || !this.handed) return null;538 const unmoved = Math.abs(pov.lat - this.given.lat) < 1e-9 &&539 Math.abs(pov.lng - this.given.lng) < 1e-9 &&540 Math.abs(pov.altitude - this.given.altitude) < 1e-9;541 // The write-back, and the last place a number is looked at before the542 // other pane is standing on it (`saneCamera`, and `globe.js:182` before543 // it). `cameraOf` already refuses one it cannot invert; this holds the544 // one it can to the lens the chart is about to draw it under, and answers545 // nothing at all rather than a place that is not one -- which the host546 // reads as "leave the chart where it is" (`AtlasViewport.flipPane`).547 const back = unmoved ? this.handed : this.cameraOf(pov);548 const context = this.context;549 return context ? saneCamera(back, context.lens, context.grid) : back;550 }551552 /**553 * Leave the page, and give back everything a sphere holds.554 *555 * `leave` is the reader putting the pane away, and a keystroke undoes it;556 * this is the element itself going, and nothing survives it. A detached557 * globe keeps a live WebGL context, an animation frame asking for the next558 * one forever, and two callbacks closing over both — so a morph that559 * replaces the viewport on a volume navigation would leave a dead planet560 * rendering behind the new one, once per navigation.561 *562 * WHAT IS GIVEN BACK, and what deliberately is not. The names and the grid563 * are released, because every card in them is a canvas and a texture this564 * element minted. The pins are only *cleared*: a sprite's material is its565 * collection's, shared with every pin wearing the same mark and cached566 * across worlds, and its geometry is three's own quad shared by every sprite567 * in the scene — freeing those would take the marks out from under the next568 * sphere. It is also why the three groups come out of the scene before569 * `_destructor` empties it: globe.gl deallocates whatever it finds there,570 * and what it would find here is not its to free.571 *572 * AND THE ELEMENT STAYS RE-CONNECTABLE. A morph can take an element out and573 * put the same one back, so every memo that guards a rebuild is put back to574 * what a freshly constructed element carries — the lens key above all, which575 * would otherwise let `openLens` skip compositing a skin that no longer576 * exists and leave the sphere black. The world it was showing is *not*577 * forgotten: the context is what the next `enter` builds from.578 */579 disconnectedCallback(): void {580 this.dismantle();581 }582583 /**584 * Come back to the page, and start measuring it again.585 *586 * Only a *re*-connect has anything to rewire, exactly as on the chart: on587 * the first connect there is no globe yet -- nothing is built until the588 * sphere is entered -- and an observer wired before there is a planet to589 * resize would push dimensions at nothing.590 */591 connectedCallback(): void {592 if (!this.globe) return;593 this.watchSize();594 }595596 /**597 * Keep the sphere the size of its pane.598 *599 * globe.gl is told its dimensions once, at entry, out of the pane it was600 * given -- and on this page the pane moves without the window doing601 * anything: the dock folds under a keystroke, the panel beside the map602 * comes out the first time a search has something to say. A sphere that did603 * not hear about it went on drawing at the old size until something else604 * poked it, which is the defect this exists for.605 *606 * THREE THINGS MOVE WITH THE BOX, and only the first is globe.gl's:607 *608 * THE RENDERER, which is the picture itself and the camera's aspect.609 * THE FAT LINES. A `Line2` carries its width in *pixels*, which it can610 * only do by being told the size of the window it is measured against611 * (`cellBoundary`). Every boundary standing was told at the size the pane612 * had when the grid was drawn, so a resize that did not refresh them613 * leaves the whole hierarchy of line weights mis-scaled.614 * THE HORIZON. The cull is a question about where the camera is and how615 * much of the planet it can see, and a new aspect is a new answer.616 */617 private watchSize(): void {618 if (this.sizes || typeof ResizeObserver === "undefined") return;619 this.sizes = new ResizeObserver(() => this.resize());620 this.sizes.observe(this);621 }622623 /** One box change, pushed everywhere a box is remembered. */624 private resize(): void {625 const globe = this.globe;626 if (!globe) return;627 // A pane put away behind the chart is zero across, and so is one measured628 // mid-transition. Neither is a size to draw a planet at: a globe told it629 // is zero pixels wide throws its dimensions away, and the reader coming630 // back to the sphere would find nothing on it.631 const width = this.clientWidth;632 const height = this.clientHeight;633 if (!width || !height) return;634 globe.width(width);635 globe.height(height);636 for (const child of [...this.cells.children, ...this.zones.children]) {637 const material = (child as { material?: unknown }).material;638 if (material instanceof LineMaterial) material.resolution.set(width, height);639 }640 this.cull();641 }642643 /**644 * The world stopped declaring a sphere.645 *646 * A volume or a world change can land on ground no globe can be draped647 * over, and a pane that is not offered any more is not a pane to leave648 * standing: the planet on screen would go on wearing the last world's skin649 * with nothing but a hidden toggle to say otherwise. That is the defect this650 * exists for, and it is the reference's own contract — `syncGlobe` destroys651 * the instance, forgets what it was textured for, and empties the container652 * (frontend/src/globe.js).653 *654 * The camera is *not* handed back the way `leave` hands it back. A camera is655 * a place on a particular world, and the world it named is the one the656 * reader has just left; the chart opens the new world where the session657 * says, which is the same answer the reference gives by letting its mapping658 * come back null.659 */660 retire(): void {661 this.hidden = true;662 // Asked once per scene change while a plane is open, and only the first663 // one has anything to answer.664 if (!this.globe && !this.context) return;665 this.dismantle();666 // Unlike a disconnect, this is not a pane that might come back to the same667 // world: the next `enter` must build from whatever the reader opens next,668 // and until then this element is about nothing at all.669 this.context = null;670 this.equirect = null;671 }672673 private dismantle(): void {674 // Stopped, not merely forgotten: the callback closes over this element,675 // and through it over the scene, the skin and the world context.676 this.sizes?.disconnect();677 this.sizes = null;678 const globe = this.globe;679 if (globe && this.moving) globe.controls().removeEventListener("change", this.moving);680 this.moving = null;681 this.onCamera = null;682 release(this.labels);683 release(this.cells);684 release(this.zones);685 release(this.tiles);686 this.draped.clear();687 this.detailKey = "";688 this.zoneKey = "";689 this.zoneStyleKey = "";690 this.pins.clear();691 this.sprites.clear();692 if (globe) {693 globe.scene().remove(this.pins, this.labels, this.cells, this.zones, this.tiles);694 globe._destructor?.();695 // globe.gl's destructor gives back the renderer and empties the scene696 // and leaves its canvas exactly where it put it, holding the last frame697 // it drew. So the container is emptied by hand, as the reference does698 // (`elements.globe.replaceChildren()`): otherwise a retired sphere is699 // still a picture of a planet on screen, and the next build hangs a700 // second canvas behind the dead one.701 this.replaceChildren?.();702 }703 this.texture?.dispose();704 this.texture = null;705 this.skin = null;706 this.globe = null;707 this.worldKey = "";708 this.lensKey = "";709 this.labelKey = "";710 this.fitKey = "";711 this.heldCell = null;712 this.heldSystem = null;713 this.selected = "";714 this.given = null;715 this.handed = null;716 this.seam.detail = { lens: "", tiles: new Map() };717 this.seam.grid = { group: null, cell: null, fitKey: "" };718 this.seam.labels = { key: "", group: null };719 }720721 /** The globe's own rounding of its camera, non-empty only while Z is down. */722 diagnostics(): GlobeSeam {723 return this.seam;724 }725726 // ---- the pairing ----------------------------------------------------727728 /** The chart's camera as a point of view. */729 povOf(camera: ChartCamera): PointOfView | null {730 const equirect = this.equirect;731 if (!equirect) return null;732 const [lat, lng] = equirect.mapping.toLatLng(camera.x, -camera.y);733 return placeable({ lat, lng, altitude: altitudeForZoom(camera.zoom) });734 }735736 /** A point of view as the chart's camera. The inverse of `povOf`, exactly. */737 cameraOf(pov: PointOfView): ChartCamera | null {738 const equirect = this.equirect;739 const context = this.context;740 if (!equirect || !context) return null;741 const [x, y] = equirect.mapping.toWorld(pov.lat, pov.lng);742 // THE REFERENCE'S OWN GUARD, at the reference's own place: both743 // coordinates, checked before the map is told (`globe.js:182`). A744 // flattening is arithmetic over a point of view, so a point of view that745 // is not a place comes out of it as a coordinate that is not one.746 if (!Number.isFinite(x) || !Number.isFinite(y)) return null;747 return { x, y: -y, zoom: zoomForAltitude(pov.altitude, this.ceiling()), rotation: 0 };748 }749750 /**751 * Move the camera, and never to somewhere that is not a place.752 *753 * Every steer this element makes goes through here — coming up, descending754 * into a cell, following a selection — because they all end in the same755 * call and the call is one-way: a `NaN` handed to globe.gl is a `NaN` in756 * three's own camera, and there is no reading afterwards that says so.757 */758 private steer(pov: PointOfView, ms: number): void {759 const placed = placeable(pov);760 if (!placed) {761 log.warn("the sphere refused a point of view that is not a place", {762 op: "render", world: this.context?.model.slug,763 lat: String(pov.lat), lng: String(pov.lng), altitude: String(pov.altitude),764 });765 return;766 }767 this.globe?.pointOfView(placed, ms);768 }769770 /** How deep the chart is willing to go, which is the pairing's own ceiling. */771 private ceiling(): number {772 const lens = this.context?.lens;773 return lens ? viewMaxZoom(lens) : WHOLE_CHART_ZOOM;774 }775776 /**777 * One press of a zoom control, on the sphere.778 *779 * A press is one halving or doubling of the distance, read off the camera780 * where it stands rather than off a target this pane remembers -- two781 * presses in one tick therefore move the camera once.782 */783 changeZoom(delta: number): void {784 const globe = this.globe;785 if (!globe) return;786 const pov = globe.pointOfView();787 const standing = Number.isFinite(pov.altitude) ? pov.altitude : WHOLE_DISC_ALTITUDE;788 globe.pointOfView(789 { altitude: clamp(standing / 2 ** delta, NEAREST_ALTITUDE, FARTHEST_ALTITUDE) }, 180);790 }791792 /** Where the camera is looking, for the corner locator. */793 facing(): { lat: number; lng: number } | null {794 const pov = this.globe?.pointOfView();795 return pov ? { lat: pov.lat, lng: pov.lng } : null;796 }797798 // ---- building -------------------------------------------------------799800 private build(context: WorldContext): void {801 const equirect = this.equirect;802 if (!equirect) return;803 const [x, y, width, height] = equirect.px;804 this.skin = new Skin({ x, y, width, height }, context.grid);805 this.texture = new THREE.CanvasTexture(this.skin.canvas);806 this.texture.colorSpace = THREE.SRGBColorSpace;807808 this.globe = new Globe(this, { animateIn: false })809 .backgroundColor("#0b0e12")810 .showAtmosphere(true)811 .atmosphereColor("#7ea6c8")812 .atmosphereAltitude(0.12);813 wearSkin(this.globe.globeMaterial() as THREE.MeshPhongMaterial, this.texture);814815 holdControls(this.globe.controls());816817 const scene = this.globe.scene();818 scene.add(this.tiles);819 scene.add(this.zones);820 scene.add(this.pins);821 scene.add(this.labels);822 scene.add(this.cells);823 this.seam.labels.group = this.labels;824 this.seam.grid.group = this.cells;825826 this.buildSprites(context);827 this.watchCamera(this.globe);828 // The observer arrives with the planet, from here, the way the chart's829 // arrives with its world: before this there is no globe to be resized.830 this.watchSize();831 this.globe.onGlobeClick(({ lat, lng }) => this.pick(lat, lng));832 }833834 /**835 * Follow the camera, holding the handler that does it.836 *837 * One listener per built globe, and `build` is the only caller — which is838 * what keeps a sphere that has been put away and brought back reporting one839 * camera per move rather than one per life it has had.840 */841 private watchCamera(globe: GlobeInstance): void {842 this.moving = () => this.moved();843 globe.controls().addEventListener("change", this.moving);844 }845846 /**847 * One sprite per pin, built once and afterwards only shown, hidden or848 * re-dressed.849 *850 * A pin on the sphere wears what the same pin wears on the chart: its851 * collection's mark in its collection's colour, rimmed with the world's852 * declared outset. Two thousand identical cyan beads say only "something is853 * here", which is the one thing a reader can already see.854 *855 * The materials are shared and cached per collection, so this mints two856 * thousand *sprites* and a handful of textures -- and nothing here is857 * disposed on a rebuild, because none of it belongs to a pin.858 */859 private buildSprites(context: WorldContext): void {860 this.pins.clear();861 this.sprites.clear();862 const equirect = this.equirect;863 const globe = this.globe;864 if (!equirect || !globe) return;865 const marks = markersOf(context);866 for (const point of context.model.points) {867 const [lat, lng] = equirect.mapping.toLatLng(point.coordinate[0], -point.coordinate[1]);868 const chosen = point.id === context.scene.selected;869 const mark = marks.get(point.collection.id) ?? BARE_MARKER;870 const sprite = new THREE.Sprite(markerMaterial(mark, chosen));871 const size = chosen ? PIN_SELECTED_SIZE : PIN_SIZE;872 sprite.scale.set(size, size, 1);873 // Over the grid, under the writing, and on the camera's own layer until874 // `cull` says the pin has gone over the rim.875 sprite.renderOrder = ORDER.pin;876 sprite.layers.set(DRAWN_LAYER);877 const at = globe.getCoords(lat, lng, PIN_ALTITUDE);878 sprite.position.set(at.x, at.y, at.z);879 sprite.userData = {880 id: point.id, lat, lng, title: point.title, owner: point.collection.id,881 };882 this.pins.add(sprite);883 this.sprites.set(point.id, sprite);884 }885 }886887 /**888 * Dress the standing sprites for the selection.889 *890 * The chosen pin grows and takes its white ring; the one it replaced settles891 * back among the rest. Both materials come out of the same cache the build892 * drew from, so a selection costs two lookups rather than two thousand893 * canvases.894 */895 private restyle(): void {896 const context = this.context;897 if (!context) return;898 const marks = markersOf(context);899 for (const held of this.sprites.values()) {900 const sprite = held as THREE.Sprite;901 if (!sprite.isSprite) continue;902 const stood = held.userData as { id?: string; owner?: number };903 const mark = marks.get(stood.owner ?? -1) ?? BARE_MARKER;904 const chosen = stood.id === context.scene.selected;905 sprite.material = markerMaterial(mark, chosen);906 const size = chosen ? PIN_SELECTED_SIZE : PIN_SIZE;907 sprite.scale.set(size, size, 1);908 }909 }910911 /**912 * The names, while Z is held.913 *914 * The key is the sphere's own rounding of its camera — latitude, longitude,915 * altitude, the system and the cell being held — and it is the one written916 * form of the globe's camera the harness can read. Letting Z go drops every917 * name, and the key with it.918 */919 private drawLabels(): void {920 const context = this.context;921 const globe = this.globe;922 if (!context || !globe) return;923 const pov = globe.pointOfView();924 // The rounding is the contract: whole degrees and two decimals of925 // altitude, which is what every recorded `globe-labels-held` is written926 // in ("0:0:0.68:geohash:"). Finer rounding would make the key move where927 // the baseline says it stands still.928 const key = context.labelsHeld ? [929 String(Math.round(pov.lat)), String(Math.round(pov.lng)), pov.altitude.toFixed(2),930 // The system, whether or not a grid is open: which system *would*931 // divide this world is a property of the world, and the key the932 // baselines record carries it over a closed grid ("0:0:0.68:geohash:").933 context.system?.slug ?? cellSystems.systems[0]?.slug ?? "", context.cell,934 ].join(":") : "";935 if (key === this.labelKey) return;936 this.labelKey = key;937 this.seam.labels.key = key;938 // Every rebuild mints a card canvas per name and a texture over it. The939 // group being emptied is not the texture being freed, and a camera settling940 // through a flight rebuilds these dozens of times.941 release(this.labels);942 if (!context.labelsHeld) return;943 // WHICH names, not which collections. The budget is what a sphere can944 // carry; the camera is what decides who spends it.945 for (const near of labelCandidates(pov, this.standing(), LABEL_BUDGET)) {946 const at = globe.getCoords(near.lat, near.lng, LABEL_ALTITUDE);947 this.labels.add(nameCard(near.title, at));948 }949 // Cards are born visible, and a rebuild with the camera already elsewhere950 // must not leave the far side's names shining through the planet.951 this.cull();952 }953954 /** The pins a name could be raised over: standing, placed and named. */955 private *standing(): Generator<Placed> {956 for (const held of this.sprites.values()) {957 if (!held.visible) continue;958 const stood = held.userData as Partial<Placed>;959 if (stood.title === undefined || stood.lat === undefined) continue;960 if (stood.lng === undefined) continue;961 yield { title: stood.title, lat: stood.lat, lng: stood.lng };962 }963 }964965 /**966 * The horizon, enforced by hand.967 *968 * Every sprite is drawn with the depth test off, because a screen-sized969 * sprite anchored on the ground loses whatever half of itself the planet's970 * own curve reaches over at a glancing angle -- the lower half of a card,971 * and the buried half of a pin at the limb. The price of that is the far972 * side of the world shining straight through, so the silhouette is applied973 * here instead: a point at the limb sits where the cosine of its angle from974 * the camera's axis equals the radius over the distance, and anything past975 * that is ground nobody can see.976 *977 * THE PINS ARE HELD TO IT THROUGH A DIFFERENT DOOR. `visible` is spoken978 * for: it is the filters' answer, and the diagnostics' `visibleSprites`979 * is a count of it. So a pin beyond the rim is moved off the layer the980 * camera draws, and back onto it when the planet turns -- the same981 * silhouette, and the standing count never moves.982 */983 private cull(): void {984 const camera = this.globe?.camera().position;985 if (!camera) return;986 for (const group of [this.cells, this.labels, this.zones]) {987 for (const child of group.children) {988 if (!(child as THREE.Sprite).isSprite) continue;989 child.visible = facesCamera(child.position, camera);990 }991 }992 for (const pin of this.pins.children) {993 pin.layers.set(facesCamera(pin.position, camera) ? DRAWN_LAYER : BEYOND_HORIZON_LAYER);994 }995 }996997 /** The grid, from the same plan and the same tokens the chart draws. */998 /**999 * The zones, on the sphere, as the chart draws them.1000 *1001 * The chart's zone layer says what a world's ground *is*; until this the1002 * sphere drew pins over bare imagery and the first volume to carry areas on1003 * a sphere — the included Earth's countries — rendered on one pane and1004 * vanished on the other. The sphere draws what the chart draws, in the same1005 * per-feature accents its index rows wear: every shown shape's fill, faint1006 * until the shape is highlighted or selected, and its outlines over it,1007 * thicker and opaque when emphasized. A shape emphasized with a title wears1008 * a name card at its centre, which is the sphere's spelling of the chart's1009 * promoted area title. The scrim stays the chart's: dimming everything1010 * *outside* the highlights needs the even-odd geometry a flat sheet gives1011 * away and a sphere does not.1012 *1013 * Geometry is rebuilt only when the world or the legend's filter moves;1014 * a highlight or a selection restyles the meshes that already exist rather1015 * than tessellating a planet again.1016 */1017 private drawZones(): void {1018 const context = this.context;1019 const globe = this.globe;1020 const equirect = this.equirect;1021 if (!context || !globe || !equirect) return;1022 const shown = context.visibility.shapesShown;1023 const key = [this.worldKey, shown.map((shape) => shape.id).join(" ")].join("|");1024 if (key !== this.zoneKey) {1025 this.zoneKey = key;1026 this.zoneStyleKey = "";1027 release(this.zones);1028 const viewport = { width: this.clientWidth || 1, height: this.clientHeight || 1 };1029 for (const shape of shown) {1030 const fill = zoneFill(globe, shape, equirect.mapping);1031 if (fill) this.zones.add(fill);1032 const line = zoneMesh(globe, shape, equirect.mapping, {1033 color: featureColor(shape.id), opacity: 0.85, widthPx: 1.4,1034 }, viewport);1035 if (line) this.zones.add(line);1036 }1037 }1038 this.styleZones();1039 }10401041 /**1042 * Dress the zones for the scene: the chart's own weights and washes —1043 * stroke 1.4 and a tenth of the accent at rest, 2.4 and just over a quarter1044 * emphasized — and a name card over each emphasized shape that has one.1045 */1046 private styleZones(): void {1047 const context = this.context;1048 const globe = this.globe;1049 const equirect = this.equirect;1050 if (!context || !globe || !equirect) return;1051 const highlighted = new Set(context.visibility.highlightedShapes.map((shape) => shape.id));1052 const styleKey = [this.selected, ...highlighted].join(" ");1053 if (styleKey === this.zoneStyleKey) return;1054 this.zoneStyleKey = styleKey;1055 const emphasized = (id: unknown): boolean =>1056 typeof id === "string" && (highlighted.has(id) || id === this.selected);1057 for (const child of [...this.zones.children]) {1058 const worn = emphasized(child.userData.zone);1059 const material = (child as { material?: unknown }).material;1060 if (child.userData.role === "card") {1061 this.zones.remove(child);1062 dispose(child);1063 } else if (material instanceof LineMaterial) {1064 material.opacity = worn ? 1 : 0.85;1065 material.linewidth = worn ? 2.4 : 1.4;1066 } else if (material instanceof THREE.MeshBasicMaterial) {1067 material.opacity = worn ? 0.28 : 0.1;1068 }1069 }1070 for (const shape of context.visibility.shapesShown) {1071 if (!emphasized(shape.id) || !shape.title || !shape.center) continue;1072 const [lat, lng] = equirect.mapping.toLatLng(shape.center[0], -shape.center[1]);1073 const card = nameCard(shape.title, surfacePoint(globe, lat, lng, CHIP_RADIUS));1074 card.userData = { zone: shape.id, role: "card" };1075 this.zones.add(card);1076 }1077 this.cull();1078 }10791080 private drawGrid(): void {1081 const context = this.context;1082 const globe = this.globe;1083 const equirect = this.equirect;1084 if (!context || !globe || !equirect) return;1085 // Cleared *and released*, by the same call the names are released with.1086 release(this.cells);1087 // The fit key is not cleared when the grid closes. It is the last frame1088 // the camera was flown to hold a cell, and the baselines carry it through1089 // the grid closing, the pane being left, and the volume being reopened --1090 // a record of where the reader was taken, not a live flag.1091 if (!context.system || !context.scene.gridSystem) {1092 // No grid at all is *nothing held*, which is a different answer from a1093 // grid open over the whole ground and nothing chosen in it — and it is1094 // the answer that makes the next descent a change worth turning for.1095 this.seam.grid.cell = null;1096 return;1097 }1098 this.seam.grid.cell = context.cell;1099 // One cell, up to three objects, in the order the chart paints them: the1100 // tint or the dim on the ground, the boundary over it, and the address on1101 // a card in the corner. Which of the three a cell gets is the analysis1102 // lane's answer and not this pane's -- the same `gridCellVisual` the chart1103 // asks, handed the one thing only a renderer knows, which is whether the1104 // address would fit inside the cell as the camera currently sees it.1105 for (const cell of planOf(context)) {1106 // THE RING AS THE SYSTEM WALKED IT, not as the chart draws it.1107 // `cellRings` answers two questions about a flat sheet — where the1108 // antimeridian cuts a loop in two, and how a pole cell closes along the1109 // top edge of the picture — and neither is a question a sphere has. The1110 // trigonometry below is periodic, so a loop carrying its longitudes past1111 // 180 drapes exactly where it belongs, and a cut ring would be drawn as1112 // two pieces with a seam down the middle of a cell that has none.1113 const ring = ringLatLng(cell.ring, equirect.mapping);1114 const corners = boundsOf(ring);1115 if (!corners) continue;1116 const visual = gridCellVisual(context.ground, context.system, cell, {1117 subgridVisible: context.subgridVisible,1118 labelled: this.labelFits(cell, corners),1119 });1120 if (!visual) continue;1121 if (visual.fill) {1122 const [cx, cy] = context.system.on(context.ground).center(cell.hash);1123 const centre = equirect.mapping.toLatLng(cx, -cy);1124 const fill = ringFill(globe, ring, corners, centre, visual.fill);1125 if (fill) this.cells.add(fill);1126 }1127 const boundary = cellBoundary(globe, ring, visual.line,1128 { width: this.clientWidth || 1, height: this.clientHeight || 1 });1129 if (boundary) this.cells.add(boundary);1130 if (visual.label) this.cells.add(this.cellChip(visual.label, corners));1131 }1132 // The frame the camera was flown to hold the cell, which is what makes a1133 // held cell reproducible between runs rather than wherever the reader was1134 // when they chose it.1135 // The fit key is a reading of depth, not of place: the system, the cell,1136 // and the chart zoom the camera's distance reads as, in half steps. That1137 // is what the baselines record ("geohash:m:7"), and it is what decides1138 // when a chip could newly fit inside a cell's footprint.1139 // Descending holds a cell, and holding it turns the planet: a cell chosen1140 // on the far side of the world is a cell nobody can see. Only a *change*1141 // of held cell turns the camera — opening the grid over the ground the1142 // reader is already looking at moves nothing.1143 //1144 // AND ONLY WITHIN ONE SYSTEM. Cycling the system carries the held cell1145 // across to the cell of the next system that covers the same ground1146 // (`equivalentCell`), so the string changes while the place does not:1147 // "m" becomes "24" and the reader has not moved. Turning the planet for1148 // that is the camera answering a question about spelling. Both memos are1149 // written whatever happened, so the descent *after* a cycle is still read1150 // against the system it was made in.1151 const system = context.scene.gridSystem;1152 const held = this.heldCell;1153 if (held !== null &&1154 cellMoved({ system: this.heldSystem ?? "", cell: held }, { system, cell: context.cell })) {1155 this.frameCell();1156 }1157 this.heldCell = context.cell;1158 this.heldSystem = system;1159 // Chips are born visible, like the name cards, and are held to the same1160 // horizon for the same reason.1161 this.cull();1162 }11631164 /**1165 * Redraw the grid when the camera has moved far enough in depth that an1166 * address could newly fit inside a cell, or stop fitting.1167 *1168 * The fit key is that reading of depth -- the system, the cell, and the1169 * chart zoom the camera's distance reads as, in half steps -- and it is1170 * written here and nowhere else, because it is a fact about where the camera1171 * came to rest and a rebuild is not a camera move. It is never cleared: the1172 * baselines carry it through the grid closing and the pane being left, as a1173 * record of where the reader was taken.1174 */1175 private regridWhenFitChanges(): void {1176 const context = this.context;1177 const globe = this.globe;1178 if (!context || !globe || this.hidden || !context.scene.gridSystem) return;1179 const depth = Math.round(zoomForAltitude(globe.pointOfView().altitude, this.ceiling()) * 2);1180 const key = `${context.scene.gridSystem}:${context.cell}:${depth}`;1181 if (key === this.fitKey) return;1182 this.fitKey = key;1183 this.seam.grid.fitKey = key;1184 this.drawGrid();1185 }11861187 /**1188 * Whether a cell's address fits inside the cell as the camera sees it.1189 *1190 * The chart asks this of an extent over a resolution; the sphere has no1191 * resolution, so the ground-per-pixel is worked out from the camera's1192 * distance and the field of view, and the answer feeds the same gate.1193 */1194 private labelFits(cell: PlanCell, corners: Corners): boolean {1195 const globe = this.globe;1196 if (!globe) return false;1197 const size = cell.role === "neighbor"1198 ? gridTheme.neighborLabelSizePx1199 : gridTheme.labelSizePx;1200 const pov = globe.pointOfView();1201 const altitude = Math.max(pov.altitude, NEAREST_ALTITUDE / 2);1202 const field = (50 * Math.PI) / 180;1203 const groundPerPixel = (2 * altitude * GLOBE_RADIUS * Math.tan(field / 2)) /1204 Math.max(1, this.clientHeight || 1);1205 const degreePixels = ((GLOBE_RADIUS * Math.PI) / 180) / groundPerPixel;1206 const width = Math.abs(corners.east - corners.west) * degreePixels;1207 const height = Math.abs(corners.north - corners.south) * degreePixels;1208 return measure(cell.hash, `900 ${size}px ${gridTheme.labelFont}`) + 9 <= width &&1209 size + 6 <= height;1210 }12111212 /**1213 * A cell's address, on a card in its bottom-right corner.1214 *1215 * The prefix faint and the final character bright, exactly as the chart1216 * writes it, and anchored by its own corner so it stays put on screen while1217 * the planet turns under it.1218 */1219 private cellChip(1220 label: { prefix: string; final: string; color: string; textAlpha: number;1221 chip: string; sizePx: number },1222 corners: Corners,1223 ): THREE.Sprite {1224 const globe = this.globe;1225 const scale = 2;1226 const size = label.sizePx * scale;1227 const canvas = document.createElement("canvas");1228 const paper = canvas.getContext("2d");1229 const prefixFont = `500 ${size}px ${gridTheme.labelFont}`;1230 const finalFont = `900 ${size}px ${gridTheme.labelFont}`;1231 const pad = gridTheme.labelInsetPx * scale;1232 let prefixWidth = 0;1233 let finalWidth = size;1234 if (paper) {1235 paper.font = prefixFont;1236 prefixWidth = label.prefix ? paper.measureText(label.prefix).width : 0;1237 paper.font = finalFont;1238 finalWidth = paper.measureText(label.final).width;1239 }1240 canvas.width = Math.ceil(prefixWidth + finalWidth + pad * 2);1241 canvas.height = Math.ceil(size + pad * 1.4);1242 if (paper) {1243 paper.fillStyle = label.chip;1244 paper.fillRect(0, 0, canvas.width, canvas.height);1245 paper.textBaseline = "middle";1246 const middle = canvas.height / 2 + scale;1247 if (label.prefix) {1248 paper.font = prefixFont;1249 paper.fillStyle = label.color;1250 paper.globalAlpha = gridTheme.prefixAlpha * label.textAlpha;1251 paper.fillText(label.prefix, pad, middle);1252 }1253 paper.font = finalFont;1254 paper.globalAlpha = label.textAlpha;1255 paper.fillStyle = label.color;1256 paper.fillText(label.final, pad + prefixWidth, middle);1257 paper.globalAlpha = 1;1258 }1259 const texture = new THREE.CanvasTexture(canvas);1260 texture.colorSpace = THREE.SRGBColorSpace;1261 const sprite = new THREE.Sprite(new THREE.SpriteMaterial({1262 map: texture, depthTest: false, depthWrite: false,1263 transparent: true, sizeAttenuation: false,1264 }));1265 const insetLat = Math.abs(corners.north - corners.south) * 0.02;1266 const insetLng = Math.abs(corners.east - corners.west) * 0.02;1267 const at = globe1268 ? surfacePoint(globe, corners.south + insetLat, corners.east - insetLng, CHIP_RADIUS)1269 : null;1270 if (at) sprite.position.set(at.x, at.y, at.z);1271 const viewport = Math.max(1, this.clientHeight || 1);1272 const height = canvas.height / scale / viewport;1273 sprite.scale.set((height * canvas.width) / canvas.height, height, 1);1274 sprite.center.set(1, 0);1275 // The grid's own order: the sheet, the boundary, the chip -- and the chip1276 // over the pins as well, because an address is writing about the ground1277 // and not a thing standing on it (`ORDER`).1278 sprite.renderOrder = ORDER.chip;1279 return sprite;1280 }12811282 /**1283 * Turn the planet to hold the chosen cell, the way the chart fits its view1284 * when the navigator descends.1285 *1286 * The camera aims where the *system* says the cell's middle is — a pole1287 * cap's middle is the pole, which no corner of its frame would have named —1288 * and stands off by the cell's own span, so a cell fills the window whatever1289 * depth it was reached at.1290 */1291 private frameCell(): void {1292 const context = this.context;1293 const globe = this.globe;1294 const equirect = this.equirect;1295 if (!context?.system || !globe || !equirect) return;1296 const chosen = planOf(context)1297 .find((cell) => cell.role === "scope" || cell.role === "leaf");1298 if (!chosen) return;1299 const [x, y] = context.system.on(context.ground).center(chosen.hash);1300 const [lat, lng] = equirect.mapping.toLatLng(x, -y);1301 const corners = boundsOf(ringLatLng(chosen.ring, equirect.mapping));1302 if (!corners) return;1303 const span = Math.max(corners.east - corners.west, corners.north - corners.south);1304 const altitude = clamp(span / 45, NEAREST_ALTITUDE, FARTHEST_ALTITUDE);1305 this.steer({ lat, lng, altitude }, 400);1306 }13071308 /**1309 * Face the feature a card has just opened on.1310 *1311 * A ring drawn on the far side of a planet selects nothing anyone can see,1312 * so a selection made while the sphere is up turns it — keeping the reader's1313 * distance, because they chose a place and not a depth. A selection made1314 * over the chart is remembered and not acted on: coming up to the sphere1315 * afterwards lands on the chart's camera, which is where the reader was.1316 */1317 private face(id: string): void {1318 const globe = this.globe;1319 if (!globe || this.hidden || !id) return;1320 const stood = this.sprites.get(id)?.userData as1321 { lat?: number; lng?: number } | undefined;1322 if (stood?.lat !== undefined && stood.lng !== undefined) {1323 this.steer(1324 { lat: stood.lat, lng: stood.lng, altitude: globe.pointOfView().altitude }, 600);1325 return;1326 }1327 // Ground is an area, not a point, so the planet turns to show it whole —1328 // the sphere's spelling of the chart's fit — at the altitude its span1329 // asks for rather than whatever depth the reader happened to be at.1330 const shape = this.context?.model.shapeByID?.get(id);1331 const mapping = this.equirect?.mapping;1332 if (!shape || !mapping) return;1333 let corners: Corners | null = null;1334 for (const { ring } of zoneOutlines(shape)) {1335 const frame = boundsOf(ringLatLng(ring as Ring, mapping));1336 if (!frame) continue;1337 corners = corners ? {1338 north: Math.max(corners.north, frame.north),1339 south: Math.min(corners.south, frame.south),1340 east: Math.max(corners.east, frame.east),1341 west: Math.min(corners.west, frame.west),1342 } : frame;1343 }1344 if (!corners) return;1345 const centre = shape.center1346 ? mapping.toLatLng(shape.center[0], -shape.center[1])1347 : [(corners.north + corners.south) / 2, (corners.east + corners.west) / 2];1348 const span = Math.max(corners.east - corners.west, corners.north - corners.south);1349 const altitude = clamp(span / 45, NEAREST_ALTITUDE, FARTHEST_ALTITUDE);1350 this.steer({ lat: centre[0] ?? 0, lng: centre[1] ?? 0, altitude }, 600);1351 }13521353 private moved(): void {1354 this.refreshDetail();1355 if (this.context?.labelsHeld) this.drawLabels();1356 this.regridWhenFitChanges();1357 // The horizon moved with the camera even where nothing was rebuilt: the1358 // memo on the label key and the one on the fit key both stand still1359 // through a turn of the planet, and the silhouette does not.1360 this.cull();1361 const pov = this.globe?.pointOfView();1362 if (pov && this.onCamera) this.onCamera(pov);1363 }13641365 /**1366 * Drape the neighbourhood the camera is over.1367 *1368 * The level is the one whose tiles are about a quarter of what is on1369 * screen: shallower and the deep capture is wasted, deeper and the sphere1370 * asks for a hundred tiles to picture a continent.1371 *1372 * EACH TILE IS ITS OWN MESH, wearing its own texture at the size it was1373 * captured. That is the whole difference between the sphere reading as1374 * deeply as the chart and reading two levels shallower than it: the base1375 * skin is one four-thousand-pixel canvas of the entire equirect window,1376 * which is level four exactly, so a level-six tile composited into it is a1377 * 256-pixel square drawn 64 pixels wide and three quarters of the capture is1378 * thrown away before anything is drawn. Draped, the same tile covers the1379 * same ground at its own pitch, and the closer the camera comes the more of1380 * it there is to see.1381 */1382 private refreshDetail(): void {1383 const context = this.context;1384 const globe = this.globe;1385 const equirect = this.equirect;1386 if (!context?.lens || !globe || !equirect || !this.skin) return;1387 const pov = globe.pointOfView();1388 // The level the distance asks for, spoken through the pairing: one step1389 // deeper than the chart zoom the camera reads as. Nothing is draped until1390 // that is deeper than the skin already is -- a sphere seen whole is the1391 // base skin and nothing else, which is what makes "past the base skin's1392 // depth, tiles actually arrive" a statement a driver can check rather1393 // than a description of every frame.1394 const z = detailLevel(1395 zoomForAltitude(pov.altitude, this.ceiling()), context.lens.maxZoom);1396 if (z <= Math.max(TEXTURE_ZOOM, this.skin.baseLevel(context.lens))) {1397 this.clearDetail();1398 return;1399 }1400 const wanted = detailBlock(pov, z, DETAIL_TILE_BUDGET);1401 const key = `${context.lens.tiles}/${z}/` +1402 wanted.map(([x, y]) => `${x},${y}`).sort().join(" ");1403 if (key === this.detailKey) return;1404 this.detailKey = key;1405 this.seam.detail.lens = context.lens.tiles;1406 const coverage = new LensCoverage(context.lens);1407 const keep = new Set<string>();1408 for (const [x, y] of wanted) {1409 const name = `${z}/${x}/${y}`;1410 // Only where the capture says a tile was written: a lens is a picture of1411 // the ground it covered, and asking for the rest is a wall of 404s.1412 if (!coverage.has(context.grid, z, x, y)) continue;1413 keep.add(name);1414 if (this.draped.has(name)) continue;1415 const mesh = this.drapeTile(globe, context.lens, z, x, y);1416 if (!mesh) continue;1417 this.draped.set(name, mesh);1418 this.seam.detail.tiles.set(name, true);1419 this.tiles.add(mesh);1420 }1421 // What the camera has left behind. Dropped and disposed rather than kept1422 // against a return: a neighbourhood is a hundred textures, and the reader1423 // who flies back gets them from the browser's own cache.1424 for (const [name, mesh] of [...this.draped]) {1425 if (keep.has(name)) continue;1426 this.tiles.remove(mesh);1427 dispose(mesh);1428 this.draped.delete(name);1429 this.seam.detail.tiles.delete(name);1430 }1431 }14321433 /** One pyramid tile, draped at its place, invisible until its picture lands. */1434 private drapeTile(1435 globe: GlobeInstance, lens: Lens, z: number, x: number, y: number,1436 ): THREE.Mesh | null {1437 const url = this.tileURL(z, x, y);1438 if (!url) return null;1439 const span = 360 / 2 ** z;1440 const mesh = new THREE.Mesh(1441 tileGeometry(globe, -180 + x * span, 90 - y * span, span),1442 new THREE.MeshBasicMaterial());1443 // A black square teaches nothing: the tile appears when it has something1444 // to say, and the base skin carries the ground until then.1445 mesh.visible = false;1446 mesh.renderOrder = ORDER.tile;1447 new THREE.TextureLoader().load(url, (texture) => {1448 texture.colorSpace = THREE.SRGBColorSpace;1449 const material = mesh.material as THREE.MeshBasicMaterial;1450 material.map = texture;1451 material.needsUpdate = true;1452 mesh.visible = true;1453 });1454 return mesh;1455 }14561457 /** Put the neighbourhood away. A globe nobody is looking at holds none. */1458 private clearDetail(): void {1459 release(this.tiles);1460 this.draped.clear();1461 this.seam.detail.tiles.clear();1462 this.detailKey = "";1463 }14641465 private refresh(): void {1466 if (this.texture) this.texture.needsUpdate = true;1467 }14681469 private tileURL(z: number, x: number, y: number): string | null {1470 const context = this.context;1471 const lens = context?.lens;1472 if (!context || !lens) return null;1473 const extension = lens.formats[z - lens.minZoom];1474 if (!extension) return null;1475 return `${context.base}/tiles/${lens.tiles}/${z}/${x}/${y}.${extension}`;1476 }14771478 private pick(lat: number, lng: number): void {1479 const context = this.context;1480 const equirect = this.equirect;1481 if (!context || !equirect) return;1482 if (this.descend(lat, lng)) return;1483 let best: { id: string; distance: number } | null = null;1484 for (const point of context.visibility.standing()) {1485 const [plat, plng] = equirect.mapping.toLatLng(point.coordinate[0], -point.coordinate[1]);1486 const distance = (plat - lat) ** 2 + (plng - lng) ** 2;1487 if (!best || distance < best.distance) best = { id: point.id, distance };1488 }1489 // Nothing near enough is a miss, and a miss says nothing at all.1490 if (!best || best.distance > 4) return;1491 // The same road the chart's picks take: the page renders the form, this1492 // fills it and says so (data/report.ts). The sphere draws pins and only1493 // pins, so the kind is settled here.1494 reportPick({ feature: best.id, kind: "point" });1495 }14961497 /**1498 * A press on the sphere while a grid is up: one level in, under the finger.1499 *1500 * PURE GEOMETRY, and the sphere has no other way of doing it. The chart can1501 * ask which cell it drew under a pixel because it drew them onto the same1502 * surface the pointer is over; here the cells are meshes on a globe and the1503 * press answers in degrees, so the point is flattened back onto the picture1504 * and the system is asked what lies below the held cell there. Which is the1505 * navigator's own arithmetic -- `descendTarget` is what typing one more1506 * character into the field means -- so the two roads meet at the same1507 * address, and both post the same form.1508 *1509 * `descendTarget` answers `""` at the floor of the telescope, which is1510 * "there is nothing below this" rather than "the root": the report refuses1511 * an empty address, and the click is a miss.1512 *1513 * Answers whether it claimed the press. With no grid up a press is a pick,1514 * as it has always been.1515 */1516 private descend(lat: number, lng: number): boolean {1517 const context = this.context;1518 const equirect = this.equirect;1519 if (!context || !equirect) return false;1520 if (!context.system || !context.scene.gridSystem) return false;1521 const [x, y] = equirect.mapping.toWorld(lat, lng);1522 const target = context.system.on(context.ground).descendTarget(context.cell, [x, -y]);1523 reportGridPick(target);1524 return true;1525 }1526}15271528/**1529 * The cells to draw: the analysis lane's plan, in its frozen order, exactly1530 * as the chart asks for it. Two panes, one plan, no second opinion.1531 */1532/** A cell's footprint in degrees, which is what a chip is placed by. */1533interface Corners {1534 readonly north: number;1535 readonly south: number;1536 readonly east: number;1537 readonly west: number;1538}15391540/**1541 * A system's ring, landed on the sphere.1542 *1543 * The ring is continuous by contract, so a loop that crossed the antimeridian1544 * simply carries its longitudes past 180 — the trigonometry underneath is1545 * periodic and drapes it where it belongs, which is why the globe never needs1546 * the splitting the chart does.1547 */1548export function ringLatLng(ring: Ring, mapping: GeoMapping): [number, number][] {1549 return ring.map(([x, y]) => {1550 const [lat, lng] = mapping.toLatLng(x, -y);1551 return [lat, lng];1552 });1553}15541555/** A ring's frame in degrees: the fit gate, the chip's anchor, the camera. */1556export function boundsOf(ring: readonly (readonly [number, number])[]): Corners | null {1557 let north = -Infinity;1558 let south = Infinity;1559 let east = -Infinity;1560 let west = Infinity;1561 for (const [lat, lng] of ring) {1562 north = Math.max(north, lat);1563 south = Math.min(south, lat);1564 east = Math.max(east, lng);1565 west = Math.min(west, lng);1566 }1567 return Number.isFinite(north) ? { north, south, east, west } : null;1568}15691570/**1571 * A ring, subdivided by the span of each of its own segments.1572 *1573 * THE ONE THING A SPHERE ASKS OF A RING. Two corners 45° apart joined by a1574 * straight line are joined by a chord *through the planet*: at a hundred units1575 * of radius the middle of that line sits seven units under the ground it is1576 * supposed to lie on, so the sheet a fill is made of is drawn inside the world1577 * and the boundary over it disappears into the crust. The large cells are1578 * exactly the ones this ruins — a dim neighbour at the root of a plan spans1579 * tens of degrees — which is why "the dim does not render" was a fair1580 * description of a mesh that was rendering perfectly, buried.1581 *1582 * Every two degrees of span, to a ceiling of 48 steps a segment. The floor is1583 * the caller's: a fill wants one step per segment at minimum, a boundary four,1584 * because a boundary is what a reader sees the curve *of*.1585 *1586 * The loop it answers is OPEN — the closing repeat of the first point is1587 * dropped — so a fan or a strip can close it by index arithmetic.1588 */1589export function densifyRing(1590 ring: readonly (readonly [number, number])[], floor: number,1591): [number, number][] {1592 const dense: [number, number][] = [];1593 for (let at = 0; at < ring.length - 1; at++) {1594 const from = ring[at];1595 const to = ring[at + 1];1596 if (!from || !to) continue;1597 const span = Math.max(Math.abs(to[0] - from[0]), Math.abs(to[1] - from[1]));1598 const steps = clamp(Math.ceil(span / 2), floor, 48);1599 for (let step = 0; step < steps; step++) {1600 const t = step / steps;1601 dense.push([from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t]);1602 }1603 }1604 return dense;1605}16061607/** How many rings of triangles a fill of this span is built from. */1608export function fillRows(span: number): number {1609 return clamp(Math.ceil(span / 6), 2, 24);1610}16111612/** A place on the sphere at an absolute radius, in globe.gl's own spelling. */1613function surfacePoint(1614 globe: GlobeInstance, lat: number, lng: number, radius: number,1615): { x: number; y: number; z: number } {1616 return globe.getCoords(lat, lng, radius / GLOBE_RADIUS - 1);1617}16181619/**1620 * A cell's tint or dim, laid on the ground.1621 *1622 * A fan of triangles from the cell's own middle out to its densified ring,1623 * ring by ring, each vertex pushed back out to the sphere it belongs on. The1624 * spokes are walked on the sphere itself rather than in degrees, where an1625 * unwrapped ring and a wrapped centre sit hundreds of degrees apart and a pole1626 * cell's middle has no honest longitude at all — but a straight walk from the1627 * middle to the rim is still a chord, so every vertex is lifted back to the1628 * radius before it is kept. Rows by span, for the same reason the ring is1629 * densified by span: one row of triangles across forty degrees sags as badly1630 * as one segment does.1631 */1632export function ringFill(1633 globe: GlobeInstance,1634 ring: readonly (readonly [number, number])[],1635 corners: Corners,1636 centre: readonly [number, number],1637 fill: { color: string; opacity: number },1638): THREE.Mesh | null {1639 const open = densifyRing(ring, 1);1640 if (open.length < 3) return null;1641 const middle = surfacePoint(globe, centre[0], centre[1], FILL_RADIUS);1642 const radius = Math.hypot(middle.x, middle.y, middle.z) || 1;1643 const edges = open.map(([lat, lng]) => surfacePoint(globe, lat, lng, FILL_RADIUS));1644 const rows = fillRows(Math.max(corners.east - corners.west, corners.north - corners.south));1645 const positions: number[] = [middle.x, middle.y, middle.z];1646 for (let row = 1; row <= rows; row++) {1647 const t = row / rows;1648 for (const edge of edges) {1649 const x = middle.x + (edge.x - middle.x) * t;1650 const y = middle.y + (edge.y - middle.y) * t;1651 const z = middle.z + (edge.z - middle.z) * t;1652 const lift = radius / (Math.hypot(x, y, z) || 1);1653 positions.push(x * lift, y * lift, z * lift);1654 }1655 }1656 const count = open.length;1657 const at = (row: number, index: number): number => 1 + (row - 1) * count + (index % count);1658 const indices: number[] = [];1659 for (let index = 0; index < count; index++) {1660 indices.push(0, at(1, index), at(1, index + 1));1661 }1662 for (let row = 1; row < rows; row++) {1663 for (let index = 0; index < count; index++) {1664 indices.push(at(row, index), at(row + 1, index), at(row, index + 1));1665 indices.push(at(row, index + 1), at(row + 1, index), at(row + 1, index + 1));1666 }1667 }1668 const geometry = new THREE.BufferGeometry();1669 geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));1670 geometry.setIndex(indices);1671 const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({1672 color: fill.color, transparent: true, opacity: fill.opacity,1673 side: THREE.DoubleSide, depthWrite: false,1674 }));1675 mesh.renderOrder = ORDER.fill;1676 return mesh;1677}16781679/**1680 * A cell's boundary, at the weight its role is drawn in.1681 *1682 * `Line2` rather than `THREE.Line`, and the whole role table is why: WebGL1683 * draws a native line one pixel wide and silently ignores the width it was1684 * asked for, so a leaf's 2.5, a bare scope's 1.8, a child's 1.4 and a1685 * neighbour's 1.0 all came out the same hairline and the hierarchy those1686 * weights encode was erased on the sphere while the chart drew it correctly.1687 * A line built out of quads carries its width honestly — in pixels, which is1688 * why the material has to be told the size of the window it is measured1689 * against.1690 *1691 * The segments are subdivided by their span for the same reason the fill is: a1692 * straight line between two corners 45° apart is a chord through the planet,1693 * and most of it is under the ground it is supposed to be drawn on.1694 */1695export function cellBoundary(1696 globe: GlobeInstance,1697 ring: readonly (readonly [number, number])[],1698 line: { color: string; opacity: number; widthPx: number },1699 viewport: { width: number; height: number },1700): Line2 | null {1701 const positions: number[] = [];1702 for (const [lat, lng] of densifyRing(ring, 4)) {1703 const at = surfacePoint(globe, lat, lng, LINE_RADIUS);1704 positions.push(at.x, at.y, at.z);1705 }1706 if (positions.length < 6) return null;1707 // Closed by hand: the walk above is the open loop, and a boundary with a gap1708 // in it is a cell that does not enclose anything.1709 positions.push(...positions.slice(0, 3));1710 const geometry = new LineGeometry();1711 geometry.setPositions(positions);1712 const material = new LineMaterial({1713 color: line.color, transparent: true, opacity: line.opacity,1714 linewidth: line.widthPx, depthWrite: false,1715 });1716 material.resolution.set(viewport.width || 1, viewport.height || 1);1717 const loop = new Line2(geometry, material);1718 loop.computeLineDistances();1719 loop.renderOrder = ORDER.line;1720 return loop;1721}17221723/**1724 * The outlines one shape draws: every outer ring, and every hole an area1725 * carries, each marked with whether it closes. An area's rings enclose ground1726 * and close; a path's lines lie as they lie.1727 */1728export function zoneOutlines(shape: ShapeRecord): { ring: ShapeLine; close: boolean }[] {1729 const close = shape.kind === "area";1730 const out: { ring: ShapeLine; close: boolean }[] = [];1731 shape.lines.forEach((line, index) => {1732 out.push({ ring: line, close });1733 for (const hole of shape.holes[index] ?? []) out.push({ ring: hole, close });1734 });1735 return out;1736}17371738/**1739 * One shape's outlines, landed on the sphere as a single object.1740 *1741 * `LineSegments2` rather than one `Line2` per ring: a country is often an1742 * archipelago — many rings, some of them holes — and segments are what let1743 * every disjoint piece ride one geometry and one draw call. Each segment is1744 * subdivided by its span exactly as the grid's boundaries are, and for the1745 * same reason: a straight chord between two far corners passes through the1746 * planet, not along it. Payload rings arrive split at the antimeridian by1747 * their producer, so no segment spans the seam and the trigonometry drapes1748 * each piece where it belongs.1749 */1750export function zoneMesh(1751 globe: GlobeInstance,1752 shape: ShapeRecord,1753 mapping: GeoMapping,1754 line: { color: string; opacity: number; widthPx: number },1755 viewport: { width: number; height: number },1756): LineSegments2 | null {1757 const positions: number[] = [];1758 for (const { ring, close } of zoneOutlines(shape)) {1759 const dense = densifyRing(ringLatLng(ring as Ring, mapping), 4);1760 if (dense.length < 2) continue;1761 const loop = close ? [...dense, dense[0]!] : dense;1762 let from = surfacePoint(globe, loop[0]![0], loop[0]![1], ZONE_RADIUS);1763 for (let at = 1; at < loop.length; at++) {1764 const to = surfacePoint(globe, loop[at]![0], loop[at]![1], ZONE_RADIUS);1765 positions.push(from.x, from.y, from.z, to.x, to.y, to.z);1766 from = to;1767 }1768 }1769 if (!positions.length) return null;1770 const geometry = new LineSegmentsGeometry();1771 geometry.setPositions(positions);1772 const material = new LineMaterial({1773 color: line.color, transparent: true, opacity: line.opacity,1774 linewidth: line.widthPx, depthWrite: false,1775 });1776 material.resolution.set(viewport.width || 1, viewport.height || 1);1777 const drawn = new LineSegments2(geometry, material);1778 drawn.computeLineDistances();1779 drawn.renderOrder = ORDER.line;1780 drawn.userData = { zone: shape.id, role: "line" };1781 return drawn;1782}17831784/**1785 * One shape's ground, tessellated onto the sphere.1786 *1787 * The cell fills fan from a centre and cannot honestly cover a concave,1788 * holed, many-part country, so the ground is triangulated the way a flat1789 * renderer would — each part's outer ring against its holes, in degree space1790 * — and then every triangle wider than a few degrees is cut down before its1791 * vertices are lifted to the sphere, for the reason every ring is densified:1792 * a triangle's middle is a chord, and a chord through a planet is a fill1793 * buried under its own ground. Payload rings arrive split at the1794 * antimeridian, so degree space is honest for exactly the shapes that reach1795 * here.1796 */1797export function zoneFill(1798 globe: GlobeInstance,1799 shape: ShapeRecord,1800 mapping: GeoMapping,1801): THREE.Mesh | null {1802 if (shape.kind !== "area") return null;1803 const positions: number[] = [];1804 shape.lines.forEach((outer, index) => {1805 const contour = openRing(ringLatLng(outer as Ring, mapping));1806 if (contour.length < 3) return;1807 const holes = (shape.holes[index] ?? [])1808 .map((hole) => openRing(ringLatLng(hole as Ring, mapping)))1809 .filter((hole) => hole.length >= 3);1810 const flat = [contour, ...holes].flat();1811 const triangles = THREE.ShapeUtils.triangulateShape(1812 contour.map(([lat, lng]) => new THREE.Vector2(lng, lat)),1813 holes.map((hole) => hole.map(([lat, lng]) => new THREE.Vector2(lng, lat))));1814 for (const [a, b, c] of triangles) {1815 const held = [flat[a!], flat[b!], flat[c!]];1816 if (held.every(Boolean)) sinkTriangle(globe, held as [number, number][], positions);1817 }1818 });1819 if (!positions.length) return null;1820 const geometry = new THREE.BufferGeometry();1821 geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));1822 const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({1823 color: featureColor(shape.id), transparent: true, opacity: 0.1,1824 side: THREE.DoubleSide, depthWrite: false,1825 }));1826 mesh.renderOrder = ORDER.fill;1827 mesh.userData = { zone: shape.id, role: "fill" };1828 return mesh;1829}18301831/** A payload ring without its closing repeat, which a triangulation refuses. */1832function openRing(ring: [number, number][]): [number, number][] {1833 const first = ring[0];1834 const last = ring[ring.length - 1];1835 if (ring.length > 1 && first && last && first[0] === last[0] && first[1] === last[1]) {1836 return ring.slice(0, -1);1837 }1838 return ring;1839}18401841/**1842 * One triangle of ground, subdivided by its span and landed on the sphere.1843 *1844 * Uniform barycentric rows: the longest edge decides how many, every corner1845 * of every piece is lifted to the fill radius, and the pieces are appended as1846 * plain position triples — an unindexed mesh, because sharing vertices across1847 * a subdivision saves less than it costs to book-keep.1848 */1849function sinkTriangle(1850 globe: GlobeInstance, corners: [number, number][], positions: number[],1851): void {1852 const [a, b, c] = corners as [[number, number], [number, number], [number, number]];1853 const span = Math.max(1854 Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]),1855 Math.abs(b[0] - c[0]), Math.abs(b[1] - c[1]),1856 Math.abs(c[0] - a[0]), Math.abs(c[1] - a[1]));1857 const rows = clamp(Math.ceil(span / ZONE_TRIANGLE_SPAN), 1, 32);1858 const at = (i: number, j: number): [number, number] => {1859 const u = i / rows;1860 const v = j / rows;1861 return [1862 a[0] + (b[0] - a[0]) * u + (c[0] - a[0]) * v,1863 a[1] + (b[1] - a[1]) * u + (c[1] - a[1]) * v,1864 ];1865 };1866 const push = (point: [number, number]): void => {1867 const landed = surfacePoint(globe, point[0], point[1], ZONE_FILL_RADIUS);1868 positions.push(landed.x, landed.y, landed.z);1869 };1870 for (let i = 0; i < rows; i++) {1871 for (let j = 0; j < rows - i; j++) {1872 push(at(i, j));1873 push(at(i + 1, j));1874 push(at(i, j + 1));1875 if (j < rows - i - 1) {1876 push(at(i + 1, j));1877 push(at(i + 1, j + 1));1878 push(at(i, j + 1));1879 }1880 }1881 }1882}18831884/**1885 * The level the camera's distance asks the pyramid for.1886 *1887 * One step deeper than the chart zoom the distance reads as, because a tile1888 * draped on a sphere is seen at a glancing angle over most of its own ground,1889 * and never deeper than the capture actually goes.1890 */1891export function detailLevel(zoom: number, maxZoom: number): number {1892 return Math.min(Math.round(zoom) + 1, maxZoom);1893}18941895/**1896 * The block of tiles under the camera, in the pyramid's own columns and rows.1897 *1898 * How much ground the camera can possibly see is the horizon angle at this1899 * altitude, padded by one tile so tiles arrive before their ground does, then1900 * pulled in until the neighbourhood fits its budget.1901 *1902 * The block is named in tiles rather than measured in pixels. A tile is a1903 * whole square of latitude and longitude at this level, so the camera faces1904 * exactly one of them and the neighbourhood is the (2·reach+1)² square around1905 * it: wrapped in longitude, because the sphere closes, and clipped in1906 * latitude, because it does not.1907 */1908export function detailBlock(1909 pov: { lat: number; lng: number; altitude: number }, z: number, budget: number,1910): [number, number][] {1911 const columns = 2 ** z;1912 const rows = columns / 2;1913 const span = 360 / columns;1914 const horizon = (Math.acos(1 / (1 + pov.altitude)) * 180) / Math.PI + span;1915 let reach = Math.ceil(horizon / span);1916 while ((2 * reach + 1) ** 2 > budget && reach > 1) reach -= 1;1917 const centreColumn = Math.floor(((((pov.lng + 180) % 360) + 360) % 360) / span);1918 const centreRow = Math.floor((90 - pov.lat) / span);1919 const wanted: [number, number][] = [];1920 for (let down = -reach; down <= reach; down++) {1921 const row = centreRow + down;1922 if (row < 0 || row >= rows) continue;1923 for (let across = -reach; across <= reach; across++) {1924 wanted.push([(((centreColumn + across) % columns) + columns) % columns, row]);1925 }1926 }1927 return wanted;1928}19291930/**1931 * The grid one tile is draped on.1932 *1933 * Through the same latitude-and-longitude spelling the pins stand by, so a1934 * tile lands on the pixel the base skin and the markers already agree on — and1935 * on a grid rather than a quad, because a flat square laid over a curve stands1936 * off the ground in the middle and cuts into it at the corners.1937 */1938export function tileGeometry(1939 globe: GlobeInstance, west: number, north: number, span: number,1940): THREE.BufferGeometry {1941 const positions: number[] = [];1942 const uvs: number[] = [];1943 const indices: number[] = [];1944 for (let down = 0; down <= DETAIL_SEGMENTS; down++) {1945 const lat = north - (down / DETAIL_SEGMENTS) * span;1946 for (let across = 0; across <= DETAIL_SEGMENTS; across++) {1947 const lng = west + (across / DETAIL_SEGMENTS) * span;1948 const at = surfacePoint(globe, lat, lng, DETAIL_RADIUS);1949 positions.push(at.x, at.y, at.z);1950 uvs.push(across / DETAIL_SEGMENTS, 1 - down / DETAIL_SEGMENTS);1951 }1952 }1953 const stride = DETAIL_SEGMENTS + 1;1954 for (let down = 0; down < DETAIL_SEGMENTS; down++) {1955 for (let across = 0; across < DETAIL_SEGMENTS; across++) {1956 const corner = down * stride + across;1957 indices.push(corner, corner + stride, corner + 1);1958 indices.push(corner + 1, corner + stride, corner + stride + 1);1959 }1960 }1961 const geometry = new THREE.BufferGeometry();1962 geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));1963 geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));1964 geometry.setIndex(indices);1965 return geometry;1966}19671968/** One shared canvas for measuring text: measuring is not drawing. */1969let ruler: CanvasRenderingContext2D | null | undefined;19701971function measure(text: string, font: string): number {1972 ruler ??= document.createElement("canvas").getContext("2d");1973 if (!ruler) return text.length * 8;1974 ruler.font = font;1975 return ruler.measureText(text).width;1976}19771978function planOf(context: WorldContext): PlanCell[] {1979 if (!context.system) return [];1980 return cellPlan(context.ground, context.system, context.cell);1981}19821983/** A pin the camera could raise a name over. */1984export interface Placed {1985 readonly title: string;1986 readonly lat: number;1987 readonly lng: number;1988}19891990/**1991 * Hang the composited skin on the sphere's own material.1992 *1993 * TWO LINES, AND THE SECOND ONE IS THE WHOLE OF `screen-globe`. globe.gl hands1994 * out a sphere with no picture on it, and it says so in the only way a Phong1995 * material can: it sets `color` to black. Its own `globeImageUrl` clears that1996 * colour when a texture finishes loading — this seam never calls it, because1997 * the skin is a live canvas recomposited under the camera and routing four1998 * megapixels through a data URL on every camera move is not a way to draw a1999 * map — so clearing the tint is this function's job.2000 *2001 * A Phong material multiplies its map by its colour. Leave the colour black2002 * and the skin is multiplied away: the texture is there, every count in the2003 * snapshot is right, every sprite draws, and the planet under them is black.2004 * That is the exact defect only a picture catches: every counting instrument2005 * in the diagnostics reads a healthy scene over a black planet.2006 *2007 * White rather than `null`, which is what globe.gl writes: three reads a null2008 * colour as "leave the shader's own default", and the shader's own default is2009 * white. Saying white says the same thing in a value the type admits.2010 */2011export function wearSkin(material: THREE.MeshPhongMaterial, skin: THREE.Texture): void {2012 material.map = skin;2013 material.color = new THREE.Color(0xffffff);2014 material.needsUpdate = true;2015}20162017/**2018 * The great-circle separation of two places, in degrees.2019 *2020 * Which is what "near what the camera is looking at" means on a sphere: not2021 * the difference of two latitudes and two longitudes, which calls the whole2022 * arctic a neighbourhood.2023 */2024export function angularDistance(2025 a: { lat: number; lng: number }, b: { lat: number; lng: number },2026): number {2027 const rad = Math.PI / 180;2028 const inner = Math.sin(a.lat * rad) * Math.sin(b.lat * rad) +2029 Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.cos((a.lng - b.lng) * rad);2030 return (Math.acos(clamp(inner, -1, 1)) * 180) / Math.PI;2031}20322033/**2034 * The names worth raising: the nearest ones to what the camera faces, within2035 * the reach and inside the budget.2036 *2037 * The budget is a decision about a sphere -- past a couple of hundred names a2038 * planet is a word cloud -- and the ordering is a decision about a *reader*.2039 * Spending it in collection order hands every card to whichever collection2040 * happens to be rarest, wherever in the world it stands, and leaves the ground2041 * under the camera bare.2042 */2043export function labelCandidates(2044 pov: { lat: number; lng: number },2045 standing: Iterable<Placed>,2046 budget: number,2047): Placed[] {2048 const near: { placed: Placed; distance: number }[] = [];2049 for (const placed of standing) {2050 const distance = angularDistance(pov, placed);2051 if (distance > LABEL_REACH_DEG) continue;2052 near.push({ placed, distance });2053 }2054 near.sort((a, b) => a.distance - b.distance);2055 return near.slice(0, budget).map((entry) => entry.placed);2056}20572058/**2059 * Whether a point on the sphere is on the side of it the camera can see.2060 *2061 * The limb is where the cosine of a point's angle from the camera's own axis2062 * equals the radius over the camera's distance: nearer the axis than that and2063 * the point is facing us, past it and the planet is in the way.2064 */2065export function facesCamera(2066 anchor: { x: number; y: number; z: number },2067 camera: { x: number; y: number; z: number },2068 radius = GLOBE_RADIUS,2069): boolean {2070 const distance = Math.hypot(camera.x, camera.y, camera.z) || 1;2071 const reach = Math.hypot(anchor.x, anchor.y, anchor.z) || 1;2072 const facing = (anchor.x * camera.x + anchor.y * camera.y + anchor.z * camera.z) /2073 (reach * distance);2074 return facing > radius / distance;2075}20762077/**2078 * One name, on a card floated above its pin.2079 *2080 * The card is cut to the name: the text is measured in the font it will be2081 * drawn in, and the canvas, the texture and the sprite's own aspect all follow2082 * that one number. A fixed canvas clips every name longer than it and pads2083 * every name shorter, and a fixed sprite scale then stretches whatever2084 * survived to the same width regardless -- so "Olympus Mons" and "Tharsis2085 * Tholus" came out the same size and neither was the size it asked for.2086 */2087export function nameCard(2088 title: string, at: { x: number; y: number; z: number },2089): THREE.Sprite {2090 const width = Math.ceil(measure(title, CARD_FONT)) + CARD_PAD;2091 const canvas = document.createElement("canvas");2092 canvas.width = width;2093 canvas.height = CARD_TALL;2094 const paper = canvas.getContext("2d");2095 if (paper) {2096 paper.font = CARD_FONT;2097 paper.textAlign = "center";2098 paper.textBaseline = "middle";2099 paper.fillStyle = "rgba(10, 13, 17, 0.78)";2100 paper.fillRect(0, 0, width, CARD_TALL);2101 paper.fillStyle = "#e6ebf0";2102 paper.fillText(title, width / 2, 21);2103 }2104 const texture = new THREE.CanvasTexture(canvas);2105 texture.colorSpace = THREE.SRGBColorSpace;2106 const sprite = new THREE.Sprite(new THREE.SpriteMaterial({2107 // No depth test: a screen-sized card anchored on the ground loses its2108 // lower half to the planet's curve at any glancing angle. `cull` puts the2109 // horizon back by hand.2110 map: texture, depthTest: false, depthWrite: false,2111 sizeAttenuation: false, transparent: true,2112 }));2113 sprite.position.set(at.x, at.y, at.z);2114 sprite.renderOrder = ORDER.card;2115 sprite.scale.set((LABEL_HEIGHT * width) / CARD_TALL, LABEL_HEIGHT, 1);2116 // Anchored at its bottom edge, so the card floats above the marker rather2117 // than covering it.2118 sprite.center.set(0.5, 0);2119 return sprite;2120}21212122/**2123 * What a collection's marker is, as the sphere has to draw it.2124 *2125 * A face and a name: everything about how the mark is composed is the face's,2126 * shared with the chart, and the name is only what the initials fall back to2127 * while the symbol is on its way.2128 */2129export interface Marker extends MarkerFace {2130 readonly title: string;2131}21322133/**2134 * What a pin wears when its collection is not in the world's own list.2135 *2136 * Which cannot happen through the model, and is a shared cache entry rather2137 * than a fresh material anyway: a marker nobody can account for is still not2138 * two thousand canvases nobody disposes.2139 */2140const BARE_MARKER: Marker = {2141 asset: "", url: "", picture: false, color: "#4fb3d5",2142 outset: outsetColor("light"), title: "",2143};21442145/** Every collection's marker, in payload order, which is palette order. */2146function markersOf(context: WorldContext): Map<number, Marker> {2147 const outset = outsetColor(context.outset);2148 const marker = (collection: Collection, ordinal: number): [number, Marker] => [2149 collection.id,2150 {2151 asset: collection.iconAsset ?? "",2152 url: collection.iconAsset ? iconURL(context.base, collection.iconAsset) : "",2153 picture: iconIsPicture(collection),2154 // The same ladder the chart climbs, because it is the same mark: a2155 // colour that collides with the rim is lifted or dropped, and the two2156 // panes must not disagree about which colour a collection is.2157 color: legibleIconColor(collectionColor(collection, ordinal), context.outset),2158 outset,2159 title: collection.title,2160 },2161 ];2162 return new Map(context.model.collections.map(marker));2163}21642165/**2166 * One sprite material per marker and selection state, cached and shared.2167 *2168 * A material is what a pin *is*, not what a pin *has*: two thousand pins of2169 * twenty collections are forty materials, and a filter or a selection swaps2170 * which of the forty a sprite points at. The cache is therefore never2171 * disposed -- entries outlive every sprite that wore them, which is the whole2172 * point of them.2173 */2174export function markerMaterial(marker: Marker, selected: boolean): THREE.SpriteMaterial {2175 const key = `${markerKey(marker)}:${selected ? "ringed" : "plain"}`;2176 const held = markers.get(key);2177 if (held) return held;2178 // Pins keep one size on screen however close the camera comes, the way the2179 // chart draws its markers: world-sized sprites become dinner plates from low2180 // altitude.2181 //2182 // AND THEY ARE NOT CLIPPED BY THE GROUND THEY STAND ON. A pin's sprite is2183 // screen-sized around a point half a unit off the skin, so at the limb most2184 // of that square is over ground *nearer* the camera than its own anchor:2185 // tested against the depth buffer, the planet swallowed the buried half and2186 // the reader saw icons sunk to the waist in the rim. The rule is the plain2187 // one -- an icon is drawn on top of the raster -- and the far side is kept2188 // out by `cull` rather than by the depth buffer.2189 const material = new THREE.SpriteMaterial({2190 depthTest: false, depthWrite: false, sizeAttenuation: false,2191 });2192 markers.set(key, material);2193 const dress = (image: HTMLImageElement | null): void => {2194 material.map = markerTexture(marker, selected, image);2195 material.needsUpdate = true;2196 };2197 // A collection with no artwork has nothing to wait for.2198 if (!marker.asset) {2199 dress(null);2200 return material;2201 }2202 // The chart's raster, not the raw artwork: the symbol is already tinted and2203 // already haloed, and composing it a second time here would be the sphere2204 // deciding for itself what a Shrine looks like.2205 void markerRasterReady(marker).then((raster) => {2206 if (!raster) {2207 // A collection with no artwork, or one whose artwork never arrived,2208 // wears its initials — which is what a build with no icons at all draws.2209 dress(null);2210 return;2211 }2212 const image = new Image();2213 image.onload = (): void => { dress(image); };2214 image.onerror = (): void => { dress(null); };2215 image.src = raster;2216 });2217 return material;2218}22192220const markers = new Map<string, THREE.SpriteMaterial>();22212222/**2223 * The 80×80 canvas a marker is drawn on: the chart's raster, or the initials.2224 *2225 * The raster arrives finished — the symbol already carries its collection's2226 * colour and already wears its halo — so all the sphere does with it is place2227 * it, inset by eight so the ring has somewhere to go.2228 */2229function markerTexture(2230 marker: Marker, selected: boolean, image: HTMLImageElement | null,2231): THREE.CanvasTexture {2232 const canvas = document.createElement("canvas");2233 canvas.width = MARKER_SIZE;2234 canvas.height = MARKER_SIZE;2235 const paper = canvas.getContext("2d");2236 if (paper && image) {2237 paper.drawImage(image, 8, 8, 64, 64);2238 } else if (paper) {2239 const short = initialsOf(marker.title);2240 paper.font = MARKER_FONT;2241 paper.textAlign = "center";2242 paper.textBaseline = "middle";2243 paper.lineWidth = 6;2244 paper.strokeStyle = marker.outset;2245 paper.strokeText(short, 40, 41);2246 paper.fillStyle = marker.color;2247 paper.fillText(short, 40, 41);2248 }2249 if (paper && selected) {2250 paper.beginPath();2251 paper.arc(40, 40, 36, 0, Math.PI * 2);2252 paper.lineWidth = 5;2253 paper.strokeStyle = "#ffffff";2254 paper.stroke();2255 }2256 const texture = new THREE.CanvasTexture(canvas);2257 texture.colorSpace = THREE.SRGBColorSpace;2258 return texture;2259}22602261/**2262 * Drop a group's children and give the GPU back what they were holding.2263 *2264 * Dropping an object out of a group is not freeing what it owns: a texture2265 * lives until something says so, and both the names and the grid mint one per2266 * card on every rebuild. The grid is redrawn whenever the camera moves far2267 * enough in depth to change what fits, and the names whenever it settles2268 * anywhere new -- over one flight to a cell that is dozens of rebuilds of a2269 * couple of hundred objects each.2270 *2271 * A sprite's geometry is three's own, shared by every sprite in the scene, so2272 * it is the one thing here that is not this group's to give back.2273 */2274export function release(group: THREE.Group): void {2275 for (const child of [...group.children]) {2276 group.remove(child);2277 dispose(child);2278 }2279}22802281/**2282 * Give back what one drawn object holds.2283 *2284 * Three kinds pass through here and each owns something different. A sprite's2285 * geometry is three's own quad, shared by every sprite in the scene, so it is2286 * the one thing not to free; its texture is a canvas this element minted, so2287 * it is. A fill mesh owns its geometry. A `Line2` owns both — the geometry it2288 * expanded its positions into and the `LineMaterial` carrying its width — and2289 * a boundary is rebuilt every time the camera changes what fits, so leaking2290 * one is leaking dozens over a single flight to a cell.2291 */2292export function dispose(child: THREE.Object3D): void {2293 const drawn = child as THREE.Mesh & {2294 isSprite?: boolean;2295 material?: THREE.Material | THREE.Material[];2296 };2297 if (!drawn.isSprite) drawn.geometry?.dispose?.();2298 const worn = drawn.material;2299 for (const material of Array.isArray(worn) ? worn : [worn]) {2300 const held = material as (THREE.Material & { map?: THREE.Texture | null }) | undefined;2301 held?.map?.dispose?.();2302 held?.dispose?.();2303 }2304}