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 17:46:44 +02:00
// before the CDN, when PrivaPub saw it there. Day texture with the light theme, night with the dark one; motion follows prefers-reduced-motion. It
// renders only while something moves (a camera flight, 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
2026-10-04 11:03:14 +02:00
// 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 ,
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 ,
texture : state . texture ,
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 ,
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" )
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 11:44:51 +02:00
. htmlLat ( g => g . lat )
. htmlLng ( g => g . lng )
. htmlAltitude ( 0.12 )
. htmlElement ( cdnLabel )
2026-10-04 17:46:44 +02:00
sharpen ( globe )
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 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.
function sharpen ( globe ) {
const material = globe . globeMaterial ()
material . onBeforeCompile = shader => {
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>\nvarying vec3 vSpherePoint;" )
. replace ( "#include <map_fragment>" , `
#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;
#endif` )
}
material . customProgramCacheKey = () => "privapub-per-pixel-sphere"
material . needsUpdate = true
state . anisotropy = Math . min ( 8 , globe . renderer (). capabilities . getMaxAnisotropy ())
}
// Every texture the globe loads (a theme change loads the other one) wraps around horizontally, so filtering at the
// 180° meridian blends the map's two edges instead of clamping each, and gets 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 && ( map . anisotropy !== state . anisotropy || map . wrapS !== RepeatWrapping )) {
map . anisotropy = state . anisotropy
map . wrapS = RepeatWrapping
map . needsUpdate = true
wake ( 500 )
}
}
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 ))
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
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 ()
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 11:44:51 +02:00
state . globe . htmlElementsData ( state . groups )
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 ) {
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 )
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 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
}