feat(dashboard): make the KEINE ANGST show hit harder

Visual: city lights now come alive in the opening and die one by one while the
borders burn; three staccato words strobe with the heartbeat before the cut;
at the climax the borders no longer fade — they shatter into drifting sparks.
The traveler's own places ignite gold in the release act, with their real
numbers in the line ('{p} places. {c} countries. And not once did the world
hurt you.'). All heavy geometry is decimated to a vertex budget and pre-baked
into offscreen layers, so the frame path is a handful of drawImage calls — the
per-frame border stroke that dropped the show to ~1 fps is gone.

Sound: heartbeat with a click transient, wind textures, a doubled noise riser,
cue-driven sub-bass impacts and a gliding D–A–f#m–E progression with a sub
root under the anthem instead of the static pad.

Also: dashboard FAB hidden behind the show, suspended-AudioContext recovery
(Safari), tab-hidden pause for clock and audio, aborted geo fetch on close,
valid beacon markup (dismiss as sibling button), RTL-aware beacon, real
reduced-motion static frame, and native-speaker fixes for nl/sv/br/fr/it/es/
tr/gr copy.
This commit is contained in:
Maurice
2026-07-23 23:36:04 +02:00
committed by Maurice
parent c6d96872ae
commit aec541c573
6 changed files with 933 additions and 281 deletions
+13 -13
View File
@@ -25,23 +25,23 @@ export default function NoFearBeacon() {
return (
<>
<button type="button" className="fz-beacon" onClick={() => setOpen(true)}>
<div className="fz-beacon">
<button type="button" className="fz-beacon-play" onClick={() => setOpen(true)}>
<p className="fz-beacon-eyebrow">TREK 4.0.0</p>
<p className="fz-beacon-title">{copy.beaconTitle}</p>
<p className="fz-beacon-sub">{copy.beaconSub}</p>
<span className="fz-beacon-cta"><Play size={12} fill="currentColor" /> {copy.beaconCta}</span>
</button>
<span className="fz-beacon-pulse" aria-hidden />
<span
role="button"
tabIndex={0}
<button
type="button"
className="fz-beacon-dismiss"
aria-label="×"
onClick={e => { e.stopPropagation(); localStorage.setItem(DISMISS_KEY, '1'); setDismissed(true) }}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); localStorage.setItem(DISMISS_KEY, '1'); setDismissed(true) } }}
aria-label={copy.skip}
onClick={() => { localStorage.setItem(DISMISS_KEY, '1'); setDismissed(true) }}
>
<X size={12} />
</span>
<p className="fz-beacon-eyebrow">TREK 4.0.0</p>
<p className="fz-beacon-title">{copy.beaconTitle}</p>
<p className="fz-beacon-sub">{copy.beaconSub}</p>
<span className="fz-beacon-cta"><Play size={12} fill="currentColor" /> {copy.beaconCta}</span>
</button>
</button>
</div>
{open && (
<Suspense fallback={null}>
<NoFearShow onClose={() => setOpen(false)} />
+140 -46
View File
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Volume2, VolumeX, X } from 'lucide-react'
import { placesApi, tripsApi } from '../../api/client'
import apiClient from '../../api/client'
import { useTranslation } from '../../i18n'
import type { Place, Trip } from '../../types'
import { NoFearAudio, type NoFearAct } from './noFearAudio'
import { NoFearScene, type SceneState } from './noFearScene'
import { ANTHEM_CASCADE, INSPIRED_URL, noFearCopy } from './noFearLines'
@@ -15,51 +18,68 @@ interface Cue {
act?: NoFearAct
line?: LineKey | null
hard?: boolean
impact?: number
}
// The script. Times in seconds; the scene ramps below are keyed to the same clock.
// The script. Times in seconds; the scene ramps below share the same clock.
const CUES: Cue[] = [
{ at: 0.8, act: 'fear', line: 'afraid' },
{ at: 6.0, line: 'ofTheStranger' },
{ at: 12.0, act: 'dread', line: 'fearTool' },
{ at: 18.5, line: 'hateTrade' },
{ at: 25.0, act: 'silence', line: null },
{ at: 27.5, line: 'butYouTraveled' },
{ at: 33.0, act: 'hope', line: 'tables' },
{ at: 41.0, line: 'face' },
{ at: 48.0, line: 'fences' },
{ at: 54.5, line: 'notAnOpinion', hard: true },
{ at: 62.0, act: 'anthem', line: null },
{ at: 74.0, act: 'end', line: null },
{ at: 26.0, act: 'silence', line: null },
{ at: 28.5, line: 'butYouTraveled', impact: 0.5 },
{ at: 34.0, act: 'hope', line: 'tables' },
{ at: 42.0, line: 'face' },
{ at: 49.0, line: 'fences' },
{ at: 55.5, line: 'yourPlaces', impact: 0.35 },
{ at: 58.8, line: 'yourStatsCountries' },
{ at: 64.0, line: 'notAnOpinion', hard: true, impact: 1 },
{ at: 71.0, act: 'anthem', line: null, impact: 0.8 },
{ at: 84.0, act: 'end', line: null },
]
const ANTHEM_AT = 62
const CREDITS_AT = 74
const ANTHEM_AT = 71
const CREDITS_AT = 84
// The three staccato words strobe with the late fear act, just before the cut.
const FLASH_WINDOWS: [number, number][] = [[22.2, 22.85], [23.6, 24.25], [25.0, 25.65]]
const ramp = (t: number, from: number, to: number): number =>
Math.min(Math.max((t - from) / (to - from), 0), 1)
function sceneAt(t: number): SceneState {
// The blackout (25→27.4s) is the show's held breath; everything else is ramps.
const blackout = t >= 25 && t < 31 ? 1 - ramp(t, 30, 31) : 0
// Hard cut to black at 26s; the pivot line stands alone in the dark, then the
// world returns WITH the hope act (28→31.5) — that overlap is intentional.
const blackout = t >= 26 && t < 31.5 ? 1 - ramp(t, 28, 31.5) : 0
return {
land: ramp(t, 5.5, 9.5),
borderHeat: t < 33 ? ramp(t, 12, 15.5) : 0.45,
borderGone: ramp(t, 54.5, 61.5),
web: ramp(t, 33, 58),
warmth: ramp(t, 33, 45),
land: ramp(t, 6, 10),
cityLife: ramp(t, 6.5, 11),
cityDeath: ramp(t, 13, 22),
borderHeat: t < 34 ? ramp(t, 12, 15.5) : 1 - 0.55 * ramp(t, 34, 38),
borderBurst: ramp(t, 64, 70),
web: ramp(t, 34, 62),
warmth: ramp(t, 34, 46),
personalGlow: ramp(t, 55.5, 60.5),
particles: ramp(t, ANTHEM_AT, ANTHEM_AT + 4),
opacity: (1 - blackout) * (t >= CREDITS_AT ? 0.35 : 1),
}
}
/** Formats "{p} places. {c} countries. …" with the traveler's real numbers. */
function fillStats(template: string, p: number, cOrT: number): string {
return template.replace('{p}', String(p)).replace('{c}', String(cOrT)).replace('{t}', String(cOrT))
}
export default function NoFearShow({ onClose }: { onClose: () => void }) {
const { language } = useTranslation()
const copy = useMemo(() => noFearCopy(language), [language])
const overlayRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const audioRef = useRef<NoFearAudio | null>(null)
const sceneRef = useRef<NoFearScene | null>(null)
const startRef = useRef<number>(performance.now())
const statsRef = useRef<{ line: string } | null>(null)
const [cueIdx, setCueIdx] = useState(-1)
const [flashIdx, setFlashIdx] = useState(-1)
const [muted, setMuted] = useState(false)
const [credits, setCredits] = useState(false)
const [anthem, setAnthem] = useState(false)
@@ -72,41 +92,93 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
audioRef.current?.setAct('end')
}, [])
// Boot: audio (user gesture already happened — the beacon click), scene, clock.
// Boot: audio, scene, personal places, chrome side effects.
useEffect(() => {
document.body.classList.add('fz-show-open') // hides the dashboard FAB + locks scroll
const audio = new NoFearAudio()
audioRef.current = audio
if (!reducedMotion) audio.start()
const scene = new NoFearScene()
sceneRef.current = scene
const aborter = new AbortController()
const cv = canvasRef.current
if (cv) {
const fit = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
cv.width = Math.round(cv.clientWidth * dpr)
cv.height = Math.round(cv.clientHeight * dpr)
cv.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0)
scene.layout(cv.clientWidth, cv.clientHeight)
}
fit()
void scene.load(cv.clientWidth, cv.clientHeight)
window.addEventListener('resize', fit)
if (reducedMotion) skipToEnd()
return () => {
window.removeEventListener('resize', fit)
audio.dispose()
const fit = () => {
if (!cv) return
const dpr = Math.min(window.devicePixelRatio || 1, 1.75)
cv.width = Math.round(cv.clientWidth * dpr)
cv.height = Math.round(cv.clientHeight * dpr)
cv.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0)
scene.layout(cv.clientWidth, cv.clientHeight)
}
fit()
if (cv) void scene.load(cv.clientWidth, cv.clientHeight, aborter.signal)
window.addEventListener('resize', fit)
// The traveler's own places, gathered quietly during the fear act. Fail-soft:
// without them the show falls back to its generic lines.
void (async () => {
try {
const trips = await tripsApi.list() as Trip[]
if (aborter.signal.aborted || !Array.isArray(trips)) return
const lists = await Promise.all(trips.slice(0, 12).map(t => placesApi.list(t.id).catch(() => [] as Place[])))
if (aborter.signal.aborted) return
const pts = lists.flat().filter((p: Place) => p.lat != null && p.lng != null).map((p: Place) => ({ lat: p.lat as number, lng: p.lng as number }))
if (pts.length >= 3) {
sceneRef.current?.setPersonalPlaces(pts)
// Prefer real country counts from Atlas; fall back to trip counts.
try {
const { data } = await apiClient.get('/addons/atlas/stats', { signal: aborter.signal })
const countries = data?.stats?.totalCountries
statsRef.current = countries > 0
? { line: fillStats(copy.lines.yourStatsCountries, pts.length, countries) }
: { line: fillStats(copy.lines.yourStatsTrips, pts.length, trips.length) }
} catch {
statsRef.current = { line: fillStats(copy.lines.yourStatsTrips, pts.length, trips.length) }
}
}
} catch { /* not signed-in edge / API down — generic show */ }
})()
// Pause the whole show (clock + audio) while the tab is hidden.
let hiddenAt: number | null = null
const onVisibility = () => {
if (document.hidden) {
hiddenAt = performance.now()
audio.setSuspended(true)
} else {
if (hiddenAt !== null) startRef.current += performance.now() - hiddenAt
hiddenAt = null
audio.setSuspended(false)
}
}
return () => audio.dispose()
}, [reducedMotion, skipToEnd])
document.addEventListener('visibilitychange', onVisibility)
// Any real gesture inside the overlay doubles as an audio-resume recovery
// for browsers that refused the post-lazy-load AudioContext start.
const onPointer = () => audio.resume()
window.addEventListener('pointerdown', onPointer)
overlayRef.current?.focus()
if (reducedMotion) skipToEnd()
return () => {
document.body.classList.remove('fz-show-open')
window.removeEventListener('resize', fit)
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('pointerdown', onPointer)
aborter.abort()
audio.dispose()
}
}, [reducedMotion, skipToEnd, copy])
// The clock: one rAF loop drives the canvas directly and only touches React
// state on discrete cue changes.
// state on discrete cue/flash changes. Reduced motion renders one static frame.
useEffect(() => {
let raf = 0
let lastCue = -1
const cv = canvasRef.current
const ctx = cv?.getContext('2d')
if (reducedMotion) {
setCueIdx(CUES.length - 1)
if (ctx) sceneRef.current?.draw(ctx, { ...sceneAt(CREDITS_AT), particles: 0 }, CREDITS_AT)
return
}
let raf = 0
let lastCue = -1
let lastFlash = -1
const tick = () => {
const t = (performance.now() - startRef.current) / 1000
let idx = -1
@@ -115,18 +187,27 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
lastCue = idx
const cue = CUES[idx]
if (cue.act) audioRef.current?.setAct(cue.act)
if (cue.impact) audioRef.current?.impact(cue.impact)
setCueIdx(idx)
if (t >= ANTHEM_AT) setAnthem(true)
if (t >= CREDITS_AT) setCredits(true)
}
if (ctx && cv) sceneRef.current?.draw(ctx, sceneAt(t), t)
let flash = -1
for (let i = 0; i < FLASH_WINDOWS.length; i++) {
if (t >= FLASH_WINDOWS[i][0] && t < FLASH_WINDOWS[i][1]) flash = i
}
if (flash !== lastFlash) {
lastFlash = flash
setFlashIdx(flash)
}
if (ctx) sceneRef.current?.draw(ctx, sceneAt(t), t)
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [])
}, [reducedMotion])
// ESC closes, any key after the credits too.
// ESC closes the show at any point.
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
@@ -135,19 +216,31 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
const cue = cueIdx >= 0 ? CUES[cueIdx] : null
const lineKey = cue?.line ?? null
const lineText = lineKey ? copy.lines[lineKey] : null
// The personal-stats line only exists when the traveler's data arrived; the
// yourPlaces line falls back to the generic "every light" line without it.
let lineText: string | null = null
if (lineKey === 'yourStatsCountries') lineText = statsRef.current?.line ?? null
else if (lineKey === 'yourPlaces') lineText = statsRef.current ? copy.lines.yourPlaces : copy.lines.everyDot
else if (lineKey) lineText = copy.lines[lineKey]
const hard = !!cue?.hard
return (
<div className="fz-overlay" role="dialog" aria-label={copy.beaconTitle}>
<div ref={overlayRef} className="fz-overlay" role="dialog" aria-modal="true" aria-label={copy.beaconTitle} tabIndex={-1}>
<canvas ref={canvasRef} className="fz-canvas" />
{/* Red vignette while the borders burn. */}
{!anthem && cueIdx >= 2 && cueIdx <= 3 && <div className="fz-vignette" aria-hidden />}
{/* Staccato words strobing to the heartbeat, right before the cut. */}
{flashIdx >= 0 && !anthem && (
<div className="fz-flash-wrap" aria-hidden>
<span key={flashIdx} className="fz-flash">{copy.flashes[flashIdx]}</span>
</div>
)}
{/* The lines. */}
{!anthem && lineText && (
<div className="fz-line-wrap">
{!anthem && lineText && flashIdx < 0 && (
<div className="fz-line-wrap" aria-live="polite">
<p key={lineKey} className={`fz-line ${hard ? 'fz-line-hard' : ''}`}>{lineText}</p>
</div>
)}
@@ -170,6 +263,7 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
{credits && (
<div className="fz-credits">
<h1 className="fz-anthem-word fz-anthem-word-small">{copy.anthem}</h1>
<p className="fz-credit-lead">{copy.tooBeautiful}</p>
<p className="fz-credit-title">{copy.creditTitle}</p>
<p className="fz-credit-body">{copy.creditBody}</p>
<a className="fz-credit-link" href={INSPIRED_URL} target="_blank" rel="noopener noreferrer">
+63 -11
View File
@@ -65,6 +65,32 @@
to { opacity: 1; transform: translateY(0); filter: blur(0); }
}
/* Staccato words strobing to the heartbeat in the late fear act. */
.fz-flash-wrap {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.fz-flash {
font-size: clamp(52px, 9vw, 130px);
font-weight: 900;
letter-spacing: 0.08em;
color: #ff4b3e;
text-shadow: 0 0 42px rgba(235, 50, 45, 0.65), 0 2px 18px rgba(0, 0, 0, 0.9);
animation: fzFlashIn 0.65s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes fzFlashIn {
0% { opacity: 0; transform: scale(1.28); filter: blur(8px); }
18% { opacity: 1; transform: scale(1); filter: blur(0); }
80% { opacity: 1; }
100% { opacity: 0; }
}
/* ── anthem ── */
.fz-anthem {
@@ -134,6 +160,15 @@
animation: fzLineIn 1.8s ease-out both;
}
.fz-credit-lead {
font-size: clamp(16px, 1.7vw, 23px);
font-weight: 300;
font-style: italic;
opacity: 0.95;
margin: 6px 0 4px;
max-width: 680px;
}
.fz-credit-title {
font-size: clamp(17px, 1.8vw, 24px);
font-weight: 700;
@@ -216,36 +251,46 @@
/* ── beacon (dashboard trigger) ── */
/* Wrapper is a div so the dismiss can be a real sibling <button> — a button
may not contain another interactive element. */
.fz-beacon {
position: relative;
display: none;
width: 100%;
border: none;
text-align: left;
cursor: pointer;
border-radius: 16px;
padding: 18px 16px 16px;
overflow: hidden;
background:
radial-gradient(120% 90% at 20% 0%, rgba(235, 60, 55, 0.16), transparent 55%),
radial-gradient(130% 100% at 85% 100%, rgba(255, 180, 90, 0.14), transparent 60%),
#0a0c12;
color: #f5f2ea;
font-family: inherit;
}
@media (min-width: 1024px) {
.fz-beacon { display: block; }
}
.fz-beacon-play {
display: block;
width: 100%;
border: none;
background: transparent;
color: inherit;
text-align: start;
cursor: pointer;
padding: 18px 16px 16px;
font-family: inherit;
}
.fz-beacon-pulse {
position: absolute;
top: 16px;
right: 16px;
inset-inline-end: 16px;
width: 10px;
height: 10px;
border-radius: 999px;
background: #ff5a4e;
pointer-events: none;
}
.fz-beacon-pulse::before,
@@ -302,7 +347,7 @@
.fz-beacon-dismiss {
position: absolute;
top: 8px;
left: 8px;
inset-inline-start: 8px;
display: inline-flex;
padding: 5px;
border: none;
@@ -314,11 +359,18 @@
.fz-beacon-dismiss:hover { color: rgba(245, 240, 225, 0.85); }
/* Accessibility: the show honours reduced motion by jumping to its final,
static frame (handled in the component); kill the looping accents too. */
/* While the show runs: no floating dashboard chrome above it, no page scroll. */
body.fz-show-open { overflow: hidden; }
body.fz-show-open .fab-new-trip { display: none; }
/* Accessibility: with reduced motion the component renders a single static
frame and skips straight to the credits; flatten every looping accent too. */
@media (prefers-reduced-motion: reduce) {
.fz-overlay *,
.fz-beacon-pulse::before,
.fz-beacon-pulse::after,
.fz-vignette { animation: none; }
.fz-cascade-item { animation-duration: 0.01s; }
.fz-vignette {
animation-duration: 0.01s !important;
animation-iteration-count: 1 !important;
}
}
+230 -51
View File
@@ -1,6 +1,7 @@
// Procedural soundtrack for the 4.0.0 "KEINE ANGST" show. Everything is
// synthesized with the Web Audio API at runtime — a heartbeat, a low drone, a
// rising dissonance for the fear act and a warm A-major pad for the release —
// synthesized with the Web Audio API at runtime — heartbeat with a real click
// transient, wind-noise textures, a trailer-style noise riser, cue-driven
// sub-bass impacts and a moving chord progression with glided voice-leading —
// so the show ships zero audio assets and touches no copyrighted material.
export type NoFearAct = 'fear' | 'dread' | 'silence' | 'hope' | 'anthem' | 'end'
@@ -9,16 +10,32 @@ interface Voice {
stop: (at: number) => void
}
// The hope/anthem harmony: D — A — f#m — E in open voicings, five gliding
// voices each. Movement is what makes the release feel alive.
const PROGRESSION: number[][] = [
[73.42, 110.0, 146.83, 185.0, 293.66], // D2 A2 D3 F#3 D4
[82.41, 110.0, 164.81, 220.0, 277.18], // E2 A2 E3 A3 C#4
[92.5, 110.0, 138.59, 185.0, 277.18], // F#2 A2 C#3 F#3 C#4
[82.41, 123.47, 164.81, 207.65, 246.94], // E2 B2 E3 G#3 B3
]
const CHORD_SECONDS = 4.4
const BASS_ROOTS = [36.71, 41.2, 46.25, 41.2] // D1 E1 F#1 E1
export class NoFearAudio {
private ctx: AudioContext | null = null
private master: GainNode | null = null
private noiseBuf: AudioBuffer | null = null
private muted = false
private act: NoFearAct | null = null
private heartbeatTimer: number | null = null
private nextBeat = 0
private bpm = 76
private beatGain = 0.5
private beatGain = 0.55
private voices: Voice[] = []
private padVoices: OscillatorNode[] = []
private bassOsc: OscillatorNode | null = null
private chordTimer: number | null = null
private chordIdx = 0
/** Must be called from a user gesture (the beacon click) so autoplay rules allow it. */
start(): void {
@@ -26,16 +43,22 @@ export class NoFearAudio {
const Ctor = window.AudioContext ?? (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!Ctor) return
this.ctx = new Ctor()
// A lazily-loaded chunk can construct us a beat after the click gesture —
// resume() re-arms the context if the browser started it suspended.
if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => {})
this.master = this.ctx.createGain()
this.master.gain.value = this.muted ? 0 : 0.8
// A soft knee compressor keeps the stacked voices from ever clipping.
this.master.gain.value = this.muted ? 0 : 0.9
const limiter = this.ctx.createDynamicsCompressor()
limiter.threshold.value = -14
limiter.knee.value = 24
limiter.threshold.value = -12
limiter.knee.value = 22
limiter.ratio.value = 12
this.master.connect(limiter)
limiter.connect(this.ctx.destination)
// Heartbeat scheduler: 100ms tick, 300ms lookahead.
// 2s of looped white noise feeds every texture (wind, risers, ticks).
const len = this.ctx.sampleRate * 2
this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate)
const data = this.noiseBuf.getChannelData(0)
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1
this.nextBeat = this.ctx.currentTime + 0.2
this.heartbeatTimer = window.setInterval(() => this.scheduleBeats(), 100)
}
@@ -43,7 +66,52 @@ export class NoFearAudio {
setMuted(muted: boolean): void {
this.muted = muted
if (this.master && this.ctx) {
this.master.gain.setTargetAtTime(muted ? 0 : 0.8, this.ctx.currentTime, 0.05)
// The mute button is a real gesture — use it as a recovery path for
// browsers (Safari) that refused to start the context earlier.
if (!muted && this.ctx.state === 'suspended') void this.ctx.resume().catch(() => {})
this.master.gain.setTargetAtTime(muted ? 0 : 0.9, this.ctx.currentTime, 0.05)
}
}
/** Best-effort resume from any fresh user gesture (overlay pointerdown). */
resume(): void {
if (this.ctx?.state === 'suspended') void this.ctx.resume().catch(() => {})
}
/** Freeze/unfreeze with the show clock when the tab goes hidden. */
setSuspended(suspended: boolean): void {
if (!this.ctx) return
if (suspended) void this.ctx.suspend().catch(() => {})
else void this.ctx.resume().catch(() => {})
}
/** A cue-driven cinema hit: sub boom + filtered noise burst. strength 0..1. */
impact(strength = 1): void {
if (!this.ctx || !this.master) return
const t = this.ctx.currentTime + 0.02
const o = this.ctx.createOscillator()
o.type = 'sine'
o.frequency.setValueAtTime(82, t)
o.frequency.exponentialRampToValueAtTime(28, t + 0.9)
const g = this.ctx.createGain()
g.gain.setValueAtTime(0.0001, t)
g.gain.exponentialRampToValueAtTime(0.9 * strength, t + 0.018)
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.6)
o.connect(g); g.connect(this.master)
o.start(t); o.stop(t + 1.8)
if (this.noiseBuf) {
const n = this.ctx.createBufferSource()
n.buffer = this.noiseBuf
const lp = this.ctx.createBiquadFilter()
lp.type = 'lowpass'
lp.frequency.setValueAtTime(900, t)
lp.frequency.exponentialRampToValueAtTime(120, t + 0.5)
const ng = this.ctx.createGain()
ng.gain.setValueAtTime(0.0001, t)
ng.gain.exponentialRampToValueAtTime(0.32 * strength, t + 0.012)
ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.6)
n.connect(lp); lp.connect(ng); ng.connect(this.master)
n.start(t); n.stop(t + 0.8)
}
}
@@ -51,31 +119,33 @@ export class NoFearAudio {
if (!this.ctx || !this.master || this.act === act) return
this.act = act
const t = this.ctx.currentTime
// Every act replaces the sustained voices; the heartbeat just changes pace.
this.releaseVoices(act === 'silence' ? 0.12 : 1.6)
this.releaseVoices(act === 'silence' ? 0.1 : 1.6)
this.stopChords()
switch (act) {
case 'fear':
this.bpm = 76
this.beatGain = 0.5
this.drone(55, 82.4, 0.16, 260, 2.5)
this.bpm = 74
this.beatGain = 0.55
this.drone(55, 82.4, 0.15, 230, 3)
this.wind(0.05, 420)
break
case 'dread':
this.bpm = 112
this.beatGain = 0.62
this.drone(55, 78, 0.2, 320, 1.2) // detuned fifth — it beats uneasily
this.bpm = 116
this.beatGain = 0.7
this.drone(55, 77.8, 0.19, 300, 1) // flattened fifth — it beats uneasily
this.wind(0.085, 700)
this.riser(t)
break
case 'silence':
this.bpm = 0 // the cut itself is the effect
break
case 'hope':
this.bpm = 56
this.beatGain = 0.28
this.pad(t, [110, 164.81, 220, 277.18], 900, 0.075) // A2 E3 A3 C#4
this.bpm = 54
this.beatGain = 0.3
this.startChords(700, 0.085, false)
break
case 'anthem':
this.bpm = 0
this.pad(t, [110, 164.81, 220, 277.18, 329.63, 440], 2600, 0.062) // + E4 A4, filter open
this.startChords(2400, 0.075, true)
this.shimmer(t)
break
case 'end':
@@ -88,6 +158,7 @@ export class NoFearAudio {
dispose(): void {
if (this.heartbeatTimer !== null) window.clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
this.stopChords()
this.releaseVoices(0.1)
const ctx = this.ctx
this.ctx = null
@@ -95,7 +166,7 @@ export class NoFearAudio {
if (ctx) window.setTimeout(() => { void ctx.close().catch(() => {}) }, 300)
}
// ── voices ────────────────────────────────────────────────────────────────
// ── sustained voices ──────────────────────────────────────────────────────
private releaseVoices(release: number): void {
if (!this.ctx) return
@@ -104,7 +175,7 @@ export class NoFearAudio {
this.voices = []
}
private hold(gain: GainNode, oscs: OscillatorNode[]): void {
private hold(gain: GainNode, oscs: (OscillatorNode | AudioBufferSourceNode)[]): void {
this.voices.push({
stop: (at) => {
gain.gain.cancelScheduledValues(0)
@@ -114,7 +185,7 @@ export class NoFearAudio {
})
}
/** Two slightly detuned low oscillators through a lowpass — the room tone of the fear act. */
/** Two slightly detuned low saws through a lowpass — the fear act's room tone. */
private drone(f1: number, f2: number, level: number, cutoff: number, attack: number): void {
if (!this.ctx || !this.master) return
const t = this.ctx.currentTime
@@ -124,7 +195,14 @@ export class NoFearAudio {
const lp = this.ctx.createBiquadFilter()
lp.type = 'lowpass'
lp.frequency.value = cutoff
const oscs = [f1, f2].map(f => {
// A slow LFO breathes the filter so the drone never sits still.
const lfo = this.ctx.createOscillator()
lfo.frequency.value = 0.09
const lfoGain = this.ctx.createGain()
lfoGain.gain.value = cutoff * 0.35
lfo.connect(lfoGain); lfoGain.connect(lp.frequency)
lfo.start(t)
const oscs: (OscillatorNode | AudioBufferSourceNode)[] = [f1, f2].map(f => {
const o = this.ctx!.createOscillator()
o.type = 'sawtooth'
o.frequency.value = f
@@ -133,78 +211,162 @@ export class NoFearAudio {
o.start(t)
return o
})
oscs.push(lfo)
lp.connect(g)
g.connect(this.master)
this.hold(g, oscs)
}
/** A slow 14s upward sweep, quiet but relentless — the dread act's tension. */
/** Bandpassed noise that slowly swells and recedes — wind through the scene. */
private wind(level: number, freq: number): void {
if (!this.ctx || !this.master || !this.noiseBuf) return
const t = this.ctx.currentTime
const src = this.ctx.createBufferSource()
src.buffer = this.noiseBuf
src.loop = true
const bp = this.ctx.createBiquadFilter()
bp.type = 'bandpass'
bp.frequency.value = freq
bp.Q.value = 0.6
const g = this.ctx.createGain()
g.gain.setValueAtTime(0.0001, t)
g.gain.exponentialRampToValueAtTime(level, t + 4)
const lfo = this.ctx.createOscillator()
lfo.frequency.value = 0.07
const lfoGain = this.ctx.createGain()
lfoGain.gain.value = level * 0.55
lfo.connect(lfoGain); lfoGain.connect(g.gain)
lfo.start(t)
src.connect(bp); bp.connect(g); g.connect(this.master)
src.start(t)
this.hold(g, [src, lfo])
}
/** Trailer riser: a saw sweep doubled by a noise sweep, 14s of climb. */
private riser(t: number): void {
if (!this.ctx || !this.master) return
const o = this.ctx.createOscillator()
o.type = 'sawtooth'
o.frequency.setValueAtTime(180, t)
o.frequency.exponentialRampToValueAtTime(760, t + 14)
o.frequency.exponentialRampToValueAtTime(820, t + 14)
const bp = this.ctx.createBiquadFilter()
bp.type = 'bandpass'
bp.frequency.setValueAtTime(300, t)
bp.frequency.exponentialRampToValueAtTime(1200, t + 14)
bp.Q.value = 9
bp.frequency.exponentialRampToValueAtTime(1400, t + 14)
bp.Q.value = 8
const g = this.ctx.createGain()
g.gain.setValueAtTime(0.0001, t)
g.gain.exponentialRampToValueAtTime(0.05, t + 6)
g.gain.exponentialRampToValueAtTime(0.055, t + 7)
o.connect(bp); bp.connect(g); g.connect(this.master)
o.start(t)
this.hold(g, [o])
const nodes: (OscillatorNode | AudioBufferSourceNode)[] = [o]
if (this.noiseBuf) {
const n = this.ctx.createBufferSource()
n.buffer = this.noiseBuf
n.loop = true
const nbp = this.ctx.createBiquadFilter()
nbp.type = 'bandpass'
nbp.frequency.setValueAtTime(240, t)
nbp.frequency.exponentialRampToValueAtTime(3200, t + 14)
nbp.Q.value = 1.4
const ng = this.ctx.createGain()
ng.gain.setValueAtTime(0.0001, t)
ng.gain.exponentialRampToValueAtTime(0.09, t + 12)
n.connect(nbp); nbp.connect(ng); ng.connect(this.master)
n.start(t)
nodes.push(n)
this.hold(ng, [n])
}
this.hold(g, nodes)
}
/** Warm triangle-wave chord with chorus detune — the release. */
private pad(t: number, freqs: number[], cutoff: number, level: number): void {
// ── the progression ───────────────────────────────────────────────────────
/** Five gliding triangle voices + a sub root, stepping through D–A–f#m–E. */
private startChords(cutoff: number, level: number, withSub: boolean): void {
if (!this.ctx || !this.master) return
const t = this.ctx.currentTime
const lp = this.ctx.createBiquadFilter()
lp.type = 'lowpass'
lp.frequency.setValueAtTime(cutoff * 0.4, t)
lp.frequency.exponentialRampToValueAtTime(cutoff, t + 6)
lp.frequency.setValueAtTime(cutoff * 0.35, t)
lp.frequency.exponentialRampToValueAtTime(cutoff, t + 7)
const g = this.ctx.createGain()
g.gain.setValueAtTime(0.0001, t)
g.gain.exponentialRampToValueAtTime(level, t + 3.2)
this.padVoices = []
const first = PROGRESSION[0]
const oscs: OscillatorNode[] = []
for (const f of freqs) {
for (const det of [-4, 4]) {
for (let v = 0; v < first.length; v++) {
for (const det of [-5, 5]) {
const o = this.ctx.createOscillator()
o.type = 'triangle'
o.frequency.value = f
o.frequency.value = first[v]
o.detune.value = det
o.connect(lp)
o.start(t)
oscs.push(o)
this.padVoices.push(o)
}
}
lp.connect(g)
g.connect(this.master)
this.hold(g, oscs)
if (withSub) {
const sub = this.ctx.createOscillator()
sub.type = 'sine'
sub.frequency.value = BASS_ROOTS[0]
const sg = this.ctx.createGain()
sg.gain.setValueAtTime(0.0001, t)
sg.gain.exponentialRampToValueAtTime(0.16, t + 2.5)
sub.connect(sg); sg.connect(this.master)
sub.start(t)
this.bassOsc = sub
this.hold(sg, [sub])
}
this.chordIdx = 0
this.chordTimer = window.setInterval(() => this.nextChord(), CHORD_SECONDS * 1000)
}
private nextChord(): void {
if (!this.ctx || this.padVoices.length === 0) return
this.chordIdx = (this.chordIdx + 1) % PROGRESSION.length
const chord = PROGRESSION[this.chordIdx]
const t = this.ctx.currentTime
// Each pair of detuned oscillators shares a chord voice; glide legato.
for (let i = 0; i < this.padVoices.length; i++) {
const target = chord[Math.floor(i / 2) % chord.length]
this.padVoices[i].frequency.setTargetAtTime(target, t, 0.55)
}
this.bassOsc?.frequency.setTargetAtTime(BASS_ROOTS[this.chordIdx], t, 0.5)
}
private stopChords(): void {
if (this.chordTimer !== null) window.clearInterval(this.chordTimer)
this.chordTimer = null
this.padVoices = []
this.bassOsc = null
}
/** Sparse high pings on the A-major pentatonic — tiny lights going on. */
private shimmer(t: number): void {
if (!this.ctx || !this.master) return
const notes = [880, 1108.7, 1318.5, 1760]
const notes = [880, 1108.7, 1318.5, 1479.98, 1760]
const g = this.ctx.createGain()
g.gain.value = 0.05
g.gain.value = 0.055
g.connect(this.master)
const oscs: OscillatorNode[] = []
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 14; i++) {
const o = this.ctx.createOscillator()
o.type = 'sine'
o.frequency.value = notes[Math.floor(Math.random() * notes.length)]
const eg = this.ctx.createGain()
const at = t + 0.8 + i * 1.05 + Math.random() * 0.5
const at = t + 0.8 + i * 0.8 + Math.random() * 0.4
eg.gain.setValueAtTime(0.0001, at)
eg.gain.exponentialRampToValueAtTime(1, at + 0.02)
eg.gain.exponentialRampToValueAtTime(0.0001, at + 1.6)
eg.gain.exponentialRampToValueAtTime(0.0001, at + 1.9)
o.connect(eg); eg.connect(g)
o.start(at)
o.stop(at + 1.8)
o.stop(at + 2.1)
oscs.push(o)
}
this.voices.push({ stop: (stopAt) => { g.gain.setTargetAtTime(0, stopAt, 0.2); for (const o of oscs) { try { o.stop(stopAt + 0.5) } catch { /* ok */ } } } })
@@ -214,28 +376,45 @@ export class NoFearAudio {
private scheduleBeats(): void {
if (!this.ctx || !this.master || this.bpm <= 0) return
// Clamp BEFORE scheduling: after a bpm=0 act (the silence) nextBeat lags far
// behind and would otherwise back-fill a burst of past-due thumps at once.
if (this.nextBeat < this.ctx.currentTime) this.nextBeat = this.ctx.currentTime + 0.1
const ahead = this.ctx.currentTime + 0.3
while (this.nextBeat < ahead) {
this.thump(this.nextBeat, this.beatGain)
this.thump(this.nextBeat + 0.19, this.beatGain * 0.55) // the "dub"
this.thump(this.nextBeat + 0.19, this.beatGain * 0.55)
this.nextBeat += 60 / this.bpm
}
if (this.nextBeat < this.ctx.currentTime) this.nextBeat = this.ctx.currentTime + 0.1
}
/** A heartbeat with body AND transient: low sine thump + tiny filtered click. */
private thump(at: number, level: number): void {
if (!this.ctx || !this.master) return
const o = this.ctx.createOscillator()
o.type = 'sine'
o.frequency.setValueAtTime(62, at)
o.frequency.exponentialRampToValueAtTime(38, at + 0.1)
o.frequency.setValueAtTime(58, at)
o.frequency.exponentialRampToValueAtTime(34, at + 0.12)
const g = this.ctx.createGain()
g.gain.setValueAtTime(0.0001, at)
g.gain.exponentialRampToValueAtTime(level, at + 0.012)
g.gain.exponentialRampToValueAtTime(0.0001, at + 0.22)
g.gain.exponentialRampToValueAtTime(level, at + 0.01)
g.gain.exponentialRampToValueAtTime(0.0001, at + 0.24)
o.connect(g)
g.connect(this.master)
o.start(at)
o.stop(at + 0.3)
o.stop(at + 0.32)
if (this.noiseBuf) {
const n = this.ctx.createBufferSource()
n.buffer = this.noiseBuf
const hp = this.ctx.createBiquadFilter()
hp.type = 'lowpass'
hp.frequency.value = 340
const ng = this.ctx.createGain()
ng.gain.setValueAtTime(0.0001, at)
ng.gain.exponentialRampToValueAtTime(level * 0.5, at + 0.006)
ng.gain.exponentialRampToValueAtTime(0.0001, at + 0.05)
n.connect(hp); hp.connect(ng); ng.connect(this.master)
n.start(at)
n.stop(at + 0.08)
}
}
}
+164 -19
View File
@@ -18,7 +18,14 @@ export interface NoFearCopy {
face: string
fences: string
notAnOpinion: string
everyDot: string
yourPlaces: string
yourStatsCountries: string
yourStatsTrips: string
}
// Three staccato words flashed to the heartbeat in the fear act.
flashes: [string, string, string]
tooBeautiful: string
anthem: string
creditTitle: string
creditBody: string
@@ -43,7 +50,13 @@ const en: NoFearCopy = {
face: 'You know: the stranger has a face. And it looks like yours.',
fences: 'No one who has seen the world wants it locked behind fences again.',
notAnOpinion: 'Racism is not an opinion. Fascism is not an alternative.',
everyDot: 'Every one of these lights is a table where someone was welcome.',
yourPlaces: 'This — this was you.',
yourStatsCountries: '{p} places. {c} countries. And not once did the world hurt you.',
yourStatsTrips: '{p} places. {t} journeys. And not once did the world hurt you.',
},
flashes: ['FEAR.', 'HATRED.', 'WALLS.'],
tooBeautiful: 'The world is too beautiful for fear. You have seen it yourself.',
anthem: 'NO FEAR',
creditTitle: 'TREK 4.0.0 — for an open world.',
creditBody: 'Against racism, hatred and fascism. Travel turns strangers into neighbors.',
@@ -68,7 +81,13 @@ const de: NoFearCopy = {
face: 'Du weißt: Das Fremde hat ein Gesicht. Und es sieht aus wie deins.',
fences: 'Niemand, der die Welt gesehen hat, will sie wieder hinter Zäune sperren.',
notAnOpinion: 'Rassismus ist keine Meinung. Faschismus ist keine Alternative.',
everyDot: 'Jedes dieser Lichter ist ein Tisch, an dem jemand willkommen war.',
yourPlaces: 'Das hier — das warst du.',
yourStatsCountries: '{p} Orte. {c} Länder. Und nicht ein einziges Mal hat die Welt dir wehgetan.',
yourStatsTrips: '{p} Orte. {t} Reisen. Und nicht ein einziges Mal hat die Welt dir wehgetan.',
},
flashes: ['ANGST.', 'HASS.', 'MAUERN.'],
tooBeautiful: 'Die Welt ist zu schön für Angst. Du hast sie selbst gesehen.',
anthem: 'KEINE ANGST',
creditTitle: 'TREK 4.0.0 — für eine offene Welt.',
creditBody: 'Gegen Rassismus, Hass und Faschismus. Reisen macht aus Fremden Nachbarn.',
@@ -90,18 +109,24 @@ const es: NoFearCopy = {
hateTrade: 'El miedo es su herramienta. El odio, su negocio.',
butYouTraveled: 'Pero tú has viajado.',
tables: 'Has comido en mesas ajenas. Dormido bajo techos ajenos. Reído con desconocidos.',
face: 'Lo sabes: lo extranjero tiene un rostro. Y se parece al tuyo.',
face: 'Lo sabes: el extranjero tiene un rostro. Y se parece al tuyo.',
fences: 'Nadie que haya visto el mundo quiere volver a encerrarlo tras vallas.',
notAnOpinion: 'El racismo no es una opinión. El fascismo no es una alternativa.',
everyDot: 'Cada una de estas luces es una mesa donde alguien fue bienvenido.',
yourPlaces: 'Esto — esto fuiste tú.',
yourStatsCountries: '{p} lugares. {c} países. Y ni una sola vez el mundo te hizo daño.',
yourStatsTrips: '{p} lugares. {t} viajes. Y ni una sola vez el mundo te hizo daño.',
},
flashes: ['MIEDO.', 'ODIO.', 'MUROS.'],
tooBeautiful: 'El mundo es demasiado hermoso para el miedo. Tú mismo lo has visto.',
anthem: 'SIN MIEDO',
creditTitle: 'TREK 4.0.0 — por un mundo abierto.',
creditBody: 'Contra el racismo, el odio y el fascismo. Viajar convierte a extraños en vecinos.',
inspiredBy: 'Inspirado en Danger Dan — “Keine Angst”',
close: 'Llévalo contigo',
close: 'Pásalo',
skip: 'Saltar',
soundOn: 'Sonido sí',
soundOff: 'Sonido no',
soundOn: 'Con sonido',
soundOff: 'Sin sonido',
}
const fr: NoFearCopy = {
@@ -116,12 +141,18 @@ const fr: NoFearCopy = {
butYouTraveled: 'Mais toi, tu as voyagé.',
tables: 'Tu as mangé à des tables étrangères. Dormi sous des toits étrangers. Ri avec des inconnus.',
face: 'Tu le sais : l’étranger a un visage. Et il ressemble au tien.',
fences: 'Personne qui a vu le monde ne veut le remettre derrière des clôtures.',
fences: 'Personne qui ait vu le monde ne veut l’enfermer à nouveau derrière des clôtures.',
notAnOpinion: 'Le racisme n’est pas une opinion. Le fascisme n’est pas une alternative.',
everyDot: 'Chacune de ces lumières est une table où quelqu’un a été bienvenu.',
yourPlaces: 'Ça — c’était toi.',
yourStatsCountries: '{p} lieux. {c} pays. Et pas une seule fois le monde ne t’a fait de mal.',
yourStatsTrips: '{p} lieux. {t} voyages. Et pas une seule fois le monde ne t’a fait de mal.',
},
flashes: ['PEUR.', 'HAINE.', 'MURS.'],
tooBeautiful: 'Le monde est trop beau pour la peur. Tu l’as vu de tes yeux.',
anthem: 'SANS PEUR',
creditTitle: 'TREK 4.0.0 — pour un monde ouvert.',
creditBody: 'Contre le racisme, la haine et le fascisme. Voyager fait des étrangers des voisins.',
creditBody: 'Contre le racisme, la haine et le fascisme. Voyager transforme les étrangers en voisins.',
inspiredBy: 'Inspiré par Danger Dan — « Keine Angst »',
close: 'Fais-le vivre',
skip: 'Passer',
@@ -141,17 +172,23 @@ const it: NoFearCopy = {
butYouTraveled: 'Ma tu hai viaggiato.',
tables: 'Hai mangiato a tavole straniere. Dormito sotto tetti stranieri. Riso con sconosciuti.',
face: 'Lo sai: lo straniero ha un volto. E assomiglia al tuo.',
fences: 'Nessuno che ha visto il mondo vuole richiuderlo dietro recinzioni.',
fences: 'Nessuno che abbia visto il mondo vuole richiuderlo dietro recinzioni.',
notAnOpinion: 'Il razzismo non è un’opinione. Il fascismo non è un’alternativa.',
everyDot: 'Ognuna di queste luci è una tavola dove qualcuno è stato benvenuto.',
yourPlaces: 'Questo — questo eri tu.',
yourStatsCountries: '{p} luoghi. {c} paesi. E il mondo non ti ha fatto male neanche una volta.',
yourStatsTrips: '{p} luoghi. {t} viaggi. E il mondo non ti ha fatto male neanche una volta.',
},
flashes: ['PAURA.', 'ODIO.', 'MURI.'],
tooBeautiful: 'Il mondo è troppo bello per la paura. L’hai visto con i tuoi occhi.',
anthem: 'SENZA PAURA',
creditTitle: 'TREK 4.0.0 — per un mondo aperto.',
creditBody: 'Contro il razzismo, l’odio e il fascismo. Viaggiare trasforma gli estranei in vicini.',
inspiredBy: 'Ispirato a Danger Dan — “Keine Angst”',
close: 'Portalo avanti',
skip: 'Salta',
soundOn: 'Audio sì',
soundOff: 'Audio no',
soundOn: 'Audio attivo',
soundOff: 'Audio disattivato',
}
const nl: NoFearCopy = {
@@ -166,14 +203,20 @@ const nl: NoFearCopy = {
butYouTraveled: 'Maar jij hebt gereisd.',
tables: 'Je hebt aan vreemde tafels gegeten. Onder vreemde daken geslapen. Met vreemden gelachen.',
face: 'Je weet het: het vreemde heeft een gezicht. En het lijkt op het jouwe.',
fences: 'Niemand die de wereld heeft gezien, wil haar weer achter hekken zetten.',
fences: 'Niemand die de wereld heeft gezien, wil hem weer achter hekken opsluiten.',
notAnOpinion: 'Racisme is geen mening. Fascisme is geen alternatief.',
everyDot: 'Elk van deze lichtjes is een tafel waar iemand welkom was.',
yourPlaces: 'Dit — dit was jij.',
yourStatsCountries: '{p} plekken. {c} landen. En niet één keer heeft de wereld je pijn gedaan.',
yourStatsTrips: '{p} plekken. {t} reizen. En niet één keer heeft de wereld je pijn gedaan.',
},
flashes: ['ANGST.', 'HAAT.', 'MUREN.'],
tooBeautiful: 'De wereld is te mooi voor angst. Je hebt het zelf gezien.',
anthem: 'GEEN ANGST',
creditTitle: 'TREK 4.0.0 — voor een open wereld.',
creditBody: 'Tegen racisme, haat en fascisme. Reizen maakt van vreemden buren.',
inspiredBy: 'Geïnspireerd door Danger Dan — “Keine Angst”',
close: 'Draag het verder',
close: 'Geef het door',
skip: 'Overslaan',
soundOn: 'Geluid aan',
soundOff: 'Geluid uit',
@@ -193,7 +236,13 @@ const pl: NoFearCopy = {
face: 'Wiesz: obcy ma twarz. I wygląda jak twoja.',
fences: 'Nikt, kto widział świat, nie chce go znów zamykać za płotami.',
notAnOpinion: 'Rasizm to nie opinia. Faszyzm to nie alternatywa.',
everyDot: 'Każde z tych świateł to stół, przy którym ktoś był mile widziany.',
yourPlaces: 'To — to byłeś ty.',
yourStatsCountries: '{p} miejsc. {c} krajów. I ani razu świat nie zrobił ci krzywdy.',
yourStatsTrips: '{p} miejsc. {t} podróży. I ani razu świat nie zrobił ci krzywdy.',
},
flashes: ['STRACH.', 'NIENAWIŚĆ.', 'MURY.'],
tooBeautiful: 'Świat jest zbyt piękny na strach. Widziałeś to na własne oczy.',
anthem: 'BEZ STRACHU',
creditTitle: 'TREK 4.0.0 — dla otwartego świata.',
creditBody: 'Przeciw rasizmowi, nienawiści i faszyzmowi. Podróże zmieniają obcych w sąsiadów.',
@@ -218,7 +267,13 @@ const ru: NoFearCopy = {
face: 'Ты знаешь: у чужого есть лицо. И оно похоже на твоё.',
fences: 'Никто, кто видел мир, не хочет снова запирать его за заборами.',
notAnOpinion: 'Расизм — не мнение. Фашизм — не альтернатива.',
everyDot: 'Каждый из этих огней — стол, за которым кому-то были рады.',
yourPlaces: 'Это — это был ты.',
yourStatsCountries: '{p} мест. {c} стран. И ни разу мир не причинил тебе зла.',
yourStatsTrips: '{p} мест. {t} путешествий. И ни разу мир не причинил тебе зла.',
},
flashes: ['СТРАХ.', 'НЕНАВИСТЬ.', 'СТЕНЫ.'],
tooBeautiful: 'Мир слишком красив для страха. Ты видел это своими глазами.',
anthem: 'БЕЗ СТРАХА',
creditTitle: 'TREK 4.0.0 — за открытый мир.',
creditBody: 'Против расизма, ненависти и фашизма. Путешествия превращают чужих в соседей.',
@@ -243,7 +298,13 @@ const uk: NoFearCopy = {
face: 'Ти знаєш: чуже має обличчя. І воно схоже на твоє.',
fences: 'Ніхто, хто бачив світ, не хоче знову замикати його за парканами.',
notAnOpinion: 'Расизм — не думка. Фашизм — не альтернатива.',
everyDot: 'Кожен із цих вогників — стіл, за яким комусь були раді.',
yourPlaces: 'Це — це був ти.',
yourStatsCountries: '{p} місць. {c} країн. І жодного разу світ не заподіяв тобі зла.',
yourStatsTrips: '{p} місць. {t} подорожей. І жодного разу світ не заподіяв тобі зла.',
},
flashes: ['СТРАХ.', 'НЕНАВИСТЬ.', 'МУРИ.'],
tooBeautiful: 'Світ надто гарний для страху. Ти бачив це на власні очі.',
anthem: 'БЕЗ СТРАХУ',
creditTitle: 'TREK 4.0.0 — за відкритий світ.',
creditBody: 'Проти расизму, ненависті й фашизму. Подорожі перетворюють чужих на сусідів.',
@@ -268,7 +329,13 @@ const cs: NoFearCopy = {
face: 'Víš: cizí má tvář. A vypadá jako ta tvoje.',
fences: 'Nikdo, kdo viděl svět, ho nechce znovu zavírat za ploty.',
notAnOpinion: 'Rasismus není názor. Fašismus není alternativa.',
everyDot: 'Každé z těchto světel je stůl, u kterého byl někdo vítán.',
yourPlaces: 'Tohle — tohle jsi byl ty.',
yourStatsCountries: '{p} míst. {c} zemí. A svět ti ani jednou neublížil.',
yourStatsTrips: '{p} míst. {t} cest. A svět ti ani jednou neublížil.',
},
flashes: ['STRACH.', 'NENÁVIST.', 'ZDI.'],
tooBeautiful: 'Svět je na strach příliš krásný. Viděl jsi to na vlastní oči.',
anthem: 'BEZE STRACHU',
creditTitle: 'TREK 4.0.0 — pro otevřený svět.',
creditBody: 'Proti rasismu, nenávisti a fašismu. Cestování dělá z cizinců sousedy.',
@@ -285,15 +352,21 @@ const sv: NoFearCopy = {
beaconCta: 'Tryck på play.',
lines: {
afraid: 'De vill att du ska vara rädd.',
ofTheStranger: 'Rädd för främlingen. Rädd för allt du inte känner.',
ofTheStranger: 'Rädd för främlingen. Rädd för allt du inte känner till.',
fearTool: 'För rädsla stänger gränser — först på kartor, sedan i huvuden.',
hateTrade: 'Rädsla är deras verktyg. Hat är deras handel.',
hateTrade: 'Rädsla är deras verktyg. Hat är deras handelsvara.',
butYouTraveled: 'Men du har rest.',
tables: 'Du har ätit vid främmande bord. Sovit under främmande tak. Skrattat med främlingar.',
face: 'Du vet: det främmande har ett ansikte. Och det liknar ditt.',
fences: 'Ingen som har sett världen vill låsa in den bakom stängsel igen.',
notAnOpinion: 'Rasism är ingen åsikt. Fascism är inget alternativ.',
everyDot: 'Varje ljus här är ett bord där någon var välkommen.',
yourPlaces: 'Det här — det var du.',
yourStatsCountries: '{p} platser. {c} länder. Och inte en enda gång gjorde världen dig illa.',
yourStatsTrips: '{p} platser. {t} resor. Och inte en enda gång gjorde världen dig illa.',
},
flashes: ['RÄDSLA.', 'HAT.', 'MURAR.'],
tooBeautiful: 'Världen är för vacker för rädsla. Du har sett det själv.',
anthem: 'INGEN RÄDSLA',
creditTitle: 'TREK 4.0.0 — för en öppen värld.',
creditBody: 'Mot rasism, hat och fascism. Att resa gör främlingar till grannar.',
@@ -317,13 +390,19 @@ const tr: NoFearCopy = {
tables: 'Yabancı sofralarda yemek yedin. Yabancı çatılar altında uyudun. Yabancılarla güldün.',
face: 'Biliyorsun: yabancının bir yüzü var. Ve seninkine benziyor.',
fences: 'Dünyayı görmüş hiç kimse onu yeniden çitlerin arkasına kapatmak istemez.',
notAnOpinion: 'Irkçılık bir görüş değildir. Faşizm bir alternatif değildir.',
notAnOpinion: 'Irkçılık bir fikir değil. Faşizm bir alternatif değil.',
everyDot: 'Bu ışıkların her biri, birinin hoş karşılandığı bir sofra.',
yourPlaces: 'Bu — buydun sen.',
yourStatsCountries: '{p} yer. {c} ülke. Ve dünya sana bir kez bile zarar vermedi.',
yourStatsTrips: '{p} yer. {t} seyahat. Ve dünya sana bir kez bile zarar vermedi.',
},
flashes: ['KORKU.', 'NEFRET.', 'DUVARLAR.'],
tooBeautiful: 'Dünya korku için fazla güzel. Kendi gözlerinle gördün.',
anthem: 'KORKMA',
creditTitle: 'TREK 4.0.0 — açık bir dünya için.',
creditBody: 'Irkçılığa, nefrete ve faşizme karşı. Seyahat, yabancıları komşulara dönüştürür.',
inspiredBy: 'Danger Dan’den ilhamla — “Keine Angst”',
close: 'Taşımaya devam et',
close: 'İleri taşı',
skip: 'Atla',
soundOn: 'Ses açık',
soundOff: 'Ses kapalı',
@@ -343,15 +422,21 @@ const gr: NoFearCopy = {
face: 'Το ξέρεις: το ξένο έχει πρόσωπο. Και μοιάζει με το δικό σου.',
fences: 'Κανείς που είδε τον κόσμο δεν θέλει να τον ξανακλείσει πίσω από φράχτες.',
notAnOpinion: 'Ο ρατσισμός δεν είναι άποψη. Ο φασισμός δεν είναι εναλλακτική.',
everyDot: 'Καθένα από αυτά τα φώτα είναι ένα τραπέζι όπου κάποιος ήταν ευπρόσδεκτος.',
yourPlaces: 'Αυτό — αυτό ήσουν εσύ.',
yourStatsCountries: '{p} μέρη. {c} χώρες. Και ούτε μία φορά ο κόσμος δεν σε πλήγωσε.',
yourStatsTrips: '{p} μέρη. {t} ταξίδια. Και ούτε μία φορά ο κόσμος δεν σε πλήγωσε.',
},
flashes: ['ΦΟΒΟΣ.', 'ΜΙΣΟΣ.', 'ΤΕΙΧΗ.'],
tooBeautiful: 'Ο κόσμος είναι πολύ όμορφος για φόβο. Το είδες με τα μάτια σου.',
anthem: 'ΧΩΡΙΣ ΦΟΒΟ',
creditTitle: 'TREK 4.0.0 — για έναν ανοιχτό κόσμο.',
creditBody: 'Ενάντια στον ρατσισμό, το μίσος και τον φασισμό. Το ταξίδι κάνει τους ξένους γείτονες.',
inspiredBy: 'Εμπνευσμένο από τον Danger Dan — «Keine Angst»',
close: 'Δώσ’ το παρακάτω',
skip: 'Παράλειψη',
soundOn: 'Ήχος ναι',
soundOff: 'Ήχος όχι',
soundOn: 'Με ήχο',
soundOff: 'Χωρίς ήχο',
}
const hu: NoFearCopy = {
@@ -368,7 +453,13 @@ const hu: NoFearCopy = {
face: 'Tudod: az idegennek arca van. És a tiédre hasonlít.',
fences: 'Senki, aki látta a világot, nem akarja újra kerítések mögé zárni.',
notAnOpinion: 'A rasszizmus nem vélemény. A fasizmus nem alternatíva.',
everyDot: 'Ezek a fények mind egy-egy asztal, ahol valakit szívesen láttak.',
yourPlaces: 'Ez — ez te voltál.',
yourStatsCountries: '{p} hely. {c} ország. És a világ egyetlenegyszer sem bántott.',
yourStatsTrips: '{p} hely. {t} utazás. És a világ egyetlenegyszer sem bántott.',
},
flashes: ['FÉLELEM.', 'GYŰLÖLET.', 'FALAK.'],
tooBeautiful: 'A világ túl szép a félelemhez. A saját szemeddel láttad.',
anthem: 'NE FÉLJ',
creditTitle: 'TREK 4.0.0 — egy nyitott világért.',
creditBody: 'A rasszizmus, a gyűlölet és a fasizmus ellen. Az utazás szomszédokká teszi az idegeneket.',
@@ -382,18 +473,24 @@ const hu: NoFearCopy = {
const br: NoFearCopy = {
beaconTitle: 'SEM MEDO',
beaconSub: 'Um sinal por um mundo aberto.',
beaconCta: 'Aperte play.',
beaconCta: 'Aperte o play.',
lines: {
afraid: 'Eles querem que você tenha medo.',
ofTheStranger: 'Medo do estrangeiro. Medo de tudo o que você não conhece.',
fearTool: 'Porque o medo fecha fronteiras — primeiro nos mapas, depois nas mentes.',
hateTrade: 'O medo é a ferramenta deles. O ódio, o seu negócio.',
hateTrade: 'O medo é a ferramenta deles. O ódio, o negócio deles.',
butYouTraveled: 'Mas você viajou.',
tables: 'Você comeu em mesas estrangeiras. Dormiu sob tetos estrangeiros. Riu com desconhecidos.',
face: 'Você sabe: o estrangeiro tem um rosto. E ele se parece com o seu.',
fences: 'Ninguém que viu o mundo quer trancá-lo atrás de cercas de novo.',
notAnOpinion: 'Racismo não é opinião. Fascismo não é alternativa.',
everyDot: 'Cada uma destas luzes é uma mesa onde alguém foi bem-vindo.',
yourPlaces: 'Isto — isto foi você.',
yourStatsCountries: '{p} lugares. {c} países. E nem uma única vez o mundo te machucou.',
yourStatsTrips: '{p} lugares. {t} viagens. E nem uma única vez o mundo te machucou.',
},
flashes: ['MEDO.', 'ÓDIO.', 'MUROS.'],
tooBeautiful: 'O mundo é bonito demais para o medo. Você viu com os próprios olhos.',
anthem: 'SEM MEDO',
creditTitle: 'TREK 4.0.0 — por um mundo aberto.',
creditBody: 'Contra o racismo, o ódio e o fascismo. Viajar transforma estranhos em vizinhos.',
@@ -418,7 +515,13 @@ const ca: NoFearCopy = {
face: 'Ho saps: l’estranger té un rostre. I s’assembla al teu.',
fences: 'Ningú que hagi vist el món vol tornar-lo a tancar rere tanques.',
notAnOpinion: 'El racisme no és una opinió. El feixisme no és una alternativa.',
everyDot: 'Cadascuna d’aquestes llums és una taula on algú va ser benvingut.',
yourPlaces: 'Això — això eres tu.',
yourStatsCountries: '{p} llocs. {c} països. I ni una sola vegada el món et va fer mal.',
yourStatsTrips: '{p} llocs. {t} viatges. I ni una sola vegada el món et va fer mal.',
},
flashes: ['POR.', 'ODI.', 'MURS.'],
tooBeautiful: 'El món és massa bonic per a la por. Ho has vist amb els teus ulls.',
anthem: 'SENSE POR',
creditTitle: 'TREK 4.0.0 — per un món obert.',
creditBody: 'Contra el racisme, l’odi i el feixisme. Viatjar converteix els estranys en veïns.',
@@ -443,7 +546,13 @@ const id: NoFearCopy = {
face: 'Kamu tahu: orang asing punya wajah. Dan wajah itu mirip wajahmu.',
fences: 'Tak seorang pun yang pernah melihat dunia ingin menguncinya lagi di balik pagar.',
notAnOpinion: 'Rasisme bukan pendapat. Fasisme bukan alternatif.',
everyDot: 'Setiap cahaya ini adalah meja tempat seseorang disambut.',
yourPlaces: 'Ini — ini adalah kamu.',
yourStatsCountries: '{p} tempat. {c} negara. Dan dunia tak pernah sekali pun menyakitimu.',
yourStatsTrips: '{p} tempat. {t} perjalanan. Dan dunia tak pernah sekali pun menyakitimu.',
},
flashes: ['TAKUT.', 'BENCI.', 'TEMBOK.'],
tooBeautiful: 'Dunia terlalu indah untuk rasa takut. Kamu sudah melihatnya sendiri.',
anthem: 'JANGAN TAKUT',
creditTitle: 'TREK 4.0.0 — untuk dunia yang terbuka.',
creditBody: 'Melawan rasisme, kebencian, dan fasisme. Bepergian mengubah orang asing menjadi tetangga.',
@@ -468,7 +577,13 @@ const vi: NoFearCopy = {
face: 'Bạn biết: người lạ có một gương mặt. Và nó giống gương mặt bạn.',
fences: 'Không ai từng thấy thế giới lại muốn nhốt nó sau hàng rào lần nữa.',
notAnOpinion: 'Phân biệt chủng tộc không phải là quan điểm. Chủ nghĩa phát xít không phải là lựa chọn thay thế.',
everyDot: 'Mỗi ánh đèn này là một bàn ăn nơi ai đó từng được chào đón.',
yourPlaces: 'Đây — đây chính là bạn.',
yourStatsCountries: '{p} địa điểm. {c} quốc gia. Và chưa một lần nào thế giới làm bạn tổn thương.',
yourStatsTrips: '{p} địa điểm. {t} chuyến đi. Và chưa một lần nào thế giới làm bạn tổn thương.',
},
flashes: ['SỢ HÃI.', 'HẬN THÙ.', 'BỨC TƯỜNG.'],
tooBeautiful: 'Thế giới quá đẹp để phải sợ hãi. Chính bạn đã thấy điều đó.',
anthem: 'ĐỪNG SỢ',
creditTitle: 'TREK 4.0.0 — vì một thế giới rộng mở.',
creditBody: 'Chống phân biệt chủng tộc, hận thù và chủ nghĩa phát xít. Du lịch biến người lạ thành láng giềng.',
@@ -493,7 +608,13 @@ const ja: NoFearCopy = {
face: 'あなたは知っている。「よそ者」には顔があり、それはあなたの顔に似ていることを。',
fences: '世界を見た人は誰も、それを再び柵の中に閉じ込めたいとは思わない。',
notAnOpinion: 'レイシズムは意見ではない。ファシズムは選択肢ではない。',
everyDot: 'この光のひとつひとつが、誰かが歓迎された食卓です。',
yourPlaces: 'これは — あなたでした。',
yourStatsCountries: '{p}の場所。{c}の国。世界があなたを傷つけたことは、一度もなかった。',
yourStatsTrips: '{p}の場所。{t}の旅。世界があなたを傷つけたことは、一度もなかった。',
},
flashes: ['恐怖。', '憎悪。', '壁。'],
tooBeautiful: '世界は恐れるにはあまりに美しい。あなた自身が見てきたのだから。',
anthem: '恐れないで',
creditTitle: 'TREK 4.0.0 — 開かれた世界のために。',
creditBody: 'レイシズム、憎しみ、ファシズムに抗して。旅は見知らぬ人を隣人に変える。',
@@ -518,7 +639,13 @@ const ko: NoFearCopy = {
face: '당신은 압니다. 낯선 이에게도 얼굴이 있고, 그 얼굴이 당신을 닮았다는 것을.',
fences: '세상을 본 사람이라면 누구도 그것을 다시 울타리 뒤에 가두고 싶어 하지 않습니다.',
notAnOpinion: '인종차별은 의견이 아닙니다. 파시즘은 대안이 아닙니다.',
everyDot: '이 불빛 하나하나가 누군가 환영받았던 식탁입니다.',
yourPlaces: '이것이 — 바로 당신이었습니다.',
yourStatsCountries: '{p}곳의 장소. {c}개의 나라. 그리고 세상은 단 한 번도 당신을 해치지 않았습니다.',
yourStatsTrips: '{p}곳의 장소. {t}번의 여행. 그리고 세상은 단 한 번도 당신을 해치지 않았습니다.',
},
flashes: ['두려움.', '증오.', '장벽.'],
tooBeautiful: '세상은 두려워하기엔 너무 아름답습니다. 당신이 직접 보았습니다.',
anthem: '두려워하지 마',
creditTitle: 'TREK 4.0.0 — 열린 세상을 위하여.',
creditBody: '인종차별, 증오, 파시즘에 반대하며. 여행은 낯선 사람을 이웃으로 만듭니다.',
@@ -543,7 +670,13 @@ const zh: NoFearCopy = {
face: '你知道:陌生人也有一张脸,而那张脸和你的很像。',
fences: '见过世界的人,没有谁愿意再把它锁回栅栏之后。',
notAnOpinion: '种族主义不是一种观点。法西斯主义不是一种选择。',
everyDot: '这里的每一盏灯,都是一张有人被欢迎过的餐桌。',
yourPlaces: '这——就是你。',
yourStatsCountries: '{p} 个地方。{c} 个国家。而世界从未伤害过你一次。',
yourStatsTrips: '{p} 个地方。{t} 次旅行。而世界从未伤害过你一次。',
},
flashes: ['恐惧。', '仇恨。', '高墙。'],
tooBeautiful: '这个世界美得容不下恐惧。你亲眼见过。',
anthem: '别害怕',
creditTitle: 'TREK 4.0.0 — 为了一个开放的世界。',
creditBody: '反对种族主义、仇恨与法西斯主义。旅行让陌生人成为邻居。',
@@ -568,7 +701,13 @@ const zhTW: NoFearCopy = {
face: '你知道:陌生人也有一張臉,而那張臉和你的很像。',
fences: '見過世界的人,沒有誰願意再把它鎖回柵欄之後。',
notAnOpinion: '種族主義不是一種觀點。法西斯主義不是一種選項。',
everyDot: '這裡的每一盞燈,都是一張有人被歡迎過的餐桌。',
yourPlaces: '這——就是你。',
yourStatsCountries: '{p} 個地方。{c} 個國家。而世界從未傷害過你一次。',
yourStatsTrips: '{p} 個地方。{t} 次旅行。而世界從未傷害過你一次。',
},
flashes: ['恐懼。', '仇恨。', '高牆。'],
tooBeautiful: '這個世界美得容不下恐懼。你親眼見過。',
anthem: '別害怕',
creditTitle: 'TREK 4.0.0 — 為了一個開放的世界。',
creditBody: '反對種族主義、仇恨與法西斯主義。旅行讓陌生人成為鄰居。',
@@ -593,7 +732,13 @@ const ar: NoFearCopy = {
face: 'أنت تعرف: للغريب وجه. وهو يشبه وجهك.',
fences: 'لا أحد رأى العالم يريد أن يحبسه خلف الأسوار من جديد.',
notAnOpinion: 'العنصرية ليست رأيًا. الفاشية ليست بديلًا.',
everyDot: 'كل ضوء من هذه الأضواء مائدة رُحّب فيها بأحدهم.',
yourPlaces: 'هذا — كان أنت.',
yourStatsCountries: '{p} أماكن. {c} دولة. ولم يؤذِك العالم ولا مرة واحدة.',
yourStatsTrips: '{p} أماكن. {t} رحلة. ولم يؤذِك العالم ولا مرة واحدة.',
},
flashes: ['الخوف.', 'الكراهية.', 'الجدران.'],
tooBeautiful: 'العالم أجمل من أن نخاف. لقد رأيته بعينيك.',
anthem: 'لا تخف',
creditTitle: 'TREK 4.0.0 — من أجل عالم مفتوح.',
creditBody: 'ضد العنصرية والكراهية والفاشية. السفر يحوّل الغرباء إلى جيران.',
+323 -141
View File
@@ -1,25 +1,36 @@
// Canvas scene for the 4.0.0 "KEINE ANGST" show: a world of dots whose borders
// first burn red, then dissolve under a growing web of travel connections.
// Canvas scene for the 4.0.0 "KEINE ANGST" show: a world of dots whose city
// lights die while its borders burn red — until the travel web returns, the
// user's own places ignite, and the borders shatter into drifting sparks.
//
// The landmass and border geometry come from TREK's own Atlas bundle
// (/addons/atlas/countries/geo). The polygons are rasterized once onto a small
// offscreen canvas (the browser does the point-in-polygon work natively), then
// sampled into a dot grid. If the Atlas addon is disabled the show degrades
// gracefully: no map, but the city web and typography still carry it.
// Perf model: nothing expensive happens per frame. The Atlas rings are
// decimated to a global vertex budget first; land dots, borders and the
// finished part of the travel web are pre-rendered into offscreen layers
// (rebuilt on resize) and blitted with drawImage. No shadowBlur anywhere in
// the frame path — every glow is baked into a layer or a sprite.
//
// The geometry comes from TREK's own Atlas bundle (/addons/atlas/countries/geo).
// If the Atlas addon is disabled the show degrades gracefully: no map, but the
// city web and typography still carry it.
type Ring = [number, number][]
export interface SceneState {
/** 0→1: how far the land dots have faded in. */
land: number
/** 0→1: the calm city lights of the opening. */
cityLife: number
/** 0→1: how far those lights have died during the fear act. */
cityDeath: number
/** 0→1: how hot the borders burn (red act). */
borderHeat: number
/** 0→1: how far the borders have dissolved (0 = solid, 1 = gone). */
borderGone: number
/** 0→1: the borders shattering into sparks at the climax. */
borderBurst: number
/** 0→1: how much of the city web has grown. */
web: number
/** 0→1: warm ambient glow of the release act. */
warmth: number
/** 0→1: the user's own places igniting gold. */
personalGlow: number
/** 0→1: rising particles of the anthem. */
particles: number
/** Master opacity for the whole scene. */
@@ -39,20 +50,38 @@ const CITIES: [number, number][] = [
[-12.05, -77.04], [-23.55, -46.63], [-34.6, -58.38], [-33.45, -70.67], [-22.91, -43.17],
]
interface Arc { a: number; b: number; delay: number }
interface Dot { x: number; y: number; r: number; tw: number }
const LAT_TOP = 78
const LAT_BOT = -58
const TWINKLE_GROUPS = 3
const BORDER_VERTEX_BUDGET = 14000
const BURST_SPARKS = 2200
interface Arc { a: number; b: number; delay: number; baked: boolean }
interface Particle { x: number; y: number; v: number; drift: number; size: number; seed: number }
interface Spark { lon: number; lat: number; vx: number; vy: number; seed: number }
const nextFrame = () => new Promise<void>(r => requestAnimationFrame(() => r()))
export class NoFearScene {
private dots: Dot[] = []
private borders: Path2D | null = null
private rings: Ring[] = []
private dotLonLat: { x: number; y: number; r: number; g: number }[] = []
private arcs: Arc[] = []
private particlesPool: Particle[] = []
private sparks: Spark[] = []
private personal: { lat: number; lng: number }[] = []
private w = 0
private h = 0
private ready = false
/** Maps lon/lat to scene pixels. Set up in layout(). */
// Prebaked layers (screen-sized). Rebuilt on resize / when geometry arrives.
private coldLayers: HTMLCanvasElement[] = []
private warmLayers: HTMLCanvasElement[] = []
private borderLayer: HTMLCanvasElement | null = null
private webLayer: HTMLCanvasElement | null = null
private webCtx: CanvasRenderingContext2D | null = null
private citySprite: HTMLCanvasElement | null = null
private goldSprite: HTMLCanvasElement | null = null
private lastWeb = 0
private px = (lon: number): number => lon
private py = (lat: number): number => lat
@@ -66,15 +95,13 @@ export class NoFearScene {
}
const seen = new Set<string>()
CITIES.forEach((_, i) => {
const peers = CITIES
.map((_, j) => j)
.filter(j => j !== i && d2(i, j) > 900)
const peers = CITIES.map((_, j) => j).filter(j => j !== i && d2(i, j) > 900)
for (let k = 0; k < 2 && peers.length > 0; k++) {
const j = peers.splice(Math.floor(Math.random() * peers.length), 1)[0]
const key = i < j ? `${i}-${j}` : `${j}-${i}`
if (seen.has(key)) continue
seen.add(key)
this.arcs.push({ a: i, b: j, delay: Math.random() })
this.arcs.push({ a: i, b: j, delay: Math.random(), baked: false })
}
})
this.arcs.sort((x, y) => x.delay - y.delay)
@@ -86,48 +113,68 @@ export class NoFearScene {
}
}
/** Fetches the Atlas country bundle and builds dots + border paths. Safe to fail. */
async load(width: number, height: number): Promise<void> {
/** The user's own visited places — they ignite gold in the release act. */
setPersonalPlaces(points: { lat: number; lng: number }[]): void {
this.personal = points.slice(0, 400)
}
/** Fetches the Atlas country bundle and builds all static layers. Safe to fail. */
async load(width: number, height: number, signal?: AbortSignal): Promise<void> {
this.layout(width, height)
try {
const res = await fetch('/api/addons/atlas/countries/geo', { credentials: 'include' })
const res = await fetch('/api/addons/atlas/countries/geo', { credentials: 'include', signal })
if (!res.ok) return
const geo = await res.json() as { features?: { geometry?: { type: string; coordinates: unknown } }[] }
const rings: Ring[] = []
if (signal?.aborted) return
await nextFrame() // let the parse frame paint before the geometry pass
const raw: Ring[] = []
for (const f of geo.features ?? []) {
const g = f.geometry
if (!g) continue
if (g.type === 'Polygon') for (const r of g.coordinates as Ring[]) rings.push(r)
else if (g.type === 'MultiPolygon') for (const p of g.coordinates as Ring[][]) for (const r of p) rings.push(r)
if (g.type === 'Polygon') for (const r of g.coordinates as Ring[]) raw.push(r)
else if (g.type === 'MultiPolygon') for (const p of g.coordinates as Ring[][]) for (const r of p) raw.push(r)
}
if (rings.length === 0) return
this.buildDots(rings)
this.buildBorders(rings)
this.ready = true
if (raw.length === 0) return
// Decimate against a global vertex budget BEFORE any rasterizing — the
// Atlas bundle carries ~2M points, the show needs a fraction of that.
const total = raw.reduce((n, r) => n + r.length, 0)
const step = Math.max(1, Math.ceil(total / BORDER_VERTEX_BUDGET))
const rings: Ring[] = []
for (const r of raw) {
if (r.length < step * 4) continue // rings too small to survive decimation are visual noise
const thin: Ring = []
for (let i = 0; i < r.length; i += step) thin.push(r[i])
if (thin.length >= 4) rings.push(thin)
}
this.rings = rings
if (signal?.aborted) return
await nextFrame()
this.sampleDots(rings)
if (signal?.aborted) return
await nextFrame()
this.bake()
} catch {
// Atlas addon off / offline — the show runs without the map.
// Atlas addon off / offline / aborted — the show runs without the map.
}
}
layout(width: number, height: number): void {
const changed = width !== this.w || height !== this.h
this.w = width
this.h = height
// Equirectangular, latitude clamped to [-58, 78] (no empty Antarctica band),
// fit as "cover" so the map always fills the screen width.
const latTop = 78
const latBot = -58
const scale = Math.max(width / 360, height / (latTop - latBot))
// Equirectangular, latitude clamped (no empty Antarctica band), "cover" fit.
const scale = Math.max(width / 360, height / (LAT_TOP - LAT_BOT))
const mapW = 360 * scale
const mapH = (latTop - latBot) * scale
const mapH = (LAT_TOP - LAT_BOT) * scale
const ox = (width - mapW) / 2
const oy = (height - mapH) / 2
this.px = (lon: number) => ox + (lon + 180) * scale
this.py = (lat: number) => oy + (latTop - lat) * scale
this.py = (lat: number) => oy + (LAT_TOP - lat) * scale
// Layers bake in pixel space — a resize invalidates them all.
if (changed && this.rings.length > 0) this.bake()
}
private buildDots(rings: Ring[]): void {
// Rasterize the filled polygons at low resolution; the alpha channel then
// tells us where land is. ~160 columns keeps this a one-off few-ms job.
private sampleDots(rings: Ring[]): void {
const cols = 168
const rows = 84
const off = document.createElement('canvas')
@@ -136,51 +183,148 @@ export class NoFearScene {
const c = off.getContext('2d')
if (!c) return
const sx = (lon: number) => ((lon + 180) / 360) * cols
const sy = (lat: number) => ((78 - lat) / (78 - -58)) * rows
const sy = (lat: number) => ((LAT_TOP - lat) / (LAT_TOP - LAT_BOT)) * rows
c.fillStyle = '#fff'
c.beginPath()
for (const ring of rings) {
if (ring.length < 3) continue
c.moveTo(sx(ring[0][0]), sy(ring[0][1]))
for (let i = 1; i < ring.length; i++) c.lineTo(sx(ring[i][0]), sy(ring[i][1]))
c.closePath()
}
c.fill('evenodd')
const img = c.getImageData(0, 0, cols, rows).data
this.dots = []
this.dotLonLat = []
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (img[(y * cols + x) * 4 + 3] > 96) {
const lon = (x / cols) * 360 - 180
const lat = 78 - (y / rows) * (78 - -58)
this.dots.push({
x: lon, y: lat,
this.dotLonLat.push({
x: (x / cols) * 360 - 180,
y: LAT_TOP - (y / rows) * (LAT_TOP - LAT_BOT),
r: 0.8 + Math.random() * 0.9,
tw: Math.random() * Math.PI * 2,
g: Math.floor(Math.random() * TWINKLE_GROUPS),
})
}
}
}
}
private buildBorders(rings: Ring[]): void {
const path = new Path2D()
for (const ring of rings) {
if (ring.length < 8) continue // skip islets — visual noise at this scale
// Decimate: the show projection is far coarser than the Atlas zoom levels.
const step = Math.max(1, Math.floor(ring.length / 240))
path.moveTo(this.px(ring[0][0]), this.py(ring[0][1]))
for (let i = step; i < ring.length; i += step) {
path.lineTo(this.px(ring[i][0]), this.py(ring[i][1]))
}
path.closePath()
/** (Re)builds every static layer in current screen coordinates. */
private bake(): void {
const mk = () => {
const cv = document.createElement('canvas')
cv.width = Math.max(this.w, 1)
cv.height = Math.max(this.h, 1)
return cv
}
this.borders = path
// Dot layers: one cold + one warm canvas per twinkle group.
this.coldLayers = []
this.warmLayers = []
for (let g = 0; g < TWINKLE_GROUPS; g++) {
const cold = mk(); const warm = mk()
const cc = cold.getContext('2d'); const wc = warm.getContext('2d')
if (!cc || !wc) continue
cc.fillStyle = 'rgb(112, 132, 168)'
wc.fillStyle = 'rgb(214, 189, 141)'
for (const d of this.dotLonLat) {
if (d.g !== g) continue
const x = this.px(d.x); const y = this.py(d.y)
cc.beginPath(); cc.arc(x, y, d.r, 0, Math.PI * 2); cc.fill()
wc.beginPath(); wc.arc(x, y, d.r, 0, Math.PI * 2); wc.fill()
}
this.coldLayers.push(cold)
this.warmLayers.push(warm)
}
// Border layer: glow baked once via a wide soft stroke under a hot core.
const borders = mk()
const bc = borders.getContext('2d')
if (bc) {
const path = new Path2D()
for (const ring of this.rings) {
path.moveTo(this.px(ring[0][0]), this.py(ring[0][1]))
for (let i = 1; i < ring.length; i++) path.lineTo(this.px(ring[i][0]), this.py(ring[i][1]))
path.closePath()
}
bc.strokeStyle = 'rgba(210, 40, 35, 0.16)'
bc.lineWidth = 6
bc.stroke(path)
bc.strokeStyle = 'rgba(235, 62, 52, 0.55)'
bc.lineWidth = 2.6
bc.stroke(path)
bc.strokeStyle = 'rgba(255, 120, 95, 0.9)'
bc.lineWidth = 1.1
bc.stroke(path)
this.borderLayer = borders
}
// Sparks for the shatter: sampled along the borders, each with a drift vector.
this.sparks = []
const every = Math.max(1, Math.floor(this.rings.reduce((n, r) => n + r.length, 0) / BURST_SPARKS))
let idx = 0
for (const ring of this.rings) {
for (const [lon, lat] of ring) {
if (idx++ % every !== 0) continue
const ang = Math.random() * Math.PI * 2
const sp = 0.4 + Math.random() * 0.6
this.sparks.push({ lon, lat, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp - 0.55, seed: Math.random() })
}
}
// Web layer: finished arcs + lit cities accumulate here.
this.webLayer = mk()
this.webCtx = this.webLayer.getContext('2d')
for (const a of this.arcs) a.baked = false
this.lastWeb = 0
// Glow sprites: one warm (cities), one gold (the user's own places).
this.citySprite = this.makeSprite('rgba(255, 216, 140, 0.95)', 'rgba(255, 190, 110, 0.45)', 'rgba(255, 180, 100, 0)')
this.goldSprite = this.makeSprite('rgba(255, 232, 170, 1)', 'rgba(255, 205, 120, 0.55)', 'rgba(255, 195, 105, 0)')
}
/** Border paths bake in pixel coordinates, so a resize needs a rebuild — cheap enough to redo on demand. */
hasMap(): boolean {
return this.ready
private makeSprite(inner: string, mid: string, outer: string): HTMLCanvasElement {
const sprite = document.createElement('canvas')
sprite.width = 64
sprite.height = 64
const sc = sprite.getContext('2d')
if (sc) {
const grad = sc.createRadialGradient(32, 32, 0, 32, 32, 32)
grad.addColorStop(0, inner)
grad.addColorStop(0.25, mid)
grad.addColorStop(1, outer)
sc.fillStyle = grad
sc.fillRect(0, 0, 64, 64)
}
return sprite
}
private arcPoint(arc: Arc, q: number): [number, number] {
const [alat, alon] = CITIES[arc.a]
const [blat, blon] = CITIES[arc.b]
const x1 = this.px(alon); const y1 = this.py(alat)
const x2 = this.px(blon); const y2 = this.py(blat)
const mx = (x1 + x2) / 2
const my = (y1 + y2) / 2 - Math.min(Math.hypot(x2 - x1, y2 - y1) * 0.22, this.h * 0.2)
return [
(1 - q) * (1 - q) * x1 + 2 * (1 - q) * q * mx + q * q * x2,
(1 - q) * (1 - q) * y1 + 2 * (1 - q) * q * my + q * q * y2,
]
}
private strokeArc(c: CanvasRenderingContext2D, arc: Arc, upTo: number, alpha: number): void {
const steps = 26
const n = Math.max(2, Math.round(steps * upTo))
c.beginPath()
for (let i = 0; i <= n; i++) {
const [ix, iy] = this.arcPoint(arc, (i / steps))
if (i === 0) c.moveTo(ix, iy)
else c.lineTo(ix, iy)
}
c.strokeStyle = `rgba(255, 176, 90, ${0.10 * alpha})`
c.lineWidth = 4.2
c.stroke()
c.strokeStyle = `rgba(255, 202, 122, ${0.4 * alpha})`
c.lineWidth = 1.2
c.stroke()
}
private arcLocal(arc: Arc, web: number): number {
return Math.min(Math.max((web - arc.delay * 0.72) / 0.28, 0), 1)
}
draw(c: CanvasRenderingContext2D, s: SceneState, t: number): void {
@@ -188,103 +332,141 @@ export class NoFearScene {
c.clearRect(0, 0, w, h)
if (s.opacity <= 0) return
c.save()
c.globalAlpha = s.opacity
// Land dots — cold blue-grey in the fear act, warming with s.warmth.
if (s.land > 0 && this.dots.length > 0) {
for (const d of this.dots) {
const twinkle = 0.75 + 0.25 * Math.sin(t * 0.7 + d.tw)
const a = s.land * 0.55 * twinkle
const warm = s.warmth
c.fillStyle = `rgba(${Math.round(110 + warm * 105)}, ${Math.round(130 + warm * 60)}, ${Math.round(165 - warm * 25)}, ${a})`
c.beginPath()
c.arc(this.px(d.x), this.py(d.y), d.r, 0, Math.PI * 2)
c.fill()
}
}
// Borders — a hot red web that later dissolves to nothing.
if (this.borders && s.borderHeat > 0 && s.borderGone < 1) {
const alive = 1 - s.borderGone
const pulse = 0.72 + 0.28 * Math.sin(t * 2.4)
c.save()
c.globalAlpha = s.opacity * alive * s.borderHeat * pulse
c.strokeStyle = `rgba(235, 60, 55, 0.9)`
c.lineWidth = 1 + s.borderHeat * 0.6
c.shadowColor = 'rgba(235, 50, 45, 0.85)'
c.shadowBlur = 6 + s.borderHeat * 10 + s.borderGone * 22
c.stroke(this.borders)
c.restore()
}
// The web: city lights and great arcs across everything that just burned.
if (s.web > 0) {
c.save()
c.globalCompositeOperation = 'lighter'
const grow = s.web
for (const arc of this.arcs) {
// Each arc gets its own window inside the act so the web cascades.
const local = Math.min(Math.max((grow - arc.delay * 0.72) / 0.28, 0), 1)
if (local <= 0) continue
const [alat, alon] = CITIES[arc.a]
const [blat, blon] = CITIES[arc.b]
const x1 = this.px(alon); const y1 = this.py(alat)
const x2 = this.px(blon); const y2 = this.py(blat)
const mx = (x1 + x2) / 2
const my = (y1 + y2) / 2 - Math.min(Math.hypot(x2 - x1, y2 - y1) * 0.22, h * 0.2)
c.strokeStyle = `rgba(255, 196, 110, ${0.34 * local})`
c.lineWidth = 1.1
c.shadowColor = 'rgba(255, 180, 90, 0.9)'
c.shadowBlur = 7
c.beginPath()
// Draw the quadratic curve only up to `local` by subdividing.
const steps = 26
const upto = Math.max(2, Math.floor(steps * local))
for (let i = 0; i <= upto; i++) {
const q = (i / steps)
const ix = (1 - q) * (1 - q) * x1 + 2 * (1 - q) * q * mx + q * q * x2
const iy = (1 - q) * (1 - q) * y1 + 2 * (1 - q) * q * my + q * q * y2
if (i === 0) c.moveTo(ix, iy)
else c.lineTo(ix, iy)
// Land: TWINKLE_GROUPS×2 drawImages, alpha does the twinkle and the warmth mix.
if (s.land > 0 && this.coldLayers.length > 0) {
for (let g = 0; g < this.coldLayers.length; g++) {
const twinkle = 0.72 + 0.28 * Math.sin(t * 0.8 + g * 2.1)
const a = s.opacity * s.land * 0.6 * twinkle
if (s.warmth < 1) {
c.globalAlpha = a * (1 - s.warmth)
c.drawImage(this.coldLayers[g], 0, 0)
}
if (s.warmth > 0) {
c.globalAlpha = a * s.warmth
c.drawImage(this.warmLayers[g], 0, 0)
}
c.stroke()
}
// City lights on top of the arcs.
}
// The opening's calm city lights — and their death in the fear act. Each city
// dies at its own pseudo-random moment while the borders heat up.
if (this.citySprite && s.cityLife > 0 && s.cityDeath < 1 && s.web <= 0) {
c.globalCompositeOperation = 'lighter'
for (let i = 0; i < CITIES.length; i++) {
const local = Math.min(Math.max((grow - (i / CITIES.length) * 0.5) / 0.2, 0), 1)
if (local <= 0) continue
const on = Math.min(Math.max(s.cityLife * 1.5 - ((i * 29) % 100) / 100 * 0.5, 0), 1)
const deathAt = ((i * 37) % 100) / 100 * 0.8
const dead = Math.min(Math.max((s.cityDeath * 1.25 - deathAt) / 0.2, 0), 1)
const a = on * (1 - dead)
if (a <= 0) continue
// A light flickers just before it dies.
const flicker = dead > 0 && dead < 1 ? 0.5 + 0.5 * Math.sin(t * 26 + i * 3) : 1
const [lat, lon] = CITIES[i]
const px = this.px(lon); const py = this.py(lat)
const halo = 2.6 + Math.sin(t * 1.8 + i) * 0.7
c.fillStyle = `rgba(255, 214, 140, ${0.85 * local})`
c.shadowColor = 'rgba(255, 200, 120, 1)'
c.shadowBlur = 10
c.beginPath()
c.arc(px, py, 1.6 + local * 1.1, 0, Math.PI * 2)
c.fill()
c.fillStyle = `rgba(255, 190, 100, ${0.12 * local})`
c.beginPath()
c.arc(px, py, halo * 2.4, 0, Math.PI * 2)
c.fill()
const x = this.px(lon); const y = this.py(lat)
c.globalAlpha = s.opacity * 0.5 * a * flicker
c.drawImage(this.citySprite, x - 8, y - 8, 16, 16)
}
c.restore()
c.globalCompositeOperation = 'source-over'
}
// Borders: one blit; pulse via alpha. The climax shatter replaces the layer
// with sparks that drift off and burn out.
if (this.borderLayer && s.borderHeat > 0 && s.borderBurst < 1) {
const alive = 1 - s.borderBurst
const pulse = 0.72 + 0.28 * Math.sin(t * 2.4)
c.globalAlpha = s.opacity * alive * s.borderHeat * pulse
c.drawImage(this.borderLayer, 0, 0)
}
if (s.borderBurst > 0 && s.borderBurst < 1 && this.sparks.length > 0) {
c.globalCompositeOperation = 'lighter'
const prog = s.borderBurst
const ease = 1 - (1 - prog) * (1 - prog)
for (const sp of this.sparks) {
const a = (1 - prog) * (0.5 + 0.5 * Math.sin(sp.seed * 9 + t * 7))
if (a <= 0.02) continue
const x = this.px(sp.lon) + sp.vx * ease * 46
const y = this.py(sp.lat) + sp.vy * ease * 46
c.globalAlpha = s.opacity * a
c.fillStyle = sp.seed > 0.5 ? 'rgb(255, 150, 95)' : 'rgb(255, 205, 130)'
c.fillRect(x, y, 1.6, 1.6)
}
c.globalCompositeOperation = 'source-over'
}
// Web: finished arcs live in the baked layer; only growing arcs are stroked.
if (s.web > 0) {
c.globalCompositeOperation = 'lighter'
if (this.webCtx && s.web > this.lastWeb) {
for (const arc of this.arcs) {
if (!arc.baked && this.arcLocal(arc, s.web) >= 1) {
this.strokeArc(this.webCtx, arc, 1, 1)
arc.baked = true
}
}
this.lastWeb = s.web
}
if (this.webLayer) {
c.globalAlpha = s.opacity
c.drawImage(this.webLayer, 0, 0)
}
c.globalAlpha = s.opacity
for (const arc of this.arcs) {
if (arc.baked) continue
const local = this.arcLocal(arc, s.web)
if (local <= 0) continue
this.strokeArc(c, arc, local, local)
}
if (this.citySprite) {
for (let i = 0; i < CITIES.length; i++) {
const local = Math.min(Math.max((s.web - (i / CITIES.length) * 0.5) / 0.2, 0), 1)
if (local <= 0) continue
const [lat, lon] = CITIES[i]
const x = this.px(lon); const y = this.py(lat)
const halo = (11 + Math.sin(t * 1.8 + i) * 3) * (0.6 + 0.4 * local)
c.globalAlpha = s.opacity * 0.55 * local
c.drawImage(this.citySprite, x - halo, y - halo, halo * 2, halo * 2)
c.globalAlpha = s.opacity * local
c.drawImage(this.citySprite, x - 3.2, y - 3.2, 6.4, 6.4)
}
}
c.globalCompositeOperation = 'source-over'
}
// The user's own places igniting gold — each with a small overshoot pulse.
if (this.goldSprite && s.personalGlow > 0 && this.personal.length > 0) {
c.globalCompositeOperation = 'lighter'
const n = this.personal.length
for (let i = 0; i < n; i++) {
const local = Math.min(Math.max((s.personalGlow - (i / n) * 0.6) / 0.25, 0), 1)
if (local <= 0) continue
const p = this.personal[i]
const x = this.px(p.lng); const y = this.py(p.lat)
const overshoot = 1 + (1 - local) * 2.2
const halo = 7 * overshoot
c.globalAlpha = s.opacity * 0.85 * local
c.drawImage(this.goldSprite, x - halo, y - halo, halo * 2, halo * 2)
c.globalAlpha = s.opacity * local
c.drawImage(this.goldSprite, x - 2.6, y - 2.6, 5.2, 5.2)
}
c.globalCompositeOperation = 'source-over'
}
// Rising particles for the anthem.
if (s.particles > 0) {
c.save()
c.globalCompositeOperation = 'lighter'
for (const p of this.particlesPool) {
const y = ((p.y - t * p.v * 0.13) % 1.15)
const y = ((((p.y - t * p.v * 0.13) % 1.15) + 1.15) % 1.15)
const x = p.x + Math.sin(t * 0.4 + p.seed) * p.drift
c.fillStyle = `rgba(255, 210, 150, ${0.28 * s.particles * (0.4 + 0.6 * Math.sin(t + p.seed))})`
c.globalAlpha = s.opacity * 0.28 * s.particles * (0.4 + 0.6 * Math.abs(Math.sin(t + p.seed)))
c.fillStyle = 'rgb(255, 210, 150)'
c.beginPath()
c.arc(x * w, y * h, p.size, 0, Math.PI * 2)
c.fill()
}
c.restore()
c.globalCompositeOperation = 'source-over'
}
c.globalAlpha = 1
c.restore()
}
}