Files
decePubClient/wwwroot/js/globe.js
T
thepraandClaude Opus 5.5 69d0a641fe 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
2026-10-04 17:46:44 +02:00

614 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The feed's globe (Components/Feed/GlobePanel.razor), on the vendored globe.gl (wwwroot/vendor/globe, loaded the first
// time a globe is shown). It looks at the average place of the located posts in view: the spherical mean of their
// points, so posts on either side of the antimeridian average correctly, from an altitude that fits their spread.
// 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. 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
const textures = { day: vendor + "earth-blue-marble.jpg", night: vendor + "earth-night.jpg" }
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)")
let loading = null
let state = null
function loadLibrary() {
if (window.Globe)
return Promise.resolve(window.Globe)
loading ??= new Promise((resolve, reject) => {
const script = document.createElement("script")
script.src = vendor + "globe.gl.min.js"
script.onload = () => resolve(window.Globe)
script.onerror = () => { loading = null; reject(new Error("globe.gl could not be loaded")) }
document.head.appendChild(script)
})
return loading
}
// Creates the globe in element once it has a size (it may start hidden on a phone), following the cards of the
// scroll container.
export function create(element, scrollContainerId) {
dispose()
state = {
element,
scroller: document.getElementById(scrollContainerId),
globe: null,
points: new Map(),
self: null,
visible: new Set(),
hovered: null,
view: null,
spreadKm: 0,
frames: 0,
fps: 0,
renderer: null,
texture: null,
paused: false,
idle: false,
running: true,
interacting: false,
awakeUntil: 0,
observers: [],
listeners: [],
timers: [],
error: null
}
const sizeObserver = new ResizeObserver(() => resize())
sizeObserver.observe(element)
state.observers.push(sizeObserver)
resize()
watchCards()
watchTheme()
watchVisibility()
}
// The located posts of the feed: [{ key, lat, lng, src, label, host, serverLat, serverLng, range, cdn, beforeLat,
// beforeLng, beforeLabel }] (src "cdn" posts have no lat/lng, only their CDN's key), and PrivaPub's own place
// ({ lat, lng, label } or null), where every arc starts.
export function setPosts(posts, self) {
if (!state)
return
state.points = new Map(posts.filter(p => p.src === "cdn" ? !!p.cdn : Number.isFinite(p.lat) && Number.isFinite(p.lng)).map(p => [p.key, p]))
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.
export function stats() {
if (!state?.globe)
return state?.error ? { error: state.error } : null
const camera = state.globe.pointOfView()
const visibleLocated = [...state.visible].filter(key => placed(state.points.get(key))).length
return {
latitude: round(camera.lat, 2),
longitude: round(camera.lng, 2),
altitude: round(camera.altitude, 2),
visible: state.visible.size,
visibleLocated,
centroidLatitude: state.view ? round(state.view.lat, 2) : null,
centroidLongitude: state.view ? round(state.view.lng, 2) : null,
spreadKm: Math.round(state.spreadKm),
points: aggregate().length,
posts: state.points.size,
servers: new Set([...state.points.values()].map(p => p.host)).size,
cdnGroups: cdnGroups().length,
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
}
}
export function dispose() {
if (!state)
return
state.observers.forEach(observer => observer.disconnect())
state.listeners.forEach(([target, type, listener]) => target.removeEventListener(type, listener))
state.timers.forEach(timer => clearInterval(timer))
if (state.globe) {
state.globe.pauseAnimation()
state.globe._destructor?.()
state.element.replaceChildren()
}
state = null
}
function resize() {
const { width, height } = state.element.getBoundingClientRect()
if (width < 10 || height < 10) {
pause(true)
return
}
if (!state.globe) {
build(width, height)
return
}
state.globe.width(width).height(height)
pause(document.visibilityState !== "visible")
wake()
}
async function build(width, height) {
if (state.building)
return
state.building = true
const current = state
try {
const Globe = await loadLibrary()
if (state !== current)
return
const globe = new Globe(state.element, { rendererConfig: { antialias: true, alpha: true } })
state.globe = globe
globe.width(width).height(height)
.backgroundColor("rgba(0,0,0,0)")
.showAtmosphere(true)
.atmosphereAltitude(0.18)
.pointsMerge(false)
.pointAltitude(p => 0.01 + Math.min(p.count, 30) * 0.004)
.pointRadius(p => 0.25 + Math.min(Math.sqrt(p.count), 6) * 0.12)
.pointColor(p => colour(p.src))
.pointLabel(p => label(p))
.pointsTransitionDuration(reducedMotion.matches ? 0 : 600)
.ringColor(r => t => r.colour.replace(/[\d.]+\)$/, `${(1 - t).toFixed(2)})`))
.ringMaxRadius(r => r.radius)
.ringPropagationSpeed(r => r.speed)
.ringRepeatPeriod(r => r.period)
.arcColor(() => [colour("self", 0.9), colour("server", 0.9)])
.arcStroke(0.6)
.arcAltitudeAutoScale(0.4)
.arcDashLength(0.45)
.arcDashGap(0.25)
.arcDashInitialGap(1)
.arcDashAnimateTime(reducedMotion.matches ? 0 : 1600)
.htmlLat(g => g.lat)
.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()
countFrames()
drawPoints()
scheduleView()
pause(document.visibilityState !== "visible")
} catch (e) {
state.error = e.message
console.error(e)
window.logFromJs?.(e.message, "globe.build")
} finally {
if (state === current)
state.building = false
}
}
// 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() {
const scroller = state.scroller
if (!scroller)
return
const visibility = new IntersectionObserver(entries => {
for (const entry of entries) {
const key = entry.target.dataset.postId
if (entry.isIntersecting)
state.visible.add(key)
else
state.visible.delete(key)
}
scheduleView()
}, { root: scroller, threshold: 0.35 })
const observeAll = () => scroller.querySelectorAll("[data-post-id]").forEach(card => visibility.observe(card))
observeAll()
const changes = new MutationObserver(mutations => {
for (const mutation of mutations)
for (const node of mutation.removedNodes)
if (node.nodeType === 1 && node.dataset?.postId)
state.visible.delete(node.dataset.postId)
observeAll()
scheduleView()
})
changes.observe(scroller, { childList: true, subtree: true })
state.observers.push(visibility, changes)
listen(scroller, "mouseover", event => {
const card = event.target.closest?.("[data-post-id]")
if (card && card.dataset.postId !== state.hovered)
hover(card.dataset.postId)
})
listen(scroller, "mouseleave", () => hover(null))
listen(scroller, "mouseout", event => {
const from = event.target.closest?.("[data-post-id]")
const to = event.relatedTarget?.closest?.("[data-post-id]")
if (from && !to)
hover(null)
})
}
function watchTheme() {
const themeObserver = new MutationObserver(() => applyTheme())
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "style", "class"] })
state.observers.push(themeObserver)
const scheme = matchMedia("(prefers-color-scheme: dark)")
listen(scheme, "change", () => applyTheme())
}
function watchVisibility() {
listen(document, "visibilitychange", () => pause(document.visibilityState !== "visible" || !hasSize()))
listen(reducedMotion, "change", () => scheduleView())
}
function listen(target, type, listener) {
target.addEventListener(type, listener)
state.listeners.push([target, type, listener])
}
function hasSize() {
const { width, height } = state.element.getBoundingClientRect()
return width >= 10 && height >= 10
}
function pause(paused) {
if (!state?.globe || state.paused === paused)
return
state.paused = paused
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() {
if (!state?.globe)
return
const dark = window.neoTheme?.isDark?.() ?? matchMedia("(prefers-color-scheme: dark)").matches
const texture = dark ? "night" : "day"
if (state.texture !== texture) {
state.texture = texture
state.globe.globeImageUrl(textures[texture])
}
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).
function colour(kind, alpha = 0.95) {
state.colours ??= (() => {
const probe = document.createElement("span")
probe.style.display = "none"
document.body.appendChild(probe)
const read = token => {
probe.style.color = `var(${token})`
return getComputedStyle(probe).color
}
const colours = {
server: read("--color-primary"),
place: read("--color-accent"),
country: read("--color-secondary"),
nearyou: read("--color-success"),
self: read("--color-base-content")
}
probe.remove()
return colours
})()
const rgb = (state.colours[kind] ?? state.colours.server).match(/[\d.]+/g)?.slice(0, 3) ?? [128, 128, 128]
return `rgba(${rgb.join(",")},${alpha})`
}
// A post with a place on Earth (not a CDN-fronted server's).
function placed(post) {
return !!post && post.src !== "cdn"
}
// The CDN groups, each at its own longitude on a ring at 68°N, ordered by key so they keep their places.
function cdnGroups() {
const byKey = new Map()
for (const post of state.points.values()) {
if (post.src !== "cdn")
continue
const group = byKey.get(post.cdn) ?? { key: post.cdn, name: post.label, hosts: new Set(), count: 0 }
group.count++
if (post.host)
group.hosts.add(post.host)
byKey.set(post.cdn, group)
}
const groups = [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key))
groups.forEach((group, i) => {
group.lat = 68
group.lng = -180 + (i + 0.5) * 360 / groups.length
})
return groups
}
// Posts at the same place are one point; PrivaPub's own server is a point of its own.
function aggregate() {
const byPlace = new Map()
for (const post of state.points.values()) {
if (!placed(post))
continue
const kind = post.src === "event" ? "place" : post.src
const id = `${kind}:${post.lat.toFixed(2)},${post.lng.toFixed(2)}`
const point = byPlace.get(id) ?? { lat: post.lat, lng: post.lng, src: kind, label: post.label, hosts: new Set(), count: 0 }
point.count++
if (post.host)
point.hosts.add(post.host)
byPlace.set(id, point)
}
const points = [...byPlace.values()]
if (state.self)
points.push({ lat: state.self.lat, lng: state.self.lng, src: "self", label: state.self.label, hosts: new Set([state.self.host]), count: 1 })
return points
}
function drawPoints() {
if (!state?.globe)
return
state.groups = cdnGroups()
state.globe.pointsData(aggregate())
state.globe.htmlElementsData(state.groups)
drawRings()
}
function cdnLabel(group) {
const element = document.createElement("div")
element.className = "globe-cdn"
element.textContent = `☁ ${group.name} · ${group.count}`
element.title = [...group.hosts].slice(0, 10).join("\n")
return element
}
function label(point) {
const escape = text => String(text ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c])
const hosts = [...point.hosts].slice(0, 4).map(escape).join("<br>")
const more = point.hosts.size > 4 ? `<br>+${point.hosts.size - 4}` : ""
return `<div class="globe-label"><b>${escape(point.label || "")}</b><br>${hosts}${more}` +
`<br><span>${point.count} · ${escape(point.src)} · ${point.lat.toFixed(2)}, ${point.lng.toFixed(2)}</span></div>`
}
// The pulse under the hovered post, and the reach of located posts around the reader.
function drawRings() {
if (!state?.globe)
return
const rings = []
const hovered = state.hovered && state.points.get(state.hovered)
if (placed(hovered))
rings.push({ lat: hovered.lat, lng: hovered.lng, radius: 4, speed: 3, period: reducedMotion.matches ? 100000 : 900, colour: colour(hovered.src, 1) })
else if (hovered && Number.isFinite(hovered.beforeLat))
rings.push({ lat: hovered.beforeLat, lng: hovered.beforeLng, radius: 3, speed: 1.5, period: reducedMotion.matches ? 100000 : 1400, colour: colour("country", 1) })
const reach = Math.max(0, ...[...state.points.values()].filter(p => p.src === "nearyou").map(p => p.range ?? 0))
const reader = [...state.points.values()].find(p => p.src === "nearyou")
if (reader && reach > 0)
rings.push({ lat: reader.lat, lng: reader.lng, radius: reach / 111, speed: reach / 111 / 2, period: reducedMotion.matches ? 100000 : 2400, colour: colour("nearyou", 0.8) })
state.globe.ringsData(rings)
}
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) {
state.globe.arcsData([])
fly(state.view, 900)
return
}
if (!placed(post)) {
const group = state.groups?.find(g => g.key === post.cdn)
state.globe.arcsData(group && state.self ? [{ startLat: state.self.lat, startLng: state.self.lng, endLat: group.lat, endLng: group.lng }] : [])
const target = Number.isFinite(post.beforeLat) ? { lat: post.beforeLat, lng: post.beforeLng } : group
if (target && distance(state.globe.pointOfView(), target) > 2500)
fly({ lat: target.lat, lng: target.lng, altitude: state.globe.pointOfView().altitude }, 900)
return
}
const from = post.src === "place" || post.src === "event"
? (Number.isFinite(post.serverLat) ? { lat: post.serverLat, lng: post.serverLng } : state.self)
: state.self
state.globe.arcsData(from && distance(from, post) > 30
? [{ startLat: from.lat, startLng: from.lng, endLat: post.lat, endLng: post.lng }]
: [])
const camera = state.globe.pointOfView()
if (distance(camera, post) > 2500)
fly({ lat: post.lat, lng: post.lng, altitude: camera.altitude }, 900)
}
let viewTimer = null
function scheduleView() {
clearTimeout(viewTimer)
viewTimer = setTimeout(updateView, 250)
}
// The spherical mean of the located posts in view, and an altitude that fits their spread.
function updateView() {
if (!state?.globe)
return
const located = [...state.visible].map(key => state.points.get(key)).filter(placed)
const controls = state.globe.controls()
if (located.length === 0) {
state.view = null
state.spreadKm = 0
controls.autoRotate = !reducedMotion.matches
wake()
return
}
controls.autoRotate = false
let x = 0, y = 0, z = 0
for (const p of located) {
const lat = p.lat * Math.PI / 180, lng = p.lng * Math.PI / 180
x += Math.cos(lat) * Math.cos(lng)
y += Math.cos(lat) * Math.sin(lng)
z += Math.sin(lat)
}
const mean = {
lat: Math.atan2(z, Math.hypot(x, y)) * 180 / Math.PI,
lng: Math.atan2(y, x) * 180 / Math.PI
}
state.spreadKm = Math.max(0, ...located.map(p => distance(mean, p)))
mean.altitude = Math.min(3.6, (1.5 + state.spreadKm / 3500) * narrowness())
state.view = mean
if (!state.hovered)
fly(mean, 1200)
}
function fly(view, ms) {
if (!view || !state?.globe)
return
wake((reducedMotion.matches ? 0 : ms) + 400)
state.globe.pointOfView(view, reducedMotion.matches ? 0 : ms)
}
// A column taller than wide shows less of the globe across: the camera moves back to keep it whole.
function narrowness() {
const { width, height } = state.element.getBoundingClientRect()
return width > 0 && height > width ? Math.min(1.8, height / width) : 1
}
// Great-circle distance in kilometres.
function distance(a, b) {
const toRadians = degrees => degrees * Math.PI / 180
const dLat = toRadians(b.lat - a.lat), dLng = toRadians(b.lng - a.lng)
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRadians(a.lat)) * Math.cos(toRadians(b.lat)) * Math.sin(dLng / 2) ** 2
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h)))
}
function readRenderer() {
try {
const gl = state.globe.renderer().getContext()
const info = gl.getExtension("WEBGL_debug_renderer_info")
state.renderer = info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)
} catch {
state.renderer = null
}
}
// Frames per second, counted over each second while the globe renders.
function countFrames() {
const current = state
const tick = () => {
if (state !== current)
return
if (state.running)
state.frames++
keepSharp()
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
state.timers.push(setInterval(() => {
state.fps = state.running ? state.frames : 0
state.frames = 0
settle()
}, 1000))
}
function round(value, digits) {
return Number.isFinite(value) ? Number(value.toFixed(digits)) : null
}