fuzzyworld

tower / files / src / world / sky.ts

sky.ts

10.9 KB · history · blame

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 */
14import {
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'
18import { moonPhase } from './celestial.js'
19import 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 */
25export 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
31export type Dial = keyof typeof DIALS
32export const isDial = (v: unknown): v is Dial => typeof v === 'string' && v in DIALS
33
34export interface SunTimes {
35 /** Local clock hours, 0..24, in the calendar's city. */
36 sunriseH: number
37 sunsetH: number
38}
39
40export 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. */
80export 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
86const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
87const 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 */
93export 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. */
99export 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
111const 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 */
121export 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}
131const timesCache = new Map<string, SunTimes>()
132
133function 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
155const 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`. */
161export 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 */
201export 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 */
211function 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}
221const 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 */
227export 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. */
240export 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" */
246export 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}