The globe draws the poles as they are, and nothing while idle

The globe is a UV sphere: towards the poles its triangles narrow to
slivers, and texture coordinates interpolated across them pinched the
map into a star around each pole. Each pixel now computes its own
coordinates from its exact point on the sphere, with seam-safe mipmap
gradients; the map wraps horizontally and gets 8x anisotropic
filtering.

That showed a flaw in the day texture: NASA's mosaic has a lighter
strip at its east edge near the poles, a line from each pole once drawn
exactly. The day texture is now built from NASA's original Blue Marble
(December 2004) by tools/globe/prepare-day-texture.py, which blends
that strip into the map where both sides of the seam agree; same
4096x2048 size, provenance and hashes in SOURCES.md.

globe.gl redrew 60 times a second even when nothing moved; it now draws
only during camera flights, hover pulses, auto-rotation and the
reader's drags, so an idle globe costs no GPU time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-04 17:46:44 +02:00
1 parent 620cbef219
commit 69d0a641fe
8 files changed
+205 -14

No files matched your search

+113 -7
View File
@@ -4,8 +4,9 @@
// Hovering a post pulses a ring where it is and draws an arc to it from PrivaPub's server; leaving it goes back to the
// view. Posts of servers behind a CDN have no place: they are grouped per CDN in labels on a ring above the Arctic,
// which says "CDN" rather than any place; hovering one draws its arc to that group and a ring where the server was
// before the CDN, when PrivaPub saw it there. Day texture with the light theme, night with the dark one; motion follows prefers-reduced-motion, and nothing
// renders while the page or the globe is hidden. No .NET round-trip on scroll or hover: the posts' places come in one
// before the CDN, when PrivaPub saw it there. Day texture with the light theme, night with the dark one; motion follows prefers-reduced-motion. It
// renders only while something moves (a camera flight, a hovered post's pulse, auto-rotation, the reader dragging or
// zooming) and never while the page or the globe is hidden: an idle globe costs no GPU time. No .NET round-trip on scroll or hover: the posts' places come in one
// call per feed change (setPosts), the cards are matched by data-post-id.
const vendor = new URL("../vendor/globe/", import.meta.url).href
@@ -47,6 +48,10 @@ export function create(element, scrollContainerId) {
renderer: null,
texture: null,
paused: false,
idle: false,
running: true,
interacting: false,
awakeUntil: 0,
observers: [],
listeners: [],
timers: [],
@@ -71,6 +76,7 @@ export function setPosts(posts, self) {
state.self = self && Number.isFinite(self.lat) && Number.isFinite(self.lng) ? self : null
drawPoints()
scheduleView()
wake()
}
// Live numbers for the nerd stats overlay; null until the globe exists.
@@ -95,8 +101,10 @@ export function stats() {
fps: state.fps,
renderer: state.renderer,
texture: state.texture,
anisotropy: state.anisotropy,
autoRotate: !!state.globe.controls().autoRotate,
paused: state.paused,
idle: state.idle,
hovered: state.hovered && state.points.has(state.hovered) ? state.points.get(state.hovered).label ?? state.hovered : null,
arcs: state.globe.arcsData().length,
rings: state.globe.ringsData().length
@@ -129,6 +137,7 @@ function resize() {
}
state.globe.width(width).height(height)
pause(document.visibilityState !== "visible")
wake()
}
async function build(width, height) {
@@ -167,9 +176,19 @@ async function build(width, height) {
.htmlLng(g => g.lng)
.htmlAltitude(0.12)
.htmlElement(cdnLabel)
sharpen(globe)
const controls = globe.controls()
controls.autoRotateSpeed = 0.35
controls.enableDamping = true
controls.addEventListener("start", () => {
state.interacting = true
wake()
})
controls.addEventListener("end", () => {
state.interacting = false
wake(1500)
})
listen(state.element, "pointermove", () => wake(1000))
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * narrowness() }, 0)
applyTheme()
readRenderer()
@@ -187,6 +206,57 @@ async function build(width, height) {
}
}
// The globe is a UV sphere: towards the poles its triangles narrow to slivers meeting at one point, and texture
// coordinates interpolated across them pinch the map into a star around the axis. Each pixel computes its own instead,
// from its exact point on the sphere (the inverse of three.js SphereGeometry's mapping), so the map is drawn the same
// everywhere. The mipmap level comes from gradients taken on whichever of two longitude ranges has no jump at that pixel,
// and the map wraps around horizontally (keepSharp), so the 180° meridian shows no seam; anisotropic filtering keeps
// high latitudes sharp at grazing angles, at 8× at most: sharper than that is barely visible and costs on phones.
function sharpen(globe) {
const material = globe.globeMaterial()
material.onBeforeCompile = shader => {
shader.vertexShader = shader.vertexShader
.replace("#include <common>", "#include <common>\nvarying vec3 vSpherePoint;")
.replace("#include <begin_vertex>", "#include <begin_vertex>\nvSpherePoint = position;")
shader.fragmentShader = shader.fragmentShader
.replace("#include <common>", "#include <common>\nvarying vec3 vSpherePoint;")
.replace("#include <map_fragment>", `
#ifdef USE_MAP
vec3 spherePoint = normalize(vSpherePoint);
float sphereU = atan(spherePoint.z, -spherePoint.x) / (2.0 * PI);
float sphereV = 1.0 - acos(clamp(spherePoint.y, -1.0, 1.0)) / PI;
float uWhole = fract(sphereU);
float uShifted = fract(sphereU + 0.5) - 0.5;
vec2 gradientX = vec2(dFdx(uWhole), dFdx(sphereV));
vec2 gradientY = vec2(dFdy(uWhole), dFdy(sphereV));
float shiftedX = dFdx(uShifted);
float shiftedY = dFdy(uShifted);
if (abs(shiftedX) + abs(shiftedY) < abs(gradientX.x) + abs(gradientY.x)) {
gradientX.x = shiftedX;
gradientY.x = shiftedY;
}
vec4 sampledDiffuseColor = textureGrad(map, vec2(uWhole, sphereV), gradientX, gradientY);
diffuseColor *= sampledDiffuseColor;
#endif`)
}
material.customProgramCacheKey = () => "privapub-per-pixel-sphere"
material.needsUpdate = true
state.anisotropy = Math.min(8, globe.renderer().capabilities.getMaxAnisotropy())
}
// Every texture the globe loads (a theme change loads the other one) wraps around horizontally, so filtering at the
// 180° meridian blends the map's two edges instead of clamping each, and gets anisotropic filtering.
const RepeatWrapping = 1000//three.js's RepeatWrapping: the bundle does not expose THREE
function keepSharp() {
const map = state?.globe?.globeMaterial().map
if (map && (map.anisotropy !== state.anisotropy || map.wrapS !== RepeatWrapping)) {
map.anisotropy = state.anisotropy
map.wrapS = RepeatWrapping
map.needsUpdate = true
wake(500)
}
}
// Which cards are in view: an IntersectionObserver on every [data-post-id] of the scroll container, re-attached as
// the list changes; hover by delegation on the container.
function watchCards() {
@@ -257,10 +327,40 @@ function pause(paused) {
if (!state?.globe || state.paused === paused)
return
state.paused = paused
if (paused)
state.globe.pauseAnimation()
else
render()
}
// globe.gl draws every frame; this keeps its loop running only while the globe is shown and something moves.
function render() {
const run = !state.paused && !state.idle
if (run === state.running)
return
state.running = run
if (run)
state.globe.resumeAnimation()
else
state.globe.pauseAnimation()
}
// Something is about to move: draw for at least ms more.
function wake(ms = 1500) {
if (!state?.globe)
return
state.awakeUntil = Math.max(state.awakeUntil, performance.now() + ms)
if (state.idle) {
state.idle = false
render()
}
}
// Checked every second: nothing hovered, dragged, rotating or flying, so the last frame can stay on screen.
function settle() {
if (!state?.globe || state.idle)
return
if (state.hovered || state.interacting || state.globe.controls().autoRotate || performance.now() < state.awakeUntil)
return
state.idle = true
render()
}
function applyTheme() {
@@ -275,6 +375,7 @@ function applyTheme() {
state.colours = null
state.globe.atmosphereColor(colour("server", 1))
drawPoints()
wake(2000)
}
// The theme's colours, resolved to rgb (the tokens are hsl() of CSS variables, which three.js cannot read).
@@ -392,6 +493,7 @@ function hover(key) {
if (!state?.globe || state.hovered === key)
return
state.hovered = key
wake(1300)
const post = key && state.points.get(key)
drawRings()
if (!post) {
@@ -434,6 +536,7 @@ function updateView() {
state.view = null
state.spreadKm = 0
controls.autoRotate = !reducedMotion.matches
wake()
return
}
controls.autoRotate = false
@@ -458,6 +561,7 @@ function updateView() {
function fly(view, ms) {
if (!view || !state?.globe)
return
wake((reducedMotion.matches ? 0 : ms) + 400)
state.globe.pointOfView(view, reducedMotion.matches ? 0 : ms)
}
@@ -491,14 +595,16 @@ function countFrames() {
const tick = () => {
if (state !== current)
return
if (!state.paused)
if (state.running)
state.frames++
keepSharp()
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
state.timers.push(setInterval(() => {
state.fps = state.paused ? 0 : state.frames
state.fps = state.running ? state.frames : 0
state.frames = 0
settle()
}, 1000))
}
+9 -3
View File
@@ -1,12 +1,18 @@
# The globe's vendored files
Copied once from the npm registry (registry.npmjs.org) on 2026-10-04; the build and the app never fetch them from
anywhere else. To update, download the new tarballs from the registry, copy the same files, and replace the hashes.
Copied once on 2026-10-04 from the npm registry (registry.npmjs.org) and, for the day texture, from NASA
(eoimages.gsfc.nasa.gov); the build and the app never fetch them from anywhere else. To update, download the new files,
copy them the same way (the day texture through `tools/globe/prepare-day-texture.py`), and replace the hashes.
NASA's mosaic does not wrap cleanly at the 180° meridian near the poles (a lighter strip at its east edge), which the
globe, drawing each pixel at its exact place, shows as a line from each pole; the script blends that strip into the
map on both sides, only on rows where the two sides agree, and gives the rows within half a degree of each pole one
colour.
| File | From | Licence | sha256 |
|---|---|---|---|
| `globe.gl.min.js` | `globe.gl` 2.46.2, `dist/globe.gl.min.js` (UMD, defines `window.Globe`) | MIT, `LICENSE-globe.gl` | `2c3e445c04d121215910a89688b96091c8a72071c122a4f830081a39b636c94c` |
| `earth-blue-marble.jpg` | `three-globe` 2.45.3, `example/img/earth-blue-marble.jpg`, 4096×2048 | NASA Visible Earth "Blue Marble", public domain | `228deba2e4b600146bdcb6cfa359b8ead6aacc2b1c13550a29cd82824cfa1c01` |
| `earth-blue-marble.jpg` | NASA Visible Earth, Blue Marble Next Generation with topography and bathymetry, December 2004 (`world.topo.bathy.200412.3x5400x2700.jpg`, sha256 `a9f0088972dee0254610af851c4d6838ca3f2cf79176987e0a5713e2c15ec042`), seam repaired and scaled to 4096×2048 by `tools/globe/prepare-day-texture.py` | NASA, public domain | `1334612d91d5104afb0e48cce916e100575c2969baf99e21454c23535f4ce713` |
| `earth-night.jpg` | `three-globe` 2.45.3, `example/img/earth-night.jpg`, 4096×2048 | NASA Earth Observatory "Black Marble" (city lights), public domain | `355ab23dd1323315b393d7b91dd2d7ee223a1cbaaba2b48dc72ba90d371ced24` |
`globe.gl.min.js` bundles its dependencies: three.js r185 (MIT, © 2010–2026 three.js authors), three-globe (MIT, © 2019
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB