diff --git a/CLAUDE.md b/CLAUDE.md index 64b7e4c..83c978d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,7 +200,11 @@ supplies: - **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 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 was before the CDN. The posts' places reach it in one call per feed change; cards are matched by `data-post-id`. diff --git a/Components/Feed/GlobePanel.razor b/Components/Feed/GlobePanel.razor index cd7e578..59c4f0c 100644 --- a/Components/Feed/GlobePanel.razor +++ b/Components/Feed/GlobePanel.razor @@ -12,7 +12,7 @@ @if (stats is { Error: null } live) { - @Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @live.Fps fps + @Fixed(live.Latitude), @Fixed(live.Longitude) · @Fixed(live.Altitude) · @(live.Idle ? Localizer["idle"] : $"{live.Fps} fps") } 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["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("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["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(Localizer["error"], stats.Error); } @@ -179,8 +179,10 @@ public int Fps { get; set; } public string Renderer { get; set; } public string Texture { get; set; } + public int Anisotropy { get; set; } public bool AutoRotate { get; set; } public bool Paused { get; set; } + public bool Idle { get; set; } public string Hovered { get; set; } public int Arcs { get; set; } public int Rings { get; set; } diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 80d3a8a..1c9669b 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -680,4 +680,10 @@ gruppi CDN + + fermo + + + fermo: nulla si muove, nulla viene disegnato + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index 0b3ef28..520e4b1 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -680,4 +680,10 @@ CDN groups + + idle + + + idle: nothing moves, nothing is drawn + diff --git a/tools/globe/prepare-day-texture.py b/tools/globe/prepare-day-texture.py new file mode 100755 index 0000000..4980848 --- /dev/null +++ b/tools/globe/prepare-day-texture.py @@ -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 (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}") diff --git a/wwwroot/js/globe.js b/wwwroot/js/globe.js index 0a4d38d..8bead30 100644 --- a/wwwroot/js/globe.js +++ b/wwwroot/js/globe.js @@ -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 ", "#include \nvarying vec3 vSpherePoint;") + .replace("#include ", "#include \nvSpherePoint = position;") + shader.fragmentShader = shader.fragmentShader + .replace("#include ", "#include \nvarying vec3 vSpherePoint;") + .replace("#include ", ` +#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)) } diff --git a/wwwroot/vendor/globe/SOURCES.md b/wwwroot/vendor/globe/SOURCES.md index 5dfb3be..bc0aaee 100644 --- a/wwwroot/vendor/globe/SOURCES.md +++ b/wwwroot/vendor/globe/SOURCES.md @@ -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 diff --git a/wwwroot/vendor/globe/earth-blue-marble.jpg b/wwwroot/vendor/globe/earth-blue-marble.jpg index e673a7c..1c746bb 100644 Binary files a/wwwroot/vendor/globe/earth-blue-marble.jpg and b/wwwroot/vendor/globe/earth-blue-marble.jpg differ