1
0

SoCal, the whole bay, a moon, and gates that actually run

Six agents in parallel, and the two city packs independently reported the same
blocker: `focusRegions` and `coarseFactor` existed on the `City` type and
nothing implemented them. Uniform lattices would have been 2.9M points for
Southern California and 3.7M for the expanded bay. Both packs were unloadable
as written.

`buildAxis` is the answer, and it is honest about its limits: refinement is per
axis, not per rectangle, so a focus region sharpens its whole row *and* its
whole column. Two regions at opposite corners refine nearly everything between
them. Measured, not guessed — the bay went 0.53M points with one region and
1.64M with three, for detail nobody is looking at from a board this wide. One
region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s.

Then three things that were only ever right because San Francisco was the only
city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a
230-unit board; the bay is 1003 units across and the camera physically could
not retreat far enough to frame it. Fog distances were scene units pinned to
the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest
weather, which over ninety-four kilometres of bay correctly hides three
quarters of it — the night view was a black rectangle for a completely
reasonable reason. All three now derive from the board.

The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a
physical ratio of one to four hundred thousand. What is being reproduced is
what a moonlit night looks like on a screen in a lit room.

The CI gate caught itself, which is the part worth keeping. Port 8431 was
already held by a server from an earlier session, so the boot check polled a
healthy stranger while the process it started died on EADDRINUSE. It now
refuses to run rather than pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Karti Tripathi
2026-08-05 03:13:32 -07:00
parent 8bcb391455
commit 44c5a79424
25 changed files with 9246 additions and 220 deletions
+521 -19
View File
@@ -20,11 +20,12 @@
* and touches nothing. Call it every frame or once a minute; the cost is
* the same table lookup either way.
*
* The solar half is computed locally by `solar.ts` with no network, and the
* weather half degrades to `null` — which this file reads as a clear day with
* the local climatology still running. The whole engine has to work with no
* account, no key and no network, and a sky that goes flat grey the moment the
* wifi drops would fail that in the most visible way possible.
* The solar half is computed locally by `solar.ts` with no network, the lunar
* half by `moonPosition` below, and the weather half degrades to `null` — which
* this file reads as a clear day with the local climatology still running. The
* whole engine has to work with no account, no key and no network, and a sky
* that goes flat grey the moment the wifi drops would fail that in the most
* visible way possible.
*
* Wiring one to a city, in full:
*
@@ -100,18 +101,205 @@ export interface WeatherObservation {
export interface Environment {
time: Date;
sun: SolarPosition;
/**
* The other light in the sky. Required rather than optional because a night
* without it is the black rectangle this file exists to avoid, and an
* `Environment` assembled without one would silently be that.
*/
moon: MoonPosition;
/** `null` when nobody was asked. A supported state, not an error. */
weather: WeatherObservation | null;
}
/** Build an `Environment` for a place and an instant, computing the sun locally. */
/**
* Build an `Environment` for a place and an instant, computing both bodies
* locally.
*/
export function observe(
lat: number,
lng: number,
when: Date,
weather: WeatherObservation | null = null,
): Environment {
return { time: when, sun: solarPosition(lat, lng, when), weather };
return {
time: when,
sun: solarPosition(lat, lng, when),
moon: moonPosition(lat, lng, when),
weather,
};
}
// ---- The moon -------------------------------------------------------------
/**
* Where the moon is, and how much of it is lit.
*
* This lives here rather than in `solar.ts` because it exists for exactly one
* consumer: the key light after sunset. `solar.ts` is the sun's own module and
* has a much harder accuracy contract to keep — sunrise to the minute — whereas
* nothing downstream of this can tell a tenth of a degree of moon from the
* right answer.
*/
export interface MoonPosition {
/** Degrees clockwise from true north, matching `SolarPosition.azimuth`. */
azimuth: number;
/** Degrees above the horizon, corrected for parallax. */
elevation: number;
/** Fraction of the visible disc in sunlight: 0 at new, 1 at full. */
illuminated: number;
/** 0 new, 0.25 first quarter, 0.5 full, 0.75 last quarter. */
phase: number;
/** Centre to centre, in kilometres. Roughly 356,500 to 406,700. */
distanceKm: number;
}
const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
const JD_UNIX_EPOCH = 2_440_587.5;
const J2000 = 2_451_545;
/** Equatorial radius, for the parallax correction. */
const EARTH_RADIUS_KM = 6378.14;
/**
* The moon's position and phase, computed rather than fetched.
*
* Meeus, *Astronomical Algorithms* chapter 47, truncated hard. The full ELP
* series is sixty periodic terms in longitude alone; what is here is the
* thirteen largest, which are the ones with names — the equation of centre
* (6.29°, the orbit being an ellipse), the **evection** (1.27°, the sun pulling
* the orbit's own ellipse around every 32 days and the single largest thing
* Ptolemy did not know about), the **variation** (0.66°, the moon running fast
* at the syzygies and slow at the quadratures), the annual equation and the
* parallactic inequality. Against Meeus's own worked example 47.a — 1992 April
* 12, 0h TD — this returns 133.150° against his 133.163°, a latitude of -3.223°
* against -3.229°, and 368,335 km against 368,410. Thirteen thousandths of a
* degree. The moon's disc is half a degree wide, so a renderer cannot see the
* error and neither can anyone looking at it.
*
* Two deliberate omissions. The eccentricity factor `E` that Meeus applies to
* every term in the sun's mean anomaly is dropped, because it is a correction
* of about 0.017 to terms already under a fifth of a degree. And the elevation
* is not refracted — `solar.ts` owns that curve and does not export it, and at
* moonrise the moon is contributing almost nothing anyway. Parallax *is*
* applied, because it is the big one: the moon is close enough that standing on
* the surface of the Earth rather than at its centre moves it by most of a
* degree, which is fifty times the truncation error.
*/
export function moonPosition(lat: number, lng: number, when: Date): MoonPosition {
const jd = when.getTime() / MS_PER_DAY + JD_UNIX_EPOCH;
const t = (jd - J2000) / 36_525;
const sin = (deg: number) => Math.sin(deg * DEG);
const cos = (deg: number) => Math.cos(deg * DEG);
// The Delaunay arguments, Meeus 47.1-47.5: the moon's mean longitude, its
// mean elongation from the sun, the sun's mean anomaly, the moon's own mean
// anomaly, and its argument of latitude — the angle from the ascending node,
// which is what makes the moon's path wander 5° either side of the ecliptic.
const meanLongitude = 218.3164477 + 481_267.88123421 * t - 0.0015786 * t * t;
const elongation = 297.8501921 + 445_267.1114034 * t - 0.0018819 * t * t;
const sunAnomaly = 357.5291092 + 35_999.0502909 * t - 0.0001536 * t * t;
const anomaly = 134.9633964 + 477_198.8675055 * t + 0.0087414 * t * t;
const argLatitude = 93.272095 + 483_202.0175233 * t - 0.0036539 * t * t;
const d = elongation;
const m = sunAnomaly;
const mp = anomaly;
const f = argLatitude;
const longitude =
meanLongitude +
6.288774 * sin(mp) + // equation of centre
1.274027 * sin(2 * d - mp) + // evection
0.658314 * sin(2 * d) + // variation
0.213618 * sin(2 * mp) -
0.185116 * sin(m) - // annual equation
0.114332 * sin(2 * f) +
0.058793 * sin(2 * d - 2 * mp) +
0.057066 * sin(2 * d - m - mp) +
0.053322 * sin(2 * d + mp) +
0.045758 * sin(2 * d - m) -
0.040923 * sin(m - mp) -
0.03472 * sin(d) - // parallactic inequality
0.030383 * sin(m + mp);
const latitude =
5.128122 * sin(f) +
0.280602 * sin(mp + f) +
0.277693 * sin(mp - f) +
0.173237 * sin(2 * d - f) +
0.055413 * sin(2 * d - mp + f) +
0.046271 * sin(2 * d - mp - f) +
0.032573 * sin(2 * d + f) +
0.017198 * sin(2 * mp + f) +
0.009266 * sin(2 * d + mp - f) +
0.008822 * sin(2 * mp - f);
const distanceKm =
385_000.56 -
20_905.355 * cos(mp) -
3699.111 * cos(2 * d - mp) -
2955.968 * cos(2 * d) -
569.925 * cos(2 * mp);
// The sun's apparent longitude, to the same standard: needed only for the
// elongation the phase is read off, where a hundredth of a degree is three
// decimal places more than the illuminated fraction can carry.
const sunLongitude =
280.46646 +
36_000.76983 * t +
1.914602 * sin(m) +
0.019993 * sin(2 * m) +
0.000289 * sin(3 * m);
// Ecliptic to equatorial.
const obliquity = (23.4392911 - 0.0130042 * t) * DEG;
const lambda = longitude * DEG;
const beta = latitude * DEG;
const rightAscension = Math.atan2(
Math.sin(lambda) * Math.cos(obliquity) - Math.tan(beta) * Math.sin(obliquity),
Math.cos(lambda),
);
const declination = Math.asin(
clamp(
Math.sin(beta) * Math.cos(obliquity) + Math.cos(beta) * Math.sin(obliquity) * Math.sin(lambda),
-1,
1,
),
);
// Equatorial to horizontal, through the local hour angle. Greenwich sidereal
// time is Meeus 12.4: the extra 0.98564736629° a day over 360 is the Earth's
// orbital motion, which is the whole reason a sidereal day is four minutes
// short of a solar one.
const gmst = mod(280.46061837 + 360.98564736629 * (jd - J2000) + 0.000387933 * t * t, 360);
const hourAngle = (gmst + lng) * DEG - rightAscension;
const phi = lat * DEG;
const sinAltitude = clamp(
Math.sin(phi) * Math.sin(declination) +
Math.cos(phi) * Math.cos(declination) * Math.cos(hourAngle),
-1,
1,
);
let altitude = Math.asin(sinAltitude);
const azimuth = Math.atan2(
-Math.cos(declination) * Math.sin(hourAngle),
Math.sin(declination) * Math.cos(phi) - Math.cos(declination) * Math.sin(phi) * Math.cos(hourAngle),
);
altitude -= Math.asin(EARTH_RADIUS_KM / distanceKm) * Math.cos(altitude);
// Phase from the sun-moon elongation. The proper phase angle also wants the
// earth-sun distance, which moves the answer by about a sixth of a degree —
// two parts in a thousand of the illuminated fraction, and this drives a
// light rig.
const separation = mod(longitude - sunLongitude, 360);
return {
azimuth: mod(azimuth * RAD, 360),
elevation: altitude * RAD,
illuminated: (1 - Math.cos(separation * DEG)) / 2,
phase: separation / 360,
distanceKm,
};
}
// ---- Options --------------------------------------------------------------
@@ -178,6 +366,45 @@ export const PACIFIC_MARINE_LAYER: MarineLayerOptions = {
visibilityM: 5000,
};
/**
* Moonlight as a look rather than as a photometry.
*
* Full moonlight is about one four-hundred-thousandth of sunlight. Reproducing
* that ratio faithfully gives you a black screen, because a monitor has three
* orders of magnitude of range and this needs six, and because the eye that
* makes a moonlit landscape legible is doing an hour of dark adaptation that a
* lit room will not allow. So the numbers here are not the ratio; they are what
* a moonlit night *looks like* once you are in it — a low, soft, blue-shifted
* key you can read shapes by, on a sky that is deep blue rather than absent.
*
* The blue is the interesting lie. Moonlight is reflected sunlight off a
* grey-brown rock and is very slightly *warmer* than daylight, around 4,100 K.
* It looks blue because at those levels the eye is running on rods, whose peak
* sensitivity sits about 50 nm bluer than the cones' — the Purkinje shift — so
* a moonlit scene genuinely is blue to the person standing in it while being
* neutral to a light meter. Rendering it neutral is the more accurate choice
* and the wrong one, and every cinematographer since the 1930s has agreed.
*/
export interface MoonlightOptions {
/** Key intensity of a full moon, high, in clear air. Compare a noon sun at 2.1. */
intensity: number;
/** The key's colour. */
color: number;
/** How far a full moon lifts the night sky toward moonlit blue, 0..1. */
skyLift: number;
}
export const DEFAULT_MOONLIGHT: MoonlightOptions = {
// Physically absurd — moonlight is about 1/400,000 of sunlight — and the
// right number anyway. What is being reproduced is the *look* of a moonlit
// night on a screen someone is looking at in a lit room, not the photon
// count. At the honest value the map is a black rectangle, which is the bug
// this exists to fix.
intensity: 1.15,
color: 0x9db4e8,
skyLift: 0.85,
};
export interface AtmosphereOptions {
/**
* Observer longitude, degrees east. Needed for apparent solar time, which is
@@ -216,6 +443,13 @@ export interface AtmosphereOptions {
shadowFloorDeg?: number;
/** Coastal fog model, or nothing. Off unless a city asks for it. */
marineLayer?: MarineLayerOptions | null;
/**
* Moonlight, or `null` for none — which leaves the keyframe table's token
* night sidelight in charge, as it was before there was a moon to replace it.
* Defaults to `DEFAULT_MOONLIGHT`, because a black night is the failure and
* having to opt out of the fix is the wrong way round.
*/
moonlight?: MoonlightOptions | null;
}
export interface Atmosphere {
@@ -236,6 +470,26 @@ const DEFAULT_FOG_FAR = 460;
const DEFAULT_MIN_VISIBILITY_M = 4500;
const DEFAULT_SHADOW_FLOOR_DEG = 7;
/**
* The darkest the night sky is ever allowed to get, per channel.
*
* A correct night is `#000`, and `#000` is unusable: the horizon disappears,
* the skyline stops having a silhouette against anything, and the frame reads
* as a failed render rather than as darkness. These are the values of a clear
* moonless sky as a dark-adapted eye reports it rather than as a photometer
* does — still unmistakably night, with the horizon a little warmer and
* brighter than the zenith because that is where the airglow and everyone
* else's city lights are.
*/
const NIGHT_FLOOR_TOP = 0x090e1c;
const NIGHT_FLOOR_HORIZON = 0x16203a;
/** The same sky with a full moon in it. */
const MOONLIT_SKY_TOP = 0x111d3e;
const MOONLIT_SKY_HORIZON = 0x2d3c62;
const MOONLIT_HEMI_SKY = 0x2b3b60;
const MOONLIT_AMBIENT = 0x3f4c76;
/**
* Where fog starts, as a fraction of where it ends. `cityDaylight`'s 210/460 is
* 0.457 and looks right, so the ratio is held rather than the distance: a fog
@@ -456,6 +710,7 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
const floorFar = (options.minVisibilityM ?? DEFAULT_MIN_VISIBILITY_M) / metresPerUnit;
const shadowFloor = options.shadowFloorDeg ?? DEFAULT_SHADOW_FLOOR_DEG;
const marineOptions = options.marineLayer ?? null;
const moonOptions = options.moonlight === undefined ? DEFAULT_MOONLIGHT : options.moonlight;
function apply(env: Environment): LightingState {
const elevation = env.sun.elevation;
@@ -468,23 +723,37 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
// still black, and a modifier that does not know the difference will
// cheerfully raise the small hours to a uniform slate.
const day = smoothstep(-6, 6, elevation);
const night = nightFactor(elevation);
const weather = env.weather;
const cloud = clamp(weather?.cloudCover ?? 0, 0, 1);
const precipitation = clamp(weather?.precipitation ?? 0, 0, 1);
const condition = weather?.condition ?? "clear";
applyCloud(rig, cloud, day);
applyPrecipitation(rig, precipitation, condition, day);
// An observation always beats the climatology: `null` lets the model run
// free, which is what gives an offline San Francisco its summer fog, but a
// station reporting sunshine ends the argument. See `observedObscuration`.
//
// Computed here rather than after the rig passes, where it used to sit,
// because the moon needs to know how much air is in the way before it can
// say how much light is getting through. Both halves are pure functions of
// the observation and neither touches the rig, so the move is a reordering
// of independent statements and nothing else.
const observed = weather ? observedObscuration(weather) : null;
const modelled = marineOptions ? marineStrength(marineOptions, env, lng) : 0;
const obscuration =
observed === null ? modelled : observed === 0 ? 0 : Math.max(observed, modelled);
// The moon goes in before the weather does, so that an overcast night is
// the weather closing over a moonlit sky rather than over a black one.
const moon = moonOptions
? moonRig(env.moon, night, cloud, obscuration, moonOptions, shadowFloor)
: NO_MOON;
applyNight(rig, moon, night, moonOptions);
applyCloud(rig, cloud, day);
applyPrecipitation(rig, precipitation, condition, day);
let fogFar = visibilityFar(weather, condition, clearFar, metresPerUnit);
if (weather === null || weather.visibilityKm === null) {
// Rain shortens the view; a source that measured visibility has already
@@ -505,11 +774,7 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
const near = clamp(fogFar >= clearFar ? clearNear : fogFar * FOG_NEAR_RATIO, 2, fogFar * 0.9);
return {
sun: {
direction: lightDirection(env.sun, shadowFloor),
color: rig.sunColor,
intensity: Math.max(0, rig.sunIntensity),
},
sun: combineKey(lightDirection(env.sun, shadowFloor), rig.sunColor, rig.sunIntensity, moon),
hemisphere: {
sky: rig.hemiSky,
ground: rig.hemiGround,
@@ -563,6 +828,190 @@ function lightDirection(sun: SolarPosition, floorDeg: number): [number, number,
return [dir.x, dir.y, dir.z];
}
// ---- Night ----------------------------------------------------------------
/**
* How much of a night it is, from the sun alone: 0 in daylight, 1 once the
* sun's own light is gone.
*
* Exported because it is the engine's single definition of dusk, and more than
* one thing needs one. The moon takes over as the key light on this curve, and
* `nightlights.ts` reads it to decide how brightly a lit window burns against
* the sky behind it. Two modules each inventing their own idea of when night
* begins is how a city ends up switching its lights on well after the moon has
* already become the brightest thing in the frame.
*
* The upper edge is half a degree *above* the horizon rather than on it,
* because the sun's last half degree is its own disc setting and the light has
* already collapsed by then; the lower edge is the middle of nautical twilight,
* by which point what is left of the sun is a glow in one direction and not a
* light source.
*/
export function nightFactor(elevation: number): number {
return 1 - smoothstep(-8, 0.5, elevation);
}
/** The moon's contribution, once phase, altitude and the weather have had a say. */
interface MoonRig {
/** Unit vector toward the moon, floored like the sun's. `null` when it is down. */
direction: [number, number, number] | null;
color: number;
/** Key intensity. */
intensity: number;
/** How moonlit the sky and the fill are, 0..1. */
glow: number;
}
const NO_MOON: MoonRig = { direction: null, color: 0, intensity: 0, glow: 0 };
function moonRig(
moon: MoonPosition,
night: number,
cloud: number,
obscuration: number,
options: MoonlightOptions,
floorDeg: number,
): MoonRig {
// Below the horizon it contributes nothing, and the ramp above it is several
// degrees wide: a moon in the first degrees of its own rise is being
// reddened and extinguished by the same long air path that does it to the
// sun, and it has far less to lose.
const up = smoothstep(-1, 8, moon.elevation);
if (up <= 0 || night <= 0) return NO_MOON;
// Not linear in the illuminated fraction, and this is the part that makes a
// moon phase read as a moon phase. A half moon is nowhere near half as bright
// as a full one — nearer a tenth. Two things do that: at quarter phase the
// ground you can see is lit at a grazing angle and is mostly its own long
// shadows, and at full phase those shadows all hide behind the rocks casting
// them and the disc surges. The exponent is the cheap version of both. The
// floor is earthshine, and the fact that a key of exactly zero leaves a scene
// with no silhouettes in it at all.
const lit = 0.06 + 0.94 * clamp(moon.illuminated, 0, 1) ** 1.6;
// Cloud takes the key away much faster than it takes the glow away, and that
// asymmetry is the whole character of an overcast night: no shadows at all,
// but a deck lit from above that is brighter than a clear moonless sky.
const key = night * up * lit * clamp((1 - 0.9 * cloud) * (1 - 0.92 * obscuration), 0, 1);
const glow = night * up * lit * clamp(1 - 0.35 * cloud, 0, 1);
const lifted = floorDeg > 0 ? Math.max(moon.elevation, floorDeg) : moon.elevation;
return {
direction: skyDirection(moon.azimuth, lifted),
color: options.color,
intensity: options.intensity * key,
glow: clamp(glow, 0, 1),
};
}
/**
* Horizontal coordinates to the engine's axes: `x` east, `z` south, `y` up.
*
* `solar.ts` exports `sunDirection` for exactly this conversion and it would
* work unchanged on the moon — it reads only an azimuth and an elevation — but
* its argument is a `SolarPosition`, and inventing a declination and an
* equation of time to satisfy a type is worse than four lines that say what
* they mean. The convention is `solar.ts`'s and must stay it: azimuth is
* measured from north, and north is `-z`.
*/
function skyDirection(azimuth: number, elevation: number): [number, number, number] {
const el = elevation * DEG;
const az = azimuth * DEG;
const horizontal = Math.cos(el);
return [horizontal * Math.sin(az), Math.sin(el), -horizontal * Math.cos(az)];
}
/**
* Fold the night into the rig: the moon's fill, the residual glow of a moonless
* night, and the floor under the sky.
*
* The key itself is not set here — see `combineKey` — because a `LightingState`
* carries one directional light and the sun has not necessarily finished with
* it yet.
*/
function applyNight(
rig: Rig,
moon: MoonRig,
night: number,
options: MoonlightOptions | null,
): void {
if (night <= 0) return;
if (options) {
// The table's deepest stops carry a token sidelight standing in for
// moonlight and city glow, because a scene lit by hemisphere alone has no
// silhouettes in it and reads as a bug. There is a real moon now, so the
// stand-in gets out of its way — not entirely, because something still has
// to hold the shape of the city up on an overcast night at new moon.
rig.sunIntensity *= 1 - 0.85 * night;
const glow = clamp(moon.glow * options.skyLift, 0, 1);
rig.skyTop = mixHex(rig.skyTop, MOONLIT_SKY_TOP, glow);
rig.skyHorizon = mixHex(rig.skyHorizon, MOONLIT_SKY_HORIZON, glow);
rig.hemiSky = mixHex(rig.hemiSky, MOONLIT_HEMI_SKY, 0.7 * glow);
rig.ambientColor = mixHex(rig.ambientColor, MOONLIT_AMBIENT, 0.7 * glow);
rig.hemiIntensity *= 1 + 0.5 * glow;
rig.ambientIntensity *= 1 + 0.45 * glow;
}
// Starlight, airglow, and the sodium of everywhere else bouncing off whatever
// is overhead. Small, unshaped, and the difference between a night that is
// dark and a night that is missing.
rig.hemiIntensity *= 1 + 0.28 * night;
rig.ambientIntensity *= 1 + 0.22 * night;
// Upward only and per channel, so this can rescue a sky and can never dim
// one — the weather passes that follow are free to keep taking light out of
// the frame without having to know this ran.
rig.skyTop = mixHex(rig.skyTop, atLeast(rig.skyTop, NIGHT_FLOOR_TOP), night);
rig.skyHorizon = mixHex(rig.skyHorizon, atLeast(rig.skyHorizon, NIGHT_FLOOR_HORIZON), night);
}
/**
* One directional light, two things in the sky.
*
* `LightingState` carries a single key and that is the right shape — a second
* shadow-casting light is a second shadow map and a second full pass over 24k
* instances, for a source that is a two-hundred-thousandth as bright as the one
* already there. So the two get averaged, weighted by their own intensities,
* which is what a single light standing in for both ought to do: at dusk with a
* bright moon already up the key points somewhere between them, and by the time
* either one dominates it has arrived at that one. The crossover happens inside
* civil twilight, where both are near a tenth of an intensity and there is
* almost no shadow left to be wrong about.
*/
function combineKey(
sunDir: [number, number, number],
sunColor: number,
sunIntensity: number,
moon: MoonRig,
): LightingState["sun"] {
const sun = Math.max(0, sunIntensity);
if (!moon.direction || moon.intensity <= 0) {
return { direction: sunDir, color: sunColor, intensity: sun };
}
const total = sun + moon.intensity;
const weight = moon.intensity / total;
const x = lerp(sunDir[0], moon.direction[0], weight);
const y = lerp(sunDir[1], moon.direction[1], weight);
const z = lerp(sunDir[2], moon.direction[2], weight);
const length = Math.hypot(x, y, z);
// A full moon rises as the sun sets and the two stand opposite each other,
// which is the one configuration where the average of the two directions is
// nothing at all. A zero direction would put the light inside the ground and
// black the scene out, so take the brighter of the two instead.
const direction: [number, number, number] =
length < 0.05
? weight >= 0.5
? moon.direction
: sunDir
: [x / length, y / length, z / length];
return { direction, color: mixHex(sunColor, moon.color, weight), intensity: total };
}
// ---- Weather --------------------------------------------------------------
/**
@@ -847,6 +1296,21 @@ function desaturate(hex: number, t: number): number {
return encode(lerp(r, y, k), lerp(g, y, k), lerp(b, y, k));
}
/**
* Per-channel maximum, in linear light: a colour raised to a floor and never
* pushed below it.
*
* Per channel rather than by luminance, because the floor is a *colour* — a
* blue-black — and clamping a night sky by its brightness alone would let a
* grey of the same luminance through, which is the one thing the night must not
* be allowed to look like.
*/
function atLeast(hex: number, floor: number): number {
const [r, g, b] = linear(hex);
const [fr, fg, fb] = linear(floor);
return encode(Math.max(r, fr), Math.max(g, fg), Math.max(b, fb));
}
/** Multiply a colour's light, not its bytes. */
function scale(hex: number, factor: number): number {
const [r, g, b] = linear(hex);
@@ -923,10 +1387,13 @@ function wrapSigned(x: number, period: number): number {
* back a clear golden morning or it is not a model of anything — October is
* the month San Francisco is warm and cloudless and every visitor is
* surprised by it.
* - **03:00 PDT** (-23.7°): sun 0.01, hemisphere 0.25, ambient 0.10, and the
* light direction's `y` pinned at 0.122, which is sin 7° — the shadow
* floor, keeping the token night sidelight from shining up through the
* ground.
* - **03:00 PDT** (-23.7°, with the moon down): key 0.002, hemisphere 0.32,
* ambient 0.12, and the light direction's `y` pinned at 0.122, which is
* sin 7° — the shadow floor, keeping what is left of the token night
* sidelight from shining up through the ground. The key is a thousandth
* because the marine layer is at its thickest at 3 a.m. in June and takes
* 88% of it; the sky is a fog grey rather than a night blue for the same
* reason, which is right — a foggy night has no stars in it either.
* - **Solar noon, 21 December** (28.8°): sun 2.07, fog 204/453, sky exactly
* the palette's own. Out of season, the layer is not there.
*
@@ -952,4 +1419,39 @@ function wrapSigned(x: number, period: number): number {
* Tromsø on 5 January, at -2.98°, returns sun 0.30 against a twilight-blue sky
* and nothing non-finite anywhere, which is the polar-night path through
* `solar.ts` arriving here intact.
*
* **At night, San Francisco with no weather and no marine layer**, so that the
* moon can be read on its own:
*
* - **Full moon 43° up** (28 August 2026, 08:00 UTC): key 0.307, colour
* #9bb2e6, hemisphere 0.46, ambient 0.17, sky #101b39 over #2a385b. A
* seventh of the sun's noon intensity against nearly half of its fill,
* which is a soft directional key with shadows you can find and not trip
* over — on a sky that is unmistakably night and unmistakably blue.
* - **New moon, below the horizon** (12 August 2026, 08:00 UTC): key 0.008
* of the table's own sidelight colour, and the sky lands on #090e1c over
* #16203a — the floor, exactly. That is the darkest frame this file can
* produce, and it is the point: a genuinely correct night is `#000` and
* `#000` is a bug report.
* - **Half moon 4° up** (20 August 2026, 06:00 UTC): key 0.078. A quarter of
* the full moon's key from half its disc and a tenth of its altitude,
* which is the phase curve and the rise ramp both doing visible work.
* - **The same full moon with `PACIFIC_MARINE_LAYER` on**: key 0.16 and the
* sky greyed to #232b43 over #2d3855. The fog takes half the moonlight and
* all of the colour, and August is when it would.
* - **The same night reported overcast**: key 0.032 — no shadows at all —
* with the fill barely down, because a cloud deck over a full moon is a
* softbox rather than a lid.
* - **`moonlight: null`** returns the pre-moon rig unchanged: key 0.050,
* colour #2e3c66, the table's token sidelight left in charge. The night
* floor under the sky still applies, because that one is not about the
* moon.
*
* The 28 August 2026 dusk is worth watching as a sequence, because it is the
* configuration `combineKey` exists for — a full moon rising as the sun sets,
* the two of them opposite each other in the sky. At sun +0.8° the key is 0.663
* and #d4835a from the west; at -3.4° it is 0.226 and #9482a0 from between
* them; by -7.3° it is 0.303 and #9ab0e3 from the east. The shadows swing
* across the city over about half an hour, which is not an artefact — it is
* what actually happens, and on the one night a month it happens on.
*/