The chart has always drawn a world's area and path features; the sphere drew pins over bare imagery, because until the included Earth no volume put areas on a spherical world and the pane's four groups had no polygon member. Now every shape the legend lets through lands on the globe as outlines: each ring -- outer rings and the holes they carry, paths open -- as one LineSegments2 per shape, in the same per-feature accent its chart drawing and its index row wear, thicker and opaque when the shape is highlighted or selected. Segments are subdivided by span exactly as the grid's boundaries are and for the same reason: a straight chord between two far corners passes through the planet, not along it. The layer takes its own step on the radius ladder, above the grid's fills and below its boundaries, so a held cell still reads over the borders inside it. Fills, scrims and area titles stay the chart's -- a fill over an arbitrary concave, holed polygon needs a tessellation the cell fan cannot honestly give it, and an outline is what makes a border read over imagery. Rebuilt only when the world, the filter, the highlights or the selection move, released on retirement like everything else the pane mints, and told the window size the way every LineMaterial has to be.
- commit
- 9bcca4b7a539347244e9298df0117bfad89f54f9
- parent
- fd85f2cc9457b1a23df78e527f062e504162bc4c
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 1.5 of 3, Jev's estimate
| M | docs/ | 13 | +++ |
| M | render/ | 131 | +++++++++++++++++++++++++++++- |
| M | render/ | 66 | +++++++++++++++- |
| M | render/ | 5 | +- |
@@ -426,6 +426,19 @@ and the `__atlasGlobe` global's absence until then is the observable (§8.3).
426426 - **Sprites** — one per pin, built once, afterwards only shown or hidden, so a427427 filter costs a boolean per pin.428428 429+**Zones ride the sphere as outlines.** Every shape the legend lets through430+draws its rings — outer rings and holes alike, paths open — as one431+`LineSegments2` per shape, in the same per-feature accent its chart drawing432+and its index row wear, thicker and fully opaque when highlighted or433+selected. Segments are subdivided by span exactly as the grid's boundaries434+are, so a border follows the curve instead of chording through the planet,435+and the layer sits above the grid's fills and below its boundaries436+(`ZONE_RADIUS`). Fills, scrims and area titles stay the chart's: a fill over437+an arbitrary concave, holed polygon needs a tessellation the cell fan cannot438+honestly give it, and an outline is what makes a border read over imagery.439+Rebuilt only when the world, the filter, the highlights or the selection440+move; the sphere's hit test remains points-only (§10).441+429442 **The camera round trip** is the pane's one contract with the chart. Position430443 is invertible arithmetic through the declared mapping; the distance is a431444 **calibrated power law, not a field-of-view calculation**:@@ -44,6 +44,8 @@ import * as THREE from "three";
4444 import { Line2 } from "three/examples/jsm/lines/Line2.js";4545 import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";4646 import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";47+import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";48+import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js";4749 import Globe from "globe.gl";4850 import type { GlobeInstance } from "globe.gl";4951 import {@@ -57,6 +59,7 @@ import {
5759 import type { GeoMapping, PlanCell, Ring } from "@atlas/analysis";5860 import { logger } from "../log.ts";5961 import type { WorldContext } from "../context.ts";62+import type { Line as ShapeLine, ShapeRecord } from "../world/model.ts";6063 import type { Attrs, Collection, Lens } from "../data/payload.ts";6164 import { iconURL } from "../data/plane.ts";6265 import { LensCoverage } from "../data/pyramid.ts";@@ -64,7 +67,7 @@ import { reportGridPick, reportPick } from "../data/report.ts";
6467 import { cellMoved } from "../chart/grid.ts";6568 import { viewMaxZoom } from "../chart/projection.ts";6669 import { saneCamera } from "../chart/element.ts";67-import { collectionColor } from "../chart/styles.ts";70+import { collectionColor, featureColor } from "../chart/styles.ts";6871 import {6972 iconIsPicture, initialsOf, legibleIconColor, markerKey, markerRasterReady, outsetColor,7073 } from "../chart/markers.ts";@@ -139,6 +142,17 @@ const FILL_RADIUS = DETAIL_RADIUS + 0.12;
139142 const LINE_RADIUS = DETAIL_RADIUS + 0.25;140143 const CHIP_RADIUS = DETAIL_RADIUS + 0.35;141144 145+/**146+ * The zones' own step on the same ladder: over the grid's sheet, under its147+ * 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 outline rides151+ * above a cell's tint and below its edge, and a held cell still reads over152+ * the borders inside it.153+ */154+const ZONE_RADIUS = DETAIL_RADIUS + 0.18;155+142156 /**143157 * The order the sphere paints in, the top of the ladder last.144158 *@@ -309,7 +323,10 @@ export class AtlasGlobe extends HTMLElement {
309323 private readonly pins = new THREE.Group();310324 private readonly labels = new THREE.Group();311325 private readonly cells = new THREE.Group();326+ private readonly zones = new THREE.Group();312327 private readonly tiles = new THREE.Group();328+ /** The zones last drawn: the world, the filter, and the emphasis. */329+ private zoneKey = "";313330 private readonly sprites = new Map<string, THREE.Object3D>();314331 /** The tiles draped under the camera, by `z/x/y`, and what each one is. */315332 private readonly draped = new Map<string, THREE.Mesh>();@@ -445,6 +462,7 @@ export class AtlasGlobe extends HTMLElement {
445462 this.cull();446463 this.drawLabels();447464 this.drawGrid();465+ this.drawZones();448466 }449467 450468 /** Come up, taking the chart's camera with you. Builds on first entry. */@@ -604,7 +622,7 @@ export class AtlasGlobe extends HTMLElement {
604622 if (!width || !height) return;605623 globe.width(width);606624 globe.height(height);607- for (const child of this.cells.children) {625+ for (const child of [...this.cells.children, ...this.zones.children]) {608626 const material = (child as { material?: unknown }).material;609627 if (material instanceof LineMaterial) material.resolution.set(width, height);610628 }@@ -652,13 +670,15 @@ export class AtlasGlobe extends HTMLElement {
652670 this.onCamera = null;653671 release(this.labels);654672 release(this.cells);673+ release(this.zones);655674 release(this.tiles);656675 this.draped.clear();657676 this.detailKey = "";677+ this.zoneKey = "";658678 this.pins.clear();659679 this.sprites.clear();660680 if (globe) {661- globe.scene().remove(this.pins, this.labels, this.cells, this.tiles);681+ globe.scene().remove(this.pins, this.labels, this.cells, this.zones, this.tiles);662682 globe._destructor?.();663683 // globe.gl's destructor gives back the renderer and empties the scene664684 // and leaves its canvas exactly where it put it, holding the last frame@@ -784,6 +804,7 @@ export class AtlasGlobe extends HTMLElement {
784804 785805 const scene = this.globe.scene();786806 scene.add(this.tiles);807+ scene.add(this.zones);787808 scene.add(this.pins);788809 scene.add(this.labels);789810 scene.add(this.cells);@@ -962,6 +983,50 @@ export class AtlasGlobe extends HTMLElement {
962983 }963984 964985 /** The grid, from the same plan and the same tokens the chart draws. */986+ /**987+ * The zones, on the sphere.988+ *989+ * The chart's zone layer says what a world's ground *is*; until this the990+ * sphere drew pins over bare imagery and the first volume to carry areas on991+ * a sphere — the included Earth's countries — rendered on one pane and992+ * vanished on the other. The sphere draws each shape's outlines: every ring993+ * in the feature's own accent, emphasized when the shape is highlighted or994+ * selected, exactly the colours its index row and its chart drawing wear.995+ * Fills, scrims and area titles stay the chart's — a fill over an arbitrary996+ * concave, holed polygon needs a tessellation the cell fan cannot honestly997+ * give it, and an outline is what makes a border read over imagery.998+ *999+ * Rebuilt only when its inputs move: the world, the legend's filter, the1000+ * highlights, or the selection. One `LineSegments2` per shape, so a world1001+ * of a couple hundred zones costs a couple hundred draw calls, not one per1002+ * ring.1003+ */1004+ private drawZones(): void {1005+ const context = this.context;1006+ const globe = this.globe;1007+ const equirect = this.equirect;1008+ if (!context || !globe || !equirect) return;1009+ const shown = context.visibility.shapesShown;1010+ const highlighted = new Set(context.visibility.highlightedShapes.map((shape) => shape.id));1011+ const key = [1012+ this.worldKey, this.selected,1013+ shown.map((shape) => shape.id).join(" "), [...highlighted].join(" "),1014+ ].join("|");1015+ if (key === this.zoneKey) return;1016+ this.zoneKey = key;1017+ release(this.zones);1018+ const viewport = { width: this.clientWidth || 1, height: this.clientHeight || 1 };1019+ for (const shape of shown) {1020+ const emphasized = highlighted.has(shape.id) || shape.id === this.selected;1021+ const mesh = zoneMesh(globe, shape, equirect.mapping, {1022+ color: featureColor(shape.id),1023+ opacity: emphasized ? 1 : 0.85,1024+ widthPx: emphasized ? 2.4 : 1.4,1025+ }, viewport);1026+ if (mesh) this.zones.add(mesh);1027+ }1028+ }1029+9651030 private drawGrid(): void {9661031 const context = this.context;9671032 const globe = this.globe;@@ -1579,6 +1644,66 @@ export function cellBoundary(
15791644 return loop;15801645 }15811646 1647+/**1648+ * The outlines one shape draws: every outer ring, and every hole an area1649+ * carries, each marked with whether it closes. An area's rings enclose ground1650+ * and close; a path's lines lie as they lie.1651+ */1652+export function zoneOutlines(shape: ShapeRecord): { ring: ShapeLine; close: boolean }[] {1653+ const close = shape.kind === "area";1654+ const out: { ring: ShapeLine; close: boolean }[] = [];1655+ shape.lines.forEach((line, index) => {1656+ out.push({ ring: line, close });1657+ for (const hole of shape.holes[index] ?? []) out.push({ ring: hole, close });1658+ });1659+ return out;1660+}1661+1662+/**1663+ * One shape's outlines, landed on the sphere as a single object.1664+ *1665+ * `LineSegments2` rather than one `Line2` per ring: a country is often an1666+ * archipelago — many rings, some of them holes — and segments are what let1667+ * every disjoint piece ride one geometry and one draw call. Each segment is1668+ * subdivided by its span exactly as the grid's boundaries are, and for the1669+ * same reason: a straight chord between two far corners passes through the1670+ * planet, not along it. Payload rings arrive split at the antimeridian by1671+ * their producer, so no segment spans the seam and the trigonometry drapes1672+ * each piece where it belongs.1673+ */1674+export function zoneMesh(1675+ globe: GlobeInstance,1676+ shape: ShapeRecord,1677+ mapping: GeoMapping,1678+ line: { color: string; opacity: number; widthPx: number },1679+ viewport: { width: number; height: number },1680+): LineSegments2 | null {1681+ const positions: number[] = [];1682+ for (const { ring, close } of zoneOutlines(shape)) {1683+ const dense = densifyRing(ringLatLng(ring as Ring, mapping), 4);1684+ if (dense.length < 2) continue;1685+ const loop = close ? [...dense, dense[0]!] : dense;1686+ let from = surfacePoint(globe, loop[0]![0], loop[0]![1], ZONE_RADIUS);1687+ for (let at = 1; at < loop.length; at++) {1688+ const to = surfacePoint(globe, loop[at]![0], loop[at]![1], ZONE_RADIUS);1689+ positions.push(from.x, from.y, from.z, to.x, to.y, to.z);1690+ from = to;1691+ }1692+ }1693+ if (!positions.length) return null;1694+ const geometry = new LineSegmentsGeometry();1695+ geometry.setPositions(positions);1696+ const material = new LineMaterial({1697+ color: line.color, transparent: true, opacity: line.opacity,1698+ linewidth: line.widthPx, depthWrite: false,1699+ });1700+ material.resolution.set(viewport.width || 1, viewport.height || 1);1701+ const drawn = new LineSegments2(geometry, material);1702+ drawn.computeLineDistances();1703+ drawn.renderOrder = ORDER.line;1704+ return drawn;1705+}1706+15821707 /**15831708 * The level the camera's distance asks the pyramid for.15841709 *@@ -121,7 +121,7 @@ const {
121121 altitudeForZoom, angularDistance, boundsOf, cellBoundary, densifyRing, detailBlock,122122 detailLevel, facesCamera, fillRows, holdControls, initialsOf, labelCandidates,123123 markerMaterial, nameCard, release, ringFill, ringLatLng, tileGeometry, wearSkin,124- zoomForAltitude,124+ zoneMesh, zoneOutlines, zoomForAltitude,125125 } = await import("../globe/element.ts");126126 type Placed = import("../globe/element.ts").Placed;127127 @@ -682,3 +682,67 @@ test("the wheel is held to the same clamps as every other door", () => {
682682 // And the farthest keeps the planet bigger than a dot.683683 assert.ok(controls.maxDistance <= 1000, `camera can leave to ${controls.maxDistance}`);684684 });685+686+// ---- zones on the sphere ---------------------------------------------687+//688+// The chart has always drawn a world's area and path features; the sphere689+// grew its zone layer when the included Earth became the first volume to put690+// areas on a spherical world. What is contractual is small and exact: which691+// outlines a shape draws (holes ride their areas, paths stay open), and that692+// every vertex of the landed mesh sits on its own radius rather than on a693+// chord through the planet -- the same promise the grid's boundaries make.694+695+type ShapeRecord = import("../world/model.ts").ShapeRecord;696+697+/** The identity mapping: a ground whose world units are already degrees. */698+const degrees = {699+ toLatLng: (x: number, y: number): [number, number] => [y, x],700+ toWorld: (lat: number, lng: number): [number, number] => [lng, lat],701+};702+703+function zoneShape(overrides: Partial<ShapeRecord>): ShapeRecord {704+ return {705+ id: "9", title: "Vale", subtitle: "", kind: "area", shard: 0,706+ collection: { id: 1, title: "Countries", kind: "area", visible: true },707+ lines: [], holes: [], center: null, feature: { id: 9, title: "Vale", geometry: [] },708+ ...overrides,709+ } as ShapeRecord;710+}711+712+// A square area in world coordinates (y negative-down), with one hole.713+const zoneOuter = [[10, -10], [20, -10], [20, -20], [10, -20], [10, -10]] as [number, number][];714+const zoneHole = [[12, -12], [14, -12], [14, -14], [12, -14], [12, -12]] as [number, number][];715+716+test("an area's holes ride beside its outer rings, closed", () => {717+ const outlines = zoneOutlines(zoneShape({ lines: [zoneOuter], holes: [[zoneHole]] }));718+ assert.equal(outlines.length, 2);719+ assert.deepEqual(outlines.map((held) => held.close), [true, true]);720+ assert.equal(outlines[0]?.ring, zoneOuter);721+ assert.equal(outlines[1]?.ring, zoneHole);722+});723+724+test("a path's lines stay open", () => {725+ const walk = [[0, 0], [30, -5]] as [number, number][];726+ const outlines = zoneOutlines(zoneShape({ kind: "path", lines: [walk], holes: [[]] }));727+ assert.deepEqual(outlines.map((held) => held.close), [false]);728+});729+730+test("every landed zone vertex sits on its own radius, not on a chord", () => {731+ const mesh = zoneMesh(sphere, zoneShape({ lines: [zoneOuter], holes: [[zoneHole]] }),732+ degrees, { color: "#88aaff", opacity: 0.85, widthPx: 1.4 }, { width: 800, height: 600 });733+ assert.ok(mesh, "a shape with ground drew nothing");734+ const start = mesh.geometry.getAttribute("instanceStart");735+ assert.ok(start.count > 8, "the segments were not densified");736+ const radius = Math.hypot(start.getX(0), start.getY(0), start.getZ(0));737+ for (let at = 0; at < start.count; at++) {738+ const held = Math.hypot(start.getX(at), start.getY(at), start.getZ(at));739+ assert.ok(Math.abs(held - radius) < 0.01,740+ `vertex ${at} sits at radius ${held}, the surface is at ${radius}`);741+ }742+});743+744+test("a shape with no ground draws nothing on the sphere", () => {745+ const mesh = zoneMesh(sphere, zoneShape({ lines: [], holes: [] }),746+ degrees, { color: "#88aaff", opacity: 0.85, widthPx: 1.4 }, { width: 800, height: 600 });747+ assert.equal(mesh, null);748+});@@ -166,6 +166,7 @@ function world(slug: string, points: ReturnType<typeof point>[], standing: Stand
166166 },167167 visibility: {168168 at: (index: number) => ({ hidden: standing.hidden.has(points[index]?.id ?? "") }),169+ shapesShown: [], highlightedShapes: [],169170 },170171 } as unknown as WorldContext;171172 }@@ -273,7 +274,9 @@ test("a pin a filter lets back in is held to the horizon before it is drawn", ()
273274 assert.equal(pin(element, "hellas").visible, false, "filtered out to begin with");274275 275276 const shown = context as unknown as { visibility: { at: () => { hidden: boolean } } };276- shown.visibility = { at: () => ({ hidden: false }) };277+ shown.visibility = {278+ at: () => ({ hidden: false }), shapesShown: [], highlightedShapes: [],279+ } as unknown as (typeof shown)["visibility"];277280 element.update();278281 assert.equal(pin(element, "hellas").visible, true, "the filter let it back in");279282 assert.equal(drawn(pin(element, "hellas")), false, "and the planet is still in front of it");