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

+5 -1
View File
@@ -200,7 +200,11 @@ supplies:
- **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in - **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in
`wwwroot/vendor/globe/` with its textures (`SOURCES.md` has versions, licences and hashes; the service worker never `wwwroot/vendor/globe/` with its textures (`SOURCES.md` has versions, licences and hashes; the service worker never
precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an arc to a hovered precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an arc to a hovered
post, follows the theme (day/night texture), honours `prefers-reduced-motion` and pauses while hidden. CDN-fronted post, follows the theme (day/night texture), honours `prefers-reduced-motion`, and draws only while something moves
(idle, it costs no GPU time; `wake()` before anything that animates). Each pixel computes its own map coordinates
from its point on the sphere (`sharpen`), so the poles don't pinch; the map wraps horizontally and gets 8×
anisotropic filtering (`keepSharp`). The day texture is NASA's, its seam repaired by
`tools/globe/prepare-day-texture.py` (dev-only, needs Pillow). CDN-fronted
posts are labels per CDN on a ring at 68°N, never a place; hovering one draws its arc there and a ring where the server posts are labels per CDN on a ring at 68°N, never a place; hovering one draws its arc there and a ring where the server
was before the CDN. The posts' was before the CDN. The posts'
places reach it in one call per feed change; cards are matched by `data-post-id`. places reach it in one call per feed change; cards are matched by `data-post-id`.
+5 -3
View File
@@ -12,7 +12,7 @@
<i class="ion-md-planet"></i> <i class="ion-md-planet"></i>
@if (stats is { Error: null } live) @if (stats is { Error: null } live)
{ {
<span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @live.Fps fps</span> <span>@Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @(live.Idle ? Localizer["idle"] : $"{live.Fps} fps")</span>
} }
else if (stats?.Error is { Length: > 0 }) else if (stats?.Error is { Length: > 0 })
{ {
@@ -129,9 +129,9 @@
yield return NerdRow.Of(Localizer["points"], $"{stats.Points} ({stats.Posts} {Localizer["posts"]}, {stats.Servers} {Localizer["servers"]})"); yield return NerdRow.Of(Localizer["points"], $"{stats.Points} ({stats.Posts} {Localizer["posts"]}, {stats.Servers} {Localizer["servers"]})");
yield return NerdRow.Of(Localizer["CDN groups"], stats.CdnGroups > 0 ? stats.CdnGroups : null); yield return NerdRow.Of(Localizer["CDN groups"], stats.CdnGroups > 0 ? stats.CdnGroups : null);
yield return NerdRow.Of(Localizer["hovered"], stats.Hovered is { } hovered ? $"{hovered} ({stats.Arcs} {Localizer["arcs"]}, {stats.Rings} {Localizer["rings"]})" : null); yield return NerdRow.Of(Localizer["hovered"], stats.Hovered is { } hovered ? $"{hovered} ({stats.Arcs} {Localizer["arcs"]}, {stats.Rings} {Localizer["rings"]})" : null);
yield return NerdRow.Of("fps", stats.Paused ? Localizer["paused"] : stats.Fps.ToString()); yield return NerdRow.Of("fps", stats.Paused ? Localizer["paused"] : stats.Idle ? Localizer["idle: nothing moves, nothing is drawn"] : stats.Fps.ToString());
yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate); yield return NerdRow.Of(Localizer["auto-rotate"], stats.AutoRotate);
yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048" : null); yield return NerdRow.Of(Localizer["texture"], stats.Texture is { } texture ? $"{texture} 4096×2048, {stats.Anisotropy}× anisotropic" : null);
yield return NerdRow.Of("WebGL", stats.Renderer); yield return NerdRow.Of("WebGL", stats.Renderer);
yield return NerdRow.Of(Localizer["error"], stats.Error); yield return NerdRow.Of(Localizer["error"], stats.Error);
} }
@@ -179,8 +179,10 @@
public int Fps { get; set; } public int Fps { get; set; }
public string Renderer { get; set; } public string Renderer { get; set; }
public string Texture { get; set; } public string Texture { get; set; }
public int Anisotropy { get; set; }
public bool AutoRotate { get; set; } public bool AutoRotate { get; set; }
public bool Paused { get; set; } public bool Paused { get; set; }
public bool Idle { get; set; }
public string Hovered { get; set; } public string Hovered { get; set; }
public int Arcs { get; set; } public int Arcs { get; set; }
public int Rings { get; set; } public int Rings { get; set; }
+6
View File
@@ -680,4 +680,10 @@
<data name="CDN groups" xml:space="preserve"> <data name="CDN groups" xml:space="preserve">
<value>gruppi CDN</value> <value>gruppi CDN</value>
</data> </data>
<data name="idle" xml:space="preserve">
<value>fermo</value>
</data>
<data name="idle: nothing moves, nothing is drawn" xml:space="preserve">
<value>fermo: nulla si muove, nulla viene disegnato</value>
</data>
</root> </root>
+6
View File
@@ -680,4 +680,10 @@
<data name="CDN groups" xml:space="preserve"> <data name="CDN groups" xml:space="preserve">
<value>CDN groups</value> <value>CDN groups</value>
</data> </data>
<data name="idle" xml:space="preserve">
<value>idle</value>
</data>
<data name="idle: nothing moves, nothing is drawn" xml:space="preserve">
<value>idle: nothing moves, nothing is drawn</value>
</data>
</root> </root>
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# Builds wwwroot/vendor/globe/earth-blue-marble.jpg from NASA's Blue Marble Next Generation (topography and bathymetry,
# December 2004, public domain), downloaded once:
# https://eoimages.gsfc.nasa.gov/images/imagerecords/73000/73909/world.topo.bathy.200412.3x5400x2700.jpg
# usage: tools/globe/prepare-day-texture.py <that jpg> <output jpg> (needs Pillow; not part of the build)
#
# NASA's mosaic does not wrap cleanly: towards the poles a strip at its east edge is lighter than the map on both sides
# of the 180° meridian, which the globe draws as a line from each pole. On every row where the map agrees on both sides
# of the seam but the strip around it does not, the strip is replaced by a blend between the two sides. A real feature
# on the meridian (Fiji, the Ross Ice Shelf) makes the two sides differ, so its rows are left alone. Within half a degree
# of each pole a row is a circle a few kilometres across, so it gets one colour, its median, which removes the strip
# there too. Then the map is scaled to 4096×2048 (the globe's texture size) with Lanczos.
import sys
from PIL import Image
BAND = 16 # pixels on each side of the seam that may be repaired
ANCHOR = 4 # pixels averaged just outside the band, on each side
AGREE = 24 # the two sides agree when their mean channels differ by less than this
ODD = 30 # a band pixel is an artefact when it differs from the blend by more than this
POLAR = 0.5 # degrees from a pole within which a row is one colour
def mean(pixels):
return tuple(sum(p[i] for p in pixels) / len(pixels) for i in range(3))
def distance(a, b):
return sum(abs(x - y) for x, y in zip(a, b)) / 3
source, target = sys.argv[1], sys.argv[2]
image = Image.open(source).convert("RGB")
width, height = image.size
pixels = image.load()
repaired = 0
for y in range(height):
west = mean([pixels[width - BAND - 1 - i, y] for i in range(ANCHOR)])
east = mean([pixels[BAND + i, y] for i in range(ANCHOR)])
if distance(west, east) >= AGREE:
continue
columns = [width - BAND + i for i in range(BAND)] + list(range(BAND))
blend = [tuple(w + (e - w) * (i + 1) / (2 * BAND + 1) for w, e in zip(west, east)) for i in range(2 * BAND)]
if max(distance(pixels[x, y], b) for x, b in zip(columns, blend)) <= ODD:
continue
for x, b in zip(columns, blend):
pixels[x, y] = tuple(round(c) for c in b)
repaired += 1
polar = 0
for y in range(height):
latitude = 90 - (y + 0.5) * 180 / height
if abs(latitude) < 90 - POLAR:
continue
row = [pixels[x, y] for x in range(width)]
median = tuple(sorted(p[i] for p in row)[len(row) // 2] for i in range(3))
for x in range(width):
pixels[x, y] = median
polar += 1
image.resize((4096, 2048), Image.LANCZOS).save(target, "JPEG", quality=90, optimize=True, progressive=True)
print(f"{repaired} of {height} rows repaired at the seam, {polar} polar rows made one colour; wrote {target}")
+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 // 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, // 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 // 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 // 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 while the page or the globe is hidden. No .NET round-trip on scroll or hover: the posts' places come in one // 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. // call per feed change (setPosts), the cards are matched by data-post-id.
const vendor = new URL("../vendor/globe/", import.meta.url).href const vendor = new URL("../vendor/globe/", import.meta.url).href
@@ -47,6 +48,10 @@ export function create(element, scrollContainerId) {
renderer: null, renderer: null,
texture: null, texture: null,
paused: false, paused: false,
idle: false,
running: true,
interacting: false,
awakeUntil: 0,
observers: [], observers: [],
listeners: [], listeners: [],
timers: [], timers: [],
@@ -71,6 +76,7 @@ export function setPosts(posts, self) {
state.self = self && Number.isFinite(self.lat) && Number.isFinite(self.lng) ? self : null state.self = self && Number.isFinite(self.lat) && Number.isFinite(self.lng) ? self : null
drawPoints() drawPoints()
scheduleView() scheduleView()
wake()
} }
// Live numbers for the nerd stats overlay; null until the globe exists. // Live numbers for the nerd stats overlay; null until the globe exists.
@@ -95,8 +101,10 @@ export function stats() {
fps: state.fps, fps: state.fps,
renderer: state.renderer, renderer: state.renderer,
texture: state.texture, texture: state.texture,
anisotropy: state.anisotropy,
autoRotate: !!state.globe.controls().autoRotate, autoRotate: !!state.globe.controls().autoRotate,
paused: state.paused, paused: state.paused,
idle: state.idle,
hovered: state.hovered && state.points.has(state.hovered) ? state.points.get(state.hovered).label ?? state.hovered : null, hovered: state.hovered && state.points.has(state.hovered) ? state.points.get(state.hovered).label ?? state.hovered : null,
arcs: state.globe.arcsData().length, arcs: state.globe.arcsData().length,
rings: state.globe.ringsData().length rings: state.globe.ringsData().length
@@ -129,6 +137,7 @@ function resize() {
} }
state.globe.width(width).height(height) state.globe.width(width).height(height)
pause(document.visibilityState !== "visible") pause(document.visibilityState !== "visible")
wake()
} }
async function build(width, height) { async function build(width, height) {
@@ -167,9 +176,19 @@ async function build(width, height) {
.htmlLng(g => g.lng) .htmlLng(g => g.lng)
.htmlAltitude(0.12) .htmlAltitude(0.12)
.htmlElement(cdnLabel) .htmlElement(cdnLabel)
sharpen(globe)
const controls = globe.controls() const controls = globe.controls()
controls.autoRotateSpeed = 0.35 controls.autoRotateSpeed = 0.35
controls.enableDamping = true 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) globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * narrowness() }, 0)
applyTheme() applyTheme()
readRenderer() 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 // 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. // the list changes; hover by delegation on the container.
function watchCards() { function watchCards() {
@@ -257,10 +327,40 @@ function pause(paused) {
if (!state?.globe || state.paused === paused) if (!state?.globe || state.paused === paused)
return return
state.paused = paused state.paused = paused
if (paused) render()
state.globe.pauseAnimation() }
else
// 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() 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() { function applyTheme() {
@@ -275,6 +375,7 @@ function applyTheme() {
state.colours = null state.colours = null
state.globe.atmosphereColor(colour("server", 1)) state.globe.atmosphereColor(colour("server", 1))
drawPoints() drawPoints()
wake(2000)
} }
// The theme's colours, resolved to rgb (the tokens are hsl() of CSS variables, which three.js cannot read). // 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) if (!state?.globe || state.hovered === key)
return return
state.hovered = key state.hovered = key
wake(1300)
const post = key && state.points.get(key) const post = key && state.points.get(key)
drawRings() drawRings()
if (!post) { if (!post) {
@@ -434,6 +536,7 @@ function updateView() {
state.view = null state.view = null
state.spreadKm = 0 state.spreadKm = 0
controls.autoRotate = !reducedMotion.matches controls.autoRotate = !reducedMotion.matches
wake()
return return
} }
controls.autoRotate = false controls.autoRotate = false
@@ -458,6 +561,7 @@ function updateView() {
function fly(view, ms) { function fly(view, ms) {
if (!view || !state?.globe) if (!view || !state?.globe)
return return
wake((reducedMotion.matches ? 0 : ms) + 400)
state.globe.pointOfView(view, reducedMotion.matches ? 0 : ms) state.globe.pointOfView(view, reducedMotion.matches ? 0 : ms)
} }
@@ -491,14 +595,16 @@ function countFrames() {
const tick = () => { const tick = () => {
if (state !== current) if (state !== current)
return return
if (!state.paused) if (state.running)
state.frames++ state.frames++
keepSharp()
requestAnimationFrame(tick) requestAnimationFrame(tick)
} }
requestAnimationFrame(tick) requestAnimationFrame(tick)
state.timers.push(setInterval(() => { state.timers.push(setInterval(() => {
state.fps = state.paused ? 0 : state.frames state.fps = state.running ? state.frames : 0
state.frames = 0 state.frames = 0
settle()
}, 1000)) }, 1000))
} }
+9 -3
View File
@@ -1,12 +1,18 @@
# The globe's vendored files # 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 Copied once on 2026-10-04 from the npm registry (registry.npmjs.org) and, for the day texture, from NASA
anywhere else. To update, download the new tarballs from the registry, copy the same files, and replace the hashes. (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 | | 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` | | `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` | | `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 `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