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))
}
+967
View File
@@ -0,0 +1,967 @@
// The globe's nerd stats drawn where they belong (owner decision 2026-10-06: instruments in the scene, not a table): the
// Sun's ray to the point it stands over, the day/night line and the twilights, the declination and the noon meridian;
// a focused post's horizon, its Sun's height and its day; the tropics and the polar circles; the view's centre and
// reach and the posts in view; the Sun's track through the year; a small orrery with the Earth–Sun distance and the
// seasons; and the frame rate as a ring around the globe. Numbers are small chips beside their drawings.
//
// It costs nothing while the globe is idle: its geometry changes only where globe.js has already woken the globe (the
// Sun moving, the view flying, a hover, the theme, a resize), and the chips are placed after each frame globe.gl
// renders (scene.onAfterRender). three.js's classes come from objects globe.gl already made: the bundle does not expose
// THREE, and defining it would mix two copies. Where they can't be found, only the ring and the legend remain.
import { analemma, seasonsOf, sunAtPlace } from "./sun.js"
const RAD = Math.PI / 180
const R = 100//the globe's radius in scene units
const DoubleSide = 2//three.js's DoubleSide: the bundle does not expose THREE
const BANNER = 320//a canvas less tall than this is a phone's banner: the day/night line and the subsolar point only
const PERIHELION = 103//the Earth's heliocentric longitude at perihelion (the Sun's ≈283°; it drifts 1.7° a century)
const ECCENTRICITY = 0.117//the orbit's 0.0167, seven times: drawn true it would look like a circle
export function createInstruments(globe, host, options) {
const labels = options?.labels ?? {}
const language = options?.language || undefined
const colour = options?.colour
const text = (key, ...args) => (labels[key] ?? key).replace(/\{(\d)\}/g, (_, i) => args[+i] ?? "")
const decimals = digits => new Intl.NumberFormat(language, { minimumFractionDigits: digits, maximumFractionDigits: digits })
const fixed2 = decimals(2), fixed1 = decimals(1), whole = decimals(0), au = decimals(5)
const coordinates = (lat, lng) => `${fixed2.format(lat)}, ${fixed2.format(lng)}`
const overlay = document.createElement("div")
overlay.className = "globe-instruments"
overlay.setAttribute("aria-hidden", "true")
host.appendChild(overlay)
const ring = ringElement(overlay)
const legend = document.createElement("div")
legend.className = "globe-legend"
overlay.appendChild(legend)
const description = host.parentElement?.querySelector("[data-globe-view-description]") ?? null
let size = { width: host.clientWidth, height: host.clientHeight }
let full = size.height >= BANNER
let failed = false//the chips threw once: they stay where they are
let tries = 0//renders that looked for three.js's classes; globe.gl adds its globe on its first update, after this
let frames = 0
let cameraText = ""
let cameraAt = 0
const facts = { points: 0, posts: 0, servers: 0, cdnGroups: 0, countries: 0, named: 0, borders: 0, night: false, anisotropy: 0, renderer: null }
const fps = { value: 0, idle: false, paused: false, autoRotate: false }
const chips = []
let placed = []
// what is drawn, and what it says: built on the first render that finds the globe (see attach)
const scene = globe.scene()
let three = null, root = null, parts = null, tags = null, missing = null
const previous = scene.onAfterRender
scene.onAfterRender = (...args) => {
previous?.(...args)
frame()
}
const before = scene.onBeforeRender
scene.onBeforeRender = (...args) => {
before?.(...args)
if (!parts && tries >= 0)
attach()
placeOrrery()
}
let sun = null, point = null, focus = null, view = null, spreadKm = 0, arc = null, track = [], lastView = null, orreryBox = null
let legendBox = null
const Vector3 = globe.camera().position.constructor
const temp = new Vector3()
const temp2 = new Vector3()
function buildParts() {
const lines = alpha => new three.LineBasicMaterial({ transparent: true, opacity: alpha, depthWrite: false })
const fills = (alpha, side = 0) => new three.MeshBasicMaterial({ transparent: true, opacity: alpha, depthWrite: false, side })
const hud = material => {
material.depthTest = false
return material
}
const part = {}
// the Sun: built around +Z, pointed at the subsolar point
part.sunRig = group("YXZ")
part.marker = segments(dashed(circle(1.5, R * 1.003, 48), false), lines(0.95), "sun")
part.dot = mesh(sphere(0.9, 12, 8), fills(1), "sun")
part.dot.position.set(0, 0, R * 1.004)
part.ray = polyline([v(0, 0, R * 1.004), v(0, 0, R * 1.45)], lines(0.9), "sun")
part.beam = mesh(beam(0.6, 2.6, R, R * 1.45, 10), fills(0.16, DoubleSide), "sun")
part.bead = mesh(sphere(2.2, 16, 12), fills(0.95), "sun")
part.bead.position.set(0, 0, R * 1.45)
part.terminator = mesh(band(89.825, 90.175, R * 1.0025, 180), fills(0.55, DoubleSide), "white")
part.twilight = [84, 96, 102, 108].map(angle => segments(dashed(circle(angle, R * 1.002, 180), true), lines(0.6), "twilight"))
part.meridian = segments(dashed(greatCircleX(R * 1.002, 180), true), lines(0.45), "sun")
part.sunRig.add(part.marker, part.dot, part.ray, part.beam, part.bead, part.terminator, ...part.twilight, part.meridian)
// the declination: the subsolar meridian from the equator to the Sun's latitude
part.meridianRig = group("XYZ")
part.declination = polyline(arcPoints(0, 0, 24), lines(0.9), "sun")
part.meridianRig.add(part.declination)
// the Earth's own lines: tropics, polar circles, the CDN ring at 68°N
part.earthRig = group("XYZ")
part.tropics = [23.44, -23.44, 66.56, -66.56].map(lat => segments(dashed(latitudeCircle(lat, R * 1.0015, 240), true), lines(0.22), "white"))
part.cdnRing = segments(dashed(latitudeCircle(68, R * 1.0015, 240), true), lines(0.55), "server")
part.earthRig.add(...part.tropics, part.cdnRing)
// the Sun's year: where it stands over at this hour each day, the stretch to the next equinox or solstice brighter
part.year = polyline(Array.from({ length: 366 }, () => v(0, 0, R)), lines(0.3), "sun")
part.yearNext = polyline(Array.from({ length: 366 }, () => v(0, 0, R)), lines(0.85), "sun")
part.yearRig = group("XYZ")
part.yearRig.add(part.year, part.yearNext)
// a focused post's place: built around +Z, pointed at it; its Sun line, elevation and day in the scene's frame
part.placeRig = group("YXZ")
part.horizon = segments(dashed(circle(2, R * 1.002, 48), false), lines(0.9), "place")
part.pole = polyline([v(0, 0, R * 1.001), v(0, 0, R * 1.07)], lines(0.9), "place")
part.placeRig.add(part.horizon, part.pole)
part.placeWorld = group("XYZ")
part.sunLine = polyline([v(0, 0, 0), v(0, 0, 1)], lines(0.95), "sun")
part.sunLineBelow = segments(dashed(Array.from({ length: 13 }, () => v(0, 0, 0)), false), lines(0.6), "sun")
part.elevation = polyline(Array.from({ length: 17 }, () => v(0, 0, 0)), lines(0.9), "place")
part.dayLit = polyline(Array.from({ length: 121 }, () => v(0, 0, 0)), lines(0.95), "sun")
part.dayDark = segments(Array.from({ length: 240 }, () => v(0, 0, 0)), lines(0.4), "twilight")
part.placeWorld.add(part.sunLine, part.sunLineBelow, part.elevation, part.dayLit, part.dayDark)
// the view: its centre, its reach, and a ring around each placed post in view
part.viewRig = group("YXZ")
part.cross = segments([v(-2.2, 0, R * 1.002), v(2.2, 0, R * 1.002), v(0, -2.2, R * 1.002), v(0, 2.2, R * 1.002)], lines(0.9), "server")
part.spread = segments(dashed(circle(10, R * 1.002, 128), true), lines(0.65), "server")
part.viewRig.add(part.cross, part.spread)
part.inView = segments(Array.from({ length: 32 * 24 }, () => v(0, 0, 0)), lines(0.85), "place")
part.inView.geometry.setDrawRange(0, 0)
// the orrery: the Sun, the orbit (eccentricity drawn sevenfold), the Earth lit on its Sun's side with its axis
// fixed in space, ticks at the equinoxes and solstices; placed in the camera's corner before each frame
part.orrery = group("XYZ")
part.orrerySun = mesh(sphere(0.17, 16, 12), hud(fills(1)), "sun")
part.orbit = polyline(Array.from({ length: 145 }, (_, i) => orbitPoint(i * 2.5)), hud(lines(0.6)), "content")
part.ticks = segments([0, 90, 180, 270].flatMap(l => [orbitPoint(l, 0.93), orbitPoint(l, 1.07)]), hud(lines(0.8)), "content")
part.earth = group("XYZ")
part.earthDay = mesh(sphere(0.1, 16, 8, Math.PI / 2), hud(fills(1)), "server")
part.earthNight = mesh(sphere(0.1, 16, 8, Math.PI / 2), hud(fills(0.9)), "night")
part.earthNight.rotation.x = Math.PI
part.earth.add(part.earthDay, part.earthNight)
const tilt = 23.44 * RAD
part.axis = polyline([v(-Math.sin(tilt) * 0.2, 0, -Math.cos(tilt) * 0.2), v(Math.sin(tilt) * 0.2, 0, Math.cos(tilt) * 0.2)], hud(lines(0.9)), "content")
part.orrery.add(part.orrerySun, part.orbit, part.ticks, part.earth, part.axis)
part.orrery.traverse(o => o.renderOrder = 20)
part.orreryTilt = new (globe.camera().quaternion.constructor)().setFromAxisAngle(v(1, 0, 0), -1.05)
root.add(part.sunRig, part.meridianRig, part.earthRig, part.yearRig, part.placeRig, part.placeWorld, part.viewRig, part.inView, part.orrery)
return part
}
// the chips: what each says, where, and which goes first. Where is a point in the scene (hidden behind the globe), the
// same never hidden ("always"), or a point on screen whose chip starts there ("screen", the orrery's); a lifted chip
// sits that many pixels above its point, clear of what is drawn there.
const chip = (name, priority, anchor, mode = "scene", lift = 0) => {
const element = document.createElement("div")
element.className = `globe-tag globe-tag-${name}`
element.style.visibility = "hidden"
overlay.appendChild(element)
const made = { name, priority, anchor, mode, lift, element, text: "", width: 0, height: 0, x: NaN, y: NaN, shown: false }
chips.push(made)
return made
}
const say = (target, value) => {
value ??= ""
if (target.text === value)
return
target.text = value
target.element.textContent = value
target.width = target.element.offsetWidth
target.height = target.element.offsetHeight
}
const ringTag = chip("ring", 0, null)
function makeTags() {
const made = {
// the orrery's, first: nothing else is drawn in its corner
distance: chip("orrery", 1, () => belowOrrery(0), "screen"),
seasons: chip("orrery", 1, () => belowOrrery(1), "screen"),
next: chip("orrery", 2, () => belowOrrery(2), "screen"),
apsis: chip("orrery", 3, () => belowOrrery(3), "screen"),
scale: chip("orrery-faint", 3, () => orreryBox ? { x: orreryBox.right + 4, y: orreryBox.middle } : null, "screen"),
subsolar: chip("sun", 4, () => worldOf(parts.sunRig, 0, 0, R * 1.004), "scene", 12),
place: chip("place", 5, () => focus && Number.isFinite(focus.lat) ? coords(focus.lat, focus.lng, 0.002) : null, "scene", 16),
terminator: chip("white", 6, () => nearestOn(parts.sunRig, 90, R * 1.004)),
view: chip("server", 9, () => view ? worldOf(parts.viewRig, 0, -3.2, R * 1.004) : null),
spread: chip("server", 10, () => view && spreadKm > 0 ? nearestOn(parts.viewRig, Math.min(80, spreadKm / 6371 / RAD), R * 1.004) : null),
arc: chip("server", 11, () => arcTop()),
irradiance: chip("sun", 12, () => worldOf(parts.sunRig, 0, 0, R * 1.52)),
declination: chip("sun", 13, () => sun ? worldOf(parts.meridianRig, 0, R * Math.sin(sun.lat * RAD / 2), R * Math.cos(sun.lat * RAD / 2)) : null),
golden: chip("twilight", 14, () => nearestOn(parts.sunRig, 84, R * 1.004)),
civil: chip("twilight", 15, () => nearestOn(parts.sunRig, 96, R * 1.004)),
nautical: chip("twilight", 16, () => nearestOn(parts.sunRig, 102, R * 1.004)),
astronomical: chip("twilight", 17, () => nearestOn(parts.sunRig, 108, R * 1.004)),
noon: chip("sun", 18, () => worldOf(parts.sunRig, 0, R * Math.sin(25 * RAD), R * Math.cos(25 * RAD))),
midnight: chip("sun", 19, () => worldOf(parts.sunRig, 0, R * Math.sin(205 * RAD), R * Math.cos(205 * RAD))),
yearNext: chip("sun", 20, () => track.length > 0 ? coords(track.at(-1).lat, track.at(-1).lng, 0.02) : null),
year: chip("sun-faint", 21, () => yearTop()),
cancer: chip("white", 22, () => cameraLongitude(23.44)),
capricorn: chip("white", 23, () => cameraLongitude(-23.44)),
arctic: chip("white", 24, () => cameraLongitude(66.56)),
antarctic: chip("white", 25, () => cameraLongitude(-66.56)),
cdn: chip("server", 26, () => facts.cdnGroups > 0 ? cameraLongitude(68) : null)
}
chips.sort((a, b) => a.priority - b.priority)
say(made.terminator, text("day/night line"))
say(made.golden, text("golden hour"))
say(made.civil, text("civil twilight"))
say(made.nautical, text("nautical twilight"))
say(made.astronomical, text("astronomical twilight"))
say(made.noon, text("solar noon"))
say(made.midnight, text("solar midnight"))
say(made.year, text("this hour, all year"))
say(made.cancer, `${text("Tropic of Cancer")} · ${text("axial tilt")} ${fixed2.format(23.44)}°`)
say(made.capricorn, text("Tropic of Capricorn"))
say(made.arctic, `${text("Arctic Circle")} ${fixed2.format(66.56)}°`)
say(made.antarctic, text("Antarctic Circle"))
say(made.scale, text("not to scale"))
return made
}
// three.js's classes, once globe.gl has put its globe in the scene: the parts built and told what globe.js already
// said. Looked for before each render until found; after 120 renders without them only the ring and the legend remain.
function attach() {
const found = classesOf(globe)
if (typeof found === "string") {
missing = found
if (++tries < 120)
return
tries = -1
console.warn(`globe.instruments: ${missing}; only the ring and the legend remain`)
return
}
three = found
try {
root = new three.Group()
parts = buildParts()
scene.add(root)
tags = makeTags()
setColours()
refresh()
if (point)
setSun(point)
if (sun)
setMoment(sun, focus)
if (lastView)
setView(...lastView)
setArc(arc)
setPoints({})
} catch (e) {
tries = -1
parts = null
root?.removeFromParent()
console.error(e)
window.logFromJs?.(e.message, "globe.instruments.attach")
}
}
setSize(size.width, size.height)
// --- what globe.js tells it ---
function setSize(width, height) {
size = { width, height }
full = height >= BANNER
overlay.dataset.detail = full ? "full" : "banner"
ring.svg.style.display = full ? "" : "none"
legend.style.display = full ? "" : "none"
measureLegend()
refresh()
}
// what shows: in a banner the day/night line and the subsolar point only; the place and the view when there are some
function refresh() {
if (!parts)
return
for (const object of [parts.ray, parts.beam, parts.bead, ...parts.twilight, parts.meridian, parts.meridianRig, parts.earthRig, parts.yearRig,
parts.inView, parts.orrery])
object.visible = full
parts.placeRig.visible = parts.placeWorld.visible = full && !!focus && Number.isFinite(focus.lat)
parts.viewRig.visible = full && !!view
parts.cdnRing.visible = facts.cdnGroups > 0
}
// the subsolar point as it eases towards the Sun's (every frame while it travels)
function setSun(eased) {
point = eased
if (!parts)
return
parts.sunRig.rotation.set(-eased.lat * RAD, eased.lng * RAD, 0, "YXZ")
parts.meridianRig.rotation.set(0, eased.lng * RAD, 0)
rewrite(parts.declination, arcPoints(0, eased.lat, 24))
drawPlace()
}
// a new moment (now, or the focused post's) and the place it is focused on
function setMoment(moment, focused) {
sun = moment
focus = focused
if (!parts)
return
say(tags.subsolar, `${text("subsolar point")} ${coordinates(moment.lat, moment.lng)}`)
const deviation = (moment.irradiance - 1) * 100
say(tags.irradiance, `${text("irradiance")} ${whole.format(moment.irradiance * 1361)} W/m² (${deviation >= 0 ? "+" : "−"}${fixed1.format(Math.abs(deviation))}%)`)
say(tags.declination, `${text("declination")} ${moment.lat >= 0 ? "+" : "−"}${fixed2.format(Math.abs(moment.lat))}°`)
// the year from this moment, at this hour
const seasons = seasonsOf(moment)
track = analemma(moment.date)
rewrite(parts.year, track.map(p => toVector(coords(p.lat, p.lng, 0.0025))))
// the nearest and farthest the Earth comes in the year ahead, to the day: whichever is first
let nearest = 0, farthest = 0
track.forEach((p, day) => {
if (p.distance < track[nearest].distance)
nearest = day
if (p.distance > track[farthest].distance)
farthest = day
})
say(tags.apsis, nearest <= farthest
? text("{0} in {1} days", text("perihelion"), whole.format(nearest))
: text("{0} in {1} days", text("aphelion"), whole.format(farthest)))
const days = Math.max(1, Math.min(365, seasons.nextEventDays))
track = track.slice(0, days + 1)
rewrite(parts.yearNext, track.map(p => toVector(coords(p.lat, p.lng, 0.0025))))
parts.yearNext.geometry.setDrawRange(0, track.length)
say(tags.yearNext, eventName(seasons.nextEvent))
// the orrery: the Earth where it is on its orbit (the Sun's ecliptic longitude, seen from the other side)
const earthAt = orbitXYZ(moment.eclipticLongitude + 180, 1)
parts.earth.position.set(...earthAt)
parts.axis.position.set(...earthAt)
say(tags.distance, `${au.format(moment.distance)} AU · ${whole.format(moment.distance * 149597870.7)} km`)
say(tags.seasons, text("{0} in the north, {1} in the south", seasonName(seasons.north), seasonName(seasons.south)))
say(tags.next, text("{0} in {1} days", eventName(seasons.nextEvent), whole.format(seasons.nextEventDays)))
const place = focus && Number.isFinite(focus.lat) ? sunAtPlace(moment, focus.lat, focus.lng) : null
refresh()
if (place) {
parts.placeRig.rotation.set(-focus.lat * RAD, focus.lng * RAD, 0, "YXZ")
const phase = place.phase === "day" ? text("daylight") : place.phase === "night" ? text("night")
: text("{0}, {1}", text(`${place.phase === "golden" ? "golden hour" : `${place.phase} twilight`}`), text(place.morning ? "morning" : "evening"))
const day = place.dayLength >= 24 ? text("the Sun never sets") : place.dayLength <= 0 ? text("the Sun never rises")
: `${text("day length")} ${Math.floor(place.dayLength)} h ${String(Math.round(place.dayLength % 1 * 60)).padStart(2, "0")} min`
say(tags.place, [
[focus.label || coordinates(focus.lat, focus.lng), phase, text("solar time {0}", place.solarTime)].join(" · "),
`${text("elevation")} ${place.elevation >= 0 ? "" : "−"}${fixed1.format(Math.abs(place.elevation))}° · ${day}`
].join("\n"))
tags.place.element.dataset.phase = place.phase
} else
say(tags.place, "")
drawPlace()
}
// the view's centre and reach (null with no placed post in view), and the placed posts in view
function setView(centre, reachKm, inView, counts) {
lastView = [centre, reachKm, inView, counts]
view = centre
spreadKm = reachKm
if (description)
description.textContent = centre
? `${text("centre of view")} ${coordinates(centre.lat, centre.lng)}; ${text("spread")} ${whole.format(reachKm)} km; ${text("in view")} ${counts.located}/${counts.visible} ${text("placed")}`
: `${text("in view")} ${counts.located}/${counts.visible} ${text("placed")}`
if (!parts)
return
refresh()
if (centre) {
parts.viewRig.rotation.set(-centre.lat * RAD, centre.lng * RAD, 0, "YXZ")
rewrite(parts.spread, dashed(circle(Math.max(0.5, Math.min(80, reachKm / 6371 / RAD)), R * 1.002, 128), true))
say(tags.view, `${text("centre of view")} ${coordinates(centre.lat, centre.lng)} · ${text("in view")} ${counts.located}/${counts.visible}`)
say(tags.spread, `${text("spread")} ${whole.format(reachKm)} km`)
} else {
say(tags.view, "")
say(tags.spread, "")
}
// a small ring around each placed post in view
const points = parts.inView.geometry.attributes.position
let n = 0
for (const post of inView.slice(0, 32))
for (const [a, b] of ringSegments(post.lat, post.lng, 1, 12)) {
points.setXYZ(n++, a.x, a.y, a.z)
points.setXYZ(n++, b.x, b.y, b.z)
}
points.needsUpdate = true
parts.inView.geometry.setDrawRange(0, n)
}
// the hovered post's arc ({ startLat, startLng, endLat, endLng, label }), or none
function setArc(hovered) {
arc = hovered
if (!tags)
return
say(tags.arc, hovered ? `${hovered.label ? `${hovered.label} · ` : ""}${whole.format(greatCircleKm(hovered))} km` : "")
}
function setPoints(counts) {
Object.assign(facts, counts)
refresh()
if (tags)
say(tags.cdn, facts.cdnGroups > 0 ? `${text("CDN groups")} ${facts.cdnGroups}` : "")
drawLegend()
}
function setFacts(more) {
Object.assign(facts, more)
drawLegend()
}
// the frame rate, every second; the DOM only, so an idle globe stays idle
function setFps(value, state) {
Object.assign(fps, { value }, state)
const pace = fps.paused ? "paused" : fps.idle ? "idle" : value >= 50 ? "good" : value >= 30 ? "fair" : "poor"
ring.svg.dataset.pace = pace
ring.value.style.strokeDasharray = pace === "idle" || pace === "paused" ? `0 ${ring.length}` : `${ring.length * Math.min(1, value / 60)} ${ring.length}`
const rate = fps.paused ? text("paused") : fps.idle ? text("idle") : `${value} fps`
say(ringTag, [rate, fps.autoRotate ? text("auto-rotate") : null, cameraText].filter(Boolean).join(" · "))
}
function setColours() {
if (!parts || !colour)
return
const paint = kind => {
const rgb = colour(kind, 1).match(/[\d.]+/g)?.slice(0, 3).join(",") ?? "128,128,128"
return `rgb(${rgb})`
}
root.traverse(object => {
if (object.userData.paint && object.material?.color)
object.material.color.setStyle(object.userData.paint === "white" ? "rgb(255,255,255)" : paint(object.userData.paint))
})
}
// the boxes the chips took, which the country names keep clear of
function boxes() {
return placed
}
function summary() {
let error = null
if (parts && point) {
// as the rig stands now, not as the last frame drew it
parts.sunRig.updateMatrixWorld(true)
const at = worldOf(parts.sunRig, 0, 0, R)
const want = globe.getCoords(point.lat, point.lng, 0)
error = Math.hypot(at.x - want.x, at.y - want.y, at.z - want.z)
}
return { built: !!parts, missing: parts ? null : missing, tries, detail: full ? "full" : "banner", frames, chips: chips.filter(c => c.shown).length, subsolarError: error }
}
function dispose() {
tries = -1
root?.traverse(object => {
object.geometry?.dispose?.()
object.material?.dispose?.()
})
root?.removeFromParent()
scene.onAfterRender = () => { }
scene.onBeforeRender = () => { }
overlay.remove()
}
// --- after each frame globe.gl renders: the chips placed, and the ring around the globe ---
function frame() {
if (failed)
return
try {
frames++
const camera = globe.camera()
const now = performance.now()
if (now - cameraAt > 100) {
cameraAt = now
const pov = globe.pointOfView()
const next = `${text("camera")} ${coordinates(pov.lat, pov.lng)} @ ${fixed2.format(pov.altitude)}`
if (next !== cameraText) {
cameraText = next
setFps(fps.value, fps)
}
}
const taken = [[0, 0, size.width, 44]]
if (orreryBox)
taken.push(Object.assign([0, orreryBox.top - 4, orreryBox.right, orreryBox.bottom + 4], { orrery: true }))
if (legendBox)
taken.push(legendBox)
for (const target of chips) {
if (target === ringTag)
continue
const at = full && target.text && target.anchor ? target.anchor() : null
if (!at || (target.mode === "scene" && behindGlobe(at, camera.position))) {
hide(target)
continue
}
let x = 0, y = 0
if (target.mode === "screen") {
x = at.x + target.width / 2
y = at.y
} else {
temp2.copy(at).project(camera)
if (temp2.z > 1) {
hide(target)
continue
}
x = (temp2.x + 1) / 2 * size.width
y = (1 - temp2.y) / 2 * size.height - (target.lift ? target.lift + target.height / 2 : 0)
// a point on the canvas whose chip would run past its edge: the chip slides back inside
if (x >= 0 && x <= size.width)
x = Math.max(target.width / 2 + 3, Math.min(size.width - target.width / 2 - 3, x))
}
const box = [x - target.width / 2 - 2, y - target.height / 2 - 1, x + target.width / 2 + 2, y + target.height / 2 + 1]
// the orrery's own chips may sit on it
const own = target.name.startsWith("orrery")
if (box[0] < 0 || box[1] < 0 || box[2] > size.width || box[3] > size.height || taken.some(b => !(own && b.orrery) && overlap(b, box))) {
hide(target)
continue
}
taken.push(box)
show(target, x, y)
}
placeRing(camera, taken)
placed = taken
} catch (e) {
failed = true
console.error(e)
window.logFromJs?.(e.message, "globe.instruments.frame")
}
}
// the ring hugs the globe's outline on screen: its centre projected, its radius the globe's apparent one
function placeRing(camera, taken) {
if (!full)
return
temp2.set(0, 0, 0).project(camera)
const cx = (temp2.x + 1) / 2 * size.width, cy = (1 - temp2.y) / 2 * size.height
const distance = camera.position.length()
const half = Math.asin(Math.min(1, R / distance))
const radius = size.height / 2 * Math.tan(half) / Math.tan(camera.fov * RAD / 2) + 6
if (Math.abs(radius - ring.radius) > 0.5 || Math.abs(cx - ring.cx) > 0.5 || Math.abs(cy - ring.cy) > 0.5) {
ring.radius = radius
ring.cx = cx
ring.cy = cy
for (const circleElement of [ring.track, ring.value]) {
circleElement.setAttribute("cx", cx.toFixed(1))
circleElement.setAttribute("cy", cy.toFixed(1))
circleElement.setAttribute("r", radius.toFixed(1))
}
ring.value.setAttribute("transform", `rotate(-90 ${cx.toFixed(1)} ${cy.toFixed(1)})`)
ring.length = 2 * Math.PI * radius
setFps(fps.value, fps)
}
// its words at its foot, or above the globe's foot when the ring runs off the canvas
const y = Math.min(cy + radius + 4 + ringTag.height / 2, size.height - ringTag.height / 2 - 2)
const box = [cx - ringTag.width / 2, y - ringTag.height / 2, cx + ringTag.width / 2, y + ringTag.height / 2]
if (ringTag.text && !taken.some(b => overlap(b, box))) {
taken.push(box)
show(ringTag, cx, y)
} else
hide(ringTag)
}
// the orrery in the camera's top-left corner, tilted towards the reader, before each frame
// the orrery in the top left corner, below the moment's caption, sized in pixels: its orbit seen from 60° above the
// ecliptic is half as tall as it is wide; its chips stack under it
function placeOrrery() {
if (!parts || !parts.orrery.visible) {
orreryBox = null
return
}
const camera = globe.camera()
const depth = 30
const perPixel = 2 * depth * Math.tan(camera.fov * RAD / 2) / size.height
const radius = Math.max(52, Math.min(96, size.width * 0.15))
const cx = 12 + radius * 1.25, cy = 56 + radius * 0.6
orreryBox = { left: 12, top: cy - radius * 0.6, right: cx + radius * 1.25, bottom: cy + radius * 0.6, middle: cy }
parts.orrery.position.set((cx - size.width / 2) * perPixel, (size.height / 2 - cy) * perPixel, -depth).applyQuaternion(camera.quaternion).add(camera.position)
parts.orrery.quaternion.copy(camera.quaternion).multiply(parts.orreryTilt)
parts.orrery.scale.setScalar(radius * perPixel)
parts.orrery.updateMatrixWorld(true)
// the Earth's lit half faces the Sun
parts.orrerySun.getWorldPosition(temp)
parts.earth.lookAt(temp)
parts.earth.updateMatrixWorld(true)
}
// --- the focused place's Sun line, elevation and day, as the Sun eases ---
function drawPlace() {
if (!parts || !point || !focus || !Number.isFinite(focus.lat) || !sun)
return
const n = unitOf(focus.lat, focus.lng)
const s = unitOf(point.lat, point.lng)
const at = n.clone().multiplyScalar(R * 1.002)
const up = n.dot(s)
const elevation = Math.asin(Math.max(-1, Math.min(1, up))) / RAD
const tip = s.clone().multiplyScalar(9).add(at)
rewrite(parts.sunLine, [at, tip])
parts.sunLine.visible = elevation >= 0
parts.sunLineBelow.visible = elevation < 0
if (elevation < 0)
rewrite(parts.sunLineBelow, Array.from({ length: 13 }, (_, i) => s.clone().multiplyScalar(9 * i / 12).add(at)))
// the arc from the horizon to the Sun's direction, 6 units out
const flat = s.clone().addScaledVector(n, -up)
if (flat.lengthSq() < 1e-6)
flat.copy(perpendicular(n))
flat.normalize()
rewrite(parts.elevation, Array.from({ length: 17 }, (_, i) => {
const t = elevation * RAD * i / 16
return flat.clone().multiplyScalar(Math.cos(t) * 6).addScaledVector(n, Math.sin(t) * 6).add(at)
}))
// the day at the place's latitude: lit around the Sun's meridian for the day's length, dark dashes for the night
const place = sunAtPlace(sun, focus.lat, focus.lng)
const half = Math.max(0, Math.min(180, place.dayLength * 7.5))
rewrite(parts.dayLit, Array.from({ length: 121 }, (_, i) => toVector(coords(focus.lat, point.lng - half + 2 * half * i / 120, 0.003))))
parts.dayLit.visible = half > 0
const dark = []
const nightSpan = 360 - 2 * half
for (let i = 0; i < 120; i++) {
const a = point.lng + half + nightSpan * i / 120
const b = point.lng + half + nightSpan * (i + 0.5) / 120
dark.push(toVector(coords(focus.lat, a, 0.003)), toVector(coords(focus.lat, b, 0.003)))
}
rewrite(parts.dayDark, dark)
parts.dayDark.visible = nightSpan > 0
}
// --- anchors ---
function arcTop() {
if (!arc)
return null
const a = unitOf(arc.startLat, arc.startLng), b = unitOf(arc.endLat, arc.endLng)
const middle = a.clone().add(b)
if (middle.lengthSq() < 1e-6)
return null
middle.normalize()
const angle = Math.acos(Math.max(-1, Math.min(1, a.dot(b))))
return temp.copy(middle).multiplyScalar(R * (1 + angle / 2 * 0.4 * 1.1))
}
function yearTop() {
const positions = parts.year.geometry.attributes.position
let best = 0
for (let i = 1; i < positions.count; i++)
if (positions.getY(i) > positions.getY(best))
best = i
return temp.set(positions.getX(best), positions.getY(best) * 1.004, positions.getZ(best))
}
// the orrery's chips, one under another below it
function belowOrrery(slot) {
return orreryBox ? { x: orreryBox.left, y: orreryBox.bottom + 12 + slot * 20 } : null
}
function cameraLongitude(lat) {
return coords(lat, globe.pointOfView().lng, 0.004)
}
// the point of a circle (polar angle from the rig's +Z) nearest the camera
function nearestOn(rig, angle, radius) {
temp.copy(globe.camera().position)
rig.worldToLocal(temp)
const phi = Math.atan2(temp.y, temp.x)
return worldOf(rig, radius * Math.sin(angle * RAD) * Math.cos(phi), radius * Math.sin(angle * RAD) * Math.sin(phi), radius * Math.cos(angle * RAD))
}
function worldOf(object, x, y, z) {
return temp.set(x, y, z).applyMatrix4(object.matrixWorld)
}
function coords(lat, lng, altitude) {
const at = globe.getCoords(lat, lng, altitude)
return temp.set(at.x, at.y, at.z)
}
function toVector(at) {
return v(at.x, at.y, at.z)
}
// three-globe's frame: x = cos lat · sin lng, y = sin lat, z = cos lat · cos lng
function unitOf(lat, lng) {
return v(Math.cos(lat * RAD) * Math.sin(lng * RAD), Math.sin(lat * RAD), Math.cos(lat * RAD) * Math.cos(lng * RAD))
}
function perpendicular(n) {
return Math.abs(n.y) < 0.9 ? v(0, 1, 0).cross(n) : v(1, 0, 0).cross(n)
}
function ringSegments(lat, lng, radiusDeg, steps) {
const n = unitOf(lat, lng)
const e = perpendicular(n).normalize()
const u = n.clone().cross(e)
const a = radiusDeg * RAD
const points = Array.from({ length: steps }, (_, i) => {
const t = 2 * Math.PI * i / steps
return n.clone().multiplyScalar(Math.cos(a)).addScaledVector(e, Math.sin(a) * Math.cos(t)).addScaledVector(u, Math.sin(a) * Math.sin(t)).multiplyScalar(R * 1.003)
})
return points.map((p, i) => [p, points[(i + 1) % steps]])
}
function greatCircleKm(a) {
const p = unitOf(a.startLat, a.startLng), q = unitOf(a.endLat, a.endLng)
return 6371 * Math.acos(Math.max(-1, Math.min(1, p.dot(q))))
}
function behindGlobe(point, camera) {
const dx = point.x - camera.x, dy = point.y - camera.y, dz = point.z - camera.z
const a = dx * dx + dy * dy + dz * dz
const b = 2 * (camera.x * dx + camera.y * dy + camera.z * dz)
const c = camera.x * camera.x + camera.y * camera.y + camera.z * camera.z - R * R * 0.998
const discriminant = b * b - 4 * a * c
if (discriminant <= 0)
return false
const t = (-b - Math.sqrt(discriminant)) / (2 * a)
return t > 0 && t < 0.999
}
function show(target, x, y) {
if (!target.shown) {
target.shown = true
target.element.style.visibility = ""
}
// a chip placed for the first time has no x nor y (NaN), which no comparison is true of
if (!(Math.abs(x - target.x) < 0.5 && Math.abs(y - target.y) < 0.5)) {
target.x = x
target.y = y
target.element.style.transform = `translate(${(x - target.width / 2).toFixed(1)}px, ${(y - target.height / 2).toFixed(1)}px)`
}
}
function hide(target) {
if (target.shown) {
target.shown = false
target.element.style.visibility = "hidden"
}
}
function drawLegend() {
const lines = [
`${text("points")} ${facts.points} · ${text("posts")} ${facts.posts} · ${text("servers")} ${facts.servers}${facts.cdnGroups > 0 ? ` · ${text("CDN groups")} ${facts.cdnGroups}` : ""}`,
[`${text(facts.night ? "day and night maps" : "day map")} 4096×2048`, facts.anisotropy ? text("{0}× anisotropic", facts.anisotropy) : null,
facts.countries ? `${text("countries")} ${facts.named}/${facts.countries}` : null,
facts.borders ? `${text("borders")} ${facts.borders} ${text("lines")}` : null].filter(Boolean).join(" · "),
facts.renderer ?? ""
].filter(Boolean)
const value = lines.join("\n")
if (legend.textContent === value)
return
legend.textContent = value
measureLegend()
}
// the legend's box, which the chips keep clear of; measured when its words or the canvas change, never per frame
function measureLegend() {
legendBox = full && legend.textContent ? [legend.offsetLeft, legend.offsetTop - 4, legend.offsetLeft + legend.offsetWidth, size.height] : null
}
function seasonName(season) {
return text(season)
}
function eventName(next) {
return text({ "march-equinox": "March equinox", "june-solstice": "June solstice", "september-equinox": "September equinox" }[next] ?? "December solstice")
}
// --- geometry, built with three's own classes ---
function v(x, y, z) {
return new three.Vector3(x, y, z)
}
function group(order) {
const made = new three.Group()
made.rotation.order = order
return made
}
function painted(object, kind) {
object.userData.paint = kind
object.frustumCulled = false
return object
}
function polyline(points, material, kind) {
return painted(new three.Line(new three.BufferGeometry().setFromPoints(points), material), kind)
}
function segments(points, material, kind) {
return painted(new three.LineSegments(new three.BufferGeometry().setFromPoints(points), material), kind)
}
function mesh(geometry, material, kind) {
return painted(new three.Mesh(geometry, material), kind)
}
// a line's points written in place (same count), or its geometry made again when the count changed
function rewrite(object, points) {
const positions = object.geometry.attributes.position
if (positions && positions.count === points.length) {
points.forEach((p, i) => positions.setXYZ(i, p.x, p.y, p.z))
positions.needsUpdate = true
return
}
object.geometry.dispose()
object.geometry = new three.BufferGeometry().setFromPoints(points)
}
// a circle at an angle from +Z
function circle(angle, radius, steps) {
return Array.from({ length: steps + 1 }, (_, i) => {
const t = 2 * Math.PI * i / steps
return v(radius * Math.sin(angle * RAD) * Math.cos(t), radius * Math.sin(angle * RAD) * Math.sin(t), radius * Math.cos(angle * RAD))
})
}
// the great circle through +Z and +Y (x = 0): in the Sun's rig, the meridian of noon and midnight
function greatCircleX(radius, steps) {
return Array.from({ length: steps + 1 }, (_, i) => {
const t = 2 * Math.PI * i / steps
return v(0, radius * Math.sin(t), radius * Math.cos(t))
})
}
function latitudeCircle(lat, radius, steps) {
return Array.from({ length: steps + 1 }, (_, i) => {
const lng = 2 * Math.PI * i / steps
return v(radius * Math.cos(lat * RAD) * Math.sin(lng), radius * Math.sin(lat * RAD), radius * Math.cos(lat * RAD) * Math.cos(lng))
})
}
// the meridian at longitude 0 from one latitude to another
function arcPoints(from, to, steps) {
return Array.from({ length: steps + 1 }, (_, i) => {
const lat = (from + (to - from) * i / steps) * RAD
return v(0, R * 1.003 * Math.sin(lat), R * 1.003 * Math.cos(lat))
})
}
// a polyline as segments, every other one left out (or, solid, every one kept)
function dashed(points, gaps) {
const pairs = []
for (let i = 0; i < points.length - 1; i += gaps ? 2 : 1)
pairs.push(points[i], points[i + 1])
return pairs
}
function setIndexed(points, index) {
const geometry = new three.BufferGeometry().setFromPoints(points)
geometry.setIndex(index)
return geometry
}
// a band between two angles from +Z
function band(inner, outer, radius, steps) {
const points = []
const index = []
for (let i = 0; i <= steps; i++) {
const t = 2 * Math.PI * i / steps
for (const angle of [inner, outer])
points.push(v(radius * Math.sin(angle * RAD) * Math.cos(t), radius * Math.sin(angle * RAD) * Math.sin(t), radius * Math.cos(angle * RAD)))
if (i < steps)
index.push(2 * i, 2 * i + 1, 2 * i + 2, 2 * i + 1, 2 * i + 3, 2 * i + 2)
}
return setIndexed(points, index)
}
// a tapered tube along +Z
function beam(from, to, start, end, sides) {
const points = []
const index = []
for (let i = 0; i <= sides; i++) {
const t = 2 * Math.PI * i / sides
points.push(v(from * Math.cos(t), from * Math.sin(t), start), v(to * Math.cos(t), to * Math.sin(t), end))
if (i < sides)
index.push(2 * i, 2 * i + 1, 2 * i + 2, 2 * i + 1, 2 * i + 3, 2 * i + 2)
}
return setIndexed(points, index)
}
// a sphere (or a cap of it, to a polar angle from +Z)
function sphere(radius, around, down, cap = Math.PI) {
const points = []
const index = []
for (let j = 0; j <= down; j++) {
const polar = cap * j / down
for (let i = 0; i <= around; i++) {
const t = 2 * Math.PI * i / around
points.push(v(radius * Math.sin(polar) * Math.cos(t), radius * Math.sin(polar) * Math.sin(t), radius * Math.cos(polar)))
}
}
for (let j = 0; j < down; j++)
for (let i = 0; i < around; i++) {
const a = j * (around + 1) + i, b = a + around + 1
index.push(a, b, a + 1, b, b + 1, a + 1)
}
return setIndexed(points, index)
}
// the orbit's point at a heliocentric longitude, from the Sun at the focus (the ellipse's scale is 1)
function orbitXYZ(longitude, out = 1) {
const anomaly = (longitude - PERIHELION) * RAD
const r = (1 - ECCENTRICITY * ECCENTRICITY) / (1 + ECCENTRICITY * Math.cos(anomaly)) * out
return [r * Math.cos(longitude * RAD), r * Math.sin(longitude * RAD), 0]
}
function orbitPoint(longitude, out = 1) {
return v(...orbitXYZ(longitude, out))
}
return { setSize, setSun, setMoment, setView, setArc, setPoints, setFacts, setFps, setColours, boxes, summary, dispose }
}
// three.js's classes, from objects globe.gl made: its globe mesh in its group, the graticules (a LineSegments with its
// LineBasicMaterial, added hidden), an empty Mesh for its default geometry and material, the camera's Vector3. Each is
// checked by three's own is* flags; what is missing, as words, when one isn't there.
function classesOf(globe) {
try {
const scene = globe.scene()
const globeMaterial = globe.globeMaterial()
let earth = null, graticules = null
scene.traverse(object => {
if (!earth && object.isMesh && object.material === globeMaterial)
earth = object
if (!graticules && object.isLineSegments)
graticules = object
})
if (!earth?.parent?.isGroup)
return "no globe mesh in a group"
if (!graticules?.material?.isLineBasicMaterial)
return "no graticules"
const Mesh = earth.constructor
const Group = earth.parent.constructor
const LineSegments = graticules.constructor
const Line = Object.getPrototypeOf(LineSegments)
const LineBasicMaterial = graticules.material.constructor
const probe = new Mesh()
const BufferGeometry = probe.geometry.constructor
const MeshBasicMaterial = probe.material.constructor
const Vector3 = globe.camera().position.constructor
const line = new Line()
const ok = line.isLine && !line.isLineSegments && new Group().isGroup && probe.isMesh && new BufferGeometry().isBufferGeometry
&& new MeshBasicMaterial().isMeshBasicMaterial && new Vector3().isVector3
for (const made of [probe, line]) {
made.geometry.dispose()
made.material.dispose()
}
return ok ? { Mesh, Group, LineSegments, Line, LineBasicMaterial, BufferGeometry, MeshBasicMaterial, Vector3 } : "a class without its is* flag"
} catch (e) {
console.error(e)
return e.message
}
}
// the frame-rate ring: a track and a value arc, an SVG over the canvas
function ringElement(overlay) {
const ns = "http://www.w3.org/2000/svg"
const svg = document.createElementNS(ns, "svg")
svg.setAttribute("class", "globe-ring")
const track = document.createElementNS(ns, "circle")
track.setAttribute("class", "globe-ring-track")
const value = document.createElementNS(ns, "circle")
value.setAttribute("class", "globe-ring-value")
svg.append(track, value)
overlay.appendChild(svg)
return { svg, track, value, radius: 0, cx: 0, cy: 0, length: 0 }
}
function overlap(a, b) {
return a[0] < b[2] && a[2] > b[0] && a[1] < b[3] && a[3] > b[1]
}
+11
View File
@@ -27,3 +27,14 @@ few areas without a code (Kosovo, Somaliland, Northern Cyprus, …).
The service worker leaves this folder out of the offline precache (`service-worker.published.js`): the feed page
loads it the first time it shows the globe.
**Before upgrading globe.gl**, check what the app relies on inside the bundle, since it never sees `window.THREE`:
- **`wwwroot/js/instruments.js`** takes three.js's classes from objects globe.gl made (`classesOf`):
- the globe mesh, whose material is `globeMaterial()`, inside a `Group`;
- the graticules, a `LineSegments` with a `LineBasicMaterial`, added hidden;
- an empty `Mesh`, for `BufferGeometry` and `MeshBasicMaterial`;
- the camera's `Vector3`.
It also relies on the scene's `onBeforeRender` and `onAfterRender` running on every frame. If any of these change,
the instruments fall back to the ring and the legend, and the e2e `GlobeTests` fail on `built`.
- **`wwwroot/js/globe.js`** relies on the texture class of the day map and on the globe material's shader chunks.