Ratatoskr/src/app/features/scene/skyState.ts
Nibu 3b1405907a WIP ridge: physical sky over Vienna (Hillaire LUTs, Earth coefficients, real-clock sun), ACES display fitted per state, RIDGE_NIGHT_BAND removed
Not landed: 3 tests red (skyState 'night below day and dusk', two skyTheme ink checks that read the old bands), no clouds/rain/mist yet,
no stills, no Aqua-method measurement of the new sky, no Soul pass. Local branch only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 02:23:11 +00:00

142 lines
5.7 KiB
TypeScript

// Which sky the signed-out page is drawn against. The choice is one value, kept in one
// localStorage key; whoever draws the sky (the scene, the title bar's control) reads it here, and
// `skyBackdrop` says how light or dark the sky behind the login text can be in each state, so the
// text colours can be checked against it. 'auto' is a choice, not a sky: it resolves from the
// clock to day, dusk or night (`resolveSkyState`), and every sky drawn is one of those three.
import { sunElevation } from '../../../sky/sun.js';
import type { FixedSkyState } from './skyBand';
export type SkyState = 'auto' | 'day' | 'dusk' | 'night';
export const SKY_STATES: readonly SkyState[] = ['auto', 'day', 'dusk', 'night'];
export const SKY_STATE_KEY = 'ratatoskr.skyState';
const isSkyState = (v: unknown): v is SkyState => SKY_STATES.includes(v as SkyState);
function load(): SkyState {
try {
const stored = localStorage.getItem(SKY_STATE_KEY);
return isSkyState(stored) ? stored : 'auto';
} catch {
return 'auto';
}
}
export type { FixedSkyState };
/** Where the clock puts the sun: the latitude the skies are baked for (src/sky/skyTokens.js MOMENTS), the longitude of this timezone. */
const LAT = 45;
/** Sun elevation (degrees) from which it is day, and below the negative of which it is night; between, dusk (dawn included). */
export const DAY_FROM = 6;
let clock: () => Date = () => new Date();
/** Minutes west of UTC of this timezone's standard time (the larger of its January and July offsets), so daylight saving does not move the sun. */
export const standardOffsetMinutes = (now: Date): number =>
Math.max(
new Date(now.getFullYear(), 0, 1).getTimezoneOffset(),
new Date(now.getFullYear(), 6, 1).getTimezoneOffset()
);
/** The fixed sky a choice draws now: itself, or for 'auto' the sky the sun's elevation here calls for. */
export function resolveSkyState(s: SkyState, now: Date = clock()): FixedSkyState {
if (s !== 'auto') return s;
const lon = -standardOffsetMinutes(now) / 4;
const el = sunElevation(now, LAT, lon) as number;
if (el >= DAY_FROM) return 'day';
return el <= -DAY_FROM ? 'night' : 'dusk';
}
let current: SkyState | undefined;
const listeners = new Set<(s: FixedSkyState) => void>();
let timer: ReturnType<typeof setTimeout> | undefined;
let shown: FixedSkyState | undefined;
const TICK_MS = 60_000;
/** The choice (what the control shows). */
export const getSkyState = (): SkyState => {
current ??= load();
return current;
};
/** The sky being drawn: the choice, resolved. */
export const getResolvedSkyState = (): FixedSkyState => resolveSkyState(getSkyState());
const emit = () => {
shown = getResolvedSkyState();
[...listeners].forEach((cb) => cb(shown as FixedSkyState));
};
// While anyone listens and the choice is 'auto', one timer checks the clock each minute and tells
// listeners when the resolved sky changes from the one last told (`shown`, set when telling and when the
// first listener arrives, never by a later arrival or departure). Otherwise there is no timer.
function sync(): void {
clearTimeout(timer);
timer = undefined;
if (listeners.size === 0 || getSkyState() !== 'auto') return;
const tick = () => {
if (getResolvedSkyState() !== shown) emit();
timer = setTimeout(tick, TICK_MS);
};
timer = setTimeout(tick, TICK_MS);
}
/** The moment the sky is drawn for: the real time, or the test clock. */
export const skyNow = (): Date => clock();
/** Replace the clock (tests); no argument restores the real one. */
export function setSkyClock(now?: () => Date): void {
clock = now ?? (() => new Date());
sync();
}
export function setSkyState(s: SkyState): void {
if (!isSkyState(s)) return;
const changed = s !== getSkyState();
current = s;
try {
localStorage.setItem(SKY_STATE_KEY, s);
} catch {
// Storage is refused (private mode, quota): the choice holds for this page load only.
}
if (changed) {
sync();
emit();
}
}
/** Listeners hear the resolved sky: on a change of choice, and while 'auto', when the clock crosses a boundary. */
export function subscribe(cb: (s: FixedSkyState) => void): () => void {
if (listeners.size === 0) shown = getResolvedSkyState();
listeners.add(cb);
sync();
return () => {
listeners.delete(cb);
sync();
};
}
/**
* Relative luminance (WCAG, 0..1) of the lightest and darkest sky the state can show behind the
* page. Light ink is worst against `lightest`, dark ink against `darkest`. 'auto' is the band of
* the sky it resolves to now.
*/
export interface SkyBackdrop {
readonly darkest: number;
readonly lightest: number;
}
// The union of every sky a scene draws for the state: the ridge sky and the terrain sky, each from
// the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). The ridge sky is the physical sky over Vienna, its
// display fitted to the state's budget (ridge/toneMap.ts `BUDGET`): the budget is its extreme, whatever the sun's elevation. Held as constants so
// this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering
// tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts
// over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts).
// Night's lightest also covers a sky the page URL steers (shaped to the band ceiling, skyBand.ts).
// Bounds what the 8-bit screen shows too (one step each way for rounding and dither), rounded outward to six places.
const BACKDROPS: Record<FixedSkyState, SkyBackdrop> = {
day: { darkest: 0.321, lightest: 1 },
dusk: { darkest: 0.460352, lightest: 1 },
night: { darkest: 0, lightest: 0.081642 },
};
export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)];