tower / files / src / world / sky.ts
sky.ts
59c12827 2026-09-28 Pavel Safronov | 1 | /** |
| 2 | * Tower's sky at a moment: where its sun is, and how bright the world is. See design.md | |
| 3 | * §1.3.4 and decisions.md D-074. | |
| 4 | * | |
| 5 | * Tower keeps an Earth city's days. Its sun — the mirrorlet swarm gathered into one | |
| 6 | * shoal on the beam — lights at the sun cap at that city's sunrise, runs the length of | |
| 7 | * the world at a steady pace, and goes out at the far cap at the city's sunset. Then it | |
| 8 | * is night until the next sunrise, with the beam banked to a thousandth (§1.3.2). | |
| 9 | * | |
| 10 | * Everything here is a pure function of the calendar and the clock, and both 3D views | |
| 11 | * read the same answer, so the sun seen from the floor and the sun seen in through a | |
| 12 | * window are the same sun. | |
| 13 | */ | |
| 14 | import { | |
| 15 | BRIGHTNESS_FULL_W_M2, BRIGHTNESS_NIGHT_W_M2, BRIGHTNESS_TEMPERATE_W_M2, | |
42b4554e 2026-09-29 Pavel Safronov | 16 | BRIGHTNESS_TWILIGHT_W_M2, LENGTH_M, MOON_AT_FULL_OF_NIGHT, SUN_RAMP_H, |
59c12827 2026-09-28 Pavel Safronov | 17 | } from '../canon/constants.js' |
42b4554e 2026-09-29 Pavel Safronov | 18 | import { moonPhase } from './celestial.js' |
59c12827 2026-09-28 Pavel Safronov | 19 | import type { Calendar } from './schema.js' |
| 20 | ||
| 21 | /** | |
| 22 | * How bright Tower runs its day (design.md §1.3, "Illumination is a dial"). `dark` is | |
| 23 | * the beam shut down entirely: no sun, no rod, only the stars through the far cap. | |
| 24 | */ | |
| 25 | export const DIALS = { | |
| 26 | dark: 0, | |
| 27 | twilight: BRIGHTNESS_TWILIGHT_W_M2, | |
| 28 | temperate: BRIGHTNESS_TEMPERATE_W_M2, | |
| 29 | full: BRIGHTNESS_FULL_W_M2, | |
| 30 | } as const | |
| 31 | export type Dial = keyof typeof DIALS | |
| 32 | export const isDial = (v: unknown): v is Dial => typeof v === 'string' && v in DIALS | |
| 33 | ||
| 34 | export interface SunTimes { | |
| 35 | /** Local clock hours, 0..24, in the calendar's city. */ | |
| 36 | sunriseH: number | |
| 37 | sunsetH: number | |
| 38 | } | |
| 39 | ||
| 40 | export interface Sky { | |
| 41 | /** Hours since day 0 began, on the city's clock. */ | |
| 42 | hours: number | |
| 43 | day: number | |
| 44 | /** The hour of the day, 0..24. */ | |
| 45 | hourOfDay: number | |
| 46 | times: SunTimes | |
| 47 | /** | |
| 48 | * Where the sun is on the axis, metres from the sun cap — or null at night. It moves | |
| 49 | * at a constant speed from the sun cap at sunrise to the far cap at sunset. | |
| 50 | */ | |
| 51 | sunZ: number | null | |
| 52 | /** How lit the sun is, 0..1: it comes up over SUN_RAMP_H after sunrise and goes out | |
| 53 | * over the same before sunset. */ | |
| 54 | power: number | |
42b4554e 2026-09-29 Pavel Safronov | 55 | /** |
| 56 | * Where the moon is on the axis, metres from the sun cap — or null by day, or with the | |
| 57 | * beam shut down. It lights at the far cap at sunset and runs the other way from the sun, | |
| 58 | * reaching the sun cap at the next sunrise (D-088). | |
| 59 | */ | |
| 60 | moonZ: number | null | |
| 61 | /** | |
| 62 | * How much light the moon throws, as a share of the banked rod's: its lighting and going | |
| 63 | * out, times how much of it is lit tonight. 1 at full, 0 at new. | |
| 64 | */ | |
| 65 | moonLight: number | |
| 66 | /** How lit it is as it gathers and disperses, 0..1, whatever its phase: what the disc shows. */ | |
| 67 | moonRamp: number | |
| 68 | /** Earth's Moon tonight: how much of its disc is lit, and whether it is waxing. */ | |
| 69 | moonPhase: { fraction: number; waxing: boolean } | |
59c12827 2026-09-28 Pavel Safronov | 70 | /** What the world's light amounts to, W/m² mean, for the eye's adaptation. */ |
| 71 | watts: number | |
| 72 | dial: Dial | |
e331ec2a 2026-09-28 Pavel Safronov | 73 | /** The real instant, ms since 1970 UTC: what the stars behind Tower are set by (celestial.ts). */ |
| 74 | at: number | |
59c12827 2026-09-28 Pavel Safronov | 75 | } |
| 76 | ||
| 77 | // ---------------------------------------------------------------------- calendar | |
| 78 | ||
| 79 | /** The calendar's date for a world day, as UTC midnight of that date. */ | |
| 80 | export function dateOf(cal: Calendar, day: number): Date { | |
| 81 | const d = new Date(`${cal.day0}T00:00:00Z`) | |
| 82 | d.setUTCDate(d.getUTCDate() + Math.floor(day)) | |
| 83 | return d | |
| 84 | } | |
| 85 | ||
| 86 | const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] | |
| 87 | const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] | |
| 88 | ||
| 89 | /** | |
| 90 | * "Wed 23 Sep 2026": a world day as the city would write it. Spelled out here rather than | |
| 91 | * by `toLocaleDateString`, whose abbreviations differ between browsers. | |
| 92 | */ | |
| 93 | export function dateLabel(cal: Calendar, day: number): string { | |
| 94 | const d = dateOf(cal, day) | |
| 95 | return `${WEEKDAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}` | |
| 96 | } | |
| 97 | ||
| 98 | /** The city's offset from UTC at an instant, in hours — daylight saving included. */ | |
| 99 | export function utcOffsetH(zone: string, at: Date): number { | |
| 100 | const parts = new Intl.DateTimeFormat('en-US', { | |
| 101 | timeZone: zone, hourCycle: 'h23', | |
| 102 | year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', | |
| 103 | }).formatToParts(at) | |
| 104 | const get = (t: string) => Number(parts.find((p) => p.type === t)!.value) | |
| 105 | const local = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute')) | |
| 106 | return (local - Math.floor(at.getTime() / 60_000) * 60_000) / 3_600_000 | |
| 107 | } | |
| 108 | ||
| 109 | // --------------------------------------------------------------------- the sun | |
| 110 | ||
| 111 | const RAD = Math.PI / 180 | |
| 112 | ||
| 113 | /** | |
| 114 | * Sunrise and sunset in the calendar's city on a world day, local clock hours. | |
| 115 | * | |
| 116 | * NOAA's general solar position equations: the equation of time and the declination | |
| 117 | * from the fractional year, and the hour angle at which the sun's centre is 0.833° | |
| 118 | * below the horizon (refraction and the sun's radius). Good to a minute or two, which | |
| 119 | * is far finer than anyone in Tower will check. | |
| 120 | */ | |
| 121 | export function sunTimes(cal: Calendar, day: number): SunTimes { | |
| 122 | // Both views ask every frame, and the time-zone lookup is not free. | |
| 123 | const key = `${cal.timeZone}|${cal.latitude}|${cal.longitude}|${cal.day0}|${Math.floor(day)}` | |
| 124 | const known = timesCache.get(key) | |
| 125 | if (known) return known | |
| 126 | const times = computeSunTimes(cal, day) | |
| 127 | if (timesCache.size > 512) timesCache.clear() | |
| 128 | timesCache.set(key, times) | |
| 129 | return times | |
| 130 | } | |
| 131 | const timesCache = new Map<string, SunTimes>() | |
| 132 | ||
| 133 | function computeSunTimes(cal: Calendar, day: number): SunTimes { | |
| 134 | const date = dateOf(cal, day) | |
| 135 | const start = Date.UTC(date.getUTCFullYear(), 0, 1) | |
| 136 | const doy = Math.round((date.getTime() - start) / 86_400_000) + 1 | |
| 137 | const g = ((2 * Math.PI) / 365) * (doy - 1) | |
| 138 | const eqTimeMin = 229.18 * (0.000075 + 0.001868 * Math.cos(g) - 0.032077 * Math.sin(g) | |
| 139 | - 0.014615 * Math.cos(2 * g) - 0.040849 * Math.sin(2 * g)) | |
| 140 | const decl = 0.006918 - 0.399912 * Math.cos(g) + 0.070257 * Math.sin(g) | |
| 141 | - 0.006758 * Math.cos(2 * g) + 0.000907 * Math.sin(2 * g) | |
| 142 | - 0.002697 * Math.cos(3 * g) + 0.00148 * Math.sin(3 * g) | |
| 143 | const lat = cal.latitude * RAD | |
| 144 | const cosH = Math.cos(90.833 * RAD) / (Math.cos(lat) * Math.cos(decl)) - Math.tan(lat) * Math.tan(decl) | |
| 145 | // The calendar keeps to latitudes where the sun always rises and sets (schema.ts). | |
| 146 | const haDeg = Math.acos(Math.max(-1, Math.min(1, cosH))) / RAD | |
| 147 | const riseUtcMin = 720 - 4 * (cal.longitude + haDeg) - eqTimeMin | |
| 148 | const setUtcMin = 720 - 4 * (cal.longitude - haDeg) - eqTimeMin | |
| 149 | // The offset at local noon: sunrise and sunset never straddle a daylight-saving change. | |
| 150 | const offset = utcOffsetH(cal.timeZone, new Date(date.getTime() + (12 - cal.longitude / 15) * 3_600_000)) | |
| 151 | const local = (utcMin: number) => (((utcMin / 60 + offset) % 24) + 24) % 24 | |
| 152 | return { sunriseH: local(riseUtcMin), sunsetH: local(setUtcMin) } | |
| 153 | } | |
| 154 | ||
| 155 | const smooth = (x: number): number => { | |
| 156 | const t = Math.min(1, Math.max(0, x)) | |
| 157 | return t * t * (3 - 2 * t) | |
| 158 | } | |
| 159 | ||
| 160 | /** Tower's sky at `hours` since day 0 began, run at `dial`. */ | |
| 161 | export function skyAt(cal: Calendar, hours: number, dial: Dial): Sky { | |
| 162 | const day = Math.floor(hours / 24) | |
| 163 | const hourOfDay = hours - day * 24 | |
| 164 | const times = sunTimes(cal, day) | |
| 165 | const { sunriseH, sunsetH } = times | |
| 166 | const up = hourOfDay >= sunriseH && hourOfDay <= sunsetH && dial !== 'dark' | |
| 167 | const progress = (hourOfDay - sunriseH) / (sunsetH - sunriseH) | |
| 168 | const power = up | |
| 169 | ? smooth((hourOfDay - sunriseH) / SUN_RAMP_H) * smooth((sunsetH - hourOfDay) / SUN_RAMP_H) | |
| 170 | : 0 | |
| 171 | const night = dial === 'dark' ? 0 : BRIGHTNESS_NIGHT_W_M2 | |
42b4554e 2026-09-29 Pavel Safronov | 172 | const at = instantOf(cal, day, hourOfDay) |
| 173 | ||
| 174 | // The night this moment is in: from the last sunset to the next sunrise. | |
| 175 | const after = hourOfDay > sunsetH | |
| 176 | const dusk = after ? sunsetH : sunTimes(cal, day - 1).sunsetH - 24 | |
| 177 | const dawn = after ? sunTimes(cal, day + 1).sunriseH + 24 : sunriseH | |
| 178 | const dark = !up && dial !== 'dark' | |
| 179 | const moonRamp = dark | |
| 180 | ? smooth((hourOfDay - dusk) / SUN_RAMP_H) * smooth((dawn - hourOfDay) / SUN_RAMP_H) | |
| 181 | : 0 | |
| 182 | const phase = moonPhase(new Date(at)) | |
| 183 | const moonLight = moonRamp * phase.fraction * MOON_AT_FULL_OF_NIGHT | |
59c12827 2026-09-28 Pavel Safronov | 184 | return { |
| 185 | hours, day, hourOfDay, times, | |
| 186 | sunZ: up ? Math.min(LENGTH_M, Math.max(0, progress * LENGTH_M)) : null, | |
| 187 | power, | |
42b4554e 2026-09-29 Pavel Safronov | 188 | moonZ: dark ? Math.min(LENGTH_M, Math.max(0, (1 - (hourOfDay - dusk) / (dawn - dusk)) * LENGTH_M)) : null, |
| 189 | moonLight, moonRamp, moonPhase: phase, | |
| 190 | watts: night * (1 + moonLight) + (DIALS[dial] - night) * power, | |
59c12827 2026-09-28 Pavel Safronov | 191 | dial, |
42b4554e 2026-09-29 Pavel Safronov | 192 | at, |
59c12827 2026-09-28 Pavel Safronov | 193 | } |
| 194 | } | |
| 195 | ||
e18dad04 2026-09-30 Pavel Safronov | 196 | /** |
| 197 | * The sky's light split by where it comes from, W/m²: the sun, the banked rod and the moon. | |
| 198 | * They add up to `watts`. The split matters where Tower aims less light than elsewhere (the | |
| 199 | * far end, D-091): the sun and the moon are aimed there, the rod never is. | |
| 200 | */ | |
| 201 | export function lightMix(sky: Sky): { sun: number; rod: number; moon: number } { | |
| 202 | const night = sky.dial === 'dark' ? 0 : BRIGHTNESS_NIGHT_W_M2 | |
| 203 | return { sun: (DIALS[sky.dial] - night) * sky.power, rod: night, moon: night * sky.moonLight } | |
| 204 | } | |
| 205 | ||
e331ec2a 2026-09-28 Pavel Safronov | 206 | /** |
| 207 | * The real instant of a moment on the city's clock. The offset is the one at the day's | |
| 208 | * noon, as in `sunTimes`: on the two nights a year the clocks change it is an hour out | |
| 209 | * for a few hours, which moves the stars by a twenty-fourth of a degree. | |
| 210 | */ | |
| 211 | function instantOf(cal: Calendar, day: number, hourOfDay: number): number { | |
| 212 | const key = `${cal.timeZone}|${cal.day0}|${day}` | |
| 213 | let offset = offsetCache.get(key) | |
| 214 | if (offset === undefined) { | |
| 215 | offset = utcOffsetH(cal.timeZone, new Date(dateOf(cal, day).getTime() + 12 * 3_600_000)) | |
| 216 | if (offsetCache.size > 512) offsetCache.clear() | |
| 217 | offsetCache.set(key, offset) | |
| 218 | } | |
| 219 | return dateOf(cal, day).getTime() + (hourOfDay - offset) * 3_600_000 | |
| 220 | } | |
| 221 | const offsetCache = new Map<string, number>() | |
| 222 | ||
1d447a1a 2026-09-28 Pavel Safronov | 223 | /** |
| 224 | * Tower's time at a real instant: the calendar city's date and clock then, as hours since | |
| 225 | * day 0 began (D-074). Before day 0 it is day 0's first moment — the calendar has no past. | |
| 226 | */ | |
| 227 | export function hoursAt(cal: Calendar, at: Date): number { | |
| 228 | const parts = new Intl.DateTimeFormat('en-US', { | |
| 229 | timeZone: cal.timeZone, hourCycle: 'h23', | |
| 230 | year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', | |
| 231 | }).formatToParts(at) | |
| 232 | const get = (t: string) => Number(parts.find((p) => p.type === t)!.value) | |
| 233 | const date = Date.UTC(get('year'), get('month') - 1, get('day')) | |
| 234 | const days = Math.round((date - Date.parse(`${cal.day0}T00:00:00Z`)) / 86_400_000) | |
| 235 | const hours = days * 24 + get('hour') + get('minute') / 60 + get('second') / 3600 | |
| 236 | return Math.max(0, hours) | |
| 237 | } | |
| 238 | ||
59c12827 2026-09-28 Pavel Safronov | 239 | /** The hour on `day` at which the sun is over `z` metres from the sun cap. */ |
| 240 | export function hourWhenSunAt(cal: Calendar, day: number, z: number): number { | |
| 241 | const { sunriseH, sunsetH } = sunTimes(cal, day) | |
| 242 | return sunriseH + (sunsetH - sunriseH) * Math.min(1, Math.max(0, z / LENGTH_M)) | |
| 243 | } | |
| 244 | ||
| 245 | /** "05:58" */ | |
| 246 | export const clockText = (hour: number): string => { | |
| 247 | const mins = Math.round(hour * 60) % 1440 | |
| 248 | return `${String(Math.floor(mins / 60)).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')}` | |
| 249 | } |