One sign-in for both of PrivaPub's APIs: the root logs in on /clientapi, picks a persona, and each persona gets its own Mastodon token in exchange for the JWT (PersonaExchange). HttpService picks credentials and JSON by route (JWT and camelCase for /clientapi, the persona token and snake_case for /api, nothing for /oauth), retries a refused persona token once, and records every request for the nerd stats. The feed page is three columns: navigation with a persona switcher, the feed with a tab per kind (home, local, federated, nearby, communities and circles, direct, notifications, favourites, bookmarks, hashtags), and a 3D globe that looks at the spherical mean of the posts in view and draws a pulse and an arc to a hovered one. Posts sit at their own place, their event's venue, the reader (located posts) or their author's server as PrivaPub publishes it. Cards favourite, boost, bookmark, reply, mute, block and delete; the composer posts with a content warning, visibility, media and alt text, or as a located post. Older pages load as the end nears; newer ones are polled while visible. Nerd stats in tooltips: provenance, author and server, place, media, counts, paging, the globe's camera and frame rate. The mock (Faker, MessagesService, the Message models and store) and the OIDC template leftovers are gone. Strings in English and Italian. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
453 lines
15 KiB
JavaScript
453 lines
15 KiB
JavaScript
// 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. 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 }], 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 => 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 => state.points.has(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,
|
|
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)
|
|
const controls = globe.controls()
|
|
controls.autoRotateSpeed = 0.35
|
|
controls.enableDamping = true
|
|
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.5 * 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})`
|
|
}
|
|
|
|
// 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()) {
|
|
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.globe.pointsData(aggregate())
|
|
drawRings()
|
|
}
|
|
|
|
function label(point) {
|
|
const escape = text => String(text ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 (hovered)
|
|
rings.push({ lat: hovered.lat, lng: hovered.lng, radius: 4, speed: 3, period: reducedMotion.matches ? 100000 : 900, colour: colour(hovered.src, 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
|
|
const post = key && state.points.get(key)
|
|
drawRings()
|
|
if (!post) {
|
|
state.globe.arcsData([])
|
|
fly(state.view, 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(Boolean)
|
|
const controls = state.globe.controls()
|
|
if (located.length === 0) {
|
|
state.view = null
|
|
state.spreadKm = 0
|
|
controls.autoRotate = !reducedMotion.matches
|
|
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.2, Math.max(1.1, 1.1 + state.spreadKm / 3500) * narrowness())
|
|
state.view = mean
|
|
if (!state.hovered)
|
|
fly(mean, 1200)
|
|
}
|
|
|
|
function fly(view, ms) {
|
|
if (!view || !state?.globe)
|
|
return
|
|
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.paused)
|
|
state.frames++
|
|
requestAnimationFrame(tick)
|
|
}
|
|
requestAnimationFrame(tick)
|
|
state.timers.push(setInterval(() => {
|
|
state.fps = state.paused ? 0 : state.frames
|
|
state.frames = 0
|
|
}, 1000))
|
|
}
|
|
|
|
function round(value, digits) {
|
|
return Number.isFinite(value) ? Number(value.toFixed(digits)) : null
|
|
}
|