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