fuzzyworld

tower / cascadia / files / src / ui / app.ts

Branch cascadia · default branch · all branches

app.ts

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

09a64747 2026-09-07 Pavel Safronov1/**
2 * Writer-mode UI. See design.md §3.
3 *
4 * The loop this serves: place and name things on the map, autosaved to localStorage
5 * continuously; export a full backup whenever; export the static public content
62458672 2026-09-07 Pavel Safronov6 * once to seed `static/world.json` in the repo.
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'
11import {
12 type Place, type PlaceKind, PLACE_KINDS, isAreaKind, isAutoId, isTracedKind, makeId, parseWorld,
13} from '../world/schema.js'
14import { KIND_COLOR } from '../views/palette.js'
15import { WorldStore, backupFilename, download } from '../world/store.js'
16import { TowerMap } from '../views/map.js'
1affced2 2026-09-08 Pavel Safronov17import { type Surface, isSurface } from '../views/raster.js'
aa55ae67 2026-09-07 Pavel Safronov18import type { ExteriorView } from '../views/exterior.js'
7cc177f6 2026-09-07 Pavel Safronov19import type { InteriorView, Lighting } from '../views/interior.js'
20import { G0, LENGTH_M, SPIN_PERIOD_S, gravityAtHeight } from '../canon/constants.js'
21import { eyeHeightFromSlider, sliderFromEyeHeight } from './scales.js'
09a64747 2026-09-07 Pavel Safronov22
23type Tool = 'select' | PlaceKind
7cc177f6 2026-09-07 Pavel Safronov24type ViewName = 'map' | 'exterior' | 'interior'
8b6287ba 2026-09-07 Pavel Safronov25const VIEW_KEY = 'tower.view'
1affced2 2026-09-08 Pavel Safronov26const SURFACE_KEY = 'tower.surface'
7cc177f6 2026-09-07 Pavel Safronov27const isView = (v: string | null): v is ViewName =>
28 v === 'map' || v === 'exterior' || v === 'interior'
5f38efd0 2026-09-07 Pavel Safronov29
30/** Time multipliers for the exterior spin, slowest first. */
31const SPEEDS = [1, 10, 60, 300, 1200]
09a64747 2026-09-07 Pavel Safronov32
33const $ = <T extends HTMLElement>(id: string): T => {
34 const el = document.getElementById(id)
35 if (!el) throw new Error(`missing #${id}`)
36 return el as T
37}
38
39const km = (m: number): string => `${(m / 1000).toFixed(m < 10_000 ? 2 : 1)} km`
40
41/** Walked length of a traced path, following the surface (design.md §1.7). */
42const pathLength = (pts: readonly [number, number][]): number => {
43 let total = 0
44 for (let i = 1; i < pts.length; i++) {
45 total += surfaceDistance(
46 { theta: pts[i - 1]![0], z: pts[i - 1]![1] },
47 { theta: pts[i]![0], z: pts[i]![1] })
48 }
49 return total
50}
7cc177f6 2026-09-07 Pavel Safronov51/** A single representative point for a place, whatever shape it is. */
52const pointOf = (p: Place): SurfacePoint => {
53 const g = p.geometry
54 if (g.type === 'point') return { theta: g.theta, z: g.z }
55 if (g.type === 'band') return { theta: 0, z: (g.zFrom + g.zTo) / 2 }
56 return { theta: g.points[0]![0], z: g.points[0]![1] }
57}
58
59/**
60 * Where to look on arrival. A place with extent gets its far end, so standing at
61 * the head of a river looks down it; a point has no direction of its own, so the
62 * heading is left alone.
63 */
64const facingOf = (p: Place): SurfacePoint | null => {
65 const g = p.geometry
66 if (g.type !== 'polyline' && g.type !== 'polygon') return null
67 const n = g.points.length
68 let theta = 0, z = 0
69 for (const [t, pz] of g.points) { theta += t; z += pz }
70 return { theta: theta / n, z: z / n }
71}
72
09a64747 2026-09-07 Pavel Safronov73const pct = (x: number): string => `${(x * 100).toFixed(1)}%`
5f38efd0 2026-09-07 Pavel Safronov74const fmtDuration = (s: number): string =>
75 s >= 60 ? `${Math.floor(s / 60)} min ${Math.round(s % 60)} s` : `${s.toFixed(1)} s`
09a64747 2026-09-07 Pavel Safronov76
77export class App {
78 private map: TowerMap
5f38efd0 2026-09-07 Pavel Safronov79 private exterior: ExteriorView | null = null
7cc177f6 2026-09-07 Pavel Safronov80 private interior: InteriorView | null = null
5f38efd0 2026-09-07 Pavel Safronov81 private view: ViewName = 'map'
09a64747 2026-09-07 Pavel Safronov82 /**
83 * Writer mode is a UI mode, not a security boundary (D-011). It is guessable
84 * either way, and that is fine: with no backend an editor can only change the
85 * visitor's own copy.
86 *
87 * Default is WRITER for now — the world is still being built and the author is
88 * the only visitor. `?reader=1` previews what a reader sees. Flipping the
89 * default is a one-line change here when the map is ready to publish.
90 */
91 private readonly writer = !new URLSearchParams(location.search).has('reader')
fc11b064 2026-09-08 Pavel Safronov92 /**
93 * Below this width the sidebar is a drawer over the world rather than a column
94 * beside it (see the media query in style.css). The panel needs to know, because
95 * two behaviours differ: picking a place gets you out of the way of the view it
96 * just moved, and only a drawer has anything to get out of the way of.
97 */
98 private readonly narrow = matchMedia('(max-width: 720px)')
09a64747 2026-09-07 Pavel Safronov99 private tool: Tool = 'select'
100 private selectedId: string | null = null
101 private draft: [number, number][] = []
102 private probeFrom: SurfacePoint | null = null
1affced2 2026-09-08 Pavel Safronov103 private surface: Surface = 'canon'
09a64747 2026-09-07 Pavel Safronov104
105 constructor(private store: WorldStore) {
106 this.map = new TowerMap($<HTMLCanvasElement>('map'), {
107 onPick: (p, hit, shift) => this.onPick(p, hit, shift),
108 onDragPlace: (id, p) => this.onDragPlace(id, p),
109 onHover: (p) => this.onHover(p),
110 })
5f38efd0 2026-09-07 Pavel Safronov111 this.buildViews()
1affced2 2026-09-08 Pavel Safronov112 this.wireSurface()
09a64747 2026-09-07 Pavel Safronov113 this.buildTools()
114 this.buildKindSelect()
115 this.wireEditor()
116 this.wireView()
117 this.wireData()
fc11b064 2026-09-08 Pavel Safronov118 this.wirePanel()
09a64747 2026-09-07 Pavel Safronov119 this.applyMode()
120 window.addEventListener('hashchange', () => this.readHash())
5f38efd0 2026-09-07 Pavel Safronov121 window.addEventListener('resize', () => {
122 this.map.resize()
123 this.exterior?.resize()
7cc177f6 2026-09-07 Pavel Safronov124 this.interior?.resize()
5f38efd0 2026-09-07 Pavel Safronov125 })
09a64747 2026-09-07 Pavel Safronov126 window.addEventListener('keydown', (e) => this.onKey(e))
127 // A close mid-debounce must not lose the last edit.
128 window.addEventListener('pagehide', () => this.store.flush())
129 this.store.subscribe(() => this.render())
130 this.map.fit()
131 this.readHash()
dc85f192 2026-09-07 Pavel Safronov132 /**
133 * Decide the view BEFORE the first render. render() rewrites the hash from the
134 * current state, so reading `#view=` afterwards reads a hash this very
135 * constructor has already erased — which quietly broke every shared link that
136 * named a view.
137 */
138 this.view = this.initialView()
09a64747 2026-09-07 Pavel Safronov139 this.render()
dc85f192 2026-09-07 Pavel Safronov140 void this.setView(this.view)
09a64747 2026-09-07 Pavel Safronov141 }
142
5f38efd0 2026-09-07 Pavel Safronov143 // ----------------------------------------------------------------- views
144
8b6287ba 2026-09-07 Pavel Safronov145 /**
146 * Which view to open on. The hash wins so a shared link can carry the view along
147 * with the place; otherwise the last one used, so a reload does not throw the
148 * author back to the map every time.
149 */
150 private initialView(): ViewName {
151 const fromHash = new URLSearchParams(location.hash.slice(1)).get('view')
152 if (isView(fromHash)) return fromHash
153 try {
154 const stored = localStorage.getItem(VIEW_KEY)
155 if (isView(stored)) return stored
156 } catch { /* unreadable storage is not an error here */ }
157 return 'map'
158 }
159
1affced2 2026-09-08 Pavel Safronov160 /**
161 * The surface switch. Not a view and not a writer mode: it changes what ground
162 * every view is drawing, so it lives beside the terrain toggle rather than in
163 * the view bar. Persisted, because a reader who came for the scale comparison
164 * should not be dropped back into canon by a reload — and it is deliberately
165 * not carried in the hash, because a shared permalink names a place in Tower
166 * and Cascadia has no places (D-055).
167 */
168 private wireSurface(): void {
169 const sel = $<HTMLSelectElement>('f-surface')
170 try {
171 const stored = localStorage.getItem(SURFACE_KEY)
172 if (isSurface(stored)) this.surface = stored
173 } catch { /* unreadable storage is not an error here */ }
174 sel.value = this.surface
175 /**
176 * `redraw` is false for the initial call: this runs from the constructor,
177 * before the tools, the editor and the kind select exist, and render() reads
178 * all of them. The constructor renders once on its own a few lines later.
179 */
180 const apply = (redraw: boolean) => {
181 const canon = this.surface === 'canon'
182 $('surface-hint').hidden = canon
183 // The map's own hint describes how the authored raster is made, which is not
184 // true of any other surface.
185 $('map-hint').hidden = !canon
186 this.map.setSurface(this.surface)
187 this.exterior?.setSurface(this.surface)
188 this.interior?.setSurface(this.surface)
189 if (redraw) this.render()
190 }
191 sel.onchange = () => {
192 this.surface = isSurface(sel.value) ? sel.value : 'canon'
193 try {
194 localStorage.setItem(SURFACE_KEY, this.surface)
195 } catch { /* private window: the choice just will not persist */ }
196 apply(true)
197 }
198 apply(false)
199 }
200
5f38efd0 2026-09-07 Pavel Safronov201 private buildViews(): void {
202 for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) {
aa55ae67 2026-09-07 Pavel Safronov203 b.onclick = () => { void this.setView(b.dataset['view'] as ViewName) }
5f38efd0 2026-09-07 Pavel Safronov204 }
205 const spin = $<HTMLInputElement>('f-spin')
206 const speed = $<HTMLInputElement>('f-speed')
207 const apply = () => {
208 const scale = SPEEDS[Number(speed.value)]!
209 // Set the label first: it must update whether or not the view exists yet.
210 $('speed-value').textContent =
211 `— ${scale}x, one turn in ${fmtDuration(SPIN_PERIOD_S / scale)}`
212 if (!this.exterior) return
213 this.exterior.spinning = spin.checked
214 this.exterior.timeScale = scale
215 }
216 spin.onchange = apply
217 speed.oninput = apply
218 apply()
7cc177f6 2026-09-07 Pavel Safronov219 this.buildInteriorControls()
220 }
221
222 /**
223 * The interior controls have to work before the view is built, because the view
224 * is built on first use — so each handler sets its label unconditionally and only
225 * then talks to the renderer.
226 */
227 private buildInteriorControls(): void {
228 const eye = $<HTMLInputElement>('f-eye')
229 const showEye = (m: number) => {
230 $('eye-value').textContent =
231 `— ${m < 1000 ? `${m.toFixed(1)} m` : `${(m / 1000).toFixed(1)} km`}, ` +
232 `${(gravityAtHeight(m) / G0).toFixed(3)} g`
233 }
234 eye.oninput = () => {
235 const m = eyeHeightFromSlider(Number(eye.value) / 1000)
236 showEye(m)
237 this.interior?.setEyeHeight(m)
238 this.renderStanding()
239 }
240 showEye(eyeHeightFromSlider(Number(eye.value) / 1000))
241
2bb3e0de 2026-09-08 Pavel Safronov242 /** World seconds per real second, slowest first: a Tower day in 24 min at 60x. */
243 const RATES = [1, 60, 900, 3600, 14400]
244 const time = $<HTMLInputElement>('f-time')
245 const showTime = () => {
246 const m = Number(time.value)
247 $('time-value').textContent =
248 `— ${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
249 }
250 time.oninput = () => {
251 showTime()
252 $<HTMLSelectElement>('f-light').disabled = true
253 if (!this.interior) return
254 this.interior.clockDriven = true
255 this.interior.setDayPhase(Number(time.value) / 1440)
256 this.renderStanding()
257 }
258 showTime()
259
260 const cycle = $<HTMLInputElement>('f-cycle')
261 const rate = $<HTMLInputElement>('f-cycle-rate')
262 const applyCycle = () => {
263 const r = RATES[Number(rate.value)]!
264 $('cycle-value').textContent =
265 `— ${r}x, a day in ${fmtDuration(86_400 / r)}`
266 $<HTMLSelectElement>('f-light').disabled = cycle.checked || this.interior?.clockDriven === true
267 if (!this.interior) return
268 this.interior.cycleRate = r
269 this.interior.setCycling(cycle.checked)
270 }
271 cycle.onchange = applyCycle
272 rate.oninput = applyCycle
273 applyCycle()
274
7cc177f6 2026-09-07 Pavel Safronov275 const light = $<HTMLSelectElement>('f-light')
276 light.onchange = () => this.interior?.setLighting(light.value as Lighting)
5f38efd0 2026-09-07 Pavel Safronov277 }
278
279 /**
280 * The exterior renderer is built on first use. It pulls in Three.js and starts an
281 * animation loop, neither of which a reader who only ever opens the map should pay
282 * for.
283 */
aa55ae67 2026-09-07 Pavel Safronov284 private async setView(view: ViewName): Promise<void> {
5f38efd0 2026-09-07 Pavel Safronov285 this.view = view
8b6287ba 2026-09-07 Pavel Safronov286 try {
287 localStorage.setItem(VIEW_KEY, view)
288 } catch {
289 // Private window or blocked site data: the view just will not persist.
290 }
5f38efd0 2026-09-07 Pavel Safronov291 const isMap = view === 'map'
7cc177f6 2026-09-07 Pavel Safronov292 const isExterior = view === 'exterior'
293 const isInterior = view === 'interior'
294 $('map').classList.toggle('hidden', !isMap)
295 $('exterior').classList.toggle('hidden', !isExterior)
296 $('interior').classList.toggle('hidden', !isInterior)
5f38efd0 2026-09-07 Pavel Safronov297 $('map-options').classList.toggle('hidden', !isMap)
7cc177f6 2026-09-07 Pavel Safronov298 $('exterior-options').classList.toggle('hidden', !isExterior)
299 $('interior-options').classList.toggle('hidden', !isInterior)
a3f01bd7 2026-09-08 Pavel Safronov300 /**
301 * "Show terrain" is the map's control. The interior has nothing to turn off —
302 * the raster is the ground itself — and the exterior now has its own two-mode
303 * switch instead of a checkbox (D-047), so the row belongs to the map alone.
304 */
305 $('terrain-row').classList.toggle('hidden', !isMap)
7cc177f6 2026-09-07 Pavel Safronov306 $('spin').classList.toggle('hidden', !isExterior)
307 $('ext-labels').classList.toggle('hidden', !isExterior)
308 $('standing').classList.toggle('hidden', !isInterior)
309 $('int-labels').classList.toggle('hidden', !isInterior)
5f38efd0 2026-09-07 Pavel Safronov310 for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) {
311 b.classList.toggle('active', b.dataset['view'] === view)
312 }
7cc177f6 2026-09-07 Pavel Safronov313 if (!isExterior) this.exterior?.stop()
314 if (!isInterior) this.interior?.stop()
5f38efd0 2026-09-07 Pavel Safronov315 if (isMap) {
316 this.map.resize()
317 return
318 }
7cc177f6 2026-09-07 Pavel Safronov319 if (isInterior) return this.openInterior()
5f38efd0 2026-09-07 Pavel Safronov320 if (!this.exterior) {
aa55ae67 2026-09-07 Pavel Safronov321 // Genuinely deferred: Three.js is by far the largest dependency, and a static
322 // import would bundle it into the entry chunk whether or not this view is
323 // ever opened. Vite splits it into its own chunk on this await.
324 const { ExteriorView } = await import('../views/exterior.js')
5f38efd0 2026-09-07 Pavel Safronov325 this.exterior = new ExteriorView($<HTMLCanvasElement>('exterior'), {
326 onPickPoint: (p) => {
327 this.map.probe = { from: p, to: null }
328 this.selectedId = null
329 this.render()
330 },
331 onPickPlace: (id) => {
332 this.selectedId = id
333 this.render()
334 },
8b6287ba 2026-09-07 Pavel Safronov335 }, $('ext-labels'))
5f38efd0 2026-09-07 Pavel Safronov336 const spin = $<HTMLInputElement>('f-spin')
337 const speed = $<HTMLInputElement>('f-speed')
338 this.exterior.spinning = spin.checked
339 this.exterior.timeScale = SPEEDS[Number(speed.value)]!
a3f01bd7 2026-09-08 Pavel Safronov340 this.exterior.showTerrain = $<HTMLSelectElement>('f-ext-view').value === 'xray'
1affced2 2026-09-08 Pavel Safronov341 this.exterior.setSurface(this.surface)
5f38efd0 2026-09-07 Pavel Safronov342 }
343 this.exterior.resize()
344 this.exterior.setPlaces(this.store.places, this.selectedId)
345 this.exterior.start()
346 this.render()
347 }
348
7cc177f6 2026-09-07 Pavel Safronov349 /** Same deferred-import bargain as the exterior: Three.js is not the map's cost. */
350 private async openInterior(): Promise<void> {
351 if (!this.interior) {
352 const { InteriorView } = await import('../views/interior.js')
353 this.interior = new InteriorView($<HTMLCanvasElement>('interior'), {
354 onPickPlace: (id) => {
355 this.selectedId = id
356 this.focusOn(id)
357 this.render()
358 },
359 onLook: () => this.renderStanding(),
360 }, $('int-labels'))
361 // Adopt whatever was restored from storage, so the panel is not lying.
362 const eye = $<HTMLInputElement>('f-eye')
363 eye.value = String(Math.round(sliderFromEyeHeight(this.interior.eyeM) * 1000))
364 eye.dispatchEvent(new Event('input'))
365 $<HTMLSelectElement>('f-light').value = this.interior.lighting
2bb3e0de 2026-09-08 Pavel Safronov366 $<HTMLInputElement>('f-cycle').checked = this.interior.cycling
367 $<HTMLSelectElement>('f-light').disabled = this.interior.cycling
1affced2 2026-09-08 Pavel Safronov368 this.interior.setSurface(this.surface)
7cc177f6 2026-09-07 Pavel Safronov369 }
370 this.interior.resize()
371 this.interior.setPlaces(this.store.places.filter((p) => p.geometry.type !== 'band'))
372 this.interior.start()
373 this.render()
374 }
375
fc11b064 2026-09-08 Pavel Safronov376 /**
377 * The sidebar as a drawer, on a phone.
378 *
379 * Everything here is a class on #app; the layout itself is CSS, and on a wide
380 * screen the toggle, the close button and the scrim are all `display: none`, so
381 * this runs harmlessly and changes nothing. Closed on load: the world is what a
382 * visitor came for, and the panel is one tap away.
383 */
384 private wirePanel(): void {
385 $<HTMLButtonElement>('panel-toggle').onclick = () => this.setPanel(true)
386 $<HTMLButtonElement>('panel-close').onclick = () => this.setPanel(false)
387 $('scrim').onclick = () => this.setPanel(false)
388 // Crossing the breakpoint — a phone turned on its side — starts over closed,
389 // which is also what clears `inert` on the way back to the desktop column.
390 this.narrow.addEventListener('change', () => this.setPanel(false))
391 this.setPanel(false)
392 }
393
394 private setPanel(open: boolean): void {
395 $('app').classList.toggle('panel-open', open)
396 $('panel-toggle').setAttribute('aria-expanded', String(open))
397 /**
398 * A closed drawer is only translated off-screen, so without this its fields
399 * stay in the tab order and typing into the map's sidebar scrolls a panel
400 * nobody can see. `inert` never applies on a wide screen, where the panel is
401 * a column and always genuinely there.
402 */
403 $('panel').inert = this.narrow.matches && !open
404 }
405
09a64747 2026-09-07 Pavel Safronov406 /** Readers get the map, the place list and the sightline probe — nothing else. */
407 private applyMode(): void {
408 $('mode').textContent = this.writer ? 'writer' : 'reader'
cf37225c 2026-09-07 Pavel Safronov409 for (const id of ['tools-section', 'data-section']) {
0e0c2dba 2026-09-07 Pavel Safronov410 $(id).hidden = !this.writer
09a64747 2026-09-07 Pavel Safronov411 }
412 if (!this.writer) {
413 $('tool-hint').textContent = 'Shift-click two points to measure a sightline.'
414 }
415 }
416
417 // --------------------------------------------------------------- permalinks
418
419 /**
420 * The link IS the deliverable for a companion piece (D-010): "stand here and look
421 * at this" is the whole value. A place id survives the map being redrawn; a raw
422 * point survives the place being renamed.
423 */
424 private writeHash(): void {
425 const params = new URLSearchParams()
8b6287ba 2026-09-07 Pavel Safronov426 if (this.view !== 'map') params.set('view', this.view)
09a64747 2026-09-07 Pavel Safronov427 if (this.selectedId) params.set('place', this.selectedId)
428 else if (this.map.probe?.from) {
429 const p = this.map.probe.from
430 params.set('at', `${p.theta.toFixed(5)},${Math.round(p.z)}`)
431 }
432 const next = params.toString()
433 const url = next ? `#${next}` : location.pathname + location.search
434 if (location.hash.slice(1) !== next) history.replaceState(null, '', url)
435 }
436
437 private readHash(): void {
438 const params = new URLSearchParams(location.hash.slice(1))
dc85f192 2026-09-07 Pavel Safronov439 // A hash edited in place fires hashchange rather than reloading, so the view
440 // has to be honoured here too — otherwise `#view=` only ever works on a cold
441 // load, and following one link from another quietly ignores it.
442 const view = params.get('view')
443 if (isView(view) && view !== this.view) void this.setView(view)
09a64747 2026-09-07 Pavel Safronov444 const id = params.get('place')
445 if (id && this.store.get(id)) {
446 this.selectedId = id
447 this.focusOn(id)
448 this.render()
449 return
450 }
451 const at = params.get('at')
452 if (at) {
453 const [theta, z] = at.split(',').map(Number)
454 if (Number.isFinite(theta) && Number.isFinite(z)) {
455 this.map.probe = { from: { theta: theta!, z: z! }, to: null }
456 this.render()
457 }
458 }
459 }
460
461 private focusOn(id: string): void {
462 const p = this.store.get(id)
463 if (!p) return
7cc177f6 2026-09-07 Pavel Safronov464 const point = pointOf(p)
09a64747 2026-09-07 Pavel Safronov465 this.map.centreOn(point)
5f38efd0 2026-09-07 Pavel Safronov466 if (this.view === 'exterior') this.exterior?.lookAt(point)
7cc177f6 2026-09-07 Pavel Safronov467 // Each view does what "focus" means for it: the map centres, the exterior
468 // swings round, and the interior walks there — selecting a place is how you
469 // travel, since at ground level most of the world is beyond the haze horizon.
470 if (this.view === 'interior') {
471 this.interior?.visit(point, facingOf(p))
472 this.renderStanding()
473 }
09a64747 2026-09-07 Pavel Safronov474 }
475
476 // ------------------------------------------------------------------- toolbar
477
478 private buildTools(): void {
479 const host = $('tools')
480 const mk = (tool: Tool, label: string) => {
481 const b = document.createElement('button')
482 b.textContent = label
483 b.dataset['tool'] = tool
484 b.onclick = () => this.setTool(tool)
485 host.append(b)
486 return b
487 }
488 mk('select', 'Select')
489 for (const k of PLACE_KINDS) mk(k, k[0]!.toUpperCase() + k.slice(1))
490 }
491
492 private setTool(tool: Tool): void {
493 this.tool = tool
494 this.draft = []
495 this.map.draftPolygon = []
496 this.probeFrom = null
497 this.map.probe = null
498 $('sightline').classList.add('hidden')
499 $('tool-hint').textContent =
500 tool === 'select'
501 ? 'Click a marker to select. Drag to move. Shift-click two points to measure a sightline.'
502 : isAreaKind(tool)
503 ? `Click to trace the outline of a ${tool}. Enter closes it; Esc cancels.`
504 : isTracedKind(tool)
505 ? `Click to trace the course of a ${tool}. Enter finishes it; Esc cancels.`
506 : `Click the map to place a ${tool}.`
507 this.render()
508 }
509
510 // -------------------------------------------------------------- map handlers
511
512 private onPick(p: SurfacePoint, hit: Place | null, shift: boolean): void {
513 if (shift) return this.onProbe(p)
514
515 if (this.tool === 'select') {
516 this.selectedId = hit?.id ?? null
517 return this.render()
518 }
519 if (isTracedKind(this.tool)) {
520 this.draft.push([p.theta, p.z])
521 this.map.draftPolygon = this.draft
522 return this.render()
523 }
524 this.createPlace(this.tool, { type: 'point', theta: p.theta, z: p.z })
525 }
526
527 private onProbe(p: SurfacePoint): void {
528 if (!this.probeFrom) {
529 this.probeFrom = p
530 this.map.probe = { from: p, to: null }
531 $('sightline').classList.add('hidden')
532 } else {
533 this.map.probe = { from: this.probeFrom, to: p }
534 this.showSightline(this.probeFrom, p)
535 this.probeFrom = null
536 }
537 this.render()
538 }
539
540 private showSightline(a: SurfacePoint, b: SurfacePoint): void {
541 const s = sightline(a, b)
542 const el = $('sightline')
543 el.classList.remove('hidden')
544 el.textContent = [
545 `walk ${km(s.surfaceDistanceM)}`,
546 `line of sight ${km(s.directDistanceM)}`,
547 `rises to ${km(s.apexHeightM)} above the floor`,
548 `optical depth ${s.opticalDepth.toFixed(2)}`,
549 `transmission ${pct(s.transmission)}`,
550 ` ${s.clarity.toUpperCase()}`,
551 ].join('\n')
552 }
553
554 private onDragPlace(id: string, p: SurfacePoint): void {
555 const place = this.store.get(id)
556 if (place?.geometry.type !== 'point') return
557 this.store.update(id, { geometry: { type: 'point', theta: p.theta, z: p.z } })
558 }
559
560 private onHover(p: SurfacePoint | null): void {
561 const el = $('cursor')
562 if (!p) {
563 el.textContent = ''
564 el.classList.add('hidden')
565 return
566 }
567 el.classList.remove('hidden')
568 const strip = landStripIndex(p.theta)
569 el.textContent = [
570 `z ${km(p.z)} from sun cap`,
571 `θ ${((p.theta * 180) / Math.PI).toFixed(2)}°`,
572 strip === null ? 'on a star slat' : `land strip ${strip}`,
573 ].join('\n')
574 }
575
576 private onKey(e: KeyboardEvent): void {
577 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
578 if (e.key === 'Escape') return this.setTool('select')
579 if (e.key === 'Enter' && isTracedKind(this.tool as PlaceKind)) {
580 const area = isAreaKind(this.tool as PlaceKind)
581 if (this.draft.length >= (area ? 3 : 2)) {
582 this.createPlace(this.tool as PlaceKind,
583 area ? { type: 'polygon', points: this.draft } : { type: 'polyline', points: this.draft })
584 this.draft = []
585 this.map.draftPolygon = []
586 }
587 }
588 if ((e.key === 'Backspace' || e.key === 'Delete') && this.selectedId) {
589 this.deleteSelected()
590 }
591 if (e.key === 'f') this.map.fit()
592 }
593
594 private createPlace(kind: PlaceKind, geometry: Place['geometry']): void {
595 const name = `New ${kind}`
596 const place: Place = {
597 id: makeId(name, this.store.takenIds),
598 name, kind, geometry, blurb: '', notes: '', public: true,
599 }
600 this.store.add(place)
601 this.selectedId = place.id
602 this.setTool('select')
603 // renderEditor leaves a focused field alone, so set it explicitly here —
604 // creating a place while the name box has focus would otherwise show the
605 // previous place's name.
606 const field = $<HTMLInputElement>('f-name')
607 field.value = place.name
608 field.select()
609 }
610
cf37225c 2026-09-07 Pavel Safronov611 // ---------------------------------------------------------------------- view
09a64747 2026-09-07 Pavel Safronov612
613 private wireView(): void {
614 const box = $<HTMLInputElement>('f-terrain')
615 box.onchange = () => {
616 this.map.showTerrain = box.checked
617 this.map.draw()
a3f01bd7 2026-09-08 Pavel Safronov618 }
619 const ext = $<HTMLSelectElement>('f-ext-view')
620 ext.onchange = () => {
621 if (this.exterior) this.exterior.showTerrain = ext.value === 'xray'
09a64747 2026-09-07 Pavel Safronov622 }
623 }
624
625 // -------------------------------------------------------------------- editor
626
627 private buildKindSelect(): void {
628 const sel = $<HTMLSelectElement>('f-kind')
629 for (const k of PLACE_KINDS) {
630 const o = document.createElement('option')
631 o.value = k
632 o.textContent = k
633 sel.append(o)
634 }
635 }
636
637 private wireEditor(): void {
638 const patch = (p: Partial<Omit<Place, 'id'>>) => {
639 if (this.selectedId) this.store.update(this.selectedId, p)
640 }
641 $<HTMLInputElement>('f-name').oninput = (e) =>
642 this.rename((e.target as HTMLInputElement).value)
643 $<HTMLInputElement>('f-id').oninput = (e) => this.setId((e.target as HTMLInputElement).value)
644 $<HTMLSelectElement>('f-kind').onchange = (e) =>
645 patch({ kind: (e.target as HTMLSelectElement).value as PlaceKind })
646 $<HTMLTextAreaElement>('f-blurb').oninput = (e) =>
647 patch({ blurb: (e.target as HTMLTextAreaElement).value })
648 $<HTMLTextAreaElement>('f-notes').oninput = (e) =>
649 patch({ notes: (e.target as HTMLTextAreaElement).value })
650 $<HTMLInputElement>('f-public').onchange = (e) =>
651 patch({ public: (e.target as HTMLInputElement).checked })
652 $('f-delete').onclick = () => this.deleteSelected()
653 }
654
655 /**
656 * Rename, letting the id follow along. Ids are permalink targets and must be
657 * stable once published, but during world-building names churn constantly and a
658 * file full of "new-city-7" is useless to read or hand-edit. So the id tracks the
659 * name only while it still matches it; editing the id field pins it for good.
660 */
661 private rename(name: string): void {
662 const place = this.selectedId ? this.store.get(this.selectedId) : undefined
663 if (!place) return
664 const idWasAuto = isAutoId(place.id, place.name)
665 this.store.update(place.id, { name })
666 if (!idWasAuto) return
667 this.reid(place.id, name)
668 }
669
670 /**
671 * Move a place to a new id. `selectedId` is updated *before* the store emits,
672 * because the store's re-render looks the selection up by id and would otherwise
673 * find nothing and blank the editor mid-rename.
674 */
675 private reid(from: string, source: string): void {
676 const taken = new Set([...this.store.takenIds].filter((i) => i !== from))
677 const next = makeId(source, taken)
678 if (next === from) return
679 this.selectedId = next
680 if (!this.store.setId(from, next)) this.selectedId = from
681 }
682
683 private setId(raw: string): void {
684 if (this.selectedId) this.reid(this.selectedId, raw)
685 }
686
687 private deleteSelected(): void {
688 if (!this.selectedId) return
689 const place = this.store.get(this.selectedId)
690 if (place && !confirm(`Delete "${place.name}"?`)) return
691 this.store.remove(this.selectedId)
692 this.selectedId = null
693 this.render()
694 }
695
696 // ---------------------------------------------------------------------- data
697
698 private wireData(): void {
699 $('export-backup').onclick = () => {
700 this.store.flush()
701 download(backupFilename(), this.store.fullJSON())
702 }
703 $('export-static').onclick = () => {
704 this.store.flush()
705 download('world.json', this.store.publicJSON())
706 }
3b520afd 2026-09-07 Pavel Safronov707 $('reseed').onclick = async () => {
708 const n = this.store.places.length
709 if (n && !confirm(`Replace ${n} local place(s) with the published world?`)) return
710 const loaded = await this.store.seedFrom(`${import.meta.env.BASE_URL}world.json`)
711 this.selectedId = null
712 this.store.flush()
713 this.render()
714 if (!loaded) alert('No published world.json was found to load.')
715 }
716
09a64747 2026-09-07 Pavel Safronov717 const file = $<HTMLInputElement>('import-file')
718 $('import').onclick = () => file.click()
719 file.onchange = async () => {
720 const f = file.files?.[0]
721 if (!f) return
722 try {
723 const world = parseWorld(JSON.parse(await f.text()))
724 const n = this.store.places.length
725 if (n && !confirm(`Replace ${n} place(s) with ${world.places.length} from ${f.name}?`)) return
726 this.store.replaceAll(world)
727 this.selectedId = null
728 } catch (err) {
729 alert(`Could not read that file.\n\n${err instanceof Error ? err.message : String(err)}`)
730 } finally {
731 file.value = ''
732 }
733 }
734 }
735
736 // -------------------------------------------------------------------- render
737
738 private render(): void {
739 for (const b of document.querySelectorAll<HTMLButtonElement>('#tools button')) {
740 b.classList.toggle('active', b.dataset['tool'] === this.tool)
741 }
742
1affced2 2026-09-08 Pavel Safronov743 /**
744 * Places belong to Tower, not to the ground. On a non-canon surface the world
745 * list still shows them — it is the author's data, and hiding it would look
746 * like data loss — but nothing is drawn over the terrain, because a marker
747 * reading "The Capital" pinned to Puget Sound claims a correspondence that
748 * does not exist. Probes and sightlines stay: those measure the cylinder, and
749 * the cylinder is the same either way (D-055).
750 */
751 const placed = this.surface === 'canon' ? this.store.places : []
752 this.map.places = placed
09a64747 2026-09-07 Pavel Safronov753 this.map.selectedId = this.selectedId
1affced2 2026-09-08 Pavel Safronov754 this.map.dragEnabled = this.tool === 'select' && this.surface === 'canon'
5f38efd0 2026-09-07 Pavel Safronov755 if (this.view === 'exterior') {
1affced2 2026-09-08 Pavel Safronov756 this.exterior?.setPlaces(placed, this.selectedId)
5f38efd0 2026-09-07 Pavel Safronov757 this.renderSpinReadout()
758 }
7cc177f6 2026-09-07 Pavel Safronov759 if (this.view === 'interior') {
1affced2 2026-09-08 Pavel Safronov760 this.interior?.setPlaces(placed.filter((p) => p.geometry.type !== 'band'))
7cc177f6 2026-09-07 Pavel Safronov761 this.renderStanding()
762 }
09a64747 2026-09-07 Pavel Safronov763 this.map.draw()
764
765 const total = this.store.places.length
766 const pub = this.store.publicCount()
767 $('counts').textContent = `— ${total} place${total === 1 ? '' : 's'}, ${pub} public`
768
769 this.renderList()
770 this.renderEditor()
771 this.renderSave()
772 this.writeHash()
773 }
774
775 private renderList(): void {
776 const ul = $('list')
777 ul.replaceChildren()
778 for (const p of [...this.store.places].sort((a, b) => a.name.localeCompare(b.name))) {
779 const li = document.createElement('li')
780 li.className = (p.id === this.selectedId ? 'sel ' : '') + (p.public ? '' : 'private')
781 li.onclick = () => {
782 this.selectedId = p.id
11a13dbf 2026-09-07 Pavel Safronov783 this.focusOn(p.id)
09a64747 2026-09-07 Pavel Safronov784 this.render()
fc11b064 2026-09-08 Pavel Safronov785 /**
786 * On a phone the list is the drawer, and the drawer is over the world. In
787 * every view selecting a place moves you to it — the map centres, the
788 * exterior swings round, the interior walks there — so staying on the list
789 * means watching none of it happen. Get out of the way.
790 */
791 if (this.narrow.matches) this.setPanel(false)
09a64747 2026-09-07 Pavel Safronov792 }
793 const sw = document.createElement('span')
794 sw.className = 'swatch'
795 sw.style.background = KIND_COLOR[p.kind]
796 const nm = document.createElement('span')
797 nm.className = 'nm'
798 nm.textContent = p.name
799 const kd = document.createElement('span')
800 kd.className = 'kd'
801 kd.textContent = p.public ? p.kind : `${p.kind} · private`
802 li.append(sw, nm, kd)
803 ul.append(li)
804 }
805 }
806
807 private renderEditor(): void {
0e0c2dba 2026-09-07 Pavel Safronov808 const editor = $<HTMLDetailsElement>('editor')
809 const detail = $<HTMLDetailsElement>('detail')
09a64747 2026-09-07 Pavel Safronov810 const p = this.selectedId ? this.store.get(this.selectedId) : undefined
811
812 // Readers get a read-only card. Without this branch renderEditor would happily
813 // un-hide the edit panel — including the private notes field — for everyone.
814 if (!this.writer) {
0e0c2dba 2026-09-07 Pavel Safronov815 editor.hidden = true
816 detail.hidden = !p
09a64747 2026-09-07 Pavel Safronov817 if (p) {
818 $('d-name').textContent = p.name
819 $('d-blurb').textContent = p.blurb || `A ${p.kind}.`
820 $('d-where').textContent = this.whereText(p)
821 }
822 return
823 }
824
0e0c2dba 2026-09-07 Pavel Safronov825 detail.hidden = true
826 if (!p) {
827 editor.hidden = true
828 return
829 }
830 editor.hidden = false
09a64747 2026-09-07 Pavel Safronov831
832 const name = $<HTMLInputElement>('f-name')
833 if (document.activeElement !== name) name.value = p.name
834 const id = $<HTMLInputElement>('f-id')
835 if (document.activeElement !== id) id.value = p.id
836 $<HTMLSelectElement>('f-kind').value = p.kind
837 const blurb = $<HTMLTextAreaElement>('f-blurb')
838 if (document.activeElement !== blurb) blurb.value = p.blurb
839 const notes = $<HTMLTextAreaElement>('f-notes')
840 if (document.activeElement !== notes) notes.value = p.notes
841 $<HTMLInputElement>('f-public').checked = p.public
842
843 $('f-where').textContent = this.whereText(p)
844 }
845
846 /** Human-readable location, shared by the editor and the reader's detail card. */
847 private whereText(p: Place): string {
848 const g = p.geometry
849 if (g.type === 'point') {
850 const where = isSlat(g.theta)
851 ? `on a star slat (${km(slatWidth())} wide)`
852 : slatCount() === 0
853 ? 'on the open surface'
854 : `land strip ${landStripIndex(g.theta)} (${km(landStripWidth())} wide)`
855 return `z ${km(g.z)} from the sun cap · ${where}`
856 }
04d6779e 2026-09-07 Pavel Safronov857 if (g.type === 'band') {
858 return `rings the world, z ${km(g.zFrom)} to ${km(g.zTo)} (${km(g.zTo - g.zFrom)} deep)`
859 }
09a64747 2026-09-07 Pavel Safronov860 if (g.type === 'polyline') {
861 return `${g.points.length} points, ${km(pathLength(g.points))} long`
862 }
863 return `${g.points.length} vertices`
864 }
865
5f38efd0 2026-09-07 Pavel Safronov866 /** Where the spin has got to, since nothing in Tower's own sky would tell you. */
867 private renderSpinReadout(): void {
868 const e = this.exterior
869 if (!e) return
870 $('spin').textContent = [
871 `spin phase ${((e.phase * 180) / Math.PI).toFixed(0).padStart(3)}°`,
872 `period ${fmtDuration(SPIN_PERIOD_S)}`,
873 `rim speed 700.2 m/s`,
874 ].join('\n')
875 }
876
37a25a7f 2026-09-08 Pavel Safronov877 /**
878 * Put the panel's clock where the world's clock actually is.
879 *
880 * The running cycle advances `dayPhase` inside the view, and the readout was
881 * reading it off there every frame while the slider sat wherever it had last
882 * been dragged — two clocks in the same panel, disagreeing, one of them wrong.
883 *
884 * The slider is left alone while it has focus, so this cannot fight a drag in
885 * progress, and it writes nothing when the value has not changed: `input` on a
886 * range is not free, and the label rebuild would otherwise run every frame.
887 */
888 private syncTime(): void {
889 const v = this.interior
890 if (!v) return
891 const t = $<HTMLInputElement>('f-time')
892 if (document.activeElement === t) return
893 const mins = Math.round((((v.dayPhase % 1) + 1) % 1) * 1440) % 1440
894 if (Number(t.value) === mins) return
895 t.value = String(mins)
896 $('time-value').textContent =
897 `— ${String(Math.floor(mins / 60)).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')}`
898 }
899
7cc177f6 2026-09-07 Pavel Safronov900 /**
901 * Where you are standing and what can be seen from there. The sightline straight
902 * up is the one worth printing: it is the crispest far view in Tower (design.md
903 * §1.8), and it is the fact readers find hardest to believe.
904 */
905 private renderStanding(): void {
906 const v = this.interior
907 if (!v) return
37a25a7f 2026-09-08 Pavel Safronov908 this.syncTime()
7cc177f6 2026-09-07 Pavel Safronov909 const here = v.at
910 const opposite = { theta: here.theta + Math.PI, z: here.z }
911 const across = sightline(here, opposite)
912 /**
913 * The axial comparison, along whichever direction has the most room. Near a cap
914 * there is no 100 km to look down, and silently measuring a shorter line while
915 * still labelling it 100 km made the haze look far kinder than it is.
916 */
917 const deepward = LENGTH_M - here.z >= here.z
918 const span = Math.min(100_000, deepward ? LENGTH_M - here.z : here.z)
919 const along = sightline(here, { theta: here.theta, z: here.z + (deepward ? span : -span) })
920 const rep = v.report()
921 const name = this.store.places.find((p) =>
922 p.geometry.type === 'point'
923 && Math.abs(p.geometry.z - here.z) < 2000
924 && Math.abs(p.geometry.theta - here.theta) < 0.04)?.name
925 $('standing').textContent = [
926 `standing ${name ?? rep.standing}`,
927 ...(name ? [` ${rep.standing}`] : []),
928 ...rep.lines,
929 `overhead ${km(across.directDistanceM)} up · ${pct(across.transmission)} clear`,
930 `${(`${km(span)} on`).padEnd(10)} ${pct(along.transmission)} clear — the ground is the vague part`,
931 ].join('\n')
932 }
933
09a64747 2026-09-07 Pavel Safronov934 private renderSave(): void {
935 const el = $('save')
936 el.classList.toggle('error', this.store.saveState === 'error')
937 if (this.store.storageError) {
938 el.textContent = `Not saving locally: ${this.store.storageError}. Export a backup.`
939 } else if (this.store.saveState === 'saving') {
940 el.textContent = 'Saving…'
941 } else {
942 const t = this.store.lastSaved
943 el.textContent = t ? `Saved locally at ${t.toLocaleTimeString()}` : 'Saved locally'
944 }
945 }
946}