// 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, 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
// 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,
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()
}
// 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,
autoRotate: !!state.globe.controls().autoRotate,
paused: state.paused,
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")
}
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)
const controls = globe.controls()
controls.autoRotateSpeed = 0.35
controls.enableDamping = true
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
}
}
// 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
if (paused)
state.globe.pauseAnimation()
else
state.globe.resumeAnimation()
}
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()
}
// 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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c])
const hosts = [...point.hosts].slice(0, 4).map(escape).join("
")
const more = point.hosts.size > 4 ? `
+${point.hosts.size - 4}` : ""
return `