The globe's nerd stats are instruments in the scene

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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 02:15:06 +02:00
1 parent e88f4afc45
commit e94e32d198
9 files changed
+1516 -181

No files matched your search

+58 -12
View File
@@ -16,6 +16,7 @@
// 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" }
@@ -49,6 +50,9 @@ export function create(element, scrollContainerId, reference, options) {
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 },
@@ -137,12 +141,18 @@ export function stats() {
countries: state.countries.length,
countryNames: state.countryNames,
borders: state.borders.length,
sun: sunStats()
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 overlay shows it: now, or the focused post's moment and what the Sun does at its place.
function sunStats() {
// 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
@@ -181,6 +191,7 @@ export function dispose() {
state.timers.forEach(timer => clearInterval(timer))
if (state.globe) {
state.globe.pauseAnimation()
state.instruments?.dispose()
state.globe._destructor?.()
state.element.replaceChildren()
}
@@ -198,6 +209,7 @@ function resize() {
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
@@ -263,6 +275,12 @@ async function build(width, height) {
.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()
@@ -277,7 +295,8 @@ async function build(width, height) {
wake(1500)
})
listen(state.element, "pointermove", () => wake(1000))
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * narrowness() }, 0)
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()))
@@ -288,10 +307,12 @@ async function build(width, height) {
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
@@ -384,6 +405,7 @@ function keepSharp() {
night.needsUpdate = true
state.sunUniforms.nightMap.value = night
state.sunUniforms.nightReady.value = 1
state.instruments?.setFacts({ night: true })
wake(500)
}
}
@@ -405,6 +427,8 @@ function aimSun(sun) {
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(() => { })
@@ -419,12 +443,14 @@ function stepSun() {
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)
}
@@ -472,6 +498,7 @@ async function loadCountries() {
})
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)
@@ -522,7 +549,8 @@ function placeLabels(force = false) {
const perDegree = pixelsPerDegree(camera.altitude)
const horizon = 1 / (1 + camera.altitude)
const view = unit(camera.lat, camera.lng)
const taken = []
const chips = state.instruments?.boxes() ?? []
const taken = [...chips]
for (const country of state.countries) {
const element = state.labelOf.get(country)
if (!element)
@@ -546,7 +574,8 @@ function placeLabels(force = false) {
}
element.style.visibility = shown ? "" : "hidden"
}
state.countryNames = taken.length
state.countryNames = taken.length - chips.length
state.instruments?.setFacts({ named: state.countryNames })
}
function unit(lat, lng) {
@@ -688,6 +717,7 @@ function applyTheme() {
return
state.colours = null
state.globe.atmosphereColor(colour("server", 1))
state.instruments?.setColours()
drawPoints()
wake(2000)
}
@@ -707,7 +737,12 @@ function colour(kind, alpha = 0.95) {
place: read("--color-accent"),
country: read("--color-secondary"),
nearyou: read("--color-success"),
self: read("--color-base-content")
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
@@ -765,7 +800,11 @@ function drawPoints() {
if (!state?.globe)
return
state.groups = cdnGroups()
state.globe.pointsData(aggregate())
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()
}
@@ -814,12 +853,15 @@ function hover(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)
state.globe.arcsData(group && state.self ? [{ startLat: state.self.lat, startLng: state.self.lng, endLat: group.lat, endLng: group.lng }] : [])
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)
@@ -828,9 +870,9 @@ function hover(key) {
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 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)
@@ -851,6 +893,7 @@ function updateView() {
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
@@ -870,6 +913,7 @@ function updateView() {
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)
}
@@ -900,6 +944,7 @@ function readRenderer() {
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
}
@@ -923,6 +968,7 @@ function countFrames() {
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))
}