// 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. // The Earth is lit by the Sun where it really is (sun.js): the day map where the Sun is up, shaded by its height, the // night map (city lights) where it is down, blended across a twilight band, and brighter or dimmer with the Earth–Sun // distance. It shows now, or, while a post is hovered or focused, the moment that post was written; the Sun moves there // and back smoothly. Land borders are thin lines, and country names sit inside each country in the reader's language, // more of them and larger as the camera comes closer. Motion follows prefers-reduced-motion. It renders only while // something moves (a camera flight, the Sun moving, 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, and .NET only hears that the Sun's moment changed (SunChanged), to show it. import { directionOf, seasonsOf, sunAt, sunAtPlace } from "./sun.js" const vendor = new URL("../vendor/globe/", import.meta.url).href const textures = { day: vendor + "earth-blue-marble.jpg", night: vendor + "earth-night.jpg" } const countriesUrl = vendor + "countries.json" 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. reference is the .NET panel (SunChanged); options.language names the countries. export function create(element, scrollContainerId, reference, options) { dispose() const now = sunAt(new Date()) state = { element, reference, names: regionNames(options?.language), scroller: document.getElementById(scrollContainerId), sun: now, sunPoint: { lat: now.lat, lng: now.lng }, sunUniforms: { sunDirection: { value: directionOf(now) }, sunIntensity: { value: now.irradiance }, nightMap: { value: null }, nightReady: { value: 0 } }, focus: null, pinned: null, nightImage: null, countries: [], borders: [], labelOf: new Map(), labelKey: null, labelsAt: 0, countryNames: 0, 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.sunUniforms.nightReady.value ? "day+night" : "day", 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, countries: state.countries.length, countryNames: state.countryNames, borders: state.borders.length, sun: sunStats() } } // The Sun's moment as the overlay shows it: now, or the focused post's moment and what the Sun does at its place. function sunStats() { const sun = state.sun const focus = state.focus const place = focus && Number.isFinite(focus.lat) ? sunAtPlace(sun, focus.lat, focus.lng) : null const seasons = seasonsOf(sun) return { at: sun.date.toISOString(), now: !focus, latitude: round(sun.lat, 2), longitude: round(sun.lng, 2), distance: round(sun.distance, 5), irradiance: round(sun.irradiance, 4), eclipticLongitude: round(sun.eclipticLongitude, 2), obliquity: round(sun.obliquity, 3), place: focus?.label ?? null, placeLatitude: focus && Number.isFinite(focus.lat) ? round(focus.lat, 2) : null, placeLongitude: focus && Number.isFinite(focus.lng) ? round(focus.lng, 2) : null, elevation: place ? round(place.elevation, 1) : null, phase: place?.phase ?? null, morning: place?.morning ?? null, solarTime: place?.solarTime ?? null, dayLength: place ? round(place.dayLength, 2) : null, season: place?.season ?? null, hemisphere: place?.hemisphere ?? null, seasonNorth: seasons.north, seasonSouth: seasons.south, nextEvent: seasons.nextEvent, nextEventDays: seasons.nextEventDays } } 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(d => d.lat) .htmlLng(d => d.lng) .htmlAltitude(d => d.kind === "country" ? 0.004 : 0.12) .htmlElement(d => d.kind === "country" ? countryLabel(d) : cdnLabel(d)) .htmlTransitionDuration(0) // the label renderer sets display on every frame, so labels hide through visibility; country names are // placed by placeLabels .htmlElementVisibilityModifier((element, inFront) => { if (!element.classList.contains("globe-country")) element.style.visibility = inFront ? "" : "hidden" }) .pathPoints(b => b.points) .pathPointLat(p => p[0]) .pathPointLng(p => p[1]) .pathPointAlt(0.0015) .pathColor(() => "rgba(255,255,255,0.32)") .pathStroke(null) .pathTransitionDuration(0) .globeImageUrl(textures.day) sharpen(globe) loadNight() loadCountries() 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) state.timers.push(setInterval(() => { if (!state.focus) aimSun(sunAt(new Date())) }, 30000)) 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. // The Sun replaces the scene's lights: the day map where it is up, shaded by its height (cosine of its zenith angle, // eased so the low sun does not go black at once) and scaled by the Earth–Sun distance; where it is down, the night // map's city lights, warmed to sodium's colour, over a dim trace of that map's land; the two blended across twilight, // from 7° below the horizon to 6° above, with a faint warm band along the terminator. function sharpen(globe) { const material = globe.globeMaterial() material.onBeforeCompile = shader => { Object.assign(shader.uniforms, state.sunUniforms) shader.vertexShader = shader.vertexShader .replace("#include ", "#include \nvarying vec3 vSpherePoint;") .replace("#include ", "#include \nvSpherePoint = position;") shader.fragmentShader = shader.fragmentShader .replace("#include ", `#include varying vec3 vSpherePoint; uniform vec3 sunDirection; uniform float sunIntensity; uniform sampler2D nightMap; uniform float nightReady;`) .replace("#include ", ` #ifdef USE_MAP gl_FragColor = vec4(earthColor, diffuseColor.a); #else #include #endif`) .replace("#include ", ` vec3 earthColor = vec3(0.0); #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; vec3 nightSample = textureGrad(nightMap, vec2(uWhole, sphereV), gradientX, gradientY).rgb * nightReady; float glow = smoothstep(0.03, 0.12, dot(nightSample, vec3(0.2126, 0.7152, 0.0722))); vec3 cityLights = nightSample * glow * vec3(2.6, 2.1, 1.4); float sunHeight = dot(spherePoint, sunDirection); float daylight = smoothstep(-0.12, 0.10, sunHeight); vec3 dayColor = diffuseColor.rgb * sunIntensity * (0.10 + 0.90 * pow(max(sunHeight, 0.0), 0.7)); vec3 nightColor = cityLights + nightSample * 0.35 + diffuseColor.rgb * 0.015; float terminator = exp(-pow(sunHeight / 0.05, 2.0)); earthColor = mix(nightColor, dayColor, daylight) + vec3(0.50, 0.22, 0.06) * terminator * 0.05; #endif`) } material.customProgramCacheKey = () => "privapub-sunlit-sphere" material.needsUpdate = true state.anisotropy = Math.min(8, globe.renderer().capabilities.getMaxAnisotropy()) } // Both maps wrap around horizontally, so filtering at the 180° meridian blends the map's two edges instead of clamping // each, and get 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) return if (map.anisotropy !== state.anisotropy || map.wrapS !== RepeatWrapping) { map.anisotropy = state.anisotropy map.wrapS = RepeatWrapping map.needsUpdate = true wake(500) } // the night map is a texture of the same kind as the day map globe.gl loaded (the bundle does not expose THREE) if (state.nightImage?.complete && !state.sunUniforms.nightMap.value) { const night = new map.constructor(state.nightImage) night.colorSpace = map.colorSpace night.anisotropy = state.anisotropy night.wrapS = RepeatWrapping night.needsUpdate = true state.sunUniforms.nightMap.value = night state.sunUniforms.nightReady.value = 1 wake(500) } } function loadNight() { const image = new Image() image.decoding = "async" image.onload = () => { state.nightImage = image } image.src = textures.night } // The Sun moves to a new moment: its subsolar point travels there over about a second (the shorter way round), or // jumps with reduced motion. function aimSun(sun) { state.sun = sun state.sunUniforms.sunIntensity.value = sun.irradiance if (reducedMotion.matches) state.sunPoint = { lat: sun.lat, lng: sun.lng } state.sunUniforms.sunDirection.value = directionOf(state.sunPoint) // one frame for the new irradiance; stepSun keeps the globe drawing while the Sun travels wake(100) state.reference?.invokeMethodAsync("SunChanged").catch(() => { }) } // Called every frame: eases the subsolar point towards the Sun's. function stepSun() { const point = state.sunPoint const dLat = state.sun.lat - point.lat const dLng = ((state.sun.lng - point.lng + 540) % 360) - 180 if (Math.abs(dLat) < 0.02 && Math.abs(dLng) < 0.02) { if (point.lat !== state.sun.lat || point.lng !== state.sun.lng) { state.sunPoint = { lat: state.sun.lat, lng: state.sun.lng } state.sunUniforms.sunDirection.value = directionOf(state.sunPoint) } return } const k = Math.abs(dLat) < 0.5 && Math.abs(dLng) < 0.5 ? 1 : 0.12 state.sunPoint = { lat: point.lat + dLat * k, lng: point.lng + dLng * k } state.sunUniforms.sunDirection.value = directionOf(state.sunPoint) wake(300) } // The moment a post was written (its card's data-post-at), and its place when it has one (its own, or its server's // before a CDN), while it is hovered, focused or pinned; otherwise now. function focusOn(key) { const card = key ? state.scroller?.querySelector(`[data-post-id="${CSS.escape(key)}"]`) : null const at = card?.dataset.postAt ? new Date(card.dataset.postAt) : null if (!at || Number.isNaN(at.getTime())) { if (state.focus) { state.focus = null aimSun(sunAt(new Date())) } return } if (state.focus?.key === key) return const post = state.points.get(key) const lat = placed(post) ? post.lat : post?.beforeLat const lng = placed(post) ? post.lng : post?.beforeLng state.focus = { key, at, lat, lng, label: post?.label ?? null } aimSun(sunAt(at)) } // Country names in the reader's language (Intl.DisplayNames), Natural Earth's English names when the browser has none. function regionNames(language) { try { return new Intl.DisplayNames([language || navigator.language, "en"], { type: "region" }) } catch { return null } } async function loadCountries() { try { const response = await fetch(countriesUrl) const data = await response.json() if (!state?.globe) return state.borders = data.borders.map(line => { const points = [] for (let i = 0; i < line.length; i += 2) points.push([line[i + 1], line[i]]) return { points } }) state.countries = data.countries.map(c => ({ kind: "country", ...c })) state.globe.pathsData(state.borders) drawPoints() placeLabels(true) wake(500) } catch (e) { console.error(e) window.logFromJs?.(e.message, "globe.loadCountries") } } function countryLabel(country) { const element = document.createElement("div") element.className = "globe-country" element.style.visibility = "hidden" let name = country.name try { name = (country.code && state.names?.of(country.code)) || country.name } catch { } element.textContent = name state.labelOf.set(country, element) state.labelKey = null return element } // How many pixels a degree of the surface spans under the camera: the surface there is altitude globe radii away, and a // degree is a 180/π-th of a radius. function pixelsPerDegree(altitude) { const { height } = state.element.getBoundingClientRect() const fov = (state.globe.camera().fov ?? 50) * Math.PI / 180 return height / (2 * Math.tan(fov / 2) * Math.max(0.01, altitude)) * Math.PI / 180 } // Which country names show, and how large, whenever the camera moved (at most every 100 ms): largest countries first, // each where its polygon spans at least 34 px on screen (less towards the globe's edge, which foreshortens it), away // from the edge, and clear of the names already placed. Names grow from 9 to 16 px with their country and fade in over // their first pixels, so they do not pop. function placeLabels(force = false) { if (!state?.globe || state.labelOf.size === 0) return const camera = state.globe.pointOfView() const { width, height } = state.element.getBoundingClientRect() const key = `${camera.lat.toFixed(2)},${camera.lng.toFixed(2)},${camera.altitude.toFixed(3)},${width}x${height}` const now = performance.now() if (!force && (key === state.labelKey || now - state.labelsAt < 100)) return state.labelKey = key state.labelsAt = now const perDegree = pixelsPerDegree(camera.altitude) const horizon = 1 / (1 + camera.altitude) const view = unit(camera.lat, camera.lng) const taken = [] for (const country of state.countries) { const element = state.labelOf.get(country) if (!element) continue const point = unit(country.lat, country.lng) const facing = (view[0] * point[0] + view[1] * point[1] + view[2] * point[2] - horizon) / (1 - horizon) const pixels = country.size * perDegree * Math.sqrt(Math.max(facing, 0)) let shown = facing > 0.2 && pixels >= 34 if (shown) { const size = Math.max(9, Math.min(16, 8 + pixels / 20)) const at = state.globe.getScreenCoords(country.lat, country.lng, 0.004) const halfWidth = element.textContent.length * size * 0.3 + 3 const halfHeight = size * 0.6 + 2 const box = [at.x - halfWidth, at.y - halfHeight, at.x + halfWidth, at.y + halfHeight] shown = !taken.some(b => box[0] < b[2] && box[2] > b[0] && box[1] < b[3] && box[3] > b[1]) if (shown) { taken.push(box) element.style.fontSize = `${size.toFixed(1)}px` element.style.opacity = Math.min(1, 0.45 + (pixels - 34) / 50).toFixed(2) } } element.style.visibility = shown ? "" : "hidden" } state.countryNames = taken.length } function unit(lat, lng) { const φ = lat * Math.PI / 180, λ = lng * Math.PI / 180 return [Math.cos(φ) * Math.cos(λ), Math.cos(φ) * Math.sin(λ), Math.sin(φ)] } // 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, "focusin", event => { const card = event.target.closest?.("[data-post-id]") if (card && card.dataset.postId !== state.hovered) hover(card.dataset.postId) }) listen(scroller, "focusout", event => { if (!event.relatedTarget?.closest?.("[data-post-id]") && !scroller.matches(":hover")) hover(null) }) // a click on a card (not on its links or controls) pins it: the globe keeps its moment and place until it is // clicked again or another card is pinned listen(scroller, "click", event => { const card = event.target.closest?.("[data-post-id]") if (!card || event.target.closest("a, button, input, textarea, select, label, summary, video, audio, [role=button]")) return const key = card.dataset.postId scroller.querySelectorAll("[data-globe-pinned]").forEach(c => c.removeAttribute("data-globe-pinned")) state.pinned = state.pinned === key ? null : key if (state.pinned) card.setAttribute("data-globe-pinned", "") state.hovered = null hover(key) }) 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 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.map(g => ({ kind: "cdn", ...g })), ...state.countries]) 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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]) const hosts = [...point.hosts].slice(0, 4).map(escape).join("
") const more = point.hosts.size > 4 ? `
+${point.hosts.size - 4}` : "" return `
${escape(point.label || "")}
${hosts}${more}` + `
${point.count} · ${escape(point.src)} · ${point.lat.toFixed(2)}, ${point.lng.toFixed(2)}
` } // 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) { key ??= state?.pinned if (!state?.globe || state.hovered === key) return state.hovered = key wake(1300) const post = key && state.points.get(key) focusOn(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() stepSun() placeLabels() 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 }