1// The globe: a map that declares itself a sphere can be looked at as one.2// The renderer is globe.gl -- three.js with the community's globe work3// already done -- fed entirely from the bundle: the texture is composited4// from the map's own equirectangular tiles, which drape a sphere with no5// reprojection at all (u is x over width, v is y over height, exactly), and6// every pin's position comes from running its packed synthetic coordinates7// backward through the mapping the map declares. Pins stand off the surface8// as billboards wearing the same haloed category icons the chart draws, the9// selected one ringed and raised so it reads as chosen, and a click opens10// the same card it opens on the chart. The chart never goes away: this is a11// second way of seeing the same bundle, a toggle apart.12import Globe from "globe.gl";13import {14 BufferGeometry,15 Float32BufferAttribute,16 Group,17 Mesh,18 MeshBasicMaterial,19 Sprite,20 SpriteMaterial,21 SRGBColorSpace,22 TextureLoader,23} from "three";2425import { Line2 } from "three/examples/jsm/lines/Line2.js";26import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";27import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";2829import { isCollectionHidden } from "./collections.js";30import { gridTheme, overzoomLevels } from "./constants.js";31import { showPin } from "./detail.js";32import { elements } from "./dom.js";33import { activeSystem } from "./cellsystems/index.js";34import {35 gridCellPlan,36 gridCellVisual,37 pinInGridCell,38 selectGridCell,39} from "./grid.js";40import { equirectMapping, worldSurface } from "./semconv.js";41import { state } from "./state.js";42import { markerIconKey, measureLabel } from "./styles.js";43import { categoryColor, iconOutsetColor, initials } from "./theme.js";44import { clamp } from "./util.js";45import { project } from "./areas.js";4647// textureZoom picks the pyramid level the sphere wears everywhere: deep48// enough to read from orbit, shallow enough that one canvas holds the whole49// planet (level 4 is a 4096x2048 texture). Closer than that, the detail50// layer below takes over with the pyramid's own tiles.51const textureZoom = 4;5253// The sphere three-globe draws has radius 100, and its objects place a54// latitude and longitude with this exact spelling; the detail tiles use the55// same one, so they land on the pixel the base skin and the pins already56// agree on. Tiles float just off the skin, well under the pins.57const globeRadius = 100;58const detailRadius = globeRadius + 0.2;59const detailSegments = 8;60const detailTileBudget = 96;61// Screen-relative sprite sizes: with attenuation off, scale is a fraction62// of the viewport rather than a length on the sphere.63const spriteSize = 0.045;64const spriteSelectedSize = 0.08;6566function surfacePoint(lat, lng, radius) {67 const phi = ((90 - lat) * Math.PI) / 180;68 const theta = ((90 - lng) * Math.PI) / 180;69 return [70 radius * Math.sin(phi) * Math.cos(theta),71 radius * Math.cos(phi),72 radius * Math.sin(phi) * Math.sin(theta),73 ];74}7576let globe = null;77let texturedFor = "";78// The detail layer: pyramid tiles standing just off the base skin wherever79// the camera is close enough to want them, keyed by level/column/row.80const detail = { group: null, tiles: new Map(), key: "", lens: "" };81// The geohash grid drawn on the sphere: cell boundaries and their letters,82// rebuilt from the same plan the chart tiles its cells from. fitKey coarsens83// the camera so the grid rebuilds only when a zoom change could move the84// fit gate, not on every tick of a drag.85const grid = { group: null, cell: null, fitKey: "" };86// The names held up while Z is down: label sprites over the pins nearest87// the camera, rebuilt as the view settles and dropped on release.88const labels = { group: null, key: "" };89const labelBudget = 180;90// The pins the standing sprites were built from. The renderer creates91// sprites lazily on its own tick, so nothing here may assume they exist92// the moment the data is handed over -- each sprite is born already93// dressed for the current filters and selection, and the sweeps below only94// re-dress the ones that are standing.95let pinsBuilt = null;96// The sprites standing on the sphere right now, by pin, so a selection or97// filter change restyles them in place instead of rebuilding two thousand.98const sprites = new Map();99const placed = new Map();100const materials = new Map();101102// syncGlobe is called whenever a map arrives or leaves: it puts the toggle103// up only for maps that declare a sphere with a mapping the viewer can104// invert, and always returns the reader to the chart, which is where every105// map opens.106export function syncGlobe() {107 const offered = worldSurface(state.world) === "sphere" && equirectMapping(state.world) !== null;108 elements.globeToggle.hidden = !offered;109 leaveGlobe();110 if (!offered && globe) {111 clearDetailTiles();112 detail.group = null;113 grid.group = null;114 grid.cell = null;115 labels.group = null;116 labels.key = "";117 globe._destructor();118 globe = null;119 texturedFor = "";120 pinsBuilt = null;121 elements.globe.replaceChildren();122 }123}124125export function toggleGlobe() {126 if (state.globeActive) leaveGlobe();127 else void enterGlobe();128}129130// The two cameras answer to one another through the declared mapping, so131// flipping panes keeps the reader's place: the point faced and roughly how132// close. The scale pairing is a rule of thumb -- the whole disc at altitude133// 2.5 reads like the whole chart at zoom 2, and each halving of altitude134// reads like one more zoom -- which is all "the same spot" needs.135const wholeDiscAltitude = 2.5;136const wholeChartZoom = 2;137138// The camera keeps a respectful distance: never through the skin -- a139// camera inside the sphere sees the world inside out and its altitude goes140// negative, which once poisoned every conversion downstream -- and never so141// far the planet is a dot.142const nearestAltitude = 0.08;143const farthestAltitude = 4;144145function altitudeForZoom(zoom) {146 const safe = Number.isFinite(zoom) ? zoom : wholeChartZoom;147 return clamp(wholeDiscAltitude / 2 ** (safe - wholeChartZoom), nearestAltitude, farthestAltitude);148}149150function zoomForAltitude(altitude) {151 const safe = Number.isFinite(altitude) ? Math.max(altitude, nearestAltitude / 2) : wholeDiscAltitude;152 const ceiling = (state.lens?.maxZoom ?? wholeChartZoom) + overzoomLevels;153 return clamp(wholeChartZoom + Math.log2(wholeDiscAltitude / safe), 0, ceiling);154}155156// changeGlobeZoom is the zoom buttons' reading on the sphere: one press is157// one halving or doubling of the distance, inside the same bounds the158// wheel is held to.159export function changeGlobeZoom(delta) {160 if (!globe || !state.globeActive) return;161 const pov = globe.pointOfView();162 const altitude = clamp(163 (Number.isFinite(pov.altitude) ? pov.altitude : wholeDiscAltitude) / 2 ** delta,164 nearestAltitude,165 farthestAltitude,166 );167 globe.pointOfView({ altitude }, 180);168}169170function leaveGlobe() {171 // Whatever the globe was facing, the chart opens on: the same place at a172 // comparable closeness, so the flip reads as turning a page, not losing173 // one. A map change lands here too, but its mapping no longer answers174 // and the new map's view is left alone.175 const mapping = equirectMapping(state.world);176 const view = state.engine?.getView();177 if (globe && state.globeActive && mapping && view) {178 const pov = globe.pointOfView();179 const [worldX, worldY] = mapping.toWorld(pov.lat, pov.lng);180 // Nothing unfinite may reach the chart's view: a broken number here181 // once blacked out both panes with no way back.182 if (Number.isFinite(worldX) && Number.isFinite(worldY)) {183 view.cancelAnimations();184 view.setCenter([worldX, -worldY]);185 view.setZoom(zoomForAltitude(pov.altitude));186 }187 }188 state.globeActive = false;189 elements.globe.hidden = true;190 elements.globeToggle.setAttribute("aria-pressed", "false");191 elements.viewport.hidden = false;192 // The neighborhood of tiles under the camera goes back to the pyramid;193 // the base skin is all a put-away globe keeps.194 clearDetailTiles();195 // The overview goes back to marking the chart's window; the memo that196 // spares it redundant work must not spare it this change.197 state.overviewKey = "";198 document.dispatchEvent(new Event("atlas:globe-camera"));199}200201// globeCamera says where the globe is looking, for the overview's reticle;202// nowhere, when the reader is on the chart.203export function globeCamera() {204 if (!globe || !state.globeActive) return null;205 const pov = globe.pointOfView();206 return { lat: pov.lat, lng: pov.lng };207}208209// aimGlobe turns the globe to face a place, keeping the reader's distance.210export function aimGlobe(lat, lng, ease) {211 if (!globe) return;212 const altitude = globe.pointOfView().altitude;213 globe.pointOfView({ lat, lng, altitude }, ease ? 180 : 0);214}215216async function enterGlobe() {217 const mapping = equirectMapping(state.world);218 if (!mapping) return;219 state.globeActive = true;220 elements.viewport.hidden = true;221 elements.globe.hidden = false;222 elements.globeToggle.setAttribute("aria-pressed", "true");223 state.overviewKey = "";224225 if (!globe) {226 globe = Globe({ animateIn: false })(elements.globe)227 .backgroundColor("#0b0e12")228 .showAtmosphere(true)229 .atmosphereColor("#8a6f5b")230 .atmosphereAltitude(0.12)231 .objectLat((point) => point.lat)232 .objectLng((point) => point.lng)233 .objectAltitude((point) => (point.pin === state.selectedPin ? 0.03 : 0.014))234 .objectThreeObject((point) => spriteFor(point.pin))235 .objectLabel((point) => point.pin.location.title)236 .onObjectClick((point) => showPin(point.pin))237 .onObjectHover((point) => {238 elements.globe.style.cursor = point ? "pointer" : "";239 })240 .onZoom(() => {241 document.dispatchEvent(new Event("atlas:globe-camera"));242 updateDetailTiles();243 rebuildGlobeLabels();244 regridWhenFitChanges();245 cullHiddenLabels();246 });247 document.addEventListener("atlas:selection", syncSelection);248 document.addEventListener("atlas:filters", syncFilters);249 // A grid change moves both the boundaries and which pins are held to250 // the chosen cell -- and which names Z is holding up.251 document.addEventListener("atlas:grid", () => {252 rebuildGlobeGrid();253 syncFilters();254 rebuildGlobeLabels();255 });256 document.addEventListener("atlas:labels", rebuildGlobeLabels);257 // A cell chosen on the sphere is chosen the way the chart chooses it:258 // the point pressed names its next-deeper cell through the same259 // reverse-halving the navigator types.260 globe.onGlobeClick(({ lat, lng }) => {261 if (!state.gridEnabled) return;262 const held = equirectMapping(state.world);263 if (!held) return;264 const [worldX, worldY] = held.toWorld(lat, lng);265 const target = activeSystem().descendTarget(state.gridCell, [worldX, -worldY]);266 if (target) selectGridCell(target);267 });268 // The camera stays outside the skin: closer than the nearest altitude269 // the world turns inside out, and the numbers it produces poison every270 // pane they touch.271 const controls = globe.controls();272 controls.minDistance = globeRadius * (1 + nearestAltitude);273 controls.maxDistance = globeRadius * (1 + farthestAltitude);274 detail.group = new Group();275 globe.scene().add(detail.group);276 grid.group = new Group();277 globe.scene().add(grid.group);278 labels.group = new Group();279 globe.scene().add(labels.group);280 // A window on the globe's working parts for the parity harness and a281 // plain browser: counts, never control.282 window.__atlasGlobe = { detail, grid, labels, sprites };283 }284 resizeGlobe();285286 // The globe opens facing what the chart was showing, as close as the287 // chart was: the flip keeps the reader's place in both directions.288 const view = state.engine?.getView();289 const center = view?.getCenter();290 if (center && Number.isFinite(center[0]) && Number.isFinite(center[1])) {291 const [lat, lng] = mapping.toLatLng(center[0], -center[1]);292 globe.pointOfView({ lat, lng, altitude: altitudeForZoom(view.getZoom()) });293 } else {294 globe.pointOfView({ lat: 10, lng: 0, altitude: wholeDiscAltitude });295 }296297 const lens = state.lens || state.world.lenses[0];298 const key = `${state.volume.stamp}:${state.world.slug}:${lens.tiles}`;299 if (texturedFor !== key) {300 texturedFor = key;301 const texture = await composeTexture(lens);302 if (texture && texturedFor === key) {303 globe.globeImageUrl(texture);304 }305 }306 if (pinsBuilt !== state.features) {307 pinsBuilt = state.features;308 sprites.clear();309 placed.clear();310 globe.objectsData(spherePins(mapping));311 }312 syncFilters();313 restyleSelection();314 updateDetailTiles();315 rebuildGlobeGrid();316 rebuildGlobeLabels();317 document.dispatchEvent(new Event("atlas:globe-camera"));318}319320// rebuildGlobeGrid redraws the geohash cells on the sphere as the globe's321// adapter over gridCellVisual: the same tokens the chart styles from become322// fat-line boundaries, translucent fill quads, and corner chip sprites, so323// the two projections read as one instrument. Descending into a cell also324// turns the globe to frame it, the way the chart fits its view.325function rebuildGlobeGrid() {326 if (!globe || !grid.group) return;327 for (const child of [...grid.group.children]) {328 grid.group.remove(child);329 child.geometry?.dispose();330 child.material?.map?.dispose();331 child.material?.dispose();332 }333 const mapping = state.globeActive ? equirectMapping(state.world) : null;334 if (!mapping || !state.gridEnabled) {335 grid.cell = null;336 return;337 }338 const system = activeSystem();339 for (const cell of gridCellPlan()) {340 const ringLL = ringLatLng(cell.ring, mapping);341 const corners = ringBounds(ringLL);342 const visual = gridCellVisual(cell, {343 subgridVisible: state.subgridVisible,344 labelled: gridLabelFits(cell, corners),345 });346 if (!visual) continue;347 if (visual.fill) {348 const centerLL = pointLatLng(system.center(cell.hash), mapping);349 grid.group.add(ringFill(ringLL, centerLL, visual.fill));350 }351 grid.group.add(cellBoundary(ringLL, visual.line));352 if (visual.label) grid.group.add(cellChip(visual.label, corners));353 }354 if (grid.cell !== null && grid.cell !== state.gridCell) {355 frameGridCell(mapping);356 }357 grid.cell = state.gridCell;358 // Chips are born visible; a rebuild with the camera already elsewhere359 // must not leave far-side chips shining through the planet.360 cullHiddenLabels();361}362363// ringLatLng lands a system's world-pixel ring on the sphere. The ring is364// continuous by contract, so a loop that crossed the antimeridian simply365// carries longitudes past 180 -- the trigonometry underneath is periodic366// and drapes it where it belongs, which is why the globe never needs the367// splitting the chart does.368function ringLatLng(ring, mapping) {369 return ring.map(([x, y]) => mapping.toLatLng(x, -y));370}371372function pointLatLng(point, mapping) {373 return mapping.toLatLng(point[0], -point[1]);374}375376// densifyRing subdivides each ring segment by its span, returning the open377// loop the fill fans from.378function densifyRing(ringLL) {379 const open = [];380 for (let at = 0; at < ringLL.length - 1; at++) {381 const [fromLat, fromLng] = ringLL[at];382 const [toLat, toLng] = ringLL[at + 1];383 const span = Math.max(Math.abs(toLat - fromLat), Math.abs(toLng - fromLng));384 const steps = clamp(Math.ceil(span / 2), 1, 48);385 for (let step = 0; step < steps; step++) {386 const t = step / steps;387 open.push([fromLat + (toLat - fromLat) * t, fromLng + (toLng - fromLng) * t]);388 }389 }390 return open;391}392393// ringBounds is the cell's frame in degrees, for the fit gate, the chip394// anchor, and the camera.395function ringBounds(ringLL) {396 let north = -Infinity;397 let south = Infinity;398 let west = Infinity;399 let east = -Infinity;400 for (const [lat, lng] of ringLL) {401 north = Math.max(north, lat);402 south = Math.min(south, lat);403 west = Math.min(west, lng);404 east = Math.max(east, lng);405 }406 return { north, south, west, east };407}408409// cellScreenPx estimates how many pixels a cell spans on screen: degrees of410// ground per pixel fall out of the camera's distance and field of view. It411// is the globe's answer to the chart's extent-over-resolution, feeding the412// same fit gate.413function cellScreenPx(corners) {414 const pov = globe.pointOfView();415 const altitude = Math.max(pov.altitude, nearestAltitude / 2);416 const fovRadians = (50 * Math.PI) / 180;417 const groundPerPx =418 (2 * altitude * globeRadius * Math.tan(fovRadians / 2)) /419 Math.max(1, elements.globe.clientHeight || 1);420 const degreePx = ((globeRadius * Math.PI) / 180) / groundPerPx;421 return {422 width: Math.abs(corners.east - corners.west) * degreePx,423 height: Math.abs(corners.north - corners.south) * degreePx,424 };425}426427// gridLabelFits is labelFitsCell spoken in the globe's terms: the chip --428// measured in the same fixed pitch the chart measures -- must fit inside429// the cell's projected footprint.430function gridLabelFits(cell, corners) {431 const neighbor = cell.role === "neighbor";432 const size = neighbor ? gridTheme.neighborLabelSizePx : gridTheme.labelSizePx;433 const font = `900 ${size}px ${gridTheme.labelFont}`;434 const px = cellScreenPx(corners);435 return measureLabel(cell.hash, font) + 9 <= px.width && size + 6 <= px.height;436}437438// ringFill lays a cell's tint or dim on the ground: a fan of triangles439// from the cell's own centre out to its ring, each spoke subdivided so the440// sheet follows the curve instead of sagging under it, just off the detail441// tiles and under the boundary lines. The spokes are walked on the sphere442// itself -- chord-lerp between the centre's vector and each ring vector,443// re-projected to the surface -- never in lat/lng, where an unwrapped ring444// and a wrapped centre sit hundreds of degrees apart and a pole cell's445// centre has no honest longitude at all; either smears the sheet around446// the planet.447function ringFill(ringLL, centerLL, fill) {448 const radius = detailRadius + 0.12;449 // The fan needs spokes as dense as the boundary's steps: with only the450 // ring's own corners, the sheet between spokes sags below the ground and451 // the fill opens into almond-shaped gaps.452 const open = densifyRing(ringLL);453 const bounds = ringBounds(ringLL);454 const span = Math.max(bounds.east - bounds.west, bounds.north - bounds.south);455 const rows = clamp(Math.ceil(span / 6), 2, 24);456 const center = surfacePoint(centerLL[0], centerLL[1], radius);457 const edges = open.map(([lat, lng]) => surfacePoint(lat, lng, radius));458 const positions = [...center];459 const indices = [];460 for (let row = 1; row <= rows; row++) {461 const t = row / rows;462 for (const edge of edges) {463 const x = center[0] + (edge[0] - center[0]) * t;464 const y = center[1] + (edge[1] - center[1]) * t;465 const z = center[2] + (edge[2] - center[2]) * t;466 const lift = radius / (Math.hypot(x, y, z) || 1);467 positions.push(x * lift, y * lift, z * lift);468 }469 }470 const count = open.length;471 const at = (row, index) => 1 + (row - 1) * count + (index % count);472 for (let index = 0; index < count; index++) {473 indices.push(0, at(1, index), at(1, index + 1));474 }475 for (let row = 1; row < rows; row++) {476 for (let index = 0; index < count; index++) {477 indices.push(at(row, index), at(row + 1, index), at(row, index + 1));478 indices.push(at(row, index + 1), at(row + 1, index), at(row + 1, index + 1));479 }480 }481 const geometry = new BufferGeometry();482 geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));483 geometry.setIndex(indices);484 const material = new MeshBasicMaterial({485 color: fill.color,486 transparent: true,487 opacity: fill.opacity,488 depthWrite: false,489 side: 2,490 });491 const mesh = new Mesh(geometry, material);492 mesh.renderOrder = 1;493 return mesh;494}495496// rebuildGlobeLabels raises names over the pins while Z is held: the ones497// nearest what the camera faces, within a budget -- a planet of two498// thousand names at once is noise, and the chart's own flood is bounded by499// its window the same way. Rebuilt as the camera settles, dropped on500// release.501function rebuildGlobeLabels() {502 if (!globe || !labels.group) return;503 const wanted = state.globeActive && state.labelsHeld;504 const pov = wanted ? globe.pointOfView() : null;505 const key = wanted506 ? `${Math.round(pov.lat)}:${Math.round(pov.lng)}:${pov.altitude.toFixed(2)}:${state.gridSystem}:${state.gridCell}`507 : "";508 if (key === labels.key) return;509 labels.key = key;510 for (const child of [...labels.group.children]) {511 labels.group.remove(child);512 child.material?.map?.dispose();513 child.material?.dispose();514 }515 if (!wanted) return;516517 const nearby = [];518 for (const [pin, sprite] of sprites) {519 if (!sprite.visible) continue;520 const stood = placed.get(pin);521 if (!stood) continue;522 const distance = angularDistance(pov, stood);523 if (distance > 85) continue;524 nearby.push({ pin, stood, distance });525 }526 nearby.sort((a, b) => a.distance - b.distance);527 for (const { pin, stood } of nearby.slice(0, labelBudget)) {528 labels.group.add(labelSprite(pin, stood));529 }530 cullHiddenLabels();531}532533// cullHiddenLabels enforces the horizon that the label sprites' materials534// no longer test for: a card whose anchor has slipped past the planet's535// silhouette goes invisible instead of shining through it. A point at the536// limb sits where the cosine of its angle from the camera's axis equals537// radius over distance; anything beyond that is the far side.538function cullHiddenLabels() {539 if (!globe) return;540 const camera = globe.camera().position;541 const distance = camera.length() || 1;542 const horizon = globeRadius / distance;543 for (const group of [grid.group, labels.group]) {544 if (!group) continue;545 for (const child of group.children) {546 if (!child.isSprite) continue;547 const anchor = child.position;548 const reach = anchor.length() || 1;549 const facing =550 (anchor.x * camera.x + anchor.y * camera.y + anchor.z * camera.z) /551 (reach * distance);552 child.visible = facing > horizon;553 }554 }555}556557// angularDistance is the great-circle separation in degrees, which is what558// "near what the camera faces" means on a sphere.559function angularDistance(a, b) {560 const rad = Math.PI / 180;561 const inner =562 Math.sin(a.lat * rad) * Math.sin(b.lat * rad) +563 Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.cos((a.lng - b.lng) * rad);564 return (Math.acos(clamp(inner, -1, 1)) * 180) / Math.PI;565}566567// labelSprite writes one pin's name on a small card floated above its568// marker, screen-sized like the marker itself.569function labelSprite(pin, stood) {570 const title = pin.location.title;571 const canvas = document.createElement("canvas");572 const context = canvas.getContext("2d");573 const font = "600 26px Inter, system-ui, sans-serif";574 context.font = font;575 const width = Math.ceil(context.measureText(title).width) + 24;576 canvas.width = width;577 canvas.height = 40;578 context.font = font;579 context.textAlign = "center";580 context.textBaseline = "middle";581 context.fillStyle = "rgba(10, 13, 17, 0.78)";582 context.fillRect(0, 0, width, 40);583 context.fillStyle = "#e6ebf0";584 context.fillText(title, width / 2, 21);585 const material = new SpriteMaterial({586 // No depth test: a screen-sized card anchored on the ground loses its587 // lower half to the planet's own curve at any glancing angle. The588 // horizon is enforced by hand in cullHiddenLabels instead.589 depthTest: false,590 depthWrite: false,591 sizeAttenuation: false,592 transparent: true,593 });594 new TextureLoader().load(canvas.toDataURL("image/png"), (texture) => {595 material.map = texture;596 material.needsUpdate = true;597 });598 const sprite = new Sprite(material);599 sprite.position.set(...surfacePoint(stood.lat, stood.lng, detailRadius + 0.4));600 sprite.renderOrder = 4;601 const height = 0.028;602 sprite.scale.set((height * width) / 40, height, 1);603 // Anchored at its bottom edge -- the far end of center's 0..1 contract --604 // the card floats above the marker instead of covering it.605 sprite.center.set(0.5, 0);606 return sprite;607}608609// cellBoundary drapes a cell's frame at its token weight. WebGL's plain610// lines are one pixel whatever they ask for, so the frame is a fat line --611// Line2 -- whose width is spoken in the same pixels the chart strokes.612// The ring arrives pre-tessellated by its system; each segment is further613// subdivided by its own span so long geohash edges still follow the curve.614function cellBoundary(ringLL, line) {615 const positions = [];616 for (let at = 0; at < ringLL.length - 1; at++) {617 const [fromLat, fromLng] = ringLL[at];618 const [toLat, toLng] = ringLL[at + 1];619 const span = Math.max(Math.abs(toLat - fromLat), Math.abs(toLng - fromLng));620 const steps = clamp(Math.ceil(span / 2), 4, 48);621 for (let step = 0; step < steps; step++) {622 const t = step / steps;623 positions.push(...surfacePoint(624 fromLat + (toLat - fromLat) * t,625 fromLng + (toLng - fromLng) * t,626 detailRadius + 0.25,627 ));628 }629 }630 positions.push(...positions.slice(0, 3));631 const geometry = new LineGeometry();632 geometry.setPositions(positions);633 const material = new LineMaterial({634 color: line.color,635 transparent: true,636 opacity: line.opacity,637 linewidth: line.widthPx,638 depthWrite: false,639 });640 material.resolution.set(elements.globe.clientWidth || 1, elements.globe.clientHeight || 1);641 const loop = new Line2(geometry, material);642 loop.computeLineDistances();643 loop.renderOrder = 2;644 return loop;645}646647// cellChip writes a cell's address on a small card in its bottom-right648// corner -- the bounding-box convention -- with the prefix faint and the649// final character bright, exactly as the chart writes it.650function cellChip(label, corners) {651 const scaleUp = 2;652 const size = label.sizePx * scaleUp;653 // One size for both cuts: weight and alpha carry the faintness, and the654 // baseline holds, because mixed sizes on one line wobble the address.655 const prefixFont = `500 ${size}px ${gridTheme.labelFont}`;656 const finalFont = `900 ${size}px ${gridTheme.labelFont}`;657 const canvas = document.createElement("canvas");658 const context = canvas.getContext("2d");659 context.font = prefixFont;660 const prefixWidth = label.prefix ? context.measureText(label.prefix).width : 0;661 context.font = finalFont;662 const finalWidth = context.measureText(label.final).width;663 const pad = 5 * scaleUp;664 canvas.width = Math.ceil(prefixWidth + finalWidth + pad * 2);665 canvas.height = size + pad * 1.4;666 context.fillStyle = label.chip;667 context.fillRect(0, 0, canvas.width, canvas.height);668 context.textBaseline = "middle";669 const middle = canvas.height / 2 + scaleUp;670 if (label.prefix) {671 context.font = prefixFont;672 context.fillStyle = label.color;673 context.globalAlpha = gridTheme.prefixAlpha * label.textAlpha;674 context.fillText(label.prefix, pad, middle);675 }676 context.font = finalFont;677 context.globalAlpha = label.textAlpha;678 context.fillStyle = label.color;679 context.fillText(label.final, pad + prefixWidth, middle);680 context.globalAlpha = 1;681682 const material = new SpriteMaterial({683 // Same bargain as the name cards: no depth test, horizon by hand.684 depthTest: false,685 depthWrite: false,686 transparent: true,687 sizeAttenuation: false,688 });689 new TextureLoader().load(canvas.toDataURL("image/png"), (texture) => {690 material.map = texture;691 material.needsUpdate = true;692 });693 const sprite = new Sprite(material);694 // Just inside the corner, anchored by its own bottom-right, fixed on695 // screen like the chart's chips.696 const insetLat = Math.abs(corners.north - corners.south) * 0.02;697 const insetLng = Math.abs(corners.east - corners.west) * 0.02;698 sprite.position.set(...surfacePoint(699 corners.south + insetLat,700 corners.east - insetLng,701 detailRadius + 0.35,702 ));703 const viewport = Math.max(1, elements.globe.clientHeight || 1);704 const heightScale = canvas.height / scaleUp / viewport;705 sprite.scale.set((heightScale * canvas.width) / canvas.height, heightScale, 1);706 sprite.center.set(1, 0);707 sprite.renderOrder = 3;708 return sprite;709}710711// frameGridCell turns the globe to hold the chosen cell, the way the chart712// fits its view when the navigator descends.713function frameGridCell(mapping) {714 const plan = gridCellPlan();715 const chosen = plan.find((cell) => cell.role === "scope" || cell.role === "leaf");716 if (!chosen) return;717 // The camera aims where the system says the cell's middle is -- a pole718 // cap's middle is the pole, which no frame corner would have named.719 const [lat, lng] = pointLatLng(activeSystem().center(chosen.hash), mapping);720 const bounds = ringBounds(ringLatLng(chosen.ring, mapping));721 const span = Math.max(bounds.east - bounds.west, bounds.north - bounds.south);722 const altitude = clamp(span / 45, nearestAltitude, farthestAltitude);723 globe.pointOfView({ lat, lng, altitude }, 400);724}725726// updateDetailTiles keeps the pyramid under the camera: past the base727// skin's depth, the level the altitude asks for is fetched tile by tile728// around the point being faced -- the same tiles the chart reads, each one729// a perfect square of latitude and longitude draped at its place. Away730// from the camera the base skin carries on; this layer only ever holds the731// neighborhood being looked at.732function updateDetailTiles() {733 if (!globe || !detail.group) return;734 const lens = state.globeActive ? state.lens || state.world.lenses[0] : null;735 if (!lens) {736 clearDetailTiles();737 return;738 }739 if (detail.lens !== lens.tiles) {740 clearDetailTiles();741 detail.lens = lens.tiles;742 }743 const pov = globe.pointOfView();744 const zoom = Math.min(745 Math.round(zoomForAltitude(pov.altitude)) + 1,746 lens.maxZoom,747 );748 if (zoom <= textureZoom || !lens.formats[zoom]) {749 clearDetailTiles();750 detail.key = "";751 return;752 }753754 const columns = 2 ** zoom;755 const rows = columns / 2;756 const span = 360 / columns;757 // How much ground the camera can possibly see: the horizon angle at this758 // altitude, padded a little so tiles arrive before their ground does.759 const horizon = (Math.acos(1 / (1 + pov.altitude)) * 180) / Math.PI + span;760 const centerColumn = Math.floor((((pov.lng + 180) % 360) + 360) % 360 / span);761 const centerRow = Math.floor((90 - pov.lat) / span);762 let reach = Math.ceil(horizon / span);763 while ((2 * reach + 1) ** 2 > detailTileBudget && reach > 1) reach--;764765 const wanted = new Set();766 for (let dr = -reach; dr <= reach; dr++) {767 const row = centerRow + dr;768 if (row < 0 || row >= rows) continue;769 for (let dc = -reach; dc <= reach; dc++) {770 const column = (((centerColumn + dc) % columns) + columns) % columns;771 wanted.add(`${zoom}/${column}/${row}`);772 }773 }774 const key = `${lens.tiles}:${[...wanted].sort().join(",")}`;775 if (key === detail.key) return;776 detail.key = key;777778 for (const [name, mesh] of detail.tiles) {779 if (wanted.has(name)) continue;780 detail.group.remove(mesh);781 disposeTile(mesh);782 detail.tiles.delete(name);783 }784 for (const name of wanted) {785 if (detail.tiles.has(name)) continue;786 const [z, column, row] = name.split("/").map(Number);787 const mesh = tileMesh(lens, z, column, row);788 detail.tiles.set(name, mesh);789 detail.group.add(mesh);790 }791}792793// tileMesh drapes one pyramid tile at its place on the sphere: a grid of794// points through the same latitude-longitude spelling the pins stand by,795// wearing the tile image once it arrives.796function tileMesh(lens, zoom, column, row) {797 const span = 360 / 2 ** zoom;798 const west = -180 + column * span;799 const north = 90 - row * span;800 const positions = [];801 const uvs = [];802 const indices = [];803 for (let i = 0; i <= detailSegments; i++) {804 const lat = north - (i / detailSegments) * span;805 for (let j = 0; j <= detailSegments; j++) {806 const lng = west + (j / detailSegments) * span;807 positions.push(...surfacePoint(lat, lng, detailRadius));808 uvs.push(j / detailSegments, 1 - i / detailSegments);809 }810 }811 const stride = detailSegments + 1;812 for (let i = 0; i < detailSegments; i++) {813 for (let j = 0; j < detailSegments; j++) {814 const corner = i * stride + j;815 indices.push(corner, corner + stride, corner + 1);816 indices.push(corner + 1, corner + stride, corner + stride + 1);817 }818 }819 const geometry = new BufferGeometry();820 geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));821 geometry.setAttribute("uv", new Float32BufferAttribute(uvs, 2));822 geometry.setIndex(indices);823824 const material = new MeshBasicMaterial();825 const mesh = new Mesh(geometry, material);826 // Invisible until its picture arrives: a black square teaches nothing.827 mesh.visible = false;828 const url = `${state.volume.base}/tiles/${lens.tiles}/${zoom}/${column}/${row}.${lens.formats[zoom]}`;829 new TextureLoader().load(url, (texture) => {830 texture.colorSpace = SRGBColorSpace;831 material.map = texture;832 material.needsUpdate = true;833 mesh.visible = true;834 });835 return mesh;836}837838function clearDetailTiles() {839 if (!detail.group) return;840 for (const mesh of detail.tiles.values()) {841 detail.group.remove(mesh);842 disposeTile(mesh);843 }844 detail.tiles.clear();845 detail.key = "";846}847848function disposeTile(mesh) {849 mesh.geometry.dispose();850 mesh.material.map?.dispose();851 mesh.material.dispose();852}853854// restyleSelection dresses the standing sprites for the current selection:855// the chosen pin grows, takes its ring, lifts a little higher off the856// ground; the one it replaced settles back among the rest.857function restyleSelection() {858 if (!globe) return;859 for (const [pin, sprite] of sprites) {860 const selected = pin === state.selectedPin;861 sprite.material = material(pin.category, selected);862 const size = selected ? spriteSelectedSize : spriteSize;863 sprite.scale.set(size, size, 1);864 }865 // Altitude rides the accessor, so re-declaring it reseats the two that866 // moved along with everyone else -- cheap at this scale.867 globe.objectAltitude((point) => (point.pin === state.selectedPin ? 0.03 : 0.014));868}869870// syncSelection answers a selection made while the globe is up: restyle,871// and turn to face the chosen pin, because a ring on the far side of a872// planet selects nothing anyone can see. Entering the globe restyles873// without turning -- the camera there belongs to the chart's view.874function syncSelection() {875 if (!globe || !state.globeActive) return;876 restyleSelection();877 const stood = placed.get(state.selectedPin);878 if (stood) {879 const altitude = globe.pointOfView().altitude;880 globe.pointOfView({ lat: stood.lat, lng: stood.lng, altitude }, 600);881 }882}883884// pinShown is the one visibility rule the sphere holds a pin to: the885// legend's and search's filters, and the chosen geohash cell, exactly as886// the chart holds them.887function pinShown(pin) {888 if (pin.filteredHidden || isCollectionHidden(pin.category.id)) return false;889 if (state.gridEnabled && state.gridCell && !pinInGridCell(pin)) return false;890 return true;891}892893// syncFilters shows and hides sprites as the legend and the search decide,894// the same visibility the chart draws from.895function syncFilters() {896 if (!globe || !state.globeActive) return;897 for (const [pin, sprite] of sprites) {898 sprite.visible = pinShown(pin);899 }900}901902// spriteFor stands one pin up as a billboard wearing its category's icon --903// the same haloed, tinted raster the chart composes -- so what a marker is904// reads the same on the sphere as on the sheet.905function spriteFor(pin) {906 const sprite = new Sprite(material(pin.category, pin === state.selectedPin));907 const size = pin === state.selectedPin ? spriteSelectedSize : spriteSize;908 sprite.scale.set(size, size, 1);909 // Born dressed: the renderer creates sprites on its own tick, after any910 // sweep that ran at hand-over, so the filters must already be worn.911 sprite.visible = pinShown(pin);912 sprites.set(pin, sprite);913 return sprite;914}915916// material caches one sprite material per category and selection state. The917// icon raster is the chart's own when it has arrived; a category still918// waiting, or one with no artwork at all, wears its initials the same way919// the chart's fallback draws them.920function material(category, selected) {921 const key = `${markerIconKey(category)}:${selected ? "ringed" : "plain"}`;922 let held = materials.get(key);923 if (held) return held;924 // Pins keep one size on screen however close the camera is, the way the925 // chart draws its markers -- world-sized sprites become dinner plates at926 // low altitude.927 held = new SpriteMaterial({ depthWrite: false, sizeAttenuation: false });928 materials.set(key, held);929 const icon = state.markerIcons.get(markerIconKey(category));930 const finish = (image) => {931 const canvas = document.createElement("canvas");932 canvas.width = 80;933 canvas.height = 80;934 const context = canvas.getContext("2d");935 if (image) {936 context.drawImage(image, 8, 8, 64, 64);937 } else {938 context.font = "900 26px Inter, system-ui, sans-serif";939 context.textAlign = "center";940 context.textBaseline = "middle";941 context.lineWidth = 6;942 context.strokeStyle = iconOutsetColor();943 context.strokeText(initials(category.title), 40, 41);944 context.fillStyle = categoryColor(category);945 context.fillText(initials(category.title), 40, 41);946 }947 if (selected) {948 context.beginPath();949 context.arc(40, 40, 36, 0, Math.PI * 2);950 context.lineWidth = 5;951 context.strokeStyle = "#ffffff";952 context.stroke();953 }954 new TextureLoader().load(canvas.toDataURL("image/png"), (texture) => {955 held.map = texture;956 held.needsUpdate = true;957 });958 };959 if (icon) {960 const image = new Image();961 image.onload = () => finish(image);962 image.onerror = () => finish(null);963 image.src = icon;964 } else {965 finish(null);966 }967 return held;968}969970// refreshGlobe re-enters the globe when what it shows has moved underneath971// it: another raster lens chosen, a category switched in the legend.972export function refreshGlobe() {973 if (state.globeActive) void enterGlobe();974}975976// resizeGlobe keeps the canvas the size of its pane; globe.gl sizes once at977// construction and must be told when the pane moves. The grid follows: its978// line widths and chips are spoken in pixels of this very pane.979export function resizeGlobe() {980 if (!globe || elements.globe.hidden) return;981 globe.width(elements.globe.clientWidth).height(elements.globe.clientHeight);982 rebuildGlobeGrid();983}984985// regridWhenFitChanges redraws the grid only when the camera has moved far986// enough in depth that a label could newly fit or stop fitting.987function regridWhenFitChanges() {988 if (!globe || !state.globeActive || !state.gridEnabled) return;989 const pov = globe.pointOfView();990 const key = `${state.gridSystem}:${state.gridCell}:${Math.round(zoomForAltitude(pov.altitude) * 2)}`;991 if (key === grid.fitKey) return;992 grid.fitKey = key;993 rebuildGlobeGrid();994}995996// spherePins stands every visible pin on the planet: packed synthetic997// coordinates, through the viewer's own projection to world pixels, then998// backward through the declared mapping to true latitude and longitude.999function spherePins(mapping) {1000 const points = [];1001 for (const pin of state.features) {1002 const [x, negY] = project(pin.location.lat, pin.location.lng);1003 const [lat, lng] = mapping.toLatLng(x, -negY);1004 points.push({ lat, lng, pin });1005 placed.set(pin, { lat, lng });1006 }1007 return points;1008}10091010// composeTexture stitches the sphere's skin from the bundle's own tiles at1011// one fixed level: the full width of the pyramid and the top half of its1012// rows, which is the whole planet by the declared mapping. The result rides1013// as a data URL, so the globe asks the app for nothing it has not already1014// been given.1015async function composeTexture(lens) {1016 const zoom = Math.min(textureZoom, lens.maxZoom);1017 const format = lens.formats[zoom];1018 if (!format) return null;1019 const columns = 2 ** zoom;1020 const rows = columns / 2;1021 const canvas = document.createElement("canvas");1022 canvas.width = columns * 256;1023 canvas.height = rows * 256;1024 const context = canvas.getContext("2d");1025 const jobs = [];1026 for (let y = 0; y < rows; y++) {1027 for (let x = 0; x < columns; x++) {1028 const url = `${state.volume.base}/tiles/${lens.tiles}/${zoom}/${x}/${y}.${format}`;1029 jobs.push(loadTile(url).then((image) => {1030 if (image) context.drawImage(image, x * 256, y * 256, 256, 256);1031 }));1032 }1033 }1034 await Promise.all(jobs);1035 return canvas.toDataURL("image/jpeg", 0.92);1036}10371038function loadTile(url) {1039 return new Promise((resolve) => {1040 const image = new Image();1041 image.onload = () => resolve(image);1042 image.onerror = () => resolve(null);1043 image.src = url;1044 });1045}