fuzzyworld

tower / files / src / ui / eye-gauge.ts

eye-gauge.ts

7.4 KB · history · blame

1/**
2 * The eye-height gauge: a vertical altimeter up the side of the interior view (D-082).
3 *
4 * The author asked for the height as a height — "a vertical height" rather than a slider in
5 * the panel — marked with things of known size, so a number like 412 m arrives as "a bit
6 * above the Eiffel Tower". The scale is the slider's own logarithm (scales.ts), so the
7 * marks spread evenly from a person to a weather balloon.
8 *
9 * A widget of its own rather than a rotated <input type=range>: the marks have to sit
10 * exactly level with the thumb, and a native range keeps its thumb inset by an amount
11 * each browser chooses. It is still a slider to assistive tech and the keyboard.
12 */
13import { EYE_MAX_M, EYE_MIN_M, eyeHeightFromSlider, sliderFromEyeHeight } from './scales.js'
14
15/** Things of known height, low to high. Real-world, so they need no explaining. */
16export const LANDMARKS: readonly { m: number; name: string }[] = [
17 { m: 1.7, name: 'an adult' },
18 { m: 8, name: 'a two-storey house' },
19 { m: 30, name: 'a ten-storey block' },
20 { m: 93, name: 'the Statue of Liberty' },
21 { m: 330, name: 'the Eiffel Tower' },
22 { m: 828, name: 'the Burj Khalifa' },
23 { m: 3_776, name: 'Mount Fuji' },
24 { m: 8_849, name: 'Mount Everest' },
25 { m: 11_000, name: 'an airliner cruising' },
26 { m: 18_000, name: 'Concorde cruising' },
27 { m: 35_000, name: 'a weather balloon' },
28 // Tower's own, and the top of the scale: nobody goes higher (D-084).
29 { m: EYE_MAX_M, name: 'the beam' },
30]
31
32/**
33 * What the height is like, in words: level with a landmark when within a tenth of it,
34 * otherwise a multiple of the highest one it has passed.
35 */
36export function compareHeight(m: number): string {
37 const near = LANDMARKS.find((l) => Math.abs(m / l.m - 1) <= 0.1)
38 if (near) return `≈ ${near.name}`
39 const below = [...LANDMARKS].reverse().find((l) => l.m < m)
40 if (!below) return LANDMARKS[0]!.name
41 const x = m / below.m
42 return `${x < 10 ? x.toFixed(1) : Math.round(x)}× ${below.name}`
43}
44
45export class EyeGauge {
46 /** True while a drag or key is changing it, so the view does not fight the hand. */
47 active = false
48 private track: HTMLDivElement
49 private thumb: HTMLDivElement
50 private readout: HTMLDivElement
51 private marks: HTMLDivElement[] = []
52 private m = EYE_MIN_M
53 private sleep: ReturnType<typeof setTimeout> | null = null
54
55 /**
56 * `onInput` is called with the new height whenever the reader moves it; `describe`
57 * gives the readout's second line (the local gravity) for a height.
58 */
59 constructor(
60 host: HTMLElement,
61 private onInput: (m: number) => void,
62 private describe: (m: number) => string,
63 ) {
64 host.innerHTML = ''
65 this.track = el('div', 'gauge-track')
66 this.track.tabIndex = 0
67 this.track.setAttribute('role', 'slider')
68 this.track.setAttribute('aria-label', 'Eye height')
69 this.track.setAttribute('aria-orientation', 'vertical')
70 this.track.setAttribute('aria-valuemin', String(EYE_MIN_M))
71 this.track.setAttribute('aria-valuemax', String(EYE_MAX_M))
72 for (const l of LANDMARKS) {
73 const mark = el('div', 'gauge-mark')
74 mark.style.bottom = `${sliderFromEyeHeight(l.m) * 100}%`
75 mark.innerHTML = `<span>${l.name}</span><i>${fmtM(l.m)}</i>`
76 this.track.append(mark)
77 this.marks.push(mark)
78 }
79 this.thumb = el('div', 'gauge-thumb')
80 this.readout = el('div', 'gauge-readout')
81 this.thumb.append(this.readout)
82 this.track.append(this.thumb)
83 host.append(this.track)
84 new ResizeObserver(() => this.uncrowd()).observe(this.track)
85 this.attach()
86 this.set(this.m)
87 }
88
89 get value(): number {
90 return this.m
91 }
92
93 /** Move to a height without reporting it: for the view telling the gauge. */
94 set(m: number): void {
95 const next = Math.min(EYE_MAX_M, Math.max(EYE_MIN_M, m))
96 // A height that has really changed shows the readout again; the view re-telling the same
97 // height every frame does not.
98 if (Math.abs(next / this.m - 1) > 0.005) this.wake()
99 this.m = next
100 const t = sliderFromEyeHeight(this.m)
101 this.thumb.style.bottom = `${t * 100}%`
102 this.readout.innerHTML =
103 `<b>${fmtM(this.m)}</b> · ${this.describe(this.m)}<br><span>${compareHeight(this.m)}</span>`
104 this.track.setAttribute('aria-valuenow', this.m.toFixed(1))
105 this.track.setAttribute('aria-valuetext', `${fmtM(this.m)}, ${compareHeight(this.m)}`)
106 // Passed marks light up, and the one it is level with most of all.
107 LANDMARKS.forEach((l, i) => {
108 this.marks[i]!.classList.toggle('passed', l.m <= this.m * 1.1)
109 this.marks[i]!.classList.toggle('level', Math.abs(this.m / l.m - 1) <= 0.1)
110 })
111 }
112
113 /**
114 * Show the readout, and let it fade again once the height has been still for a while. On a
115 * phone the readout box covers a good share of the view, so it is only there while the
116 * height is changing or the gauge is in hand (style.css); elsewhere it stays.
117 */
118 private wake(): void {
119 this.readout.classList.add('awake')
120 if (this.sleep) clearTimeout(this.sleep)
121 this.sleep = setTimeout(() => {
122 this.sleep = null
123 if (!this.active) this.readout.classList.remove('awake')
124 }, READOUT_STAYS_MS)
125 }
126
127 /**
128 * On a short view some marks land on top of each other (Everest and the airliner are a
129 * quarter of the scale's e-fold apart). Walking up from the ground, a mark too close to
130 * the last one named keeps its tick and loses its name.
131 */
132 private uncrowd(): void {
133 const h = this.track.clientHeight
134 let last = -Infinity
135 LANDMARKS.forEach((l, i) => {
136 const y = sliderFromEyeHeight(l.m) * h
137 const crowded = y - last < 15
138 this.marks[i]!.classList.toggle('crowded', crowded)
139 if (!crowded) last = y
140 })
141 }
142
143 private input(m: number): void {
144 this.set(m)
145 this.onInput(this.m)
146 }
147
148 private attach(): void {
149 const fromPointer = (e: PointerEvent) => {
150 const r = this.track.getBoundingClientRect()
151 this.input(eyeHeightFromSlider(1 - (e.clientY - r.top) / r.height))
152 }
153 this.track.addEventListener('pointerdown', (e) => {
154 this.active = true
155 this.wake()
156 this.track.setPointerCapture(e.pointerId)
157 this.track.focus()
158 fromPointer(e)
159 e.preventDefault()
160 })
161 this.track.addEventListener('pointermove', (e) => {
162 if (this.active && this.track.hasPointerCapture(e.pointerId)) fromPointer(e)
163 })
164 const end = () => { this.active = false; this.wake() }
165 this.track.addEventListener('pointerup', end)
166 this.track.addEventListener('pointercancel', end)
167 // Keys: a step is 1% of the scale, a page 10%; Home and End go to the ends.
168 this.track.addEventListener('keydown', (e) => {
169 const t = sliderFromEyeHeight(this.m)
170 const step = { ArrowUp: 0.01, ArrowRight: 0.01, ArrowDown: -0.01, ArrowLeft: -0.01,
171 PageUp: 0.1, PageDown: -0.1 }[e.key]
172 if (step !== undefined) this.input(eyeHeightFromSlider(t + step))
173 else if (e.key === 'Home') this.input(EYE_MIN_M)
174 else if (e.key === 'End') this.input(EYE_MAX_M)
175 else return
176 // The view flies on arrow keys too; this one is the gauge's.
177 e.preventDefault()
178 e.stopPropagation()
179 })
180 }
181}
182
183/** How long the readout stays after the height last changed, where it fades at all. */
184export const READOUT_STAYS_MS = 2000
185
186export const fmtM = (m: number): string =>
187 m < 10 ? `${m.toFixed(1)} m` : m < 1000 ? `${Math.round(m)} m` : `${(m / 1000).toFixed(1)} km`
188
189function el(tag: 'div', cls: string): HTMLDivElement {
190 const d = document.createElement(tag)
191 d.className = cls
192 return d
193}