fuzzyworld

tower / interior-flight / files / src / ui / app.ts

Branch interior-flight · default branch · all branches

app.ts

34.3 KB · history · blame

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