The globe under the Sun of now or of a post, with borders and country names
The Sun lights the globe where it really is (wwwroot/js/sun.js, the Astronomical Almanac's low-precision solar coordinates): the day map shaded by the Sun's height and the Earth–Sun distance, warm city lights on the night side, and a twilight blend about 13° wide between them. It shows now, refreshed every 30 s, or the moment of the hovered, focused or pinned post: a click on a card pins it, and the subsolar point travels there the shorter way round. The panel's top corner shows that moment, the phase of the day and solar time at the post's place, and the season in its hemisphere (or in both, for a post without a place). Its tooltip shows the subsolar point, declination, axial tilt, distance, irradiance, day length and the next equinox or solstice. Land borders are thin paths. Country names are in the reader's language and appear as the camera comes closer: largest countries first, away from the globe's edge, never overlapping. They come from world-atlas 2.0.2 (Natural Earth 1:50m), prepared by tools/globe/prepare-countries.py, and are vendored with their hashes in SOURCES.md. 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
69d0a641fe
commit
0f68e74e38
13 files changed
+1066
-50
No files matched your search
+331
-24
@@ -4,13 +4,22 @@
|
||||
// Hovering a post pulses a ring where it is and draws an arc to it from PrivaPub's server; leaving it goes back to the
|
||||
// view. Posts of servers behind a CDN have no place: they are grouped per CDN in labels on a ring above the Arctic,
|
||||
// which says "CDN" rather than any place; hovering one draws its arc to that group and a ring where the server was
|
||||
// before the CDN, when PrivaPub saw it there. Day texture with the light theme, night with the dark one; motion follows prefers-reduced-motion. It
|
||||
// renders only while something moves (a camera flight, a hovered post's pulse, auto-rotation, the reader dragging or
|
||||
// zooming) and never while the page or the globe is hidden: an idle globe costs no GPU time. No .NET round-trip on scroll or hover: the posts' places come in one
|
||||
// call per feed change (setPosts), the cards are matched by data-post-id.
|
||||
// before the CDN, when PrivaPub saw it there.
|
||||
// The Earth is lit by the Sun where it really is (sun.js): the day map where the Sun is up, shaded by its height, the
|
||||
// night map (city lights) where it is down, blended across a twilight band, and brighter or dimmer with the Earth–Sun
|
||||
// distance. It shows now, or, while a post is hovered or focused, the moment that post was written; the Sun moves there
|
||||
// and back smoothly. Land borders are thin lines, and country names sit inside each country in the reader's language,
|
||||
// more of them and larger as the camera comes closer. Motion follows prefers-reduced-motion. It renders only while
|
||||
// something moves (a camera flight, the Sun moving, a hovered post's pulse, auto-rotation, the reader dragging or
|
||||
// zooming) and never while the page or the globe is hidden: an idle globe costs no GPU time. No .NET round-trip on
|
||||
// scroll or hover: the posts' places come in one call per feed change (setPosts), the cards are matched by
|
||||
// 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"
|
||||
|
||||
const vendor = new URL("../vendor/globe/", import.meta.url).href
|
||||
const textures = { day: vendor + "earth-blue-marble.jpg", night: vendor + "earth-night.jpg" }
|
||||
const countriesUrl = vendor + "countries.json"
|
||||
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)")
|
||||
|
||||
let loading = null
|
||||
@@ -30,12 +39,27 @@ function loadLibrary() {
|
||||
}
|
||||
|
||||
// Creates the globe in element once it has a size (it may start hidden on a phone), following the cards of the
|
||||
// scroll container.
|
||||
export function create(element, scrollContainerId) {
|
||||
// scroll container. reference is the .NET panel (SunChanged); options.language names the countries.
|
||||
export function create(element, scrollContainerId, reference, options) {
|
||||
dispose()
|
||||
const now = sunAt(new Date())
|
||||
state = {
|
||||
element,
|
||||
reference,
|
||||
names: regionNames(options?.language),
|
||||
scroller: document.getElementById(scrollContainerId),
|
||||
sun: now,
|
||||
sunPoint: { lat: now.lat, lng: now.lng },
|
||||
sunUniforms: { sunDirection: { value: directionOf(now) }, sunIntensity: { value: now.irradiance }, nightMap: { value: null }, nightReady: { value: 0 } },
|
||||
focus: null,
|
||||
pinned: null,
|
||||
nightImage: null,
|
||||
countries: [],
|
||||
borders: [],
|
||||
labelOf: new Map(),
|
||||
labelKey: null,
|
||||
labelsAt: 0,
|
||||
countryNames: 0,
|
||||
globe: null,
|
||||
points: new Map(),
|
||||
self: null,
|
||||
@@ -100,14 +124,50 @@ export function stats() {
|
||||
cdnGroups: cdnGroups().length,
|
||||
fps: state.fps,
|
||||
renderer: state.renderer,
|
||||
texture: state.texture,
|
||||
texture: state.sunUniforms.nightReady.value ? "day+night" : "day",
|
||||
anisotropy: state.anisotropy,
|
||||
autoRotate: !!state.globe.controls().autoRotate,
|
||||
paused: state.paused,
|
||||
idle: state.idle,
|
||||
hovered: state.hovered && state.points.has(state.hovered) ? state.points.get(state.hovered).label ?? state.hovered : null,
|
||||
arcs: state.globe.arcsData().length,
|
||||
rings: state.globe.ringsData().length
|
||||
rings: state.globe.ringsData().length,
|
||||
countries: state.countries.length,
|
||||
countryNames: state.countryNames,
|
||||
borders: state.borders.length,
|
||||
sun: sunStats()
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const sun = state.sun
|
||||
const focus = state.focus
|
||||
const place = focus && Number.isFinite(focus.lat) ? sunAtPlace(sun, focus.lat, focus.lng) : null
|
||||
const seasons = seasonsOf(sun)
|
||||
return {
|
||||
at: sun.date.toISOString(),
|
||||
now: !focus,
|
||||
latitude: round(sun.lat, 2),
|
||||
longitude: round(sun.lng, 2),
|
||||
distance: round(sun.distance, 5),
|
||||
irradiance: round(sun.irradiance, 4),
|
||||
eclipticLongitude: round(sun.eclipticLongitude, 2),
|
||||
obliquity: round(sun.obliquity, 3),
|
||||
place: focus?.label ?? null,
|
||||
placeLatitude: focus && Number.isFinite(focus.lat) ? round(focus.lat, 2) : null,
|
||||
placeLongitude: focus && Number.isFinite(focus.lng) ? round(focus.lng, 2) : null,
|
||||
elevation: place ? round(place.elevation, 1) : null,
|
||||
phase: place?.phase ?? null,
|
||||
morning: place?.morning ?? null,
|
||||
solarTime: place?.solarTime ?? null,
|
||||
dayLength: place ? round(place.dayLength, 2) : null,
|
||||
season: place?.season ?? null,
|
||||
hemisphere: place?.hemisphere ?? null,
|
||||
seasonNorth: seasons.north,
|
||||
seasonSouth: seasons.south,
|
||||
nextEvent: seasons.nextEvent,
|
||||
nextEventDays: seasons.nextEventDays
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,11 +232,28 @@ async function build(width, height) {
|
||||
.arcDashGap(0.25)
|
||||
.arcDashInitialGap(1)
|
||||
.arcDashAnimateTime(reducedMotion.matches ? 0 : 1600)
|
||||
.htmlLat(g => g.lat)
|
||||
.htmlLng(g => g.lng)
|
||||
.htmlAltitude(0.12)
|
||||
.htmlElement(cdnLabel)
|
||||
.htmlLat(d => d.lat)
|
||||
.htmlLng(d => d.lng)
|
||||
.htmlAltitude(d => d.kind === "country" ? 0.004 : 0.12)
|
||||
.htmlElement(d => d.kind === "country" ? countryLabel(d) : cdnLabel(d))
|
||||
.htmlTransitionDuration(0)
|
||||
// the label renderer sets display on every frame, so labels hide through visibility; country names are
|
||||
// placed by placeLabels
|
||||
.htmlElementVisibilityModifier((element, inFront) => {
|
||||
if (!element.classList.contains("globe-country"))
|
||||
element.style.visibility = inFront ? "" : "hidden"
|
||||
})
|
||||
.pathPoints(b => b.points)
|
||||
.pathPointLat(p => p[0])
|
||||
.pathPointLng(p => p[1])
|
||||
.pathPointAlt(0.0015)
|
||||
.pathColor(() => "rgba(255,255,255,0.32)")
|
||||
.pathStroke(null)
|
||||
.pathTransitionDuration(0)
|
||||
.globeImageUrl(textures.day)
|
||||
sharpen(globe)
|
||||
loadNight()
|
||||
loadCountries()
|
||||
const controls = globe.controls()
|
||||
controls.autoRotateSpeed = 0.35
|
||||
controls.enableDamping = true
|
||||
@@ -190,6 +267,10 @@ async function build(width, height) {
|
||||
})
|
||||
listen(state.element, "pointermove", () => wake(1000))
|
||||
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * narrowness() }, 0)
|
||||
state.timers.push(setInterval(() => {
|
||||
if (!state.focus)
|
||||
aimSun(sunAt(new Date()))
|
||||
}, 30000))
|
||||
applyTheme()
|
||||
readRenderer()
|
||||
countFrames()
|
||||
@@ -212,15 +293,32 @@ async function build(width, height) {
|
||||
// everywhere. The mipmap level comes from gradients taken on whichever of two longitude ranges has no jump at that pixel,
|
||||
// and the map wraps around horizontally (keepSharp), so the 180° meridian shows no seam; anisotropic filtering keeps
|
||||
// high latitudes sharp at grazing angles, at 8× at most: sharper than that is barely visible and costs on phones.
|
||||
// The Sun replaces the scene's lights: the day map where it is up, shaded by its height (cosine of its zenith angle,
|
||||
// eased so the low sun does not go black at once) and scaled by the Earth–Sun distance; where it is down, the night
|
||||
// map's city lights, warmed to sodium's colour, over a dim trace of that map's land; the two blended across twilight,
|
||||
// from 7° below the horizon to 6° above, with a faint warm band along the terminator.
|
||||
function sharpen(globe) {
|
||||
const material = globe.globeMaterial()
|
||||
material.onBeforeCompile = shader => {
|
||||
Object.assign(shader.uniforms, state.sunUniforms)
|
||||
shader.vertexShader = shader.vertexShader
|
||||
.replace("#include <common>", "#include <common>\nvarying vec3 vSpherePoint;")
|
||||
.replace("#include <begin_vertex>", "#include <begin_vertex>\nvSpherePoint = position;")
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
.replace("#include <common>", "#include <common>\nvarying vec3 vSpherePoint;")
|
||||
.replace("#include <common>", `#include <common>
|
||||
varying vec3 vSpherePoint;
|
||||
uniform vec3 sunDirection;
|
||||
uniform float sunIntensity;
|
||||
uniform sampler2D nightMap;
|
||||
uniform float nightReady;`)
|
||||
.replace("#include <opaque_fragment>", `
|
||||
#ifdef USE_MAP
|
||||
gl_FragColor = vec4(earthColor, diffuseColor.a);
|
||||
#else
|
||||
#include <opaque_fragment>
|
||||
#endif`)
|
||||
.replace("#include <map_fragment>", `
|
||||
vec3 earthColor = vec3(0.0);
|
||||
#ifdef USE_MAP
|
||||
vec3 spherePoint = normalize(vSpherePoint);
|
||||
float sphereU = atan(spherePoint.z, -spherePoint.x) / (2.0 * PI);
|
||||
@@ -237,24 +335,212 @@ function sharpen(globe) {
|
||||
}
|
||||
vec4 sampledDiffuseColor = textureGrad(map, vec2(uWhole, sphereV), gradientX, gradientY);
|
||||
diffuseColor *= sampledDiffuseColor;
|
||||
vec3 nightSample = textureGrad(nightMap, vec2(uWhole, sphereV), gradientX, gradientY).rgb * nightReady;
|
||||
float glow = smoothstep(0.03, 0.12, dot(nightSample, vec3(0.2126, 0.7152, 0.0722)));
|
||||
vec3 cityLights = nightSample * glow * vec3(2.6, 2.1, 1.4);
|
||||
float sunHeight = dot(spherePoint, sunDirection);
|
||||
float daylight = smoothstep(-0.12, 0.10, sunHeight);
|
||||
vec3 dayColor = diffuseColor.rgb * sunIntensity * (0.10 + 0.90 * pow(max(sunHeight, 0.0), 0.7));
|
||||
vec3 nightColor = cityLights + nightSample * 0.35 + diffuseColor.rgb * 0.015;
|
||||
float terminator = exp(-pow(sunHeight / 0.05, 2.0));
|
||||
earthColor = mix(nightColor, dayColor, daylight) + vec3(0.50, 0.22, 0.06) * terminator * 0.05;
|
||||
#endif`)
|
||||
}
|
||||
material.customProgramCacheKey = () => "privapub-per-pixel-sphere"
|
||||
material.customProgramCacheKey = () => "privapub-sunlit-sphere"
|
||||
material.needsUpdate = true
|
||||
state.anisotropy = Math.min(8, globe.renderer().capabilities.getMaxAnisotropy())
|
||||
}
|
||||
|
||||
// Every texture the globe loads (a theme change loads the other one) wraps around horizontally, so filtering at the
|
||||
// 180° meridian blends the map's two edges instead of clamping each, and gets anisotropic filtering.
|
||||
// Both maps wrap around horizontally, so filtering at the 180° meridian blends the map's two edges instead of clamping
|
||||
// each, and get anisotropic filtering.
|
||||
const RepeatWrapping = 1000//three.js's RepeatWrapping: the bundle does not expose THREE
|
||||
function keepSharp() {
|
||||
const map = state?.globe?.globeMaterial().map
|
||||
if (map && (map.anisotropy !== state.anisotropy || map.wrapS !== RepeatWrapping)) {
|
||||
if (!map)
|
||||
return
|
||||
if (map.anisotropy !== state.anisotropy || map.wrapS !== RepeatWrapping) {
|
||||
map.anisotropy = state.anisotropy
|
||||
map.wrapS = RepeatWrapping
|
||||
map.needsUpdate = true
|
||||
wake(500)
|
||||
}
|
||||
// the night map is a texture of the same kind as the day map globe.gl loaded (the bundle does not expose THREE)
|
||||
if (state.nightImage?.complete && !state.sunUniforms.nightMap.value) {
|
||||
const night = new map.constructor(state.nightImage)
|
||||
night.colorSpace = map.colorSpace
|
||||
night.anisotropy = state.anisotropy
|
||||
night.wrapS = RepeatWrapping
|
||||
night.needsUpdate = true
|
||||
state.sunUniforms.nightMap.value = night
|
||||
state.sunUniforms.nightReady.value = 1
|
||||
wake(500)
|
||||
}
|
||||
}
|
||||
|
||||
function loadNight() {
|
||||
const image = new Image()
|
||||
image.decoding = "async"
|
||||
image.onload = () => {
|
||||
state.nightImage = image
|
||||
}
|
||||
image.src = textures.night
|
||||
}
|
||||
|
||||
// The Sun moves to a new moment: its subsolar point travels there over about a second (the shorter way round), or
|
||||
// jumps with reduced motion.
|
||||
function aimSun(sun) {
|
||||
state.sun = sun
|
||||
state.sunUniforms.sunIntensity.value = sun.irradiance
|
||||
if (reducedMotion.matches)
|
||||
state.sunPoint = { lat: sun.lat, lng: sun.lng }
|
||||
state.sunUniforms.sunDirection.value = directionOf(state.sunPoint)
|
||||
// one frame for the new irradiance; stepSun keeps the globe drawing while the Sun travels
|
||||
wake(100)
|
||||
state.reference?.invokeMethodAsync("SunChanged").catch(() => { })
|
||||
}
|
||||
|
||||
// Called every frame: eases the subsolar point towards the Sun's.
|
||||
function stepSun() {
|
||||
const point = state.sunPoint
|
||||
const dLat = state.sun.lat - point.lat
|
||||
const dLng = ((state.sun.lng - point.lng + 540) % 360) - 180
|
||||
if (Math.abs(dLat) < 0.02 && Math.abs(dLng) < 0.02) {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
wake(300)
|
||||
}
|
||||
|
||||
// The moment a post was written (its card's data-post-at), and its place when it has one (its own, or its server's
|
||||
// before a CDN), while it is hovered, focused or pinned; otherwise now.
|
||||
function focusOn(key) {
|
||||
const card = key ? state.scroller?.querySelector(`[data-post-id="${CSS.escape(key)}"]`) : null
|
||||
const at = card?.dataset.postAt ? new Date(card.dataset.postAt) : null
|
||||
if (!at || Number.isNaN(at.getTime())) {
|
||||
if (state.focus) {
|
||||
state.focus = null
|
||||
aimSun(sunAt(new Date()))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (state.focus?.key === key)
|
||||
return
|
||||
const post = state.points.get(key)
|
||||
const lat = placed(post) ? post.lat : post?.beforeLat
|
||||
const lng = placed(post) ? post.lng : post?.beforeLng
|
||||
state.focus = { key, at, lat, lng, label: post?.label ?? null }
|
||||
aimSun(sunAt(at))
|
||||
}
|
||||
|
||||
// Country names in the reader's language (Intl.DisplayNames), Natural Earth's English names when the browser has none.
|
||||
function regionNames(language) {
|
||||
try {
|
||||
return new Intl.DisplayNames([language || navigator.language, "en"], { type: "region" })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCountries() {
|
||||
try {
|
||||
const response = await fetch(countriesUrl)
|
||||
const data = await response.json()
|
||||
if (!state?.globe)
|
||||
return
|
||||
state.borders = data.borders.map(line => {
|
||||
const points = []
|
||||
for (let i = 0; i < line.length; i += 2)
|
||||
points.push([line[i + 1], line[i]])
|
||||
return { points }
|
||||
})
|
||||
state.countries = data.countries.map(c => ({ kind: "country", ...c }))
|
||||
state.globe.pathsData(state.borders)
|
||||
drawPoints()
|
||||
placeLabels(true)
|
||||
wake(500)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
window.logFromJs?.(e.message, "globe.loadCountries")
|
||||
}
|
||||
}
|
||||
|
||||
function countryLabel(country) {
|
||||
const element = document.createElement("div")
|
||||
element.className = "globe-country"
|
||||
element.style.visibility = "hidden"
|
||||
let name = country.name
|
||||
try {
|
||||
name = (country.code && state.names?.of(country.code)) || country.name
|
||||
} catch {
|
||||
}
|
||||
element.textContent = name
|
||||
state.labelOf.set(country, element)
|
||||
state.labelKey = null
|
||||
return element
|
||||
}
|
||||
|
||||
// How many pixels a degree of the surface spans under the camera: the surface there is altitude globe radii away, and a
|
||||
// degree is a 180/π-th of a radius.
|
||||
function pixelsPerDegree(altitude) {
|
||||
const { height } = state.element.getBoundingClientRect()
|
||||
const fov = (state.globe.camera().fov ?? 50) * Math.PI / 180
|
||||
return height / (2 * Math.tan(fov / 2) * Math.max(0.01, altitude)) * Math.PI / 180
|
||||
}
|
||||
|
||||
// Which country names show, and how large, whenever the camera moved (at most every 100 ms): largest countries first,
|
||||
// each where its polygon spans at least 34 px on screen (less towards the globe's edge, which foreshortens it), away
|
||||
// from the edge, and clear of the names already placed. Names grow from 9 to 16 px with their country and fade in over
|
||||
// their first pixels, so they do not pop.
|
||||
function placeLabels(force = false) {
|
||||
if (!state?.globe || state.labelOf.size === 0)
|
||||
return
|
||||
const camera = state.globe.pointOfView()
|
||||
const { width, height } = state.element.getBoundingClientRect()
|
||||
const key = `${camera.lat.toFixed(2)},${camera.lng.toFixed(2)},${camera.altitude.toFixed(3)},${width}x${height}`
|
||||
const now = performance.now()
|
||||
if (!force && (key === state.labelKey || now - state.labelsAt < 100))
|
||||
return
|
||||
state.labelKey = key
|
||||
state.labelsAt = now
|
||||
const perDegree = pixelsPerDegree(camera.altitude)
|
||||
const horizon = 1 / (1 + camera.altitude)
|
||||
const view = unit(camera.lat, camera.lng)
|
||||
const taken = []
|
||||
for (const country of state.countries) {
|
||||
const element = state.labelOf.get(country)
|
||||
if (!element)
|
||||
continue
|
||||
const point = unit(country.lat, country.lng)
|
||||
const facing = (view[0] * point[0] + view[1] * point[1] + view[2] * point[2] - horizon) / (1 - horizon)
|
||||
const pixels = country.size * perDegree * Math.sqrt(Math.max(facing, 0))
|
||||
let shown = facing > 0.2 && pixels >= 34
|
||||
if (shown) {
|
||||
const size = Math.max(9, Math.min(16, 8 + pixels / 20))
|
||||
const at = state.globe.getScreenCoords(country.lat, country.lng, 0.004)
|
||||
const halfWidth = element.textContent.length * size * 0.3 + 3
|
||||
const halfHeight = size * 0.6 + 2
|
||||
const box = [at.x - halfWidth, at.y - halfHeight, at.x + halfWidth, at.y + halfHeight]
|
||||
shown = !taken.some(b => box[0] < b[2] && box[2] > b[0] && box[1] < b[3] && box[3] > b[1])
|
||||
if (shown) {
|
||||
taken.push(box)
|
||||
element.style.fontSize = `${size.toFixed(1)}px`
|
||||
element.style.opacity = Math.min(1, 0.45 + (pixels - 34) / 50).toFixed(2)
|
||||
}
|
||||
}
|
||||
element.style.visibility = shown ? "" : "hidden"
|
||||
}
|
||||
state.countryNames = taken.length
|
||||
}
|
||||
|
||||
function unit(lat, lng) {
|
||||
const φ = lat * Math.PI / 180, λ = lng * Math.PI / 180
|
||||
return [Math.cos(φ) * Math.cos(λ), Math.cos(φ) * Math.sin(λ), Math.sin(φ)]
|
||||
}
|
||||
|
||||
// Which cards are in view: an IntersectionObserver on every [data-post-id] of the scroll container, re-attached as
|
||||
@@ -292,6 +578,29 @@ function watchCards() {
|
||||
hover(card.dataset.postId)
|
||||
})
|
||||
listen(scroller, "mouseleave", () => hover(null))
|
||||
listen(scroller, "focusin", event => {
|
||||
const card = event.target.closest?.("[data-post-id]")
|
||||
if (card && card.dataset.postId !== state.hovered)
|
||||
hover(card.dataset.postId)
|
||||
})
|
||||
listen(scroller, "focusout", event => {
|
||||
if (!event.relatedTarget?.closest?.("[data-post-id]") && !scroller.matches(":hover"))
|
||||
hover(null)
|
||||
})
|
||||
// a click on a card (not on its links or controls) pins it: the globe keeps its moment and place until it is
|
||||
// clicked again or another card is pinned
|
||||
listen(scroller, "click", event => {
|
||||
const card = event.target.closest?.("[data-post-id]")
|
||||
if (!card || event.target.closest("a, button, input, textarea, select, label, summary, video, audio, [role=button]"))
|
||||
return
|
||||
const key = card.dataset.postId
|
||||
scroller.querySelectorAll("[data-globe-pinned]").forEach(c => c.removeAttribute("data-globe-pinned"))
|
||||
state.pinned = state.pinned === key ? null : key
|
||||
if (state.pinned)
|
||||
card.setAttribute("data-globe-pinned", "")
|
||||
state.hovered = null
|
||||
hover(key)
|
||||
})
|
||||
listen(scroller, "mouseout", event => {
|
||||
const from = event.target.closest?.("[data-post-id]")
|
||||
const to = event.relatedTarget?.closest?.("[data-post-id]")
|
||||
@@ -366,12 +675,6 @@ function settle() {
|
||||
function applyTheme() {
|
||||
if (!state?.globe)
|
||||
return
|
||||
const dark = window.neoTheme?.isDark?.() ?? matchMedia("(prefers-color-scheme: dark)").matches
|
||||
const texture = dark ? "night" : "day"
|
||||
if (state.texture !== texture) {
|
||||
state.texture = texture
|
||||
state.globe.globeImageUrl(textures[texture])
|
||||
}
|
||||
state.colours = null
|
||||
state.globe.atmosphereColor(colour("server", 1))
|
||||
drawPoints()
|
||||
@@ -452,7 +755,7 @@ function drawPoints() {
|
||||
return
|
||||
state.groups = cdnGroups()
|
||||
state.globe.pointsData(aggregate())
|
||||
state.globe.htmlElementsData(state.groups)
|
||||
state.globe.htmlElementsData([...state.groups.map(g => ({ kind: "cdn", ...g })), ...state.countries])
|
||||
drawRings()
|
||||
}
|
||||
|
||||
@@ -490,11 +793,13 @@ function drawRings() {
|
||||
}
|
||||
|
||||
function hover(key) {
|
||||
key ??= state?.pinned
|
||||
if (!state?.globe || state.hovered === key)
|
||||
return
|
||||
state.hovered = key
|
||||
wake(1300)
|
||||
const post = key && state.points.get(key)
|
||||
focusOn(key)
|
||||
drawRings()
|
||||
if (!post) {
|
||||
state.globe.arcsData([])
|
||||
@@ -598,6 +903,8 @@ function countFrames() {
|
||||
if (state.running)
|
||||
state.frames++
|
||||
keepSharp()
|
||||
stepSun()
|
||||
placeLabels()
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Where the Sun is, for the globe's day and night (wwwroot/js/globe.js): the Astronomical Almanac's low-precision solar
|
||||
// coordinates, good to about 0.01° between 1950 and 2050. Everything here is a pure function of a date.
|
||||
|
||||
const radians = Math.PI / 180
|
||||
const J2000 = Date.UTC(2000, 0, 1, 12)//2000-01-01 12:00 UTC
|
||||
|
||||
// The subsolar point (where the Sun is overhead), the Earth–Sun distance and the angles that place the Sun in the year.
|
||||
export function sunAt(date) {
|
||||
const days = (date.getTime() - J2000) / 86400000
|
||||
const meanLongitude = 280.460 + 0.9856474 * days
|
||||
const meanAnomaly = (357.528 + 0.9856003 * days) * radians
|
||||
const eclipticLongitude = degrees(meanLongitude + 1.915 * Math.sin(meanAnomaly) + 0.020 * Math.sin(2 * meanAnomaly))
|
||||
const obliquity = 23.439 - 0.0000004 * days
|
||||
const declination = Math.asin(Math.sin(obliquity * radians) * Math.sin(eclipticLongitude * radians)) / radians
|
||||
const rightAscension = Math.atan2(Math.cos(obliquity * radians) * Math.sin(eclipticLongitude * radians), Math.cos(eclipticLongitude * radians)) / radians
|
||||
const siderealTime = 280.46061837 + 360.98564736629 * days
|
||||
const distance = 1.00014 - 0.01671 * Math.cos(meanAnomaly) - 0.00014 * Math.cos(2 * meanAnomaly)
|
||||
return {
|
||||
date,
|
||||
lat: declination,
|
||||
lng: longitude(rightAscension - siderealTime),
|
||||
distance,
|
||||
irradiance: 1 / (distance * distance),
|
||||
eclipticLongitude,
|
||||
obliquity
|
||||
}
|
||||
}
|
||||
|
||||
// What the Sun does at a place: its elevation, the phase of the day, the local solar time, the day's length, the season
|
||||
// in that hemisphere and the next equinox or solstice.
|
||||
export function sunAtPlace(sun, lat, lng) {
|
||||
const hourAngle = longitude(lng - sun.lng)
|
||||
const elevation = Math.asin(
|
||||
Math.sin(lat * radians) * Math.sin(sun.lat * radians) + Math.cos(lat * radians) * Math.cos(sun.lat * radians) * Math.cos(hourAngle * radians)) / radians
|
||||
const solarHours = (((12 + hourAngle / 15) % 24) + 24) % 24
|
||||
// sunrise and sunset as almanacs give them: the Sun's upper edge on the horizon, refraction included (−0.833°)
|
||||
const sunset = (Math.sin(-0.833 * radians) - Math.sin(lat * radians) * Math.sin(sun.lat * radians)) /
|
||||
(Math.cos(lat * radians) * Math.cos(sun.lat * radians))
|
||||
const dayLength = sunset <= -1 ? 24 : sunset >= 1 ? 0 : 2 * Math.acos(sunset) / radians / 15
|
||||
const north = lat >= 0
|
||||
const seasons = seasonsOf(sun)
|
||||
return {
|
||||
elevation,
|
||||
phase: elevation > 6 ? "day" : elevation > 0 ? "golden" : elevation > -6 ? "civil" : elevation > -12 ? "nautical" : elevation > -18 ? "astronomical" : "night",
|
||||
morning: hourAngle < 0,
|
||||
solarTime: `${String(Math.floor(solarHours)).padStart(2, "0")}:${String(Math.floor(solarHours % 1 * 60)).padStart(2, "0")}`,
|
||||
dayLength,
|
||||
hemisphere: north ? "north" : "south",
|
||||
season: north ? seasons.north : seasons.south,
|
||||
nextEvent: seasons.nextEvent,
|
||||
nextEventDays: seasons.nextEventDays
|
||||
}
|
||||
}
|
||||
|
||||
// The astronomical season in each hemisphere (from the equinoxes and solstices, by the Sun's ecliptic longitude) and the
|
||||
// next equinox or solstice, in days at the Sun's mean pace.
|
||||
export function seasonsOf(sun) {
|
||||
const quarter = Math.floor(sun.eclipticLongitude / 90) % 4
|
||||
const nextBoundary = (quarter + 1) * 90
|
||||
return {
|
||||
north: ["spring", "summer", "autumn", "winter"][quarter],
|
||||
south: ["autumn", "winter", "spring", "summer"][quarter],
|
||||
nextEvent: { 90: "june-solstice", 180: "september-equinox", 270: "december-solstice", 360: "march-equinox" }[nextBoundary],
|
||||
nextEventDays: Math.round((nextBoundary - sun.eclipticLongitude) / 0.9856)
|
||||
}
|
||||
}
|
||||
|
||||
// The Sun's direction in the frame of three.js's SphereGeometry, the frame the globe's shader draws in: texture u is
|
||||
// 0 at longitude −180°, and a point is (−cos 2πu · cos lat, sin lat, sin 2πu · cos lat).
|
||||
export function directionOf(sun) {
|
||||
const u = (sun.lng + 180) / 360 * 2 * Math.PI
|
||||
const lat = sun.lat * radians
|
||||
return [-Math.cos(u) * Math.cos(lat), Math.sin(lat), Math.sin(u) * Math.cos(lat)]
|
||||
}
|
||||
|
||||
function degrees(value) {
|
||||
return ((value % 360) + 360) % 360
|
||||
}
|
||||
|
||||
function longitude(value) {
|
||||
const wrapped = degrees(value + 180) - 180
|
||||
return wrapped === -180 ? 180 : wrapped
|
||||
}
|
||||
Reference in new issue
Block a user