2026-10-04 11:03:14 +02:00
|
|
|
|
// 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
|
2026-10-04 11:44:51 +02:00
|
|
|
|
// 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
|
2026-10-04 20:23:42 +02:00
|
|
|
|
// 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"
|
2026-10-04 11:03:14 +02:00
|
|
|
|
|
|
|
|
|
|
const vendor = new URL("../vendor/globe/", import.meta.url).href
|
|
|
|
|
|
const textures = { day: vendor + "earth-blue-marble.jpg", night: vendor + "earth-night.jpg" }
|
2026-10-04 20:23:42 +02:00
|
|
|
|
const countriesUrl = vendor + "countries.json"
|
2026-10-04 11:03:14 +02:00
|
|
|
|
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
|
2026-10-04 20:23:42 +02:00
|
|
|
|
// scroll container. reference is the .NET panel (SunChanged); options.language names the countries.
|
|
|
|
|
|
export function create(element, scrollContainerId, reference, options) {
|
2026-10-04 11:03:14 +02:00
|
|
|
|
dispose()
|
2026-10-04 20:23:42 +02:00
|
|
|
|
const now = sunAt(new Date())
|
2026-10-04 11:03:14 +02:00
|
|
|
|
state = {
|
|
|
|
|
|
element,
|
2026-10-04 20:23:42 +02:00
|
|
|
|
reference,
|
|
|
|
|
|
names: regionNames(options?.language),
|
2026-10-04 11:03:14 +02:00
|
|
|
|
scroller: document.getElementById(scrollContainerId),
|
2026-10-04 20:23:42 +02:00
|
|
|
|
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,
|
2026-10-04 11:03:14 +02:00
|
|
|
|
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,
|
2026-10-04 17:46:44 +02:00
|
|
|
|
idle: false,
|
|
|
|
|
|
running: true,
|
|
|
|
|
|
interacting: false,
|
|
|
|
|
|
awakeUntil: 0,
|
2026-10-04 11:03:14 +02:00
|
|
|
|
observers: [],
|
|
|
|
|
|
listeners: [],
|
|
|
|
|
|
timers: [],
|
|
|
|
|
|
error: null
|
|
|
|
|
|
}
|
|
|
|
|
|
const sizeObserver = new ResizeObserver(() => resize())
|
|
|
|
|
|
sizeObserver.observe(element)
|
|
|
|
|
|
state.observers.push(sizeObserver)
|
|
|
|
|
|
resize()
|
|
|
|
|
|
watchCards()
|
|
|
|
|
|
watchTheme()
|
|
|
|
|
|
watchVisibility()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 11:44:51 +02:00
|
|
|
|
// 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.
|
2026-10-04 11:03:14 +02:00
|
|
|
|
export function setPosts(posts, self) {
|
|
|
|
|
|
if (!state)
|
|
|
|
|
|
return
|
2026-10-04 11:44:51 +02:00
|
|
|
|
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]))
|
2026-10-04 11:03:14 +02:00
|
|
|
|
state.self = self && Number.isFinite(self.lat) && Number.isFinite(self.lng) ? self : null
|
|
|
|
|
|
drawPoints()
|
|
|
|
|
|
scheduleView()
|
2026-10-04 17:46:44 +02:00
|
|
|
|
wake()
|
2026-10-04 11:03:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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()
|
2026-10-04 11:44:51 +02:00
|
|
|
|
const visibleLocated = [...state.visible].filter(key => placed(state.points.get(key))).length
|
2026-10-04 11:03:14 +02:00
|
|
|
|
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,
|
2026-10-04 11:44:51 +02:00
|
|
|
|
cdnGroups: cdnGroups().length,
|
2026-10-04 11:03:14 +02:00
|
|
|
|
fps: state.fps,
|
|
|
|
|
|
renderer: state.renderer,
|
2026-10-04 20:23:42 +02:00
|
|
|
|
texture: state.sunUniforms.nightReady.value ? "day+night" : "day",
|
2026-10-04 17:46:44 +02:00
|
|
|
|
anisotropy: state.anisotropy,
|
2026-10-04 11:03:14 +02:00
|
|
|
|
autoRotate: !!state.globe.controls().autoRotate,
|
|
|
|
|
|
paused: state.paused,
|
2026-10-04 17:46:44 +02:00
|
|
|
|
idle: state.idle,
|
2026-10-04 11:03:14 +02:00
|
|
|
|
hovered: state.hovered && state.points.has(state.hovered) ? state.points.get(state.hovered).label ?? state.hovered : null,
|
|
|
|
|
|
arcs: state.globe.arcsData().length,
|
2026-10-04 20:23:42 +02:00
|
|
|
|
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
|
2026-10-04 11:03:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
2026-10-04 17:46:44 +02:00
|
|
|
|
wake()
|
2026-10-04 11:03:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-10-04 20:23:42 +02:00
|
|
|
|
.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)
|
2026-10-04 17:46:44 +02:00
|
|
|
|
sharpen(globe)
|
2026-10-04 20:23:42 +02:00
|
|
|
|
loadNight()
|
|
|
|
|
|
loadCountries()
|
2026-10-04 11:03:14 +02:00
|
|
|
|
const controls = globe.controls()
|
|
|
|
|
|
controls.autoRotateSpeed = 0.35
|
|
|
|
|
|
controls.enableDamping = true
|
2026-10-04 17:46:44 +02:00
|
|
|
|
controls.addEventListener("start", () => {
|
|
|
|
|
|
state.interacting = true
|
|
|
|
|
|
wake()
|
|
|
|
|
|
})
|
|
|
|
|
|
controls.addEventListener("end", () => {
|
|
|
|
|
|
state.interacting = false
|
|
|
|
|
|
wake(1500)
|
|
|
|
|
|
})
|
|
|
|
|
|
listen(state.element, "pointermove", () => wake(1000))
|
2026-10-04 11:44:51 +02:00
|
|
|
|
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * narrowness() }, 0)
|
2026-10-04 20:23:42 +02:00
|
|
|
|
state.timers.push(setInterval(() => {
|
|
|
|
|
|
if (!state.focus)
|
|
|
|
|
|
aimSun(sunAt(new Date()))
|
|
|
|
|
|
}, 30000))
|
2026-10-04 11:03:14 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 17:46:44 +02:00
|
|
|
|
// 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.
|
2026-10-04 20:23:42 +02:00
|
|
|
|
// 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.
|
2026-10-04 17:46:44 +02:00
|
|
|
|
function sharpen(globe) {
|
|
|
|
|
|
const material = globe.globeMaterial()
|
|
|
|
|
|
material.onBeforeCompile = shader => {
|
2026-10-04 20:23:42 +02:00
|
|
|
|
Object.assign(shader.uniforms, state.sunUniforms)
|
2026-10-04 17:46:44 +02:00
|
|
|
|
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
|
2026-10-04 20:23:42 +02:00
|
|
|
|
.replace("#include <common>", `#include <common>
|
|
|
|
|
|
varying vec3 vSpherePoint;
|
|
|
|
|
|
uniform vec3 sunDirection;
|
|
|
|
|
|
uniform float sunIntensity;
|
|
|
|
|
|
uniform sampler2D nightMap;
|
|
|
|
|
|
uniform float nightReady;`)
|
|
|
|
|
|
.replace("#include <opaque_fragment>", `
|
|
|
|
|
|
#ifdef USE_MAP
|
|
|
|
|
|
gl_FragColor = vec4(earthColor, diffuseColor.a);
|
|
|
|
|
|
#else
|
|
|
|
|
|
#include <opaque_fragment>
|
|
|
|
|
|
#endif`)
|
2026-10-04 17:46:44 +02:00
|
|
|
|
.replace("#include <map_fragment>", `
|
2026-10-04 20:23:42 +02:00
|
|
|
|
vec3 earthColor = vec3(0.0);
|
2026-10-04 17:46:44 +02:00
|
|
|
|
#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;
|
2026-10-04 20:23:42 +02:00
|
|
|
|
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;
|
2026-10-04 17:46:44 +02:00
|
|
|
|
#endif`)
|
|
|
|
|
|
}
|
2026-10-04 20:23:42 +02:00
|
|
|
|
material.customProgramCacheKey = () => "privapub-sunlit-sphere"
|
2026-10-04 17:46:44 +02:00
|
|
|
|
material.needsUpdate = true
|
|
|
|
|
|
state.anisotropy = Math.min(8, globe.renderer().capabilities.getMaxAnisotropy())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 20:23:42 +02:00
|
|
|
|
// 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.
|
2026-10-04 17:46:44 +02:00
|
|
|
|
const RepeatWrapping = 1000//three.js's RepeatWrapping: the bundle does not expose THREE
|
|
|
|
|
|
function keepSharp() {
|
|
|
|
|
|
const map = state?.globe?.globeMaterial().map
|
2026-10-04 20:23:42 +02:00
|
|
|
|
if (!map)
|
|
|
|
|
|
return
|
|
|
|
|
|
if (map.anisotropy !== state.anisotropy || map.wrapS !== RepeatWrapping) {
|
2026-10-04 17:46:44 +02:00
|
|
|
|
map.anisotropy = state.anisotropy
|
|
|
|
|
|
map.wrapS = RepeatWrapping
|
|
|
|
|
|
map.needsUpdate = true
|
|
|
|
|
|
wake(500)
|
|
|
|
|
|
}
|
2026-10-04 20:23:42 +02:00
|
|
|
|
// 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(φ)]
|
2026-10-04 17:46:44 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 11:03:14 +02:00
|
|
|
|
// 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))
|
2026-10-04 20:23:42 +02:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
2026-10-04 11:03:14 +02:00
|
|
|
|
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
|
2026-10-04 17:46:44 +02:00
|
|
|
|
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)
|
2026-10-04 11:03:14 +02:00
|
|
|
|
state.globe.resumeAnimation()
|
2026-10-04 17:46:44 +02:00
|
|
|
|
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()
|
2026-10-04 11:03:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyTheme() {
|
|
|
|
|
|
if (!state?.globe)
|
|
|
|
|
|
return
|
|
|
|
|
|
state.colours = null
|
|
|
|
|
|
state.globe.atmosphereColor(colour("server", 1))
|
|
|
|
|
|
drawPoints()
|
2026-10-04 17:46:44 +02:00
|
|
|
|
wake(2000)
|
2026-10-04 11:03:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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})`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 11:44:51 +02:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 11:03:14 +02:00
|
|
|
|
// 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()) {
|
2026-10-04 11:44:51 +02:00
|
|
|
|
if (!placed(post))
|
|
|
|
|
|
continue
|
2026-10-04 11:03:14 +02:00
|
|
|
|
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
|
2026-10-04 11:44:51 +02:00
|
|
|
|
state.groups = cdnGroups()
|
2026-10-04 11:03:14 +02:00
|
|
|
|
state.globe.pointsData(aggregate())
|
2026-10-04 20:23:42 +02:00
|
|
|
|
state.globe.htmlElementsData([...state.groups.map(g => ({ kind: "cdn", ...g })), ...state.countries])
|
2026-10-04 11:03:14 +02:00
|
|
|
|
drawRings()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 11:44:51 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 11:03:14 +02:00
|
|
|
|
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)
|
2026-10-04 11:44:51 +02:00
|
|
|
|
if (placed(hovered))
|
2026-10-04 11:03:14 +02:00
|
|
|
|
rings.push({ lat: hovered.lat, lng: hovered.lng, radius: 4, speed: 3, period: reducedMotion.matches ? 100000 : 900, colour: colour(hovered.src, 1) })
|
2026-10-04 11:44:51 +02:00
|
|
|
|
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) })
|
2026-10-04 11:03:14 +02:00
|
|
|
|
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) {
|
2026-10-04 20:23:42 +02:00
|
|
|
|
key ??= state?.pinned
|
2026-10-04 11:03:14 +02:00
|
|
|
|
if (!state?.globe || state.hovered === key)
|
|
|
|
|
|
return
|
|
|
|
|
|
state.hovered = key
|
2026-10-04 17:46:44 +02:00
|
|
|
|
wake(1300)
|
2026-10-04 11:03:14 +02:00
|
|
|
|
const post = key && state.points.get(key)
|
2026-10-04 20:23:42 +02:00
|
|
|
|
focusOn(key)
|
2026-10-04 11:03:14 +02:00
|
|
|
|
drawRings()
|
|
|
|
|
|
if (!post) {
|
|
|
|
|
|
state.globe.arcsData([])
|
|
|
|
|
|
fly(state.view, 900)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-10-04 11:44:51 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-10-04 11:03:14 +02:00
|
|
|
|
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
|
2026-10-04 11:44:51 +02:00
|
|
|
|
const located = [...state.visible].map(key => state.points.get(key)).filter(placed)
|
2026-10-04 11:03:14 +02:00
|
|
|
|
const controls = state.globe.controls()
|
|
|
|
|
|
if (located.length === 0) {
|
|
|
|
|
|
state.view = null
|
|
|
|
|
|
state.spreadKm = 0
|
|
|
|
|
|
controls.autoRotate = !reducedMotion.matches
|
2026-10-04 17:46:44 +02:00
|
|
|
|
wake()
|
2026-10-04 11:03:14 +02:00
|
|
|
|
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)))
|
2026-10-04 11:44:51 +02:00
|
|
|
|
mean.altitude = Math.min(3.6, (1.5 + state.spreadKm / 3500) * narrowness())
|
2026-10-04 11:03:14 +02:00
|
|
|
|
state.view = mean
|
|
|
|
|
|
if (!state.hovered)
|
|
|
|
|
|
fly(mean, 1200)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fly(view, ms) {
|
|
|
|
|
|
if (!view || !state?.globe)
|
|
|
|
|
|
return
|
2026-10-04 17:46:44 +02:00
|
|
|
|
wake((reducedMotion.matches ? 0 : ms) + 400)
|
2026-10-04 11:03:14 +02:00
|
|
|
|
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
|
2026-10-04 17:46:44 +02:00
|
|
|
|
if (state.running)
|
2026-10-04 11:03:14 +02:00
|
|
|
|
state.frames++
|
2026-10-04 17:46:44 +02:00
|
|
|
|
keepSharp()
|
2026-10-04 20:23:42 +02:00
|
|
|
|
stepSun()
|
|
|
|
|
|
placeLabels()
|
2026-10-04 11:03:14 +02:00
|
|
|
|
requestAnimationFrame(tick)
|
|
|
|
|
|
}
|
|
|
|
|
|
requestAnimationFrame(tick)
|
|
|
|
|
|
state.timers.push(setInterval(() => {
|
2026-10-04 17:46:44 +02:00
|
|
|
|
state.fps = state.running ? state.frames : 0
|
2026-10-04 11:03:14 +02:00
|
|
|
|
state.frames = 0
|
2026-10-04 17:46:44 +02:00
|
|
|
|
settle()
|
2026-10-04 11:03:14 +02:00
|
|
|
|
}, 1000))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function round(value, digits) {
|
|
|
|
|
|
return Number.isFinite(value) ? Number(value.toFixed(digits)) : null
|
|
|
|
|
|
}
|