Repository navigation
Expand file tree
/
Copy pathsite.js
More file actions
302 lines (282 loc) · 12.8 KB
/
Copy pathsite.js
File metadata and controls
302 lines (282 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
import { byId } from './examples/catalog.js'
import { plotsVisible, signalTime } from './assets/signal.js'
import { PreviewContext } from './assets/hero-context.js'
import { renderAudio } from './assets/render.js'
import { highlight, mountExample, remember } from './examples/browser.js'
const dialog = document.getElementById('example-dialog')
const title = document.getElementById('dialog-title')
const description = document.getElementById('dialog-description')
let dispose = null
let activeId = null
const unlockPage = () => document.documentElement.classList.remove('modal-open')
// while the modal is open the address is the example's own page, so the link shares as that page
const homeURL = new URL('./', location.href)
// Both engines run hero.js. The package renders in a worker; native runs live.
const volume = document.getElementById('hero-volume')
const engine = document.getElementById('hero-engine')
const play = document.querySelector('[data-run]')
const playStatus = document.querySelector('.hero-play-status')
const renderTime = document.getElementById('hero-render-time')
let playing = null, rendering = null, runId = 0
let nativeQueue = Promise.resolve(), nativeRun = null
let closing = Promise.resolve()
remember(volume, 'hero-volume')
volume?.addEventListener('input', () => {
if (playing?.level) playing.level.gain.value = Number(volume.value) / 100
})
function attach(context, id) {
if (id !== runId) { closeOutput(context); return }
playing = context
context.level.gain.value = Number(volume?.value ?? 60) / 100
follow(context)
context.addEventListener('statechange', () => {
if (context.state !== 'closed' || playing !== context) return
playing = null
play.setAttribute('aria-label', 'Play preview')
delete play.dataset.state
})
}
addEventListener('audiocontext', ({ detail: context }) => {
if (nativeRun !== null) attach(context, nativeRun)
})
function closeOutput(context) {
if (context) closing = Promise.all([closing, context.close().catch(() => {})])
return closing
}
function stopHero() {
runId++
rendering?.abort()
rendering = null
const context = playing
playing = null
followed = null
play.setAttribute('aria-label', 'Play preview')
play.removeAttribute('aria-busy')
delete play.dataset.state
playStatus.textContent = ''
return closeOutput(context)
}
async function startHero() {
const previousOutput = stopHero()
renderTime.textContent = ''
const id = runId
const controller = rendering = new AbortController()
const usePackage = engine.getAttribute('aria-checked') === 'true'
play.dataset.state = 'running'
play.setAttribute('aria-label', 'Stop preview')
play.setAttribute('aria-busy', 'true')
playStatus.textContent = usePackage ? 'Rendering…' : ''
try {
if (usePackage) {
// Open the output from the user gesture, while the worker renders off-thread.
const context = new PreviewContext()
attach(context, id)
const [, , { buffer, renderMs }] = await Promise.all([previousOutput, context.resume(), renderAudio('hero', {}, controller.signal)])
if (id !== runId) return
const source = context.createBufferSource()
source.buffer = buffer
source.connect(context.destination)
source.onended = () => closeOutput(context)
source.start()
const elapsed = renderMs < 1000 ? `${Math.round(renderMs)} ms` : `${(renderMs / 1000).toFixed(2)} s`
renderTime.textContent = `${buffer.duration} s rendered in ${elapsed}`
} else {
// Imports cannot be aborted. Serialize them so a late native constructor
// can only announce itself for the run that requested it.
nativeQueue = nativeQueue.catch(() => {}).then(async () => {
await previousOutput
if (id !== runId) return
nativeRun = id
try { await import(`${play.dataset.run}?run=${Date.now()}-${id}`) }
finally { nativeRun = null }
})
await nativeQueue
}
if (id === runId && playing) {
const capture = await playing.capture
if (id === runId) playStatus.textContent = !capture && plotsVisible()
? 'Live visualization unavailable in this browser.' : ''
}
} catch (error) {
if (id !== runId) return
stopHero()
renderTime.textContent = ''
playStatus.textContent = usePackage
? 'web-audio-api could not render. Try again or select Native.'
: 'Native audio could not start. Tap play to try again.'
} finally {
if (id === runId) {
rendering = null
play.removeAttribute('aria-busy')
}
}
}
play.addEventListener('click', () => playing || rendering ? stopHero() : startHero())
engine.addEventListener('click', () => {
renderTime.textContent = ''
engine.setAttribute('aria-checked', String(engine.getAttribute('aria-checked') !== 'true'))
if (playing || rendering) startHero()
else playStatus.textContent = ''
})
addEventListener('pagehide', stopHero)
// While the hero file plays, the panel beside its code follows the analyser the
// page-side context taps its output through: a rolling envelope and the live
// spectrum in the same bars, on absolute dBFS scales so silence draws silence.
// When the context closes the panel keeps its last frame.
const signal = document.querySelector('.hero-signal')
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)')
let followed = null
async function follow(context) {
if (!signal || !plotsVisible()) return
followed = context
const capture = await context.capture
if (followed !== context || context.state === 'closed' || !plotsVisible()) return
if (!capture) {
document.querySelector('.hero-play-status').textContent = 'Live visualization unavailable in this browser.'
return
}
const peakPath = signal.querySelector('.wave-peak'), rmsPath = signal.querySelector('.wave-rms')
const spectrumPath = signal.querySelector('.hero-spectrum path')
const bins = new Float32Array(100)
const bar = (i, amplitude) => {
const level = amplitude > 0 ? Math.max(0, 1 + 20 * Math.log10(amplitude) / 48) : 0
const half = Math.max(0.5, level * 106)
return `M${2 + i * 4} ${(110 - half).toFixed(1)}V${(110 + half).toFixed(1)}`
}
const axis = signal.querySelectorAll('.art-row')[1]
axis.firstElementChild.textContent = '−3 s'
axis.lastElementChild.textContent = 'now'
// 100 bars over 3 s, and one more entering at the right as the oldest leaves at the left:
// the bars change when a cell or a capture is new, and slide on every display frame between
const count = 101, span = 3 * count / 100
let version = -1, cell = NaN, heard, painted = 0
const frame = now => {
if (followed !== context || context.state === 'closed') return
requestAnimationFrame(frame)
if (!plotsVisible() || reducedMotion.matches && now - painted < 160) return
painted = now
const history = capture.history, time = signalTime(context, history), at = history.position(count, span, time)
if (cell !== Math.ceil(at) || version !== history.version) {
cell = Math.ceil(at); version = history.version
const columns = history.columns(count, span, time)
peakPath.setAttribute('d', columns.map((v, i) => v ? bar(i - 1, v.peak) : '').join(''))
rmsPath.setAttribute('d', columns.map((v, i) => v ? bar(i - 1, v.rms) : '').join(''))
}
const slide = `translate(${((cell - at) * 4).toFixed(2)} 0)`
peakPath.setAttribute('transform', slide)
rmsPath.setAttribute('transform', slide)
// the spectrum of the capture now sounding, in step with the envelope
const latest = history.frames.findLast(block => block.end <= time)
if (latest === heard) return
heard = latest
bins.fill(0)
if (latest) for (let bin = 1; bin < latest.spectrum.length; bin++) {
const frequency = bin * context.sampleRate / (latest.spectrum.length * 2)
const k = Math.floor(Math.log(frequency / 40) / Math.log(16000 / 40) * bins.length)
if (k >= 0 && k < bins.length) bins[k] = Math.max(bins[k], latest.spectrum[bin] / 255)
}
spectrumPath.setAttribute('d', Array.from(bins, (v, i) => `M${2 + i * 4} 220V${(220 - Math.max(1, v * 106)).toFixed(1)}`).join(''))
}
requestAnimationFrame(frame)
}
// Each runtime mark explains itself: its tip opens above the mark, or below when the top of the
// viewport is too close, after a moment of hover and at once on focus or click; a click or tap pins it,
// Escape or a press elsewhere closes it. One tip at a time, and while one is open or just closed the
// next mark opens without the wait. The popover rides the top layer, placed in document coordinates
// so it scrolls with its mark.
const TIP_DELAY = 600
let openTip = null, warmUntil = 0
const placeTip = () => {
if (!openTip) return
let { mark, tip } = openTip, rect = mark.getBoundingClientRect(), above = rect.top - tip.offsetHeight - 8
tip.style.left = `${scrollX + Math.max(16, Math.min(rect.left, innerWidth - tip.offsetWidth - 16))}px`
tip.style.top = `${scrollY + (above >= 8 ? above : rect.bottom + 8)}px`
}
const closeTip = () => { openTip?.tip.hidePopover(); openTip = null; warmUntil = performance.now() + TIP_DELAY }
for (let mark of document.querySelectorAll('.hero-stack button[aria-describedby]')) {
let tip = document.getElementById(mark.getAttribute('aria-describedby'))
if (!tip?.showPopover) continue
let wait = 0
let show = pinned => {
clearTimeout(wait)
if (openTip && openTip.tip !== tip) closeTip()
if (!tip.matches(':popover-open')) tip.showPopover()
openTip = { mark, tip, pinned: pinned || Boolean(openTip?.pinned) }
placeTip()
}
let rest = () => { clearTimeout(wait); if (openTip?.tip === tip && !openTip.pinned) closeTip() }
mark.addEventListener('click', () => openTip?.tip === tip && openTip.pinned ? closeTip() : show(true))
mark.addEventListener('focus', () => show(false))
mark.addEventListener('blur', rest)
mark.addEventListener('pointerenter', () => { wait = setTimeout(() => show(false), openTip || performance.now() < warmUntil ? 0 : TIP_DELAY) })
mark.addEventListener('pointerleave', rest)
}
// wider than the phone it is read on: the graph opens centred, not against its left edge
const art = document.querySelector('.hero-art .graph-scroll')
if (art) {
let centre = () => { art.scrollLeft = (art.scrollWidth - art.clientWidth) / 2 }
centre()
addEventListener('resize', centre)
addEventListener('load', centre)
}
addEventListener('pointerdown', event => { if (openTip && !event.target.closest('.hero-stack')) closeTip() })
addEventListener('keydown', event => { if (event.key === 'Escape' && openTip) closeTip() })
addEventListener('resize', placeTip)
highlight()
// Highlighting is one registry for the whole page; whenever the modal loads
// source, highlight the page again so the hero keeps its colors.
const exampleCode = document.getElementById('example-code')
if (exampleCode) new MutationObserver(() => highlight()).observe(exampleCode, { childList: true, characterData: true, subtree: true })
function openExample(id, updateHistory = true) {
let example = byId.get(id)
if (!example) return
// Disposal relinquishes DOM ownership synchronously; device close may finish later.
dispose?.()
activeId = id
title.textContent = example.title
description.textContent = example.description
dispose = mountExample(dialog, id)
if (!dialog.open) {
document.documentElement.classList.add('modal-open')
dialog.showModal()
}
if (updateHistory) history.replaceState(null, '', new URL(`examples/${id}/`, homeURL))
requestAnimationFrame(() => dialog.querySelector('#demo-fields input, #demo-fields select, #demo-run')?.focus({ preventScroll: true }))
}
for (let link of document.querySelectorAll('[data-open-example]')) {
link.addEventListener('click', event => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
openExample(link.dataset.openExample)
})
}
const closeDialog = () => {
if (!dialog.open) return
unlockPage()
dialog.close()
}
dialog.querySelector('[data-close-dialog]').addEventListener('click', closeDialog)
dialog.addEventListener('click', event => { if (event.target === dialog) closeDialog() })
dialog.addEventListener('cancel', unlockPage)
addEventListener('keydown', event => {
if (event.key !== 'Escape' || !dialog.open) return
event.preventDefault()
closeDialog()
}, { capture: true })
dialog.addEventListener('close', () => {
if (dialog.open) return // a queued close event from before an immediate reopen
unlockPage()
dispose?.()
dispose = null
activeId = null
if (location.href !== homeURL.href) history.replaceState(null, '', homeURL)
highlight()
})
addEventListener('hashchange', () => {
let id = decodeURIComponent(location.hash.slice(1))
if (id && id !== activeId) openExample(id, false)
else if (!id) closeDialog()
})
addEventListener('pagehide', () => dispose?.())
let initialId = decodeURIComponent(location.hash.slice(1))
if (byId.has(initialId)) openExample(initialId, false)