Owner decision 2026-10-06: the globe tells its numbers where they belong instead of in a table. instruments.js adds one three.js group to globe.gl's scene and a DOM overlay of chips: - the Sun: a ray and a bead over the subsolar point (with the irradiance), the day/night line, the golden hour and the three twilights, the declination, the noon and midnight meridian, and the analemma at this hour, its stretch to the next equinox or solstice brighter; - a focused post's place: its horizon, its Sun's line and height, and its day lit on its latitude, with one chip for the place, the phase, the solar time, the elevation and the day's length; - the tropics and polar circles (the axial tilt), the CDN ring, the view's centre and spread, and rings around the posts in view; - a small orrery in the top left corner, re-anchored to the camera before each render, with the Earth–Sun distance, the seasons, the next equinox or solstice and the next perihelion or aphelion stacked under it; - the frame rate as a ring around the globe; the rest (points, textures, countries, renderer) as a legend. The chips are placed after each frame globe.gl draws, clear of each other, the moment's caption, the orrery and the legend, and slide back inside the canvas at its edge; country names keep clear of them. Geometry changes only inside a wake globe.js already made, so an idle globe still draws nothing. three.js's classes come from globe.gl's own objects, looked for before each render until its globe is in the scene. A phone's banner draws the day/night line and the subsolar point; full screen draws everything, and a globe built in the banner now moves its camera back when opened full screen. GlobePanel loses both tooltips and the one-second poll that read every post into a table; the Sun's rows stay as screen-reader text, and a failed start says so. Seven keys only the tooltips used are gone. GlobeTests (desktop with a located post written from Milano and pinned, in both themes and turned to the subsolar point; a phone's banner, full screen and back; an idle globe drawing no frame) pass. Under SwiftShader the instruments cost 1–3 ms of a ~50 ms frame and under 0.1 ms of script; FeedPerfTests' heaps are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
978 lines
37 KiB
JavaScript
978 lines
37 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. 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"
|
||
import { createInstruments } from "./instruments.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)")
|
||
// a touch screen: a tap on a card opens its thread there (main.js), so cards are pinned with a mouse only
|
||
const canHover = matchMedia("(hover: hover)")
|
||
|
||
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),
|
||
language: options?.language,
|
||
labels: options?.labels ?? {},
|
||
instruments: null,
|
||
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(),
|
||
// for the end-to-end tests: what the instruments drew, and the frames rendered so far (still while idle)
|
||
instruments: state.instruments?.summary() ?? null,
|
||
frame: state.globe.renderer().info.render.frame
|
||
}
|
||
}
|
||
|
||
// The Sun's moment as the panel's caption and its text for screen readers show it: now, or the focused post's moment
|
||
// and what the Sun does at its place; null until the globe exists.
|
||
export function sunStats() {
|
||
if (!state)
|
||
return null
|
||
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.instruments?.dispose()
|
||
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)
|
||
state.instruments?.setSize(width, height)
|
||
pause(document.visibilityState !== "visible")
|
||
wake()
|
||
// a new shape (the phone's banner opened full screen, or closed again) wants the camera at another height
|
||
const shape = narrowness()
|
||
if (Math.abs(shape - (state.shape ?? shape)) > 0.05) {
|
||
if (state.view)
|
||
scheduleView()
|
||
else
|
||
state.globe.pointOfView({ ...state.globe.pointOfView(), altitude: 2.6 * shape }, reducedMotion.matches ? 0 : 600)
|
||
}
|
||
state.shape = shape
|
||
}
|
||
|
||
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)
|
||
// the nerd stats, drawn in the scene (instruments.js)
|
||
state.instruments = createInstruments(globe, state.element, { labels: state.labels, language: state.language, colour })
|
||
state.instruments.setSize(width, height)
|
||
state.instruments.setFacts({ anisotropy: state.anisotropy })
|
||
state.instruments.setMoment(state.sun, state.focus)
|
||
state.instruments.setSun(state.sunPoint)
|
||
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))
|
||
state.shape = narrowness()
|
||
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * state.shape }, 0)
|
||
state.timers.push(setInterval(() => {
|
||
if (!state.focus)
|
||
aimSun(sunAt(new Date()))
|
||
}, 30000))
|
||
applyTheme()
|
||
readRenderer()
|
||
countFrames()
|
||
drawPoints()
|
||
scheduleView()
|
||
pause(document.visibilityState !== "visible")
|
||
state.reference?.invokeMethodAsync("SunChanged").catch(() => { })
|
||
} catch (e) {
|
||
state.error = e.message
|
||
console.error(e)
|
||
window.logFromJs?.(e.message, "globe.build")
|
||
state.reference?.invokeMethodAsync("GlobeFailed", e.message).catch(() => { })
|
||
} 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 <common>", "#include <common>\nvarying vec3 vSpherePoint;")
|
||
.replace("#include <begin_vertex>", "#include <begin_vertex>\nvSpherePoint = position;")
|
||
shader.fragmentShader = shader.fragmentShader
|
||
.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`)
|
||
.replace("#include <map_fragment>", `
|
||
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
|
||
state.instruments?.setFacts({ night: true })
|
||
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)
|
||
state.instruments?.setMoment(sun, state.focus)
|
||
state.instruments?.setSun(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)
|
||
state.instruments?.setSun(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)
|
||
state.instruments?.setSun(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)
|
||
state.instruments?.setFacts({ countries: state.countries.length, borders: state.borders.length })
|
||
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 chips = state.instruments?.boxes() ?? []
|
||
const taken = [...chips]
|
||
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 - chips.length
|
||
state.instruments?.setFacts({ named: state.countryNames })
|
||
}
|
||
|
||
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. With a mouse only: on a touch screen the tap opens the post's thread
|
||
listen(scroller, "click", event => {
|
||
const card = event.target.closest?.("[data-post-id]")
|
||
if (!canHover.matches || !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))
|
||
state.instruments?.setColours()
|
||
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"),
|
||
// the instruments': the Sun, the twilights, the orrery's lines and the Earth's night side
|
||
sun: read("--color-warning"),
|
||
twilight: read("--color-info"),
|
||
content: read("--color-base-content"),
|
||
night: read("--color-neutral")
|
||
}
|
||
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()
|
||
const points = aggregate()
|
||
state.globe.pointsData(points)
|
||
state.instruments?.setPoints({
|
||
points: points.length, posts: state.points.size, servers: new Set([...state.points.values()].map(p => p.host)).size, cdnGroups: state.groups.length
|
||
})
|
||
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("<br>")
|
||
const more = point.hosts.size > 4 ? `<br>+${point.hosts.size - 4}` : ""
|
||
return `<div class="globe-label"><b>${escape(point.label || "")}</b><br>${hosts}${more}` +
|
||
`<br><span>${point.count} · ${escape(point.src)} · ${point.lat.toFixed(2)}, ${point.lng.toFixed(2)}</span></div>`
|
||
}
|
||
|
||
// The pulse under the hovered post, and the reach of located posts around the reader.
|
||
function drawRings() {
|
||
if (!state?.globe)
|
||
return
|
||
const rings = []
|
||
const hovered = state.hovered && state.points.get(state.hovered)
|
||
if (placed(hovered))
|
||
rings.push({ lat: hovered.lat, lng: hovered.lng, radius: 4, speed: 3, period: reducedMotion.matches ? 100000 : 900, colour: colour(hovered.src, 1) })
|
||
else if (hovered && Number.isFinite(hovered.beforeLat))
|
||
rings.push({ lat: hovered.beforeLat, lng: hovered.beforeLng, radius: 3, speed: 1.5, period: reducedMotion.matches ? 100000 : 1400, colour: colour("country", 1) })
|
||
const reach = Math.max(0, ...[...state.points.values()].filter(p => p.src === "nearyou").map(p => p.range ?? 0))
|
||
const reader = [...state.points.values()].find(p => p.src === "nearyou")
|
||
if (reader && reach > 0)
|
||
rings.push({ lat: reader.lat, lng: reader.lng, radius: reach / 111, speed: reach / 111 / 2, period: reducedMotion.matches ? 100000 : 2400, colour: colour("nearyou", 0.8) })
|
||
state.globe.ringsData(rings)
|
||
}
|
||
|
||
function hover(key) {
|
||
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([])
|
||
state.instruments?.setArc(null)
|
||
fly(state.view, 900)
|
||
return
|
||
}
|
||
if (!placed(post)) {
|
||
const group = state.groups?.find(g => g.key === post.cdn)
|
||
const toCdn = group && state.self ? { startLat: state.self.lat, startLng: state.self.lng, endLat: group.lat, endLng: group.lng, label: group.name } : null
|
||
state.globe.arcsData(toCdn ? [toCdn] : [])
|
||
state.instruments?.setArc(toCdn)
|
||
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
|
||
const toPost = from && distance(from, post) > 30 ? { startLat: from.lat, startLng: from.lng, endLat: post.lat, endLng: post.lng, label: post.label } : null
|
||
state.globe.arcsData(toPost ? [toPost] : [])
|
||
state.instruments?.setArc(toPost)
|
||
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
|
||
state.instruments?.setView(null, 0, [], { located: 0, visible: state.visible.size })
|
||
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
|
||
state.instruments?.setView(mean, state.spreadKm, located, { located: located.length, visible: state.visible.size })
|
||
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)
|
||
state.instruments?.setFacts({ renderer: state.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()
|
||
state.instruments?.setFps(state.fps, { idle: state.idle, paused: state.paused, autoRotate: !!state.globe?.controls().autoRotate })
|
||
}, 1000))
|
||
}
|
||
|
||
function round(value, digits) {
|
||
return Number.isFinite(value) ? Number(value.toFixed(digits)) : null
|
||
}
|