tower / files / src / world / sky.ts
sky.ts
| 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, |
| 16 | BRIGHTNESS_TWILIGHT_W_M2, LENGTH_M, MOON_AT_FULL_OF_NIGHT, SUN_RAMP_H, |
| 17 | } from '../canon/constants.js' |
| 18 | import { moonPhase } from './celestial.js' |
| 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 |
| 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 } |
| 70 | /** What the world's light amounts to, W/m² mean, for the eye's adaptation. */ |
| 71 | watts: number |
| 72 | dial: Dial |
| 73 | /** The real instant, ms since 1970 UTC: what the stars behind Tower are set by (celestial.ts). */ |
| 74 | at: number |
| 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 |
| 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 |
| 184 | return { |
| 185 | hours, day, hourOfDay, times, |
| 186 | sunZ: up ? Math.min(LENGTH_M, Math.max(0, progress * LENGTH_M)) : null, |
| 187 | power, |
| 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, |
| 191 | dial, |
| 192 | at, |
| 193 | } |
| 194 | } |
| 195 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | } |