Files
decePubClient/wwwroot/js/instruments.js
T

967 lines
41 KiB
JavaScript
Raw Permalink Normal View History

// 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]
}