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:
1 parent
e88f4afc45
commit
e94e32d198
9 files changed
+1516
-181
No files matched your search
+58
-12
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
Vendored
+11
@@ -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.
|
||||
Reference in new issue
Block a user