From 83024e6ab5b5706c27465bec2ea801d34cf5e66a Mon Sep 17 00:00:00 2001 From: "Craig C." Date: Fri, 23 Oct 2026 20:35:50 +0300 Subject: [PATCH 01/10] build(site): @northguild/gmt 1.20.0 For its leap-second table (`taiMinusUtc` in `@northguild/gmt/timescale`), which the sky uses for terrestrial time. --- apps/site/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/site/package.json b/apps/site/package.json index eb0b627..8644fac 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -10,7 +10,7 @@ "preview": "wrangler dev" }, "dependencies": { - "@northguild/gmt": "^1.19.0", + "@northguild/gmt": "^1.20.0", "@northguild/theme": "workspace:*", "astro": "7.2.7" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 443f4a3..dbdc0d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: apps/site: dependencies: '@northguild/gmt': - specifier: ^1.19.0 - version: 1.19.0 + specifier: ^1.20.0 + version: 1.20.0 '@northguild/theme': specifier: workspace:* version: link:../../packages/theme @@ -1065,8 +1065,8 @@ packages: peerDependencies: oxlint: '>=1.0.0' - '@northguild/gmt@1.19.0': - resolution: {integrity: sha512-RWOyb9G4z57t1wF2j7TrHivHW/MHFQHI8Ub63tWUIwhzBhf968gh84wvvBOrzc5GI36GkZbVJxOPj6Rj+ymSDA==} + '@northguild/gmt@1.20.0': + resolution: {integrity: sha512-mrykNrPuYW7fxGronTxUYhK5UDfQ66DIIA/vMuwr3wW3jBlZb4WxwG/MF7isdMcadAZJ5rCWeV90AQKq3XuNXg==} engines: {node: '>=22.16.0'} '@oslojs/encoding@1.1.0': @@ -3451,7 +3451,7 @@ snapshots: dependencies: oxlint: 1.89.0 - '@northguild/gmt@1.19.0': + '@northguild/gmt@1.20.0': dependencies: '@js-temporal/polyfill': 0.5.1 From b580470f18193afa36107edcc4f7da5101a3505b Mon Sep 17 00:00:00 2001 From: "Craig C." Date: Fri, 23 Oct 2026 20:35:51 +0300 Subject: [PATCH 02/10] feat(site): a lighter frost on the header and cards The glass blur was 24px, which hid the stars and constellation lines behind the header and library cards entirely. At 4px they show through, softened. The lines already sit under both (z-index -1). --- apps/site/src/components/Header.astro | 3 +++ apps/site/src/components/RepoCard.astro | 2 ++ 2 files changed, 5 insertions(+) diff --git a/apps/site/src/components/Header.astro b/apps/site/src/components/Header.astro index 57e11f8..4380700 100644 --- a/apps/site/src/components/Header.astro +++ b/apps/site/src/components/Header.astro @@ -33,7 +33,10 @@ const links = [ diff --git a/apps/site/src/scripts/aurora.ahead.ts b/apps/site/src/scripts/aurora.ahead.ts new file mode 100644 index 0000000..4d015b6 --- /dev/null +++ b/apps/site/src/scripts/aurora.ahead.ts @@ -0,0 +1,225 @@ +// When the sun and moon next show in this window, from each site: the worker +// steps ahead through time and asks, at each step, whether the body is up, +// clear of the skyline and inside the frame, skipping ahead while it is far. All clock reading, arithmetic +// and formatting goes through @northguild/gmt, in the visitor's own time zone. + +import { + addUnix, + endOfUnix, + areUnixEqualBy, + formatCalendarUnix, + formatUnix, + roundUnix, + totalDuration, +} from "@northguild/gmt"; +import type { Site } from "./aurora.sites"; +import { sunAndMoon, type Vec3 } from "./aurora.sky"; + +/** One body's next spell in view, as text: `now` when it is in view already. */ +export type BodyTimes = { now: boolean; when: string }; +/** The moon also brings its phase at that time, and its age (0 new, π full) + * for the icon. */ +export type MoonTimes = BodyTimes & { phase: string; age: number }; +/** The sun, its halo or pillar (which can show while the disc is behind the + * mountains), and the moon. */ +export type SiteTimes = { sun: BodyTimes; halo: BodyTimes; moon: MoonTimes }; + +/** What one site's view shows on a screen this size. Each test returns 0 + * when the thing shows, otherwise a lower bound on the angle (radians) the + * sky must still turn through before it can, or a tiny positive number when + * it is close. */ +export type Seer = { + /** Any part of a disc (centre d, angular radius r) in frame and clear of + * the skyline, while its centre is above `above` (radians). */ + disc: (d: Vec3, r: number, above: number) => number; + /** The sun's pillar while it is low (2° of it at least), or a quarter of + * its 22° halo while its disc (radius r) shows too: in frame and clear of + * the skyline, while the sun is up (as the sky draws them). */ + halo: (sun: Vec3, r: number) => number; +}; + +const LOCAL = { timeZone: "local" } as const; +/** An ISO 8601 duration (a constant, so always valid) in milliseconds. */ +const ms = (iso: string) => totalDuration(iso, "milliseconds")!; +const HOUR = ms("PT1H"); +// No point on the sky moves faster than 15° an hour as the earth turns; the +// moon adds about half a degree. So a body this far from the frame cannot +// reach it sooner than angle / RATE, and the search skips ahead that much. +const RATE = (16 * Math.PI) / 180 / HOUR; +// Near the frame, a step a minute, so even the briefest spell (the sun +// peeking between peaks) is caught; each edge is then refined by halving. +const STEP = ms("PT1M"); +const EDGE = ms("PT10S"); +// Steps per slice, so the sky keeps drawing while we look. +const SLICE = 1500; +// How high the sun must be seen to count: the sky fades the sun, its halo +// and its pillar in as its seen height (y, lifted by refraction) goes from +// -0.0047 to 0.0347 (sun() in aurora.shaders.ts), and they count from half +// strength, y = 0.015, about 0.86° up. Lower, they are drawn too faintly. +export const SUN_UP = Math.asin(0.015); + +/** The first moment in (a, b] that seen() holds, given it fails at a and + * holds at b. */ +function edge(seen: (ms: number) => boolean, a: number, b: number): number { + while (b - a > EDGE) { + const mid = Math.floor((a + b) / 2); + if (seen(mid)) b = mid; + else a = mid; + } + return b; +} + +/** The next spell in view from `from`: [start, end], with start = from when + * already in view and end null when it lasts longer than `longest`; null + * when none starts before `until`. Yields between slices. */ +function* spell( + away: (ms: number) => number, + from: number, + until: number, + longest: number, +): Generator { + const seen = (ms: number) => away(ms) === 0; + let n = 0; + let t = from; + let far = away(t); + if (far > 0) { + for (;;) { + const next = t + Math.max(STEP, Math.ceil(far / RATE)); + if (next > until) return null; + const then = away(next); + if (then === 0) { + t = edge(seen, t, next); + break; + } + [t, far] = [next, then]; + if (++n % SLICE === 0) yield; + } + } + const start = t; + const gone = (ms: number) => !seen(ms); + const cap = start + longest; + while (t < cap) { + const next = t + STEP; + if (gone(next)) return [start, edge(gone, t, next)]; + t = next; + if (++n % SLICE === 0) yield; + } + return [start, null]; +} + +const PRINCIPAL = ["New moon", "First quarter", "Full moon", "Last quarter"]; +const BETWEEN = [ + "Waxing crescent", + "Waxing gibbous", + "Waning gibbous", + "Waning crescent", +]; + +/** Phase name and how much is lit, e.g. "Waxing gibbous, 78% lit". The + * principal phases hold within 7° of elongation, about half a day. */ +function phaseText(age: number, lit: number): string { + const deg = (age * 180) / Math.PI; + const quarter = Math.round(deg / 90); + const name = + Math.abs(deg - quarter * 90) < 7 + ? PRINCIPAL[quarter % 4]! + : BETWEEN[Math.floor(deg / 90) % 4]!; + return `${name}, ${Math.round(lit * 100)}% lit`; +} + +const capital = (text: string) => + text.charAt(0).toLocaleUpperCase() + text.slice(1); + +/** "today at 21:40", "tomorrow at 02:10", "in 3 days at …", then a date. */ +const day = (ms: number, now: number) => + formatCalendarUnix(ms, undefined, { ...LOCAL, reference: now }); + +/** The time alone when it falls on the same local day as `ref`. */ +const until = (ms: number, ref: number, now: number) => + areUnixEqualBy(ms, ref, "day", LOCAL) + ? formatUnix(ms, undefined, { ...LOCAL, timeStyle: "short" }) + : day(ms, now); + +function describe( + found: [number, number | null] | null, + now: number, +): BodyTimes { + if (!found) return { now: false, when: "Not in view in the coming year" }; + const minute = (ms: number) => + roundUnix(ms, { smallestUnit: "minute", ...LOCAL }) ?? ms; + const [start, end] = found; + if (start === now) { + return { + now: true, + when: end + ? `In view now, until ${until(minute(end), now, now)}` + : "In view now", + }; + } + const from = minute(start); + return { + now: false, + when: end + ? `${capital(day(from, now))} – ${until(minute(end), from, now)}` + : capital(day(from, now)), + }; +} + +/** For each site, when the sun and moon are next in view; returns the times + * and the moment they next need working out again: when a spell starts or + * ends, just after local midnight (so "today" and "tomorrow" stay true), and + * at least hourly. Yields between slices of the search. */ +export function* lookAhead( + sites: Site[], + seers: Seer[], + now: number, +): Generator { + // A year ahead on the calendar; on bad input, no search at all. + const year = addUnix(now, { years: 1 }, LOCAL) ?? now; + const longest = ms("P2D"); + const midnight = (endOfUnix(now, "day", LOCAL) ?? now + HOUR) + 1; + let next = Math.min(now + HOUR, midnight); + const times: SiteTimes[] = []; + for (const [i, site] of sites.entries()) { + const see = seers[i]!; + const sun = yield* spell( + (ms) => { + const at = sunAndMoon(site, ms); + return see.disc(at.sun, at.sunRadius, SUN_UP); + }, + now, + year, + longest, + ); + const halo = yield* spell( + (ms) => { + const at = sunAndMoon(site, ms); + return see.halo(at.sun, at.sunRadius); + }, + now, + year, + longest, + ); + const moon = yield* spell( + (ms) => { + const at = sunAndMoon(site, ms); + return see.disc(at.moon, at.moonRadius, -Math.PI / 2); + }, + now, + year, + longest, + ); + for (const found of [sun, halo, moon]) { + for (const ms of found ?? []) { + if (ms !== null && ms > now && ms < next) next = ms; + } + } + const { age, lit } = sunAndMoon(site, moon?.[0] ?? now); + times.push({ + sun: describe(sun, now), + halo: describe(halo, now), + moon: { ...describe(moon, now), phase: phaseText(age, lit), age }, + }); + } + return { times, next }; +} diff --git a/apps/site/src/scripts/aurora.sky.ts b/apps/site/src/scripts/aurora.sky.ts index 05a9f11..5001506 100644 --- a/apps/site/src/scripts/aurora.sky.ts +++ b/apps/site/src/scripts/aurora.sky.ts @@ -494,10 +494,12 @@ export type Sky = { planets: Float32Array; }; -export function sky(site: Site, ms: number): Sky { +/** The clock and the world frame for a site at a moment: Julian day, + * Julian centuries of terrestrial time (for the orbits; sidereal time uses + * the clock), local sidereal time, latitude, the equatorial-to-world matrix + * and the obliquity. */ +function frame(site: Site, ms: number) { const jd = julian(ms); - // Julian centuries of terrestrial time for the orbits; sidereal time uses - // the clock. const t = (jd + DELTA_T - 2451545) / 36525; const gmst = (280.46061837 + 360.98564736629 * (jd - 2451545) + 0.000387933 * t * t) * @@ -506,18 +508,66 @@ export function sky(site: Site, ms: number): Sky { const lat = site.lat * RAD; const hor = horizon(lat, lst); const eps = (23.4392911 - 0.0130042 * t) * RAD; + return { t, lst, lat, hor, eps }; +} - // Topocentric moon: subtract the observer's position from the geocentric one. - const [ml, mb, dist] = moonEcliptic(t); - const geo = ecliptic(ml, mb, eps).map((c) => c * dist) as Vec3; +/** Topocentric moon (km, equatorial of date): the geocentric position less + * the observer's. */ +function topocentric(geo: Vec3, lat: number, lst: number): Vec3 { const u = Math.atan(0.99664719 * Math.tan(lat)); const rc = 6378.14 * Math.cos(u); const rs = 6378.14 * 0.99664719 * Math.sin(u); - const topo: Vec3 = [ + return [ geo[0] - rc * Math.cos(lst), geo[1] - rc * Math.sin(lst), geo[2] - rs, ]; +} + +/** Just the sun and moon, for looking ahead: world directions, the moon's + * age as its elongation east of the sun in ecliptic longitude (0 new, π + * full, radians in 0..2π), and the fraction of its disc lit. */ +/** The sun's angular radius (radians) from its distance. */ +function sunRadiusAt(t: number): number { + const m = (357.52911 + 35999.05029 * t) * RAD; + const au = 1.00014 - 0.01671 * Math.cos(m) - 0.00014 * Math.cos(2 * m); + return (959.63 / au) * ARCSEC; +} + +export function sunAndMoon( + site: Site, + ms: number, +): { + sun: Vec3; + sunRadius: number; + moon: Vec3; + moonRadius: number; + age: number; + lit: number; +} { + const { t, lst, lat, hor, eps } = frame(site, ms); + const [ml, mb, dist] = moonEcliptic(t); + const sl = sunLongitude(t); + const geo = ecliptic(ml, mb, eps).map((c) => c * dist) as Vec3; + const topo = topocentric(geo, lat, lst); + const age = (((ml - sl) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + return { + sun: lift(normalize(apply(hor, ecliptic(sl, 0, eps)))), + sunRadius: sunRadiusAt(t), + moon: lift(normalize(apply(hor, topo))), + moonRadius: Math.asin(1737.4 / Math.hypot(...topo)), + age, + lit: (1 - Math.cos(mb) * Math.cos(age)) / 2, + }; +} + +export function sky(site: Site, ms: number): Sky { + const { t, lst, lat, hor, eps } = frame(site, ms); + + // Topocentric moon: subtract the observer's position from the geocentric one. + const [ml, mb, dist] = moonEcliptic(t); + const geo = ecliptic(ml, mb, eps).map((c) => c * dist) as Vec3; + const topo = topocentric(geo, lat, lst); const range = Math.hypot(...topo); const moon = normalize(apply(hor, topo)); const sun = normalize(apply(hor, ecliptic(sunLongitude(t), 0, eps))); diff --git a/apps/site/src/scripts/aurora.worker.ts b/apps/site/src/scripts/aurora.worker.ts index eccc624..592d645 100644 --- a/apps/site/src/scripts/aurora.worker.ts +++ b/apps/site/src/scripts/aurora.worker.ts @@ -24,8 +24,9 @@ import { skyFs, } from "./aurora.shaders"; import { getSystemTimeZone, getUnixNow } from "@northguild/gmt"; +import { SUN_UP, lookAhead, type Seer, type SiteTimes } from "./aurora.ahead"; import { MOON } from "./aurora.moon"; -import { SITES } from "./aurora.sites"; +import { SITES, type Site } from "./aurora.sites"; import { PLANETS, apply, @@ -78,7 +79,10 @@ export type AuroraMessage = /** What is drawn near this point (CSS pixels), within reach pixels. */ | { type: "pick"; x: number; y: number; reach: number } /** The page no longer shows the last pick; stop following it. */ - | { type: "drop" }; + | { type: "drop" } + /** Whether the page wants the sun and moon times (the popover is open or + * about to be). */ + | { type: "times"; want: boolean }; /** One unbroken run of a constellation line in CSS pixels, [x, y, x, y, …], * drawn from the end nearer the hovered star; step counts lines out from it. */ @@ -110,7 +114,9 @@ export type WorkerMessage = | "on" | "off" | { site: number } - | { pick: SkyLabel | null }; + | { pick: SkyLabel | null } + /** When the sun and moon next show in this window, per site in SITES order. */ + | { times: SiteTimes[]; zone: string }; const scope = self as unknown as { onmessage: ((event: MessageEvent) => void) | null; @@ -226,6 +232,162 @@ function starData(buf: ArrayBuffer): Float32Array { return out; } +/** Camera basis as columns (right, up, forward), heading from north, and + * the tangent of half the vertical field of view. On a portrait screen the + * page's cards cover the middle, so the view widens and tilts until the + * horizon sits about a third of the way down: the aurora then shows above + * the cards and the land or water below them. */ +function camera( + site: Site, + width: number, + height: number, +): { cam: Float32Array; tanY: number } { + const aspect = (width || 1) / (height || 1); + const portrait = Math.min(1, Math.max(0, (1 - aspect) / 0.45)); + const fov = (site.fov + 15 * portrait) * RAD; + const tanY = Math.tan(fov / 2); + const landscape = + -Math.tan(site.pitch * RAD) / Math.tan((site.fov / 2) * RAD); + const horizon = landscape + (0.3 - landscape) * portrait; + const pitch = Math.atan(-horizon * tanY); + const yaw = site.heading * RAD; + const fwd = [ + Math.sin(yaw) * Math.cos(pitch), + Math.sin(pitch), + Math.cos(yaw) * Math.cos(pitch), + ] as const; + const right = [Math.cos(yaw), 0, -Math.sin(yaw)] as const; + const cam = new Float32Array([ + ...right, + fwd[1] * right[2] - fwd[2] * right[1], + fwd[2] * right[0] - fwd[0] * right[2], + fwd[0] * right[1] - fwd[1] * right[0], + ...fwd, + ]); + return { cam, tanY }; +} + +/** A direction to CSS pixels on a width × height screen, or null behind the + * camera. */ +function project( + view: { cam: Float32Array; tanY: number }, + width: number, + height: number, + d: Vec3, +): [number, number] | null { + const { cam, tanY } = view; + const w = width || 1; + const h = height || 1; + const z = d[0] * cam[6]! + d[1] * cam[7]! + d[2] * cam[8]!; + if (z <= 0) return null; + const nx = + (d[0] * cam[0]! + d[1] * cam[1]! + d[2] * cam[2]!) / (z * tanY * (w / h)); + const ny = (d[0] * cam[3]! + d[1] * cam[4]! + d[2] * cam[5]!) / (z * tanY); + return [((nx + 1) / 2) * w, ((1 - ny) / 2) * h]; +} + +/** Ridge elevation by azimuth (degrees), radians; what is below it is + * hidden. Straight between samples, as the ridge mesh is drawn. */ +function ridgeFor(site: Site): (az: number) => number { + const skyline = SKYLINES[site.id]; + if (!skyline) return () => -Math.PI / 2; + const elev = new Int16Array(bytes(skyline.elev).buffer); + return (az) => { + const x = (az - skyline.from) / skyline.step; + const i = Math.floor(x); + const a = elev[i] ?? elev[i + 1] ?? -9000; + const b = elev[i + 1] ?? a; + return ((a + (b - a) * (x - i)) / 100) * RAD; + }; +} + +// The 22° halo's radius, and the height of the sun (its y) above which the +// pillar is gone, as the sky draws them (sun() in aurora.shaders.ts). +const HALO = 0.384; +const PILLAR_TOP = 0.11; + +/** Points around a circle of angular radius r about d (its centre too when + * r is small enough to be a disc), as world directions. */ +function ring(d: Vec3, r: number, n: number, centre: boolean): Vec3[] { + // A basis across the line of sight: level, then up the sky. + let e1: Vec3 = [d[2], 0, -d[0]]; + const l = Math.hypot(e1[0], e1[2]); + e1 = l > 1e-9 ? [e1[0] / l, 0, e1[2] / l] : [1, 0, 0]; + const e2: Vec3 = [ + d[1] * e1[2] - d[2] * e1[1], + d[2] * e1[0] - d[0] * e1[2], + d[0] * e1[1] - d[1] * e1[0], + ]; + const [c, s] = [Math.cos(r), Math.sin(r)]; + const out: Vec3[] = centre ? [d] : []; + for (let k = 0; k < n; k++) { + const t = (2 * Math.PI * k) / n; + const [u, v] = [Math.cos(t) * s, Math.sin(t) * s]; + out.push([ + d[0] * c + e1[0] * u + e2[0] * v, + d[1] * c + e1[1] * u + e2[1] * v, + d[2] * c + e1[2] * u + e2[2] * v, + ]); + } + return out; +} + +/** What a site's view shows on a width × height screen (see Seer). A point + * shows when it is in frame and above the skyline the ridge mesh draws. */ +function seer(site: Site, width: number, height: number): Seer { + const [w, h] = [width || 1, height || 1]; + const view = camera(site, w, h); + const ridge = ridgeFor(site); + const fwd = [view.cam[6]!, view.cam[7]!, view.cam[8]!]; + // From the middle of the frame to its corner: nothing farther is in frame. + const reach = Math.atan(Math.hypot(view.tanY * (w / h), view.tanY)); + const shows = (p: Vec3) => { + if (Math.asin(p[1]) < ridge(Math.atan2(p[0], p[2]) / RAD)) return false; + const xy = project(view, w, h, p); + return !!xy && xy[0] >= 0 && xy[0] <= w && xy[1] >= 0 && xy[1] <= h; + }; + // How far beyond `edge` of the frame's middle d is, and below `above`. + const far = (d: Vec3, edge: number, above: number) => { + const cos = d[0] * fwd[0]! + d[1] * fwd[1]! + d[2] * fwd[2]!; + const off = Math.acos(Math.min(1, Math.max(-1, cos))); + return Math.max(off - reach - edge, above - Math.asin(d[1])); + }; + return { + disc: (d, r, above) => { + const gap = far(d, r, above); + if (gap > 0) return gap; + // The centre and 16 points round the rim: a sliver counts. + return ring(d, r, 16, true).some(shows) ? 0 : 1e-6; + }, + halo: (sun, r) => { + const gap = far(sun, HALO, SUN_UP); + if (gap > 0) return gap; + // The ring is drawn too faintly to see on its own (and not at all over + // the aurora), so it counts only beside the sun's own disc, and only + // when a quarter of it shows (18 of 72 points, 5° apart round it). + if (ring(sun, r, 16, true).some(shows)) { + let ringShows = 0; + for (const p of ring(sun, HALO, 72, false)) { + if (shows(p) && ++ringShows >= 18) return 0; + } + } + // The pillar over a low sun (below about 6°, as the sky draws it): at + // least 2° of it, sampled every half degree up to 6° above the sun. + if (sun[1] >= PILLAR_TOP) return 1e-6; + const alt = Math.asin(sun[1]); + const az = Math.atan2(sun[0], sun[2]); + let pillarShows = 0; + for (let k = 1; k <= 12; k++) { + const up = alt + k * 0.5 * RAD; + const c = Math.cos(up); + const p: Vec3 = [Math.sin(az) * c, Math.sin(up), Math.cos(az) * c]; + if (shows(p) && ++pillarShows >= 4) return 0; + } + return 1e-6; + }, + }; +} + function start(init: Extract): void { const { canvas } = init; const gl = canvas.getContext("webgl", { @@ -355,36 +517,9 @@ function start(init: Extract): void { let tilt = [0, 0]; // Ridge elevation by azimuth (degrees), radians; what is below it is hidden. let ridgeAt = (_az: number) => -Math.PI / 2; - let cam = new Float32Array(9); + let cam: Float32Array = new Float32Array(9); let tanY = 1; - // Camera basis as columns (right, up, forward), heading from north. On a - // portrait screen the page's cards cover the middle, so the view widens and - // tilts until the horizon sits about a third of the way down: the aurora then - // shows above the cards and the land or water below them. - const aim = () => { - const aspect = (width || 1) / (height || 1); - const portrait = Math.min(1, Math.max(0, (1 - aspect) / 0.45)); - const fov = (site.fov + 15 * portrait) * RAD; - tanY = Math.tan(fov / 2); - const landscape = - -Math.tan(site.pitch * RAD) / Math.tan((site.fov / 2) * RAD); - const horizon = landscape + (0.3 - landscape) * portrait; - const pitch = Math.atan(-horizon * tanY); - const yaw = site.heading * RAD; - const fwd = [ - Math.sin(yaw) * Math.cos(pitch), - Math.sin(pitch), - Math.cos(yaw) * Math.cos(pitch), - ] as const; - const right = [Math.cos(yaw), 0, -Math.sin(yaw)] as const; - cam = new Float32Array([ - ...right, - fwd[1] * right[2] - fwd[2] * right[1], - fwd[2] * right[0] - fwd[0] * right[2], - fwd[0] * right[1] - fwd[1] * right[0], - ...fwd, - ]); - }; + const aim = () => ({ cam, tanY } = camera(site, width, height)); const place = (index: number) => { site = SITES[index] ?? SITES[0]!; // Field lines climb toward magnetic south at the site's dip. @@ -394,13 +529,7 @@ function start(init: Extract): void { aim(); const skyline = SKYLINES[site.id]; const mesh = skyline ? ridgeMesh(skyline) : new Float32Array(0); - if (skyline) { - const elev = new Int16Array(bytes(skyline.elev).buffer); - ridgeAt = (az) => - ((elev[Math.round((az - skyline.from) / skyline.step)] ?? -9000) / - 100) * - RAD; - } + ridgeAt = ridgeFor(site); gl.bindBuffer(gl.ARRAY_BUFFER, ridgeBuf); gl.bufferData(gl.ARRAY_BUFFER, mesh, gl.STATIC_DRAW); ridgeCount = mesh.length / 4; @@ -446,16 +575,7 @@ function start(init: Extract): void { let figures: Record = {}; const figureOf = new Map(); // A direction to CSS pixels, or null behind the camera. - const screen = (d: Vec3): [number, number] | null => { - const w = width || 1; - const h = height || 1; - const z = d[0] * cam[6]! + d[1] * cam[7]! + d[2] * cam[8]!; - if (z <= 0) return null; - const nx = - (d[0] * cam[0]! + d[1] * cam[1]! + d[2] * cam[2]!) / (z * tanY * (w / h)); - const ny = (d[0] * cam[3]! + d[1] * cam[4]! + d[2] * cam[5]!) / (z * tanY); - return [((nx + 1) / 2) * w, ((1 - ny) / 2) * h]; - }; + const screen = (d: Vec3) => project({ cam, tanY }, width, height, d); // Up and clear of the skyline. const clear = (d: Vec3, above: number) => { const alt = Math.asin(d[1]); @@ -625,6 +745,39 @@ function start(init: Extract): void { if (!xy) held = NOTHING; }; + // Sun and moon times, while the page wants them: worked out fresh from the + // clock a slice at a time between frames, and again when a spell starts or + // ends, after local midnight, at least hourly, when the window changes size + // and when the tab is shown again (background timers can run late). + let wantTimes = false; + let timesRun = 0; + let timesTimer = 0; + const times = () => { + clearTimeout(timesTimer); + const run = ++timesRun; + if (!wantTimes) return; + const [w, h] = [width || 1, height || 1]; + const seers = SITES.map((s) => seer(s, w, h)); + const job = lookAhead(SITES, seers, wall()); + const slice = () => { + if (run !== timesRun) return; + const step = job.next(); + if (!step.done) { + timesTimer = self.setTimeout(slice, 0); + return; + } + scope.postMessage({ + times: step.value.times, + zone: getSystemTimeZone(), + }); + timesTimer = self.setTimeout( + times, + Math.max(step.value.next - wall(), 0) + 1000, + ); + }; + slice(); + }; + const quad = (program: WebGLProgram) => { const a = attrib(program, "aPos", tri, 2); gl.drawArrays(gl.TRIANGLES, 0, 3); @@ -832,6 +985,7 @@ function start(init: Extract): void { resize(); draw(); follow(); + times(); } else if (data.type === "still") { still = data.still; stop(); @@ -840,11 +994,18 @@ function start(init: Extract): void { } else if (data.type === "hidden") { hidden = data.hidden; if (hidden) stop(); - else run(); + else { + run(); + times(); + } } else if (data.type === "pick") { scope.postMessage({ pick: pick(data.x, data.y, data.reach) }); } else if (data.type === "drop") { held = NOTHING; + } else if (data.type === "times") { + if (data.want === wantTimes) return; + wantTimes = data.want; + times(); } }; } From bfa693473e4d1286a901318ad8c3bc33834ff154 Mon Sep 17 00:00:00 2001 From: "Craig C." Date: Fri, 23 Oct 2026 20:38:01 +0300 Subject: [PATCH 06/10] feat(site): the sky panel names the gmt version it runs on A quiet footer under the credits: "Site runs on @northguild/gmt vX.Y.Z", linking to the GMT docs. The version is read from the installed package at build time, so it follows every upgrade; gmt does not export its package.json, so it is imported by path and only the string reaches the page. --- apps/site/src/components/Aurora.astro | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/site/src/components/Aurora.astro b/apps/site/src/components/Aurora.astro index edb386f..606f417 100644 --- a/apps/site/src/components/Aurora.astro +++ b/apps/site/src/components/Aurora.astro @@ -5,6 +5,10 @@ import { compass, coords, } from "../scripts/aurora.sites"; +// The installed @northguild/gmt's version, read at build time so the footer +// follows every upgrade. The package does not export its package.json, so +// it is read by path; only the version string reaches the page. +import { version as gmtVersion } from "../../node_modules/@northguild/gmt/package.json"; ---