fuzzyworld

tower / files / src / ui / app.ts

app.ts

Blame: the commit that last changed each line · file · history

09a64747 2026-09-07 Pavel Safronov1/**
9d5a061c 2026-09-27 Pavel Safronov2 * The panel and everything it drives. See design.md §3.
09a64747 2026-09-07 Pavel Safronov3 *
9d5a061c 2026-09-27 Pavel Safronov4 * The app reads the world and never changes it: the world is `content/world.json` in
5 * the repository, edited there (D-072). What is here is looking — three views, a detail
6 * card, the weather board and the sightline probe.
09a64747 2026-09-07 Pavel Safronov7 */
cf37225c 2026-09-07 Pavel Safronov8import { landStripWidth, slatCount, slatWidth } from '../canon/slats.js'
09a64747 2026-09-07 Pavel Safronov9import { type SurfacePoint, landStripIndex, isSlat, surfaceDistance } from '../canon/geometry.js'
10import { sightline } from '../world/instruments.js'
9d5a061c 2026-09-27 Pavel Safronov11import { type Feature, type World, anchorOf } from '../world/schema.js'
376f2ce6 2026-09-16 Pavel Safronov12import { terrain } from '../terrain/service.js'
a088e5d5 2026-09-17 Pavel Safronov13import { seaStateOf } from '../terrain/sea-state.js'
9d5a061c 2026-09-27 Pavel Safronov14import { hoursOf, occurrences, timeOf } from '../world/weather.js'
09a64747 2026-09-07 Pavel Safronov15import { KIND_COLOR } from '../views/palette.js'
16import { TowerMap } from '../views/map.js'
908833aa 2026-09-28 Pavel Safronov17import type { ExteriorView, HullMode } from '../views/exterior.js'
59c12827 2026-09-28 Pavel Safronov18import type { InteriorView } from '../views/interior.js'
19import { clock } from '../world/clock.js'
3cc2b5bd 2026-10-04 Pavel Safronov20import { type Framing, type Photo, parsePhoto, photoId } from '../world/photos.js'
42b4554e 2026-09-29 Pavel Safronov21import { type Sky, clockText, dateLabel, hoursAt, isDial, skyAt } from '../world/sky.js'
00ceeedb 2026-09-27 Pavel Safronov22import { CIRCUMFERENCE_M, G0, LENGTH_M, RADIUS_M, SPIN_PERIOD_S, gravityAtHeight } from '../canon/constants.js'
3e898b51 2026-09-28 Pavel Safronov23import { EyeGauge } from './eye-gauge.js'
09a64747 2026-09-07 Pavel Safronov24
7cc177f6 2026-09-07 Pavel Safronov25type ViewName = 'map' | 'exterior' | 'interior'
8b6287ba 2026-09-07 Pavel Safronov26const VIEW_KEY = 'tower.view'
aa201500 2026-09-14 Pavel Safronov27/** The exterior's hull reading, remembered across reloads like the view itself (D-061). */
28const HULL_KEY = 'tower.exteriorHull'
3e898b51 2026-09-28 Pavel Safronov29/** Whether the standing readout is folded down to its title (D-082). */
30const STANDING_KEY = 'tower.standingReadout'
1c0284d0 2026-09-27 Pavel Safronov31/** Whether labels are shown, in every view, remembered the same way. */
32const LABELS_KEY = 'tower.labels'
7cc177f6 2026-09-07 Pavel Safronov33const isView = (v: string | null): v is ViewName =>
34 v === 'map' || v === 'exterior' || v === 'interior'
5f38efd0 2026-09-07 Pavel Safronov35
36/** Time multipliers for the exterior spin, slowest first. */
37const SPEEDS = [1, 10, 60, 300, 1200]
09a64747 2026-09-07 Pavel Safronov38
39const $ = <T extends HTMLElement>(id: string): T => {
40 const el = document.getElementById(id)
41 if (!el) throw new Error(`missing #${id}`)
42 return el as T
43}
44
45const km = (m: number): string => `${(m / 1000).toFixed(m < 10_000 ? 2 : 1)} km`
46
47/** Walked length of a traced path, following the surface (design.md §1.7). */
48const pathLength = (pts: readonly [number, number][]): number => {
49 let total = 0
50 for (let i = 1; i < pts.length; i++) {
51 total += surfaceDistance(
52 { theta: pts[i - 1]![0], z: pts[i - 1]![1] },
53 { theta: pts[i]![0], z: pts[i]![1] })
54 }
55 return total
56}
376f2ce6 2026-09-16 Pavel Safronov57/** How long a compile runs before the status dialog appears (world-generation.md §10.1). */
58const STATUS_DELAY_MS = 400
59/** How long each line stays up at least, so short steps read as a sequence, not a flicker. */
60const STATUS_LINE_MS = 140
7cc177f6 2026-09-07 Pavel Safronov61
62/**
63 * Where to look on arrival. A place with extent gets its far end, so standing at
64 * the head of a river looks down it; a point has no direction of its own, so the
65 * heading is left alone.
66 */
376f2ce6 2026-09-16 Pavel Safronov67const facingOf = (p: Feature): SurfacePoint | null => {
7cc177f6 2026-09-07 Pavel Safronov68 const g = p.geometry
69 if (g.type !== 'polyline' && g.type !== 'polygon') return null
70 const n = g.points.length
71 let theta = 0, z = 0
72 for (const [t, pz] of g.points) { theta += t; z += pz }
73 return { theta: theta / n, z: z / n }
74}
75
09a64747 2026-09-07 Pavel Safronov76const pct = (x: number): string => `${(x * 100).toFixed(1)}%`
00ceeedb 2026-09-27 Pavel Safronov77/** Ground a feature's shape covers, m². A band rings the world. */
78const areaOfGeometry = (g: Feature['geometry']): number => {
79 if (g.type === 'band') return Math.abs(g.zTo - g.zFrom) * CIRCUMFERENCE_M
80 if (g.type !== 'polygon') return 0
81 let sum = 0
82 for (let i = 0; i < g.points.length; i++) {
83 const p = g.points[i]!, q = g.points[(i + 1) % g.points.length]!
84 sum += p[0] * RADIUS_M * q[1] - q[0] * RADIUS_M * p[1]
85 }
86 return Math.abs(sum) / 2
87}
88
89/** A big count, as people say it: 3.1 billion rather than 3,100,000,000. */
90const fmtCount = (n: number): string => {
91 if (n >= 1e9) return `${(n / 1e9).toFixed(1)} billion`
92 if (n >= 1e6) return `${(n / 1e6).toFixed(1)} million`
93 if (n >= 1e3) return `${Math.round(n / 1e3)} thousand`
94 return String(Math.round(n))
95}
96
5f38efd0 2026-09-07 Pavel Safronov97const fmtDuration = (s: number): string =>
1d447a1a 2026-09-28 Pavel Safronov98 s >= 3600 ? `${+(s / 3600).toFixed(1)} h`
99 : s >= 60 ? `${Math.floor(s / 60)} min ${Math.round(s % 60)} s` : `${s.toFixed(1)} s`
09a64747 2026-09-07 Pavel Safronov100
42b4554e 2026-09-29 Pavel Safronov101/**
102 * The moon tonight, for the time panel (D-088): how much of it is lit, which way it is going,
103 * and where it is on its way from the far cap to the sun cap.
104 */
105function moonText(sky: Sky): string {
106 const pct = Math.round(sky.moonPhase.fraction * 100)
107 if (sky.moonZ === null || sky.moonRamp <= 0) return ''
108 if (pct < 1) return 'The moon is new: no moon tonight.'
109 const name = pct >= 97 ? 'full' : pct <= 3 ? 'new' : `${pct}% lit and ${sky.moonPhase.waxing ? 'waxing' : 'waning'}`
110 const dusk = sky.hourOfDay > sky.times.sunsetH ? sky.times.sunsetH : sky.times.sunsetH - 24
111 const dawn = sky.hourOfDay > sky.times.sunsetH ? sky.times.sunriseH + 24 : sky.times.sunriseH
112 const speed = LENGTH_M / 1000 / (dawn - dusk)
113 return `The moon, ${name}, is ${km(sky.moonZ)} from the sun cap, moving sunward at about ${speed.toFixed(0)} km/h.`
114}
115
09a64747 2026-09-07 Pavel Safronov116export class App {
117 private map: TowerMap
5f38efd0 2026-09-07 Pavel Safronov118 private exterior: ExteriorView | null = null
7cc177f6 2026-09-07 Pavel Safronov119 private interior: InteriorView | null = null
3e898b51 2026-09-28 Pavel Safronov120 /** Built with the panel, before the view: it has to show a height before there is one. */
121 private eye!: EyeGauge
14d35b7d 2026-09-16 Pavel Safronov122 /**
123 * The deferred imports in flight. Opening on a `#view=` link asks for a view twice
124 * before the first import has resolved — once reading the hash, once choosing the
125 * initial view — and without these each built its own renderer: two WebGL contexts,
126 * two sets of terrain tiles, and every exterior caption drawn twice.
127 */
128 private exteriorModule: Promise<typeof import('../views/exterior.js')> | null = null
129 private interiorModule: Promise<typeof import('../views/interior.js')> | null = null
5f38efd0 2026-09-07 Pavel Safronov130 private view: ViewName = 'map'
fc11b064 2026-09-08 Pavel Safronov131 /**
132 * Below this width the sidebar is a drawer over the world rather than a column
133 * beside it (see the media query in style.css). The panel needs to know, because
134 * two behaviours differ: picking a place gets you out of the way of the view it
135 * just moved, and only a drawer has anything to get out of the way of.
136 */
137 private readonly narrow = matchMedia('(max-width: 720px)')
09a64747 2026-09-07 Pavel Safronov138 private selectedId: string | null = null
139 private probeFrom: SurfacePoint | null = null
140
9d5a061c 2026-09-27 Pavel Safronov141 constructor(private world: World) {
09a64747 2026-09-07 Pavel Safronov142 this.map = new TowerMap($<HTMLCanvasElement>('map'), {
143 onPick: (p, hit, shift) => this.onPick(p, hit, shift),
144 onHover: (p) => this.onHover(p),
145 })
1d447a1a 2026-09-28 Pavel Safronov146 /**
147 * The clock opens at the calendar city's time now, and runs on at the speed it was last
148 * run at (D-074) — unless the page names a moment with `?clock=<hours since day 0>`,
149 * which holds it there, stopped. That is how the screenshot tour gets the same sky twice.
150 */
151 const pinned = new URLSearchParams(location.search).get('clock')
152 const at = pinned !== null && pinned.trim() !== '' && Number.isFinite(Number(pinned)) ? Number(pinned) : null
153 clock.start(at ?? hoursAt(world.calendar, new Date()), at === null)
5f38efd0 2026-09-07 Pavel Safronov154 this.buildViews()
09a64747 2026-09-07 Pavel Safronov155 this.wireView()
fc11b064 2026-09-08 Pavel Safronov156 this.wirePanel()
3cc2b5bd 2026-10-04 Pavel Safronov157 this.wirePhotos()
b4b8e46c 2026-09-28 Pavel Safronov158 void this.wireBranches()
09a64747 2026-09-07 Pavel Safronov159 window.addEventListener('hashchange', () => this.readHash())
5f38efd0 2026-09-07 Pavel Safronov160 window.addEventListener('resize', () => {
161 this.map.resize()
162 this.exterior?.resize()
7cc177f6 2026-09-07 Pavel Safronov163 this.interior?.resize()
5f38efd0 2026-09-07 Pavel Safronov164 })
09a64747 2026-09-07 Pavel Safronov165 window.addEventListener('keydown', (e) => this.onKey(e))
376f2ce6 2026-09-16 Pavel Safronov166 this.wireTerrain()
09a64747 2026-09-07 Pavel Safronov167 this.map.fit()
168 this.readHash()
dc85f192 2026-09-07 Pavel Safronov169 /**
170 * Decide the view BEFORE the first render. render() rewrites the hash from the
171 * current state, so reading `#view=` afterwards reads a hash this very
172 * constructor has already erased — which quietly broke every shared link that
173 * named a view.
174 */
175 this.view = this.initialView()
09a64747 2026-09-07 Pavel Safronov176 this.render()
dc85f192 2026-09-07 Pavel Safronov177 void this.setView(this.view)
09a64747 2026-09-07 Pavel Safronov178 }
179
376f2ce6 2026-09-16 Pavel Safronov180 // --------------------------------------------------------------- terrain
181
182 /**
9d5a061c 2026-09-27 Pavel Safronov183 * Hand the world to the terrain service, and show the status dialog while it is being
184 * made. The detail card's readouts are read off the compiled world, so they are
185 * redrawn once it exists.
376f2ce6 2026-09-16 Pavel Safronov186 */
187 private wireTerrain(): void {
188 const service = terrain()
189 const dialog = $('status')
190 const line = $('status-line')
191 const fill = $('status-fill')
192 /** Lines waiting their turn. Steps can finish faster than a line can be read. */
193 const queue: string[] = []
194 let lastLine = 0
195 /** Lines shown this generation: the bar follows them, so it never runs ahead of the words. */
196 let told = 0
197 let timer: ReturnType<typeof setTimeout> | null = null
abc0aad0 2026-10-01 Pavel Safronov198 /**
199 * The world stands still until it has been made (D-096): from the first compile until
200 * the status dialog has gone, or — for a compile quick enough never to show it — until
201 * the compile is done. Held from the start, before the first compile has even begun.
202 */
203 const hold = (): void => clock.setHeld(service.progress.busy || !dialog.hidden || timer !== null)
204 clock.setHeld(true)
376f2ce6 2026-09-16 Pavel Safronov205
206 const pump = (): void => {
207 timer = null
abc0aad0 2026-10-01 Pavel Safronov208 hold()
376f2ce6 2026-09-16 Pavel Safronov209 const p = service.progress
210 const now = performance.now()
211 if (dialog.hidden) {
212 // Quick compiles never show the dialog at all, and their lines are dropped.
abc0aad0 2026-10-01 Pavel Safronov213 if (!p.busy) { queue.length = 0; told = 0; hold(); return }
376f2ce6 2026-09-16 Pavel Safronov214 const waited = now - p.startedAt
215 if (waited < STATUS_DELAY_MS) {
216 timer = setTimeout(pump, STATUS_DELAY_MS - waited)
217 return
218 }
219 dialog.hidden = false
220 }
221 // Once the world is ready, the lines still waiting run through quickly: they are
222 // a record of what was made, and nobody should wait on the telling of it.
223 const lineMs = p.busy ? STATUS_LINE_MS : STATUS_LINE_MS / 4
224 if (queue.length && now - lastLine >= lineMs) {
225 line.textContent = `${queue.shift()}…`
226 lastLine = now
227 told++
228 }
229 const shown = !p.busy && !queue.length ? p.total : Math.min(p.done, told)
230 fill.style.width = `${Math.round((100 * shown) / Math.max(1, p.total))}%`
231 // Once up, it stays until every line has had its moment, then goes.
232 if (!p.busy && !queue.length && now - lastLine >= lineMs) {
233 dialog.hidden = true
234 told = 0
abc0aad0 2026-10-01 Pavel Safronov235 hold()
376f2ce6 2026-09-16 Pavel Safronov236 return
237 }
238 timer = setTimeout(pump, lineMs)
239 }
240
241 service.onProgress(() => {
242 const { label, busy } = service.progress
243 if (busy && label && label !== queue.at(-1)) queue.push(label)
244 if (!timer) pump()
245 })
9d5a061c 2026-09-27 Pavel Safronov246 void service.setWorld(this.world)
247 service.onWorld(() => this.renderDetail())
248 }
249
250 private get(id: string): Feature | undefined {
251 return this.world.features.find((f) => f.id === id)
376f2ce6 2026-09-16 Pavel Safronov252 }
253
5f38efd0 2026-09-07 Pavel Safronov254 // ----------------------------------------------------------------- views
255
8b6287ba 2026-09-07 Pavel Safronov256 /**
257 * Which view to open on. The hash wins so a shared link can carry the view along
258 * with the place; otherwise the last one used, so a reload does not throw the
259 * author back to the map every time.
260 */
261 private initialView(): ViewName {
262 const fromHash = new URLSearchParams(location.hash.slice(1)).get('view')
263 if (isView(fromHash)) return fromHash
264 try {
265 const stored = localStorage.getItem(VIEW_KEY)
266 if (isView(stored)) return stored
267 } catch { /* unreadable storage is not an error here */ }
268 return 'map'
269 }
270
5f38efd0 2026-09-07 Pavel Safronov271 private buildViews(): void {
272 for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) {
aa55ae67 2026-09-07 Pavel Safronov273 b.onclick = () => { void this.setView(b.dataset['view'] as ViewName) }
5f38efd0 2026-09-07 Pavel Safronov274 }
275 const spin = $<HTMLInputElement>('f-spin')
276 const speed = $<HTMLInputElement>('f-speed')
277 const apply = () => {
278 const scale = SPEEDS[Number(speed.value)]!
279 // Set the label first: it must update whether or not the view exists yet.
280 $('speed-value').textContent =
281 `— ${scale}x, one turn in ${fmtDuration(SPIN_PERIOD_S / scale)}`
282 if (!this.exterior) return
283 this.exterior.spinning = spin.checked
284 this.exterior.timeScale = scale
285 }
286 spin.onchange = apply
287 speed.oninput = apply
288 apply()
99a2821b 2026-09-29 Pavel Safronov289 $('f-ext-tour').onclick = () => {
290 const v = this.exterior
291 if (!v) return
292 if (v.touring) v.stopTour()
293 else {
294 v.startTour()
295 if (this.narrow.matches) this.setPanel(false)
296 }
297 this.syncTour()
298 }
299 // A tour also stops from inside its view — a drag, Esc — so the labels are kept in step.
300 setInterval(() => this.syncTour(), 500)
7cc177f6 2026-09-07 Pavel Safronov301 this.buildInteriorControls()
59c12827 2026-09-28 Pavel Safronov302 this.buildTimeControls()
7cc177f6 2026-09-07 Pavel Safronov303 }
304
305 /**
306 * The interior controls have to work before the view is built, because the view
307 * is built on first use — so each handler sets its label unconditionally and only
308 * then talks to the renderer.
309 */
310 private buildInteriorControls(): void {
3e898b51 2026-09-28 Pavel Safronov311 this.eye = new EyeGauge($('eye-gauge'), (m) => {
7cc177f6 2026-09-07 Pavel Safronov312 this.interior?.setEyeHeight(m)
313 this.renderStanding()
3e898b51 2026-09-28 Pavel Safronov314 }, (m) =>
315 // Gravity goes by height above the datum, so the ground underfoot counts too, as in
316 // the standing readout; the old slider's label left it out and disagreed with it.
317 `${(gravityAtHeight(m + (this.interior?.elevationM ?? 0)) / G0).toFixed(3)} g`)
318
319 // The standing readout folds down to its title, and stays as it was left.
320 const standing = $('standing')
321 const toggle = $('standing-toggle')
322 const fold = (collapsed: boolean) => {
323 standing.classList.toggle('collapsed', collapsed)
324 toggle.setAttribute('aria-expanded', String(!collapsed))
325 }
326 // Folded by default on a phone, where open it covers a third of the view.
327 let stored: string | null = null
328 try {
329 stored = localStorage.getItem(STANDING_KEY)
330 } catch { /* unreadable storage just means the default */ }
331 fold(stored ? stored === 'collapsed' : this.narrow.matches)
332 toggle.onclick = () => {
333 const collapsed = !standing.classList.contains('collapsed')
334 fold(collapsed)
335 try {
336 localStorage.setItem(STANDING_KEY, collapsed ? 'collapsed' : 'open')
337 } catch { /* not remembering the fold is survivable */ }
7cc177f6 2026-09-07 Pavel Safronov338 }
339
a088e5d5 2026-09-17 Pavel Safronov340 $('f-tour').onclick = () => {
341 const v = this.interior
342 if (!v) return
343 if (v.touring) v.stopTour()
344 else {
345 v.startTour()
346 // On a phone the drawer covers the view; the tour is for watching.
347 if (this.narrow.matches) this.setPanel(false)
348 }
349 this.renderStanding()
350 }
351
59c12827 2026-09-28 Pavel Safronov352 }
353
354 /**
355 * The world clock's controls (clock.ts). They are the panel's, not a view's: every view
356 * shows the same moment, so the time is set once for all of them (D-074).
357 */
358 private buildTimeControls(): void {
359 const cal = this.world.calendar
360 $('time-hint').textContent =
361 `Tower keeps ${cal.city}'s clock and its days. Its sun lights at the sun cap at ` +
362 `${cal.city}'s sunrise, runs the length of the world at a steady pace, and goes out ` +
363 `at the far cap at ${cal.city}'s sunset; then it is night, with the beam banked to a ` +
364 `thousandth.`
365 $<HTMLInputElement>('f-time').oninput = (e) =>
366 clock.setHourOfDay(Number((e.target as HTMLInputElement).value) / 60)
a088e5d5 2026-09-17 Pavel Safronov367 const day = $<HTMLInputElement>('f-day')
368 day.onchange = () => {
369 const v = Number(day.value)
59c12827 2026-09-28 Pavel Safronov370 if (Number.isFinite(v) && v >= 0) clock.setDay(v)
a088e5d5 2026-09-17 Pavel Safronov371 }
59c12827 2026-09-28 Pavel Safronov372 /** World seconds per real second, slowest first: a Tower day in 24 min at 60x. */
373 const RATES = [1, 60, 900, 3600, 14400]
2bb3e0de 2026-09-08 Pavel Safronov374 const rate = $<HTMLInputElement>('f-cycle-rate')
59c12827 2026-09-28 Pavel Safronov375 const saved = RATES.indexOf(clock.rate)
376 if (saved >= 0) rate.value = String(saved)
377 const applyRate = () => {
2bb3e0de 2026-09-08 Pavel Safronov378 const r = RATES[Number(rate.value)]!
59c12827 2026-09-28 Pavel Safronov379 $('cycle-value').textContent = `— ${r}x, a day in ${fmtDuration(86_400 / r)}`
380 clock.setRate(r)
2bb3e0de 2026-09-08 Pavel Safronov381 }
59c12827 2026-09-28 Pavel Safronov382 rate.oninput = applyRate
383 applyRate()
384 const cycle = $<HTMLInputElement>('f-cycle')
385 cycle.onchange = () => clock.setRunning(cycle.checked)
7cc177f6 2026-09-07 Pavel Safronov386 const light = $<HTMLSelectElement>('f-light')
59c12827 2026-09-28 Pavel Safronov387 light.value = clock.dial
388 light.onchange = () => { if (isDial(light.value)) clock.setDial(light.value) }
389 // A running clock changes every frame; the panel follows it at most once a frame.
390 let queued = false
391 clock.subscribe(() => {
392 if (queued) return
393 queued = true
394 requestAnimationFrame(() => { queued = false; this.renderTime() })
395 })
396 this.renderTime()
5f38efd0 2026-09-07 Pavel Safronov397 }
398
59c12827 2026-09-28 Pavel Safronov399 /** Where the map's sun marker was last drawn, so a running clock redraws it only when it moves. */
400 private mapSunKm = NaN
42b4554e 2026-09-29 Pavel Safronov401 private mapMoonKm = NaN
59c12827 2026-09-28 Pavel Safronov402
5f38efd0 2026-09-07 Pavel Safronov403 /**
59c12827 2026-09-28 Pavel Safronov404 * Everything that follows the clock: the panel's time, where the sun is, the weather board
405 * and its sea state, the map's sun marker and the interior's readout.
406 */
407 private renderTime(): void {
408 const cal = this.world.calendar
409 const sky = skyAt(cal, clock.hours, clock.dial)
410 $('time-now').textContent = `— day ${sky.day}, ${clockText(sky.hourOfDay)}`
411 $('day-value').textContent = `— ${dateLabel(cal, sky.day)}, ${cal.city}`
412 const { sunriseH, sunsetH } = sky.times
413 const speed = LENGTH_M / 1000 / (sunsetH - sunriseH)
414 const where = sky.sunZ !== null
415 ? `The sun is ${km(sky.sunZ)} from the sun cap, moving deepward at ${speed.toFixed(0)} km/h.`
416 : clock.dial === 'dark' ? 'The beam is shut down: no sun, and no night light either.'
42b4554e 2026-09-29 Pavel Safronov417 : `Night: the sun is out, and the beam banked to a thousandth. ${moonText(sky)}`
59c12827 2026-09-28 Pavel Safronov418 $('sun-now').textContent = `Sunrise ${clockText(sunriseH)}, sunset ${clockText(sunsetH)}. ${where}`
419 const day = $<HTMLInputElement>('f-day')
420 if (document.activeElement !== day && day.value !== String(sky.day)) day.value = String(sky.day)
421 const t = $<HTMLInputElement>('f-time')
422 const mins = Math.floor(sky.hourOfDay * 60) % 1440
423 if (document.activeElement !== t && Number(t.value) !== mins) t.value = String(mins)
424 $('time-value').textContent = `— ${clockText(sky.hourOfDay)}`
425 const cycle = $<HTMLInputElement>('f-cycle')
426 if (cycle.checked !== clock.running) cycle.checked = clock.running
427 this.renderWeather()
428 const sunKm = sky.sunZ === null ? NaN : Math.round(sky.sunZ / 500) / 2
42b4554e 2026-09-29 Pavel Safronov429 const moonKm = sky.moonZ === null ? NaN : Math.round(sky.moonZ / 500) / 2
430 if (!Object.is(sunKm, this.mapSunKm) || !Object.is(moonKm, this.mapMoonKm)) {
59c12827 2026-09-28 Pavel Safronov431 this.mapSunKm = sunKm
42b4554e 2026-09-29 Pavel Safronov432 this.mapMoonKm = moonKm
59c12827 2026-09-28 Pavel Safronov433 this.map.sunZ = sky.sunZ
42b4554e 2026-09-29 Pavel Safronov434 this.map.moon = sky.moonZ === null || sky.moonRamp <= 0 ? null
435 : { z: sky.moonZ, fraction: sky.moonPhase.fraction }
59c12827 2026-09-28 Pavel Safronov436 if (this.view === 'map') this.map.draw()
437 }
438 if (this.view === 'interior') this.renderStanding()
439 }
440
441 /**
442 * The exterior renderer is built on first use. /**
5f38efd0 2026-09-07 Pavel Safronov443 * The exterior renderer is built on first use. It pulls in Three.js and starts an
444 * animation loop, neither of which a reader who only ever opens the map should pay
445 * for.
446 */
aa55ae67 2026-09-07 Pavel Safronov447 private async setView(view: ViewName): Promise<void> {
5f38efd0 2026-09-07 Pavel Safronov448 this.view = view
8b6287ba 2026-09-07 Pavel Safronov449 try {
450 localStorage.setItem(VIEW_KEY, view)
451 } catch {
452 // Private window or blocked site data: the view just will not persist.
453 }
5f38efd0 2026-09-07 Pavel Safronov454 const isMap = view === 'map'
7cc177f6 2026-09-07 Pavel Safronov455 const isExterior = view === 'exterior'
456 const isInterior = view === 'interior'
457 $('map').classList.toggle('hidden', !isMap)
458 $('exterior').classList.toggle('hidden', !isExterior)
459 $('interior').classList.toggle('hidden', !isInterior)
5f38efd0 2026-09-07 Pavel Safronov460 $('map-options').classList.toggle('hidden', !isMap)
7cc177f6 2026-09-07 Pavel Safronov461 $('exterior-options').classList.toggle('hidden', !isExterior)
462 $('interior-options').classList.toggle('hidden', !isInterior)
a3f01bd7 2026-09-08 Pavel Safronov463 /**
464 * "Show terrain" is the map's control. The interior has nothing to turn off —
465 * the raster is the ground itself — and the exterior now has its own two-mode
466 * switch instead of a checkbox (D-047), so the row belongs to the map alone.
467 */
468 $('terrain-row').classList.toggle('hidden', !isMap)
7cc177f6 2026-09-07 Pavel Safronov469 $('spin').classList.toggle('hidden', !isExterior)
470 $('ext-labels').classList.toggle('hidden', !isExterior)
471 $('standing').classList.toggle('hidden', !isInterior)
3e898b51 2026-09-28 Pavel Safronov472 $('eye-gauge').classList.toggle('hidden', !isInterior)
7cc177f6 2026-09-07 Pavel Safronov473 $('int-labels').classList.toggle('hidden', !isInterior)
5f38efd0 2026-09-07 Pavel Safronov474 for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) {
475 b.classList.toggle('active', b.dataset['view'] === view)
476 }
7cc177f6 2026-09-07 Pavel Safronov477 if (!isExterior) this.exterior?.stop()
478 if (!isInterior) this.interior?.stop()
5f38efd0 2026-09-07 Pavel Safronov479 if (isMap) {
480 this.map.resize()
481 return
482 }
7cc177f6 2026-09-07 Pavel Safronov483 if (isInterior) return this.openInterior()
5f38efd0 2026-09-07 Pavel Safronov484 if (!this.exterior) {
aa55ae67 2026-09-07 Pavel Safronov485 // Genuinely deferred: Three.js is by far the largest dependency, and a static
486 // import would bundle it into the entry chunk whether or not this view is
487 // ever opened. Vite splits it into its own chunk on this await.
14d35b7d 2026-09-16 Pavel Safronov488 const { ExteriorView } = await (this.exteriorModule ??= import('../views/exterior.js'))
489 if (this.exterior) return // the other request built it, and is starting it
5f38efd0 2026-09-07 Pavel Safronov490 this.exterior = new ExteriorView($<HTMLCanvasElement>('exterior'), {
491 onPickPoint: (p) => {
492 this.map.probe = { from: p, to: null }
493 this.selectedId = null
494 this.render()
495 },
8b6287ba 2026-09-07 Pavel Safronov496 }, $('ext-labels'))
5f38efd0 2026-09-07 Pavel Safronov497 const spin = $<HTMLInputElement>('f-spin')
498 const speed = $<HTMLInputElement>('f-speed')
499 this.exterior.spinning = spin.checked
500 this.exterior.timeScale = SPEEDS[Number(speed.value)]!
908833aa 2026-09-28 Pavel Safronov501 this.exterior.hullMode = $<HTMLSelectElement>('f-ext-view').value as HullMode
5f38efd0 2026-09-07 Pavel Safronov502 }
503 this.exterior.resize()
504 this.exterior.start()
505 this.render()
506 }
507
7cc177f6 2026-09-07 Pavel Safronov508 /** Same deferred-import bargain as the exterior: Three.js is not the map's cost. */
509 private async openInterior(): Promise<void> {
510 if (!this.interior) {
14d35b7d 2026-09-16 Pavel Safronov511 const { InteriorView } = await (this.interiorModule ??= import('../views/interior.js'))
512 if (this.interior) return // the other request built it, and is starting it
7cc177f6 2026-09-07 Pavel Safronov513 this.interior = new InteriorView($<HTMLCanvasElement>('interior'), {
514 onPickPlace: (id) => {
515 this.selectedId = id
516 this.focusOn(id)
517 this.render()
518 },
519 onLook: () => this.renderStanding(),
520 }, $('int-labels'))
3e898b51 2026-09-28 Pavel Safronov521 // Adopt whatever was restored from storage, so the gauge is not lying.
522 this.eye.set(this.interior.eyeM)
7cc177f6 2026-09-07 Pavel Safronov523 }
524 this.interior.resize()
9d5a061c 2026-09-27 Pavel Safronov525 this.interior.setPlaces(this.world.features.filter((p) => p.geometry.type !== 'band'))
7cc177f6 2026-09-07 Pavel Safronov526 this.interior.start()
527 this.render()
528 }
529
fc11b064 2026-09-08 Pavel Safronov530 /**
531 * The sidebar as a drawer, on a phone.
532 *
533 * Everything here is a class on #app; the layout itself is CSS, and on a wide
534 * screen the toggle, the close button and the scrim are all `display: none`, so
535 * this runs harmlessly and changes nothing. Closed on load: the world is what a
536 * visitor came for, and the panel is one tap away.
537 */
b4b8e46c 2026-09-28 Pavel Safronov538 /**
539 * The branch picker. The published site holds every branch — the default at the root,
540 * the rest in folders — and each folder carries a `branches.json` listing them, with
541 * links relative to it (tools/build-pages.sh). Where there is no such list, or only one
542 * branch in it, as on a dev server, the picker stays hidden. Switching keeps the hash,
543 * so the other branch opens on the same view and place.
544 */
545 private async wireBranches(): Promise<void> {
546 type Branch = { branch: string; href: string; current: boolean }
547 let list: Branch[]
548 try {
549 const res = await fetch(`${import.meta.env.BASE_URL}branches.json`, { cache: 'no-store' })
550 if (!res.ok) return
551 const raw: unknown = await res.json()
552 if (!Array.isArray(raw)) return
553 list = raw.filter((b): b is Branch =>
554 typeof b?.branch === 'string' && typeof b?.href === 'string' && typeof b?.current === 'boolean')
555 } catch {
556 return // A dev server answers with the page itself, which is not JSON: no picker.
557 }
558 if (list.length < 2) return
559 const picker = $<HTMLSelectElement>('branch')
560 picker.replaceChildren(...list.map((b) => Object.assign(document.createElement('option'), {
561 value: b.href, textContent: b.branch, selected: b.current,
562 })))
563 picker.onchange = () => { location.href = picker.value + location.hash }
564 picker.hidden = false
565 }
566
fc11b064 2026-09-08 Pavel Safronov567 private wirePanel(): void {
568 $<HTMLButtonElement>('panel-toggle').onclick = () => this.setPanel(true)
569 $<HTMLButtonElement>('panel-close').onclick = () => this.setPanel(false)
570 $('scrim').onclick = () => this.setPanel(false)
571 // Crossing the breakpoint — a phone turned on its side — starts over closed,
572 // which is also what clears `inert` on the way back to the desktop column.
573 this.narrow.addEventListener('change', () => this.setPanel(false))
574 this.setPanel(false)
575 }
576
577 private setPanel(open: boolean): void {
578 $('app').classList.toggle('panel-open', open)
579 $('panel-toggle').setAttribute('aria-expanded', String(open))
580 /**
581 * A closed drawer is only translated off-screen, so without this its fields
582 * stay in the tab order and typing into the map's sidebar scrolls a panel
583 * nobody can see. `inert` never applies on a wide screen, where the panel is
584 * a column and always genuinely there.
585 */
586 $('panel').inert = this.narrow.matches && !open
587 }
588
3cc2b5bd 2026-10-04 Pavel Safronov589 // ------------------------------------------------------------------- photos
590
591 /** The shot last gone back to, offered as the name when the next one is saved. */
592 private photoName = ''
593
594 /**
595 * Photographs (world/photos.ts, D-099). Saving one, and going back to one, are for the
596 * author at a dev server, which is what keeps the list (vite.config.ts): the button, the
597 * key and the list are not there on the published site. Holding one is the photo tool's
598 * way in, and is always there, as the views' own handles are — it does nothing until it
599 * is called.
600 */
601 private wirePhotos(): void {
602 ;(window as unknown as Record<string, unknown>)['towerPhotos'] = {
603 show: (raw: unknown) => this.showPhoto(parsePhoto(raw), true),
604 }
605 if (!import.meta.env.DEV) return
606 $('photo-row').hidden = false
607 $('f-photo').onclick = () => { void this.savePhoto() }
608 const list = $<HTMLSelectElement>('f-photos')
609 // Read again whenever the list is reached for: the file is edited by hand as well.
610 list.onpointerdown = list.onfocus = () => { void this.listPhotos() }
611 list.onchange = () => {
612 const id = list.value
613 list.value = ''
614 if (id) void this.gotoPhoto(id)
615 }
616 void this.listPhotos()
617 }
618
619 /** The saved shots, as the dev server has them now, into the list. */
620 private async listPhotos(): Promise<unknown[]> {
621 let shots: unknown[] = []
622 try {
623 const res = await fetch('/__photos', { cache: 'no-store' })
624 const raw: unknown = res.ok ? await res.json() : []
625 if (Array.isArray(raw)) shots = raw
626 } catch { /* no dev server answering just means an empty list */ }
627 const list = $<HTMLSelectElement>('f-photos')
628 const ids = shots.map((s) => String((s as { id?: unknown } | null)?.id ?? ''))
629 const had = [...list.options].slice(1).map((o) => o.value)
630 // Left alone when nothing changed: rebuilding an open menu closes it.
631 if (ids.join('\n') !== had.join('\n')) {
632 list.replaceChildren(list.options[0]!, ...shots.map((s, i) => {
633 const view = String((s as { view?: unknown } | null)?.view ?? '')
634 return Object.assign(document.createElement('option'), { value: ids[i]!, textContent: `${ids[i]} — ${view}` })
635 }))
636 }
637 return shots
638 }
639
640 /** Go back to a saved shot: its view, its camera, its moment — and carry on from there. */
641 private async gotoPhoto(id: string): Promise<void> {
642 const say = (text: string): void => { $('photo-status').textContent = text }
643 const raw = (await this.listPhotos()).find((s) => (s as { id?: unknown } | null)?.id === id)
644 try {
645 await this.showPhoto(parsePhoto(raw), false)
646 this.photoName = id
647 say(`At ${id}, with the clock${this.view === 'exterior' ? ' and the spin' : ''} stopped. `
648 + 'Save under the same name to re-frame it.')
649 } catch (e) {
650 say(`Cannot go to ${id}: ${e instanceof Error ? e.message : String(e)}`)
651 }
652 }
653
654 /** The open view as a photo, less its name: where the camera is, the moment, the Light. */
655 private takePhoto(): Framing | string {
656 const v = this.view === 'exterior' ? this.exterior : this.view === 'interior' ? this.interior : null
657 if (!v) return 'Open the exterior or the interior first: a shot is of one of those.'
658 // A tour's flyers and its camera are the tour's; a shot saved from one would come back without them.
659 if (v.touring) return 'Stop the tour first (Esc): a shot does not keep a tour\'s flyers.'
660 const box = $(this.view).getBoundingClientRect()
661 const to = (x: number, places: number): number => Number(x.toFixed(places))
662 const common = { hours: to(clock.hours, 4), dial: clock.dial, aspect: to(box.width / box.height, 4) }
663 if (this.view === 'exterior') {
664 const s = this.exterior!.shot()
665 const km = (p: [number, number, number]): [number, number, number] => [to(p[0], 3), to(p[1], 3), to(p[2], 3)]
666 return {
667 view: 'exterior', ...common, hull: $<HTMLSelectElement>('f-ext-view').value as HullMode,
668 position: km(s.position), target: km(s.target), phase: to(s.phase, 5), fovDeg: to(s.fovDeg, 2),
669 }
670 }
671 const s = this.interior!.shot()
672 return {
673 view: 'interior', ...common,
674 theta: to(s.theta, 6), z: to(s.z, 1), eyeM: to(s.eyeM, 2),
675 yaw: to(s.yaw, 5), pitch: to(s.pitch, 5), fovDeg: s.fovDeg, spin: to(s.spin, 5),
676 }
677 }
678
679 /** Name the shot on screen and hand it to the dev server, which keeps the list. */
680 private async savePhoto(): Promise<void> {
681 const say = (text: string): void => { $('photo-status').textContent = text }
682 // Taken before the name is asked for, so it is the frame that was on screen at the key.
683 const shot = this.takePhoto()
684 if (typeof shot === 'string') return say(shot)
685 const name = prompt('Name this shot. It is rendered to photos/<name>.png; an existing name re-frames that shot.', this.photoName)
686 if (name === null) return
687 const id = photoId(name)
688 if (!id) return say('A shot needs a name with a letter or a digit in it.')
689 const post = (replace: boolean): Promise<Response> => fetch('/__photos', {
690 method: 'POST', headers: { 'Content-Type': 'application/json' },
691 body: JSON.stringify({ photo: { id, ...shot }, replace }),
692 })
693 try {
694 let res = await post(false)
695 if (res.status === 409) {
696 if (!confirm(`There is already a shot called ${id}. Replace it with this framing?`)) return
697 res = await post(true)
698 }
699 say(res.ok
700 ? `Saved ${id}. Render it with: npm run photos -- --only ${id}`
701 : `Not saved: ${await res.text()}`)
702 if (res.ok) {
703 this.photoName = id
704 void this.listPhotos()
705 }
706 } catch {
707 say('Not saved: the dev server did not answer.')
708 }
709 }
710
711 /**
712 * Open a photo's view at its moment: held there for the photo tool to take, or not, for
713 * the author to carry on from. Either way the clock is stopped at the shot's hour, and
714 * outside the spin is switched off, or the framing would be gone before it was seen.
715 */
716 private async showPhoto(p: Photo, hold: boolean): Promise<void> {
717 clock.setRunning(false)
718 clock.set(p.hours)
719 clock.setDial(p.dial)
720 $<HTMLSelectElement>('f-light').value = p.dial
721 if (p.view === 'exterior') {
722 const hull = $<HTMLSelectElement>('f-ext-view')
723 hull.value = p.hull
724 hull.dispatchEvent(new Event('change'))
725 const spin = $<HTMLInputElement>('f-spin')
726 spin.checked = false
727 spin.dispatchEvent(new Event('change'))
728 }
729 await this.setView(p.view)
730 if (p.view === 'exterior') this.exterior?.poseShot(p, hold)
731 else {
732 this.interior?.poseShot(p, hold)
733 if (this.interior) this.eye.set(this.interior.eyeM)
734 }
735 this.render()
736 }
737
09a64747 2026-09-07 Pavel Safronov738 // --------------------------------------------------------------- permalinks
739
740 /**
741 * The link IS the deliverable for a companion piece (D-010): "stand here and look
742 * at this" is the whole value. A place id survives the map being redrawn; a raw
743 * point survives the place being renamed.
744 */
745 private writeHash(): void {
746 const params = new URLSearchParams()
8b6287ba 2026-09-07 Pavel Safronov747 if (this.view !== 'map') params.set('view', this.view)
09a64747 2026-09-07 Pavel Safronov748 if (this.selectedId) params.set('place', this.selectedId)
749 else if (this.map.probe?.from) {
750 const p = this.map.probe.from
751 params.set('at', `${p.theta.toFixed(5)},${Math.round(p.z)}`)
752 }
753 const next = params.toString()
754 const url = next ? `#${next}` : location.pathname + location.search
755 if (location.hash.slice(1) !== next) history.replaceState(null, '', url)
756 }
757
758 private readHash(): void {
759 const params = new URLSearchParams(location.hash.slice(1))
dc85f192 2026-09-07 Pavel Safronov760 // A hash edited in place fires hashchange rather than reloading, so the view
761 // has to be honoured here too — otherwise `#view=` only ever works on a cold
762 // load, and following one link from another quietly ignores it.
763 const view = params.get('view')
764 if (isView(view) && view !== this.view) void this.setView(view)
09a64747 2026-09-07 Pavel Safronov765 const id = params.get('place')
9d5a061c 2026-09-27 Pavel Safronov766 if (id && this.get(id)) {
09a64747 2026-09-07 Pavel Safronov767 this.selectedId = id
768 this.focusOn(id)
769 this.render()
770 return
771 }
772 const at = params.get('at')
773 if (at) {
774 const [theta, z] = at.split(',').map(Number)
775 if (Number.isFinite(theta) && Number.isFinite(z)) {
776 this.map.probe = { from: { theta: theta!, z: z! }, to: null }
777 this.render()
778 }
779 }
780 }
781
782 private focusOn(id: string): void {
9d5a061c 2026-09-27 Pavel Safronov783 const p = this.get(id)
09a64747 2026-09-07 Pavel Safronov784 if (!p) return
376f2ce6 2026-09-16 Pavel Safronov785 const point = anchorOf(p)
09a64747 2026-09-07 Pavel Safronov786 this.map.centreOn(point)
5f38efd0 2026-09-07 Pavel Safronov787 if (this.view === 'exterior') this.exterior?.lookAt(point)
7cc177f6 2026-09-07 Pavel Safronov788 // Each view does what "focus" means for it: the map centres, the exterior
789 // swings round, and the interior walks there — selecting a place is how you
790 // travel, since at ground level most of the world is beyond the haze horizon.
791 if (this.view === 'interior') {
792 this.interior?.visit(point, facingOf(p))
793 this.renderStanding()
794 }
09a64747 2026-09-07 Pavel Safronov795 }
796
797 // -------------------------------------------------------------- map handlers
798
376f2ce6 2026-09-16 Pavel Safronov799 private onPick(p: SurfacePoint, hit: Feature | null, shift: boolean): void {
09a64747 2026-09-07 Pavel Safronov800 if (shift) return this.onProbe(p)
9d5a061c 2026-09-27 Pavel Safronov801 this.selectedId = hit?.id ?? null
802 this.render()
09a64747 2026-09-07 Pavel Safronov803 }
804
805 private onProbe(p: SurfacePoint): void {
806 if (!this.probeFrom) {
807 this.probeFrom = p
808 this.map.probe = { from: p, to: null }
809 $('sightline').classList.add('hidden')
810 } else {
811 this.map.probe = { from: this.probeFrom, to: p }
812 this.showSightline(this.probeFrom, p)
813 this.probeFrom = null
814 }
815 this.render()
816 }
817
818 private showSightline(a: SurfacePoint, b: SurfacePoint): void {
819 const s = sightline(a, b)
820 const el = $('sightline')
821 el.classList.remove('hidden')
822 el.textContent = [
823 `walk ${km(s.surfaceDistanceM)}`,
824 `line of sight ${km(s.directDistanceM)}`,
825 `rises to ${km(s.apexHeightM)} above the floor`,
826 `optical depth ${s.opticalDepth.toFixed(2)}`,
827 `transmission ${pct(s.transmission)}`,
828 ` ${s.clarity.toUpperCase()}`,
829 ].join('\n')
830 }
831
832 private onHover(p: SurfacePoint | null): void {
833 const el = $('cursor')
834 if (!p) {
835 el.textContent = ''
836 el.classList.add('hidden')
837 return
838 }
839 el.classList.remove('hidden')
840 const strip = landStripIndex(p.theta)
841 el.textContent = [
842 `z ${km(p.z)} from sun cap`,
843 `θ ${((p.theta * 180) / Math.PI).toFixed(2)}°`,
844 strip === null ? 'on a star slat' : `land strip ${strip}`,
845 ].join('\n')
846 }
847
848 private onKey(e: KeyboardEvent): void {
849 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
9d5a061c 2026-09-27 Pavel Safronov850 // Esc drops a half-measured or finished sightline.
e7a9f9dd 2026-09-17 Pavel Safronov851 if (e.key === 'Escape') {
9d5a061c 2026-09-27 Pavel Safronov852 this.probeFrom = null
853 this.map.probe = null
854 $('sightline').classList.add('hidden')
855 this.render()
09a64747 2026-09-07 Pavel Safronov856 }
376f2ce6 2026-09-16 Pavel Safronov857 // F frames the map, but not while flying: it sits beside W A S D.
858 if (e.key === 'f' && this.view === 'map') this.map.fit()
3cc2b5bd 2026-10-04 Pavel Safronov859 if (import.meta.env.DEV && (e.key === 'p' || e.key === 'P') && !e.metaKey && !e.ctrlKey && !e.altKey) {
860 void this.savePhoto()
861 }
1c0284d0 2026-09-27 Pavel Safronov862 if (e.key === 'l' || e.key === 'L') {
863 const box = $<HTMLInputElement>('f-labels')
864 box.checked = !box.checked
865 box.dispatchEvent(new Event('change'))
866 }
09a64747 2026-09-07 Pavel Safronov867 }
868
cf37225c 2026-09-07 Pavel Safronov869 // ---------------------------------------------------------------------- view
09a64747 2026-09-07 Pavel Safronov870
871 private wireView(): void {
872 const box = $<HTMLInputElement>('f-terrain')
873 box.onchange = () => {
874 this.map.showTerrain = box.checked
875 this.map.draw()
a3f01bd7 2026-09-08 Pavel Safronov876 }
877 const ext = $<HTMLSelectElement>('f-ext-view')
aa201500 2026-09-14 Pavel Safronov878 // Restored into the control itself, which is what the exterior reads when it is
879 // first built — so a view opened on load starts in the remembered reading.
880 try {
881 const stored = localStorage.getItem(HULL_KEY)
ab6ae94f 2026-09-28 Pavel Safronov882 if (stored === 'true' || stored === 'rock' || stored === 'pipes' || stored === 'rockpipes'
883 || stored === 'xray') ext.value = stored
aa201500 2026-09-14 Pavel Safronov884 } catch { /* unreadable storage just means the default, true */ }
a3f01bd7 2026-09-08 Pavel Safronov885 ext.onchange = () => {
908833aa 2026-09-28 Pavel Safronov886 if (this.exterior) this.exterior.hullMode = ext.value as HullMode
aa201500 2026-09-14 Pavel Safronov887 try {
888 localStorage.setItem(HULL_KEY, ext.value)
889 } catch { /* not remembering the hull is survivable */ }
09a64747 2026-09-07 Pavel Safronov890 }
1c0284d0 2026-09-27 Pavel Safronov891
892 // One switch for every view's labels: the map's names, and the 3D views' captions.
893 const labels = $<HTMLInputElement>('f-labels')
894 try {
895 if (localStorage.getItem(LABELS_KEY) === 'off') labels.checked = false
896 } catch { /* unreadable storage just means labels on */ }
897 const applyLabels = () => {
898 $('app').classList.toggle('no-labels', !labels.checked)
899 this.map.showLabels = labels.checked
900 this.map.draw()
901 }
902 labels.onchange = () => {
903 applyLabels()
904 try {
905 localStorage.setItem(LABELS_KEY, labels.checked ? 'on' : 'off')
906 } catch { /* not remembering it is survivable */ }
907 }
908 applyLabels()
09a64747 2026-09-07 Pavel Safronov909 }
910
9d5a061c 2026-09-27 Pavel Safronov911 // ------------------------------------------------------------------ readouts
09a64747 2026-09-07 Pavel Safronov912
9d5a061c 2026-09-27 Pavel Safronov913 /**
914 * What the sea is doing over a lake or sea *now*, by the clock: the wind it usually
915 * has, or whatever the weather plan has instead. Read off the compiled world, so it
916 * says what was actually generated.
917 */
918 private seaStateText(f: Feature): string {
919 if (f.kind !== 'lake' && f.kind !== 'sea') return ''
80bcc97a 2026-09-17 Pavel Safronov920 const b = terrain().compiled?.basins.find((x) => x.id === f.id)
a088e5d5 2026-09-17 Pavel Safronov921 const world = terrain().world
9d5a061c 2026-09-27 Pavel Safronov922 if (!b || !world) return ''
a088e5d5 2026-09-17 Pavel Safronov923 const st = seaStateOf(world, b, this.worldHours())
924 const weather = st.kind === 'calm' || st.kind === 'breeze' ? '' : ` A ${st.kind} is blowing (${st.wind.speedMS.toFixed(0)} m/s).`
9d5a061c 2026-09-27 Pavel Safronov925 return (st.sea.heightM < 0.01
a088e5d5 2026-09-17 Pavel Safronov926 ? 'Flat calm now.'
927 : `Now: waves ${st.sea.heightM.toFixed(1)} m high, ${st.sea.lengthM.toFixed(0)} m apart, ` +
928 `every ${st.sea.periodS.toFixed(1)} s.`) + weather +
929 (st.revised ? ` Tower changed the plan: ${st.revised}` : '') +
930 (f.kind === 'sea' && Math.abs(Math.sin((st.seaWind.directionDeg * Math.PI) / 180)) > 0.2
931 ? ' The wind runs round the ring, so the sea is fully developed.' : '')
932 }
933
9d5a061c 2026-09-27 Pavel Safronov934 /** What Tower planted in a forest, over how much ground (world-generation.md §4.6). */
935 private forestText(f: Feature): string {
936 if (f.kind !== 'forest') return ''
00ceeedb 2026-09-27 Pavel Safronov937 const c = terrain().compiled?.forests.find((x) => x.id === f.id)
9d5a061c 2026-09-27 Pavel Safronov938 if (!c) return ''
00ceeedb 2026-09-27 Pavel Safronov939 const area = areaOfGeometry(f.geometry)
940 const trees = (area / 10_000) * c.densityPerHa * (1 - f.meadows)
941 const spacing = Math.sqrt(10_000 / c.densityPerHa)
9d5a061c 2026-09-27 Pavel Safronov942 return `${fmtCount(trees)} trees over ${(area / 1e6).toFixed(0)} km², about ${spacing.toFixed(1)} m apart, `
00ceeedb 2026-09-27 Pavel Safronov943 + `canopy ${c.canopyM.toFixed(0)} m.`
944 }
945
9d5a061c 2026-09-27 Pavel Safronov946 /** What a desert's landforms actually came to (world-generation.md §4.5). */
947 private desertText(f: Feature): string {
948 if (f.kind !== 'desert') return ''
51c39d8b 2026-09-17 Pavel Safronov949 const d = terrain().compiled?.deserts.find((x) => x.id === f.id)
9d5a061c 2026-09-27 Pavel Safronov950 if (!d) return ''
51c39d8b 2026-09-17 Pavel Safronov951 let rock = 0, sand = 0, cut = 0, inside = 0
952 for (let k = 0; k < d.inside.length; k += 7) {
953 if (d.inside[k]! < 0.9) continue
954 inside++
955 if (d.plateau[k]! > 0.5) rock++
956 if (d.sand[k]! > 0.5) sand++
957 if (d.cut[k]! > 3) cut++
958 }
959 const pct = (n: number) => `${Math.round((100 * n) / Math.max(1, inside))}%`
9d5a061c 2026-09-27 Pavel Safronov960 return `${pct(rock)} standing rock, ${pct(sand)} dune field, ${pct(cut)} cut by washes, `
51c39d8b 2026-09-17 Pavel Safronov961 + `${d.pans.length} salt pan${d.pans.length === 1 ? '' : 's'}.`
962 }
963
9d5a061c 2026-09-27 Pavel Safronov964 /** A range's named summits and their heights, highest first. */
965 private summitsText(f: Feature): string {
966 if (f.kind !== 'mountains' || !f.peaks.length) return ''
967 return 'Summits: ' + [...f.peaks]
968 .sort((a, b) => b.elevationM - a.elevationM)
969 .map((p) => `${p.name || 'unnamed'} ${Math.round(p.elevationM).toLocaleString('en')} m`)
970 .join(' · ')
971 }
972
a088e5d5 2026-09-17 Pavel Safronov973 // --------------------------------------------------------------- weather
974
59c12827 2026-09-28 Pavel Safronov975 /** World time, in hours since the start of day 0, from the one clock (clock.ts). */
a088e5d5 2026-09-17 Pavel Safronov976 private worldHours(): number {
59c12827 2026-09-28 Pavel Safronov977 return clock.hours
a088e5d5 2026-09-17 Pavel Safronov978 }
979
980 /** The quarter-hour the board was last drawn for, so a running clock redraws it rarely. */
981 private weatherDrawnAt = NaN
982
983 /**
9d5a061c 2026-09-27 Pavel Safronov984 * The weather board: Tower's published plan (world-generation.md §4.9), as anyone in
985 * Tower would see it — what has been announced by now, and every revision made so far,
986 * with the original struck through, because people saw it and planned around it.
a088e5d5 2026-09-17 Pavel Safronov987 */
988 private renderWeather(force = false): void {
989 const now = this.worldHours()
990 const bucket = Math.floor(now * 4)
991 if (!force && bucket === this.weatherDrawnAt) return
992 this.weatherDrawnAt = bucket
993 // The selected lake or sea's readout is a function of the clock too.
9d5a061c 2026-09-27 Pavel Safronov994 if (!force) this.renderDetail()
995 const plan = this.world.weather
996 const bodies = this.world.features.filter((f) => f.kind === 'lake' || f.kind === 'sea')
a088e5d5 2026-09-17 Pavel Safronov997 const nameOf = (id: string) => bodies.find((f) => f.id === id)?.name ?? id
998 const when = (h: number): string => {
999 const t = timeOf(h)
1000 const mins = Math.round(t.hour * 60)
1001 return `day ${t.day}, ${String(Math.floor(mins / 60)).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')}`
1002 }
1003 $('weather-now').textContent = `— ${when(now)}`
1004
1005 const board = $('weather-board')
1006 board.replaceChildren()
9d5a061c 2026-09-27 Pavel Safronov1007 // What is on, and the next fortnight, as far as it has been published.
a088e5d5 2026-09-17 Pavel Safronov1008 const shown = occurrences(plan, null, now, now + 24 * 14)
9d5a061c 2026-09-27 Pavel Safronov1009 .filter((o) => o.endH > now && o.publishedH <= now)
a088e5d5 2026-09-17 Pavel Safronov1010 if (!shown.length) {
1011 const li = document.createElement('li')
1012 li.className = 'dim'
1013 li.textContent = 'Nothing scheduled for the next fortnight: the usual winds.'
1014 board.append(li)
1015 }
1016 for (const o of shown) {
1017 const li = document.createElement('li')
1018 const on = o.startH <= now
1019 if (on) li.classList.add('now')
1020 const made = o.revisions.filter((r) => hoursOf(r.at) <= now)
1021 const head = document.createElement('div')
1022 const text = `${o.event.kind[0]!.toUpperCase()}${o.event.kind.slice(1)} over ${nameOf(o.event.target)}, ` +
1023 `${when(o.startH)} for ${o.event.durationH} h, ${o.event.wind.speedMS} m/s` + (on ? ' — now' : '')
1024 if (made.length) head.append(Object.assign(document.createElement('s'), { textContent: text }))
1025 else head.textContent = text
1026 li.append(head)
9d5a061c 2026-09-27 Pavel Safronov1027 for (const r of made) {
a088e5d5 2026-09-17 Pavel Safronov1028 li.append(Object.assign(document.createElement('div'), {
1029 className: 'rev',
9d5a061c 2026-09-27 Pavel Safronov1030 textContent: `Revised ${when(hoursOf(r.at))}: ${r.kind}, ${r.wind.speedMS} m/s. ${r.reason}`,
a088e5d5 2026-09-17 Pavel Safronov1031 }))
1032 }
1033 board.append(li)
1034 }
09a64747 2026-09-07 Pavel Safronov1035 }
1036
1037 // -------------------------------------------------------------------- render
1038
1039 private render(): void {
9d5a061c 2026-09-27 Pavel Safronov1040 this.map.places = this.world.features
09a64747 2026-09-07 Pavel Safronov1041 this.map.selectedId = this.selectedId
5f38efd0 2026-09-07 Pavel Safronov1042 if (this.view === 'exterior') {
1043 this.renderSpinReadout()
1044 }
7cc177f6 2026-09-07 Pavel Safronov1045 if (this.view === 'interior') {
9d5a061c 2026-09-27 Pavel Safronov1046 this.interior?.setPlaces(this.world.features.filter((p) => p.geometry.type !== 'band'))
7cc177f6 2026-09-07 Pavel Safronov1047 this.renderStanding()
1048 }
09a64747 2026-09-07 Pavel Safronov1049 this.map.draw()
1050
9d5a061c 2026-09-27 Pavel Safronov1051 const total = this.world.features.length
1052 $('counts').textContent = `— ${total} feature${total === 1 ? '' : 's'}`
09a64747 2026-09-07 Pavel Safronov1053
1054 this.renderList()
a088e5d5 2026-09-17 Pavel Safronov1055 this.renderWeather(true)
9d5a061c 2026-09-27 Pavel Safronov1056 this.renderDetail()
09a64747 2026-09-07 Pavel Safronov1057 this.writeHash()
1058 }
1059
1060 private renderList(): void {
1061 const ul = $('list')
1062 ul.replaceChildren()
9d5a061c 2026-09-27 Pavel Safronov1063 for (const p of [...this.world.features].sort((a, b) => a.name.localeCompare(b.name))) {
09a64747 2026-09-07 Pavel Safronov1064 const li = document.createElement('li')
9d5a061c 2026-09-27 Pavel Safronov1065 if (p.id === this.selectedId) li.className = 'sel'
09a64747 2026-09-07 Pavel Safronov1066 li.onclick = () => {
1067 this.selectedId = p.id
11a13dbf 2026-09-07 Pavel Safronov1068 this.focusOn(p.id)
09a64747 2026-09-07 Pavel Safronov1069 this.render()
fc11b064 2026-09-08 Pavel Safronov1070 /**
1071 * On a phone the list is the drawer, and the drawer is over the world. In
1072 * every view selecting a place moves you to it — the map centres, the
1073 * exterior swings round, the interior walks there — so staying on the list
1074 * means watching none of it happen. Get out of the way.
1075 */
1076 if (this.narrow.matches) this.setPanel(false)
09a64747 2026-09-07 Pavel Safronov1077 }
1078 const sw = document.createElement('span')
1079 sw.className = 'swatch'
1080 sw.style.background = KIND_COLOR[p.kind]
1081 const nm = document.createElement('span')
1082 nm.className = 'nm'
1083 nm.textContent = p.name
1084 const kd = document.createElement('span')
1085 kd.className = 'kd'
9d5a061c 2026-09-27 Pavel Safronov1086 kd.textContent = p.kind
09a64747 2026-09-07 Pavel Safronov1087 li.append(sw, nm, kd)
1088 ul.append(li)
1089 }
1090 }
1091
9d5a061c 2026-09-27 Pavel Safronov1092 /** The selected place: its name, its blurb, where it is, and what it is like now. */
1093 private renderDetail(): void {
1094 const p = this.selectedId ? this.get(this.selectedId) : undefined
1095 $('detail').hidden = !p
1096 if (!p) return
1097 $('d-name').textContent = p.name
1098 $('d-blurb').textContent = p.blurb || `A ${p.kind}.`
1099 $('d-where').textContent = this.whereText(p)
1100 const state = [this.summitsText(p), this.seaStateText(p), this.forestText(p), this.desertText(p)]
1101 .filter(Boolean).join(' ')
1102 $('d-state').textContent = state
1103 $('d-state').hidden = !state
09a64747 2026-09-07 Pavel Safronov1104 }
1105
9d5a061c 2026-09-27 Pavel Safronov1106 /** Human-readable location, for the detail card. */
376f2ce6 2026-09-16 Pavel Safronov1107 private whereText(p: Feature): string {
09a64747 2026-09-07 Pavel Safronov1108 const g = p.geometry
1109 if (g.type === 'point') {
1110 const where = isSlat(g.theta)
1111 ? `on a star slat (${km(slatWidth())} wide)`
1112 : slatCount() === 0
1113 ? 'on the open surface'
1114 : `land strip ${landStripIndex(g.theta)} (${km(landStripWidth())} wide)`
1115 return `z ${km(g.z)} from the sun cap · ${where}`
1116 }
04d6779e 2026-09-07 Pavel Safronov1117 if (g.type === 'band') {
1118 return `rings the world, z ${km(g.zFrom)} to ${km(g.zTo)} (${km(g.zTo - g.zFrom)} deep)`
1119 }
09a64747 2026-09-07 Pavel Safronov1120 if (g.type === 'polyline') {
1121 return `${g.points.length} points, ${km(pathLength(g.points))} long`
1122 }
1123 return `${g.points.length} vertices`
1124 }
1125
5f38efd0 2026-09-07 Pavel Safronov1126 /** Where the spin has got to, since nothing in Tower's own sky would tell you. */
1127 private renderSpinReadout(): void {
1128 const e = this.exterior
1129 if (!e) return
1130 $('spin').textContent = [
1131 `spin phase ${((e.phase * 180) / Math.PI).toFixed(0).padStart(3)}°`,
1132 `period ${fmtDuration(SPIN_PERIOD_S)}`,
1133 `rim speed 700.2 m/s`,
1134 ].join('\n')
1135 }
1136
376f2ce6 2026-09-16 Pavel Safronov1137 /**
3e898b51 2026-09-28 Pavel Safronov1138 * The eye-height gauge follows the view, as the clock does: keyboard flight changes
1139 * the height from inside the view, and a gauge left where it was would be a second
1140 * height disagreeing with the first. Not while it is being dragged.
376f2ce6 2026-09-16 Pavel Safronov1141 */
1142 private syncEye(): void {
1143 const v = this.interior
3e898b51 2026-09-28 Pavel Safronov1144 if (!v || this.eye.active || Math.abs(this.eye.value - v.eyeM) < 0.01) return
1145 this.eye.set(v.eyeM)
376f2ce6 2026-09-16 Pavel Safronov1146 }
1147
99a2821b 2026-09-29 Pavel Safronov1148 /** The tour buttons say what pressing them will do. */
59c12827 2026-09-28 Pavel Safronov1149 private syncTour(): void {
99a2821b 2026-09-29 Pavel Safronov1150 for (const [id, v] of [['f-tour', this.interior], ['f-ext-tour', this.exterior]] as const) {
1151 if (!v) continue
1152 const tour = v.touring ? 'Stop the tour' : 'Scenic tour'
1153 if ($(id).textContent !== tour) $(id).textContent = tour
1154 }
37a25a7f 2026-09-08 Pavel Safronov1155 }
1156
7cc177f6 2026-09-07 Pavel Safronov1157 /**
1158 * Where you are standing and what can be seen from there. The sightline straight
1159 * up is the one worth printing: it is the crispest far view in Tower (design.md
1160 * §1.8), and it is the fact readers find hardest to believe.
1161 */
1162 private renderStanding(): void {
1163 const v = this.interior
1164 if (!v) return
59c12827 2026-09-28 Pavel Safronov1165 this.syncTour()
376f2ce6 2026-09-16 Pavel Safronov1166 this.syncEye()
7cc177f6 2026-09-07 Pavel Safronov1167 const here = v.at
1168 const opposite = { theta: here.theta + Math.PI, z: here.z }
1169 const across = sightline(here, opposite)
1170 /**
1171 * The axial comparison, along whichever direction has the most room. Near a cap
1172 * there is no 100 km to look down, and silently measuring a shorter line while
1173 * still labelling it 100 km made the haze look far kinder than it is.
1174 */
1175 const deepward = LENGTH_M - here.z >= here.z
1176 const span = Math.min(100_000, deepward ? LENGTH_M - here.z : here.z)
1177 const along = sightline(here, { theta: here.theta, z: here.z + (deepward ? span : -span) })
1178 const rep = v.report()
9d5a061c 2026-09-27 Pavel Safronov1179 const name = this.world.features.find((p) =>
7cc177f6 2026-09-07 Pavel Safronov1180 p.geometry.type === 'point'
1181 && Math.abs(p.geometry.z - here.z) < 2000
1182 && Math.abs(p.geometry.theta - here.theta) < 0.04)?.name
3e898b51 2026-09-28 Pavel Safronov1183 $('standing-body').textContent = [
7cc177f6 2026-09-07 Pavel Safronov1184 `standing ${name ?? rep.standing}`,
1185 ...(name ? [` ${rep.standing}`] : []),
1186 ...rep.lines,
1187 `overhead ${km(across.directDistanceM)} up · ${pct(across.transmission)} clear`,
1188 `${(`${km(span)} on`).padEnd(10)} ${pct(along.transmission)} clear — the ground is the vague part`,
1189 ].join('\n')
1190 }
09a64747 2026-09-07 Pavel Safronov1191}