clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

zone parity on the sphere: the wash, the emphasis, and the fit

Dami committed 68e09b4
The outlines alone said where a border ran and nothing about being
chosen: highlighting a zone on the chart washes its ground in its own
accent and selecting one fits the view to it, and the sphere did
neither. Now it draws what the chart draws. Every shown area's ground is
triangulated part by part against its holes -- the cell fan cannot
honestly cover a concave, holed archipelago -- with every wide triangle
cut down before its vertices are lifted to the sphere, for the one
reason everything on this pane is subdivided: a chord between far
corners passes through the planet. The fill wears the chart's own
weights, a tenth of the accent at rest and just over a quarter when the
shape is highlighted or selected, the outline thickens the way the
chart's stroke does, and an emphasized shape with a title wears a name
card at its centre -- the sphere's spelling of the promoted area title.

Selecting a shape turns the planet to show it whole, at the altitude its
span asks for, which is the sphere's spelling of the chart's fit; a
point still keeps the reader's own depth. Geometry rebuilds only when
the world or the filter moves; a highlight or a selection restyles the
meshes that already exist rather than tessellating a planet again. The
scrim stays the chart's: dimming everything outside the highlights needs
the even-odd geometry a flat sheet gives away and a sphere does not.
commit
68e09b4d8b36722d6c10444d608f55f09a515c05
parent
9bcca4b7a539347244e9298df0117bfad89f54f9
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.1 of 3, Jev's estimate
3 files changed, +256 -50
Mdocs/render-seam.md29++-
Mrender/globe/element.ts251++++++++++++++++++++++++++----
Mrender/test/globe.test.ts26+++-
M docs/render-seam.md +17 -12
@@ -426,18 +426,23 @@ 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).429+**Zones ride the sphere as the chart draws them.** Every shape the legend430+lets through draws its ground and its rings in the same per-feature accent431+its chart drawing and its index row wear: the fill faint at rest and just432+over a quarter opaque when highlighted or selected, the outlines — outer433+rings and holes alike, paths open — thicker and opaque when emphasized, and434+an emphasized shape with a title wearing a name card at its centre, the435+sphere's spelling of the chart's promoted area title. Ground is triangulated436+part by part against its holes and every wide triangle is cut down before437+its vertices are lifted to the sphere; segments are subdivided by span438+exactly as the grid's boundaries are — both for the one reason, that a chord439+between far corners passes through the planet. The layer sits above the440+grid's fills and below its boundaries (`ZONE_FILL_RADIUS`, `ZONE_RADIUS`).441+Geometry rebuilds only when the world or the filter moves; a highlight or a442+selection restyles in place, and selecting a shape turns the planet to show443+it whole at the altitude its span asks for — the sphere's spelling of the444+chart's fit. The scrim stays the chart's (its even-odd hole-punching is a445+flat sheet's trick), and the sphere's hit test remains points-only (§10).441446 442447 **The camera round trip** is the pane's one contract with the chart. Position443448 is invertible arithmetic through the declared mapping; the distance is a
M render/globe/element.ts +214 -37
@@ -143,16 +143,25 @@ const LINE_RADIUS = DETAIL_RADIUS + 0.25;
143143 const CHIP_RADIUS = DETAIL_RADIUS + 0.35;144144 145145 /**146- * The zones' own step on the same ladder: over the grid's sheet, under its147- * boundary.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.148148 *149149 * 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.150+ * 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.153153 */154+const ZONE_FILL_RADIUS = DETAIL_RADIUS + 0.15;154155 const ZONE_RADIUS = DETAIL_RADIUS + 0.18;155156 157+/**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+ */163+const ZONE_TRIANGLE_SPAN = 3;164+156165 /**157166 * The order the sphere paints in, the top of the ladder last.158167 *
@@ -325,8 +334,10 @@ export class AtlasGlobe extends HTMLElement {
325334 private readonly cells = new THREE.Group();326335 private readonly zones = new THREE.Group();327336 private readonly tiles = new THREE.Group();328- /** The zones last drawn: the world, the filter, and the emphasis. */337+ /** The zone geometry last built: the world and the legend's filter. */329338 private zoneKey = "";339+ /** The dress the zones last wore: the selection and the highlights. */340+ private zoneStyleKey = "";330341 private readonly sprites = new Map<string, THREE.Object3D>();331342 /** The tiles draped under the camera, by `z/x/y`, and what each one is. */332343 private readonly draped = new Map<string, THREE.Mesh>();
@@ -675,6 +686,7 @@ export class AtlasGlobe extends HTMLElement {
675686 this.draped.clear();676687 this.detailKey = "";677688 this.zoneKey = "";689+ this.zoneStyleKey = "";678690 this.pins.clear();679691 this.sprites.clear();680692 if (globe) {
@@ -971,7 +983,7 @@ export class AtlasGlobe extends HTMLElement {
971983 private cull(): void {972984 const camera = this.globe?.camera().position;973985 if (!camera) return;974- for (const group of [this.cells, this.labels]) {986+ for (const group of [this.cells, this.labels, this.zones]) {975987 for (const child of group.children) {976988 if (!(child as THREE.Sprite).isSprite) continue;977989 child.visible = facesCamera(child.position, camera);
@@ -984,22 +996,23 @@ export class AtlasGlobe extends HTMLElement {
984996 985997 /** The grid, from the same plan and the same tokens the chart draws. */986998 /**987- * The zones, on the sphere.999+ * The zones, on the sphere, as the chart draws them.9881000 *9891001 * The chart's zone layer says what a world's ground *is*; until this the9901002 * sphere drew pins over bare imagery and the first volume to carry areas on9911003 * 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.1004+ * 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.9981012 *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.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.10031016 */10041017 private drawZones(): void {10051018 const context = this.context;
@@ -1007,24 +1020,61 @@ export class AtlasGlobe extends HTMLElement {
10071020 const equirect = this.equirect;10081021 if (!context || !globe || !equirect) return;10091022 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+ }1040+1041+ /**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;10101051 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);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);10271076 }1077+ this.cull();10281078 }10291079 10301080 private drawGrid(): void {
@@ -1269,9 +1319,35 @@ export class AtlasGlobe extends HTMLElement {
12691319 if (!globe || this.hidden || !id) return;12701320 const stood = this.sprites.get(id)?.userData as12711321 { lat?: number; lng?: number } | undefined;1272- if (stood?.lat === undefined || stood.lng === undefined) return;1273- this.steer(1274- { lat: stood.lat, lng: stood.lng, altitude: globe.pointOfView().altitude }, 600);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);12751351 }12761352 12771353 private moved(): void {
@@ -1701,9 +1777,110 @@ export function zoneMesh(
17011777 const drawn = new LineSegments2(geometry, material);17021778 drawn.computeLineDistances();17031779 drawn.renderOrder = ORDER.line;1780+ drawn.userData = { zone: shape.id, role: "line" };17041781 return drawn;17051782 }17061783 1784+/**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+ */1797+export 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+}1830+1831+/** A payload ring without its closing repeat, which a triangulation refuses. */1832+function 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+}1840+1841+/**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+ */1849+function 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+}1883+17071884 /**17081885 * The level the camera's distance asks the pyramid for.17091886 *
M render/test/globe.test.ts +25 -1
@@ -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- zoneMesh, zoneOutlines, zoomForAltitude,124+ zoneFill, zoneMesh, zoneOutlines, zoomForAltitude,125125 } = await import("../globe/element.ts");126126 type Placed = import("../globe/element.ts").Placed;127127
@@ -746,3 +746,27 @@ test("a shape with no ground draws nothing on the sphere", () => {
746746 degrees, { color: "#88aaff", opacity: 0.85, widthPx: 1.4 }, { width: 800, height: 600 });747747 assert.equal(mesh, null);748748 });749+750+test("a zone's ground is tessellated onto the sphere, every vertex on its radius", () => {751+ const mesh = zoneFill(sphere, zoneShape({ lines: [zoneOuter], holes: [[zoneHole]] }), degrees);752+ assert.ok(mesh, "an area with ground filled nothing");753+ const position = mesh.geometry.getAttribute("position");754+ assert.ok(position.count >= 3 && position.count % 3 === 0,755+ `${position.count} vertices is not a triangle list`);756+ const radius = Math.hypot(position.getX(0), position.getY(0), position.getZ(0));757+ for (let at = 0; at < position.count; at++) {758+ const held = Math.hypot(position.getX(at), position.getY(at), position.getZ(at));759+ assert.ok(Math.abs(held - radius) < 0.01,760+ `vertex ${at} sits at radius ${held}, the surface is at ${radius}`);761+ }762+ // The fill is dressed the way the chart dresses ground at rest, under the763+ // outline, writing no depth.764+ const worn = mesh.material as THREE.MeshBasicMaterial;765+ assert.equal(worn.opacity, 0.1);766+ assert.equal(worn.depthWrite, false);767+});768+769+test("a path fills nothing; only areas are ground", () => {770+ const walk = [[0, 0], [30, -5]] as [number, number][];771+ assert.equal(zoneFill(sphere, zoneShape({ kind: "path", lines: [walk], holes: [[]] }), degrees), null);772+});