From c6d96872ae3a3882b9bef3320e3dbb2f32821b81 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 23 Jul 2026 23:11:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=204.0.0=20release=20moment=20?= =?UTF-8?q?=E2=80=94=20KEINE=20ANGST?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A one-release statement against racism, hatred and fascism, told the way TREK knows the world: through travel. A pulsing beacon in the dashboard sidebar (desktop only, dismissible) opens a ~80s full-screen show — the world as a map of dots whose borders burn red and then dissolve under a growing web of travel connections, driven by a fully procedural Web Audio score (heartbeat, drone, A-major release; no audio assets, no copyrighted material) and hard-cut lines in all 23 TREK languages, closing on the anthem cascade and a credit to the song that sparked it (Danger Dan — Keine Angst). Self-contained in components/FourZero (own copy dictionary, no shared i18n churn); wired into the dashboard with two marked lines. Removing the folder plus those two lines retires the moment after the release. --- .../src/components/FourZero/NoFearBeacon.tsx | 52 ++ client/src/components/FourZero/NoFearShow.tsx | 203 ++++++ client/src/components/FourZero/fourzero.css | 324 +++++++++ client/src/components/FourZero/noFearAudio.ts | 241 +++++++ client/src/components/FourZero/noFearLines.ts | 646 ++++++++++++++++++ client/src/components/FourZero/noFearScene.ts | 290 ++++++++ client/src/pages/DashboardPage.tsx | 2 + 7 files changed, 1758 insertions(+) create mode 100644 client/src/components/FourZero/NoFearBeacon.tsx create mode 100644 client/src/components/FourZero/NoFearShow.tsx create mode 100644 client/src/components/FourZero/fourzero.css create mode 100644 client/src/components/FourZero/noFearAudio.ts create mode 100644 client/src/components/FourZero/noFearLines.ts create mode 100644 client/src/components/FourZero/noFearScene.ts diff --git a/client/src/components/FourZero/NoFearBeacon.tsx b/client/src/components/FourZero/NoFearBeacon.tsx new file mode 100644 index 000000000..a607ba4c7 --- /dev/null +++ b/client/src/components/FourZero/NoFearBeacon.tsx @@ -0,0 +1,52 @@ +import { lazy, Suspense, useState } from 'react' +import { Play, X } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { noFearCopy } from './noFearLines' +import './fourzero.css' + +// TREK 4.0.0 release moment — remove together with the FourZero folder. + +const NoFearShow = lazy(() => import('./NoFearShow')) + +const DISMISS_KEY = 'trek-fourzero-dismissed' + +/** + * The dashboard trigger for the 4.0.0 "KEINE ANGST" show: a dark, pulsing card + * at the top of the right sidebar (desktop only). One click starts the show; + * the small × dismisses the beacon for good on this device. + */ +export default function NoFearBeacon() { + const { language } = useTranslation() + const copy = noFearCopy(language) + const [dismissed, setDismissed] = useState(() => localStorage.getItem(DISMISS_KEY) === '1') + const [open, setOpen] = useState(false) + + if (dismissed) return null + + return ( + <> + + {open && ( + + setOpen(false)} /> + + )} + + ) +} diff --git a/client/src/components/FourZero/NoFearShow.tsx b/client/src/components/FourZero/NoFearShow.tsx new file mode 100644 index 000000000..05a3f73ba --- /dev/null +++ b/client/src/components/FourZero/NoFearShow.tsx @@ -0,0 +1,203 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Volume2, VolumeX, X } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { NoFearAudio, type NoFearAct } from './noFearAudio' +import { NoFearScene, type SceneState } from './noFearScene' +import { ANTHEM_CASCADE, INSPIRED_URL, noFearCopy } from './noFearLines' +import './fourzero.css' + +// TREK 4.0.0 release moment — remove together with the FourZero folder. + +type LineKey = keyof ReturnType['lines'] + +interface Cue { + at: number + act?: NoFearAct + line?: LineKey | null + hard?: boolean +} + +// The script. Times in seconds; the scene ramps below are keyed to 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 }, +] +const ANTHEM_AT = 62 +const CREDITS_AT = 74 + +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 + 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), + particles: ramp(t, ANTHEM_AT, ANTHEM_AT + 4), + opacity: (1 - blackout) * (t >= CREDITS_AT ? 0.35 : 1), + } +} + +export default function NoFearShow({ onClose }: { onClose: () => void }) { + const { language } = useTranslation() + const copy = useMemo(() => noFearCopy(language), [language]) + const canvasRef = useRef(null) + const audioRef = useRef(null) + const sceneRef = useRef(null) + const startRef = useRef(performance.now()) + const [cueIdx, setCueIdx] = useState(-1) + const [muted, setMuted] = useState(false) + const [credits, setCredits] = useState(false) + const [anthem, setAnthem] = useState(false) + const reducedMotion = useMemo(() => window.matchMedia('(prefers-reduced-motion: reduce)').matches, []) + + const skipToEnd = useCallback(() => { + startRef.current = performance.now() - CREDITS_AT * 1000 + setAnthem(true) + setCredits(true) + audioRef.current?.setAct('end') + }, []) + + // Boot: audio (user gesture already happened — the beacon click), scene, clock. + useEffect(() => { + const audio = new NoFearAudio() + audioRef.current = audio + if (!reducedMotion) audio.start() + const scene = new NoFearScene() + sceneRef.current = scene + 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() + } + } + return () => audio.dispose() + }, [reducedMotion, skipToEnd]) + + // The clock: one rAF loop drives the canvas directly and only touches React + // state on discrete cue changes. + useEffect(() => { + let raf = 0 + let lastCue = -1 + const cv = canvasRef.current + const ctx = cv?.getContext('2d') + const tick = () => { + const t = (performance.now() - startRef.current) / 1000 + let idx = -1 + for (let i = 0; i < CUES.length; i++) if (t >= CUES[i].at) idx = i + if (idx !== lastCue && idx >= 0) { + lastCue = idx + const cue = CUES[idx] + if (cue.act) audioRef.current?.setAct(cue.act) + setCueIdx(idx) + if (t >= ANTHEM_AT) setAnthem(true) + if (t >= CREDITS_AT) setCredits(true) + } + if (ctx && cv) sceneRef.current?.draw(ctx, sceneAt(t), t) + raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, []) + + // ESC closes, any key after the credits too. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + const cue = cueIdx >= 0 ? CUES[cueIdx] : null + const lineKey = cue?.line ?? null + const lineText = lineKey ? copy.lines[lineKey] : null + const hard = !!cue?.hard + + return ( +
+ + + {/* Red vignette while the borders burn. */} + {!anthem && cueIdx >= 2 && cueIdx <= 3 &&
} + + {/* The lines. */} + {!anthem && lineText && ( +
+

{lineText}

+
+ )} + + {/* Anthem: the big words + the cascade through every TREK language. */} + {anthem && !credits && ( +
+

{copy.anthem}

+
+ {ANTHEM_CASCADE.filter(c => c.lang !== language).map((c, i) => ( + + {c.text} + + ))} +
+
+ )} + + {/* Credits — stays until dismissed. */} + {credits && ( +
+

{copy.anthem}

+

{copy.creditTitle}

+

{copy.creditBody}

+ + {copy.inspiredBy} + + +
+ )} + + {/* Chrome: mute, skip, close. */} +
+ + {!credits && ( + + )} + +
+
+ ) +} diff --git a/client/src/components/FourZero/fourzero.css b/client/src/components/FourZero/fourzero.css new file mode 100644 index 000000000..b3e3bd39a --- /dev/null +++ b/client/src/components/FourZero/fourzero.css @@ -0,0 +1,324 @@ +/* TREK 4.0.0 "KEINE ANGST" — styles for the beacon and the show. + Temporary by design: remove together with the FourZero folder. */ + +.fz-overlay { + position: fixed; + inset: 0; + z-index: 9999; + background: #05060a; + overflow: hidden; + color: #f5f2ea; + font-family: inherit; +} + +.fz-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.fz-vignette { + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient(ellipse at center, transparent 52%, rgba(160, 25, 25, 0.28) 100%); + animation: fzVignettePulse 2.6s ease-in-out infinite; +} + +@keyframes fzVignettePulse { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 1; } +} + +/* ── the lines ── */ + +.fz-line-wrap { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0 12vw; + pointer-events: none; +} + +.fz-line { + max-width: 900px; + text-align: center; + font-size: clamp(22px, 3.2vw, 40px); + font-weight: 300; + line-height: 1.45; + letter-spacing: 0.015em; + text-shadow: 0 2px 24px rgba(0, 0, 0, 0.85); + animation: fzLineIn 1.6s cubic-bezier(0.19, 1, 0.22, 1) both; +} + +.fz-line-hard { + font-weight: 700; + font-size: clamp(26px, 3.8vw, 50px); + letter-spacing: 0.01em; +} + +@keyframes fzLineIn { + from { opacity: 0; transform: translateY(14px); filter: blur(6px); } + to { opacity: 1; transform: translateY(0); filter: blur(0); } +} + +/* ── anthem ── */ + +.fz-anthem { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4vh; + pointer-events: none; + padding: 0 6vw; +} + +.fz-anthem-word { + font-size: clamp(48px, 9vw, 128px); + font-weight: 800; + letter-spacing: 0.06em; + text-align: center; + margin: 0; + text-shadow: 0 0 60px rgba(255, 200, 120, 0.35), 0 2px 30px rgba(0, 0, 0, 0.9); + animation: fzAnthemIn 2.8s cubic-bezier(0.19, 1, 0.22, 1) both; +} + +.fz-anthem-word-small { + font-size: clamp(34px, 5vw, 64px); +} + +@keyframes fzAnthemIn { + from { opacity: 0; transform: scale(0.94); letter-spacing: 0.18em; filter: blur(10px); } + to { opacity: 1; transform: scale(1); letter-spacing: 0.06em; filter: blur(0); } +} + +.fz-cascade { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 6px 26px; + max-width: 1000px; +} + +.fz-cascade-item { + font-size: clamp(13px, 1.35vw, 19px); + font-weight: 500; + opacity: 0; + color: rgba(245, 235, 215, 0.92); + animation: fzCascadeIn 1.4s ease-out both; +} + +@keyframes fzCascadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 0.92; transform: translateY(0); } +} + +/* ── credits ── */ + +.fz-credits { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + padding: 0 8vw; + text-align: center; + animation: fzLineIn 1.8s ease-out both; +} + +.fz-credit-title { + font-size: clamp(17px, 1.8vw, 24px); + font-weight: 700; + margin: 10px 0 0; +} + +.fz-credit-body { + font-size: clamp(14px, 1.4vw, 18px); + font-weight: 300; + opacity: 0.85; + margin: 0; + max-width: 640px; +} + +.fz-credit-link { + font-size: 13px; + color: rgba(255, 200, 120, 0.9); + text-decoration: none; + border-bottom: 1px solid rgba(255, 200, 120, 0.4); + padding-bottom: 1px; + margin-top: 6px; + pointer-events: auto; +} + +.fz-credit-link:hover { color: #ffd58a; border-color: #ffd58a; } + +.fz-close-btn { + margin-top: 26px; + padding: 11px 34px; + border-radius: 999px; + border: 1px solid rgba(245, 240, 225, 0.35); + background: transparent; + color: #f5f2ea; + font-size: 14px; + font-weight: 600; + font-family: inherit; + letter-spacing: 0.04em; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +.fz-close-btn:hover { + background: rgba(245, 240, 225, 0.12); + border-color: rgba(245, 240, 225, 0.7); +} + +/* ── chrome ── */ + +.fz-chrome { + position: absolute; + right: 18px; + bottom: 16px; + display: flex; + gap: 8px; + align-items: center; + opacity: 0; + animation: fzChromeIn 1s ease-out 2.5s both; +} + +@keyframes fzChromeIn { to { opacity: 1; } } + +.fz-chrome-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-radius: 999px; + border: 1px solid rgba(245, 240, 225, 0.22); + background: rgba(10, 12, 18, 0.55); + color: rgba(245, 240, 225, 0.75); + font-size: 12px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + backdrop-filter: blur(6px); + transition: color 0.15s, border-color 0.15s; +} + +.fz-chrome-btn:hover { color: #fff; border-color: rgba(245, 240, 225, 0.55); } + +/* ── beacon (dashboard trigger) ── */ + +.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-pulse { + position: absolute; + top: 16px; + right: 16px; + width: 10px; + height: 10px; + border-radius: 999px; + background: #ff5a4e; +} + +.fz-beacon-pulse::before, +.fz-beacon-pulse::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 999px; + border: 1.5px solid rgba(255, 90, 78, 0.75); + animation: fzPulse 2.6s cubic-bezier(0.16, 1, 0.3, 1) infinite; +} + +.fz-beacon-pulse::after { animation-delay: 1.3s; } + +@keyframes fzPulse { + from { transform: scale(1); opacity: 0.9; } + to { transform: scale(3.6); opacity: 0; } +} + +.fz-beacon-eyebrow { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + color: rgba(255, 170, 120, 0.85); + margin: 0 0 6px; +} + +.fz-beacon-title { + font-size: 22px; + font-weight: 800; + letter-spacing: 0.04em; + margin: 0 0 4px; +} + +.fz-beacon-sub { + font-size: 12px; + font-weight: 400; + color: rgba(245, 240, 225, 0.68); + margin: 0; +} + +.fz-beacon-cta { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 12px; + font-size: 12px; + font-weight: 700; + color: #ffcf8e; +} + +.fz-beacon:hover .fz-beacon-cta { color: #ffe3b3; } + +.fz-beacon-dismiss { + position: absolute; + top: 8px; + left: 8px; + display: inline-flex; + padding: 5px; + border: none; + border-radius: 999px; + background: transparent; + color: rgba(245, 240, 225, 0.4); + cursor: pointer; +} + +.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. */ +@media (prefers-reduced-motion: reduce) { + .fz-beacon-pulse::before, + .fz-beacon-pulse::after, + .fz-vignette { animation: none; } + .fz-cascade-item { animation-duration: 0.01s; } +} diff --git a/client/src/components/FourZero/noFearAudio.ts b/client/src/components/FourZero/noFearAudio.ts new file mode 100644 index 000000000..baf5cd05c --- /dev/null +++ b/client/src/components/FourZero/noFearAudio.ts @@ -0,0 +1,241 @@ +// 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 — +// so the show ships zero audio assets and touches no copyrighted material. + +export type NoFearAct = 'fear' | 'dread' | 'silence' | 'hope' | 'anthem' | 'end' + +interface Voice { + stop: (at: number) => void +} + +export class NoFearAudio { + private ctx: AudioContext | null = null + private master: GainNode | 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 voices: Voice[] = [] + + /** Must be called from a user gesture (the beacon click) so autoplay rules allow it. */ + start(): void { + if (this.ctx) return + const Ctor = window.AudioContext ?? (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + if (!Ctor) return + this.ctx = new Ctor() + 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. + const limiter = this.ctx.createDynamicsCompressor() + limiter.threshold.value = -14 + limiter.knee.value = 24 + limiter.ratio.value = 12 + this.master.connect(limiter) + limiter.connect(this.ctx.destination) + // Heartbeat scheduler: 100ms tick, 300ms lookahead. + this.nextBeat = this.ctx.currentTime + 0.2 + this.heartbeatTimer = window.setInterval(() => this.scheduleBeats(), 100) + } + + 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) + } + } + + setAct(act: NoFearAct): void { + 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) + switch (act) { + case 'fear': + this.bpm = 76 + this.beatGain = 0.5 + this.drone(55, 82.4, 0.16, 260, 2.5) + 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.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 + 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.shimmer(t) + break + case 'end': + this.bpm = 0 + this.master.gain.setTargetAtTime(0, t + 2.5, 1.2) + break + } + } + + dispose(): void { + if (this.heartbeatTimer !== null) window.clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + this.releaseVoices(0.1) + const ctx = this.ctx + this.ctx = null + this.master = null + if (ctx) window.setTimeout(() => { void ctx.close().catch(() => {}) }, 300) + } + + // ── voices ──────────────────────────────────────────────────────────────── + + private releaseVoices(release: number): void { + if (!this.ctx) return + const at = this.ctx.currentTime + release + for (const v of this.voices) v.stop(at) + this.voices = [] + } + + private hold(gain: GainNode, oscs: OscillatorNode[]): void { + this.voices.push({ + stop: (at) => { + gain.gain.cancelScheduledValues(0) + gain.gain.setTargetAtTime(0, Math.max(at - 0.1, 0), 0.25) + for (const o of oscs) { try { o.stop(at + 1.5) } catch { /* already stopped */ } } + }, + }) + } + + /** Two slightly detuned low oscillators through a lowpass — the room tone of the fear act. */ + private drone(f1: number, f2: number, level: number, cutoff: number, attack: number): void { + if (!this.ctx || !this.master) return + const t = this.ctx.currentTime + const g = this.ctx.createGain() + g.gain.setValueAtTime(0.0001, t) + g.gain.exponentialRampToValueAtTime(level, t + attack) + const lp = this.ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.value = cutoff + const oscs = [f1, f2].map(f => { + const o = this.ctx!.createOscillator() + o.type = 'sawtooth' + o.frequency.value = f + o.detune.value = Math.random() * 7 - 3.5 + o.connect(lp) + o.start(t) + return o + }) + lp.connect(g) + g.connect(this.master) + this.hold(g, oscs) + } + + /** A slow 14s upward sweep, quiet but relentless — the dread act's tension. */ + 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) + const bp = this.ctx.createBiquadFilter() + bp.type = 'bandpass' + bp.frequency.setValueAtTime(300, t) + bp.frequency.exponentialRampToValueAtTime(1200, t + 14) + bp.Q.value = 9 + const g = this.ctx.createGain() + g.gain.setValueAtTime(0.0001, t) + g.gain.exponentialRampToValueAtTime(0.05, t + 6) + o.connect(bp); bp.connect(g); g.connect(this.master) + o.start(t) + this.hold(g, [o]) + } + + /** Warm triangle-wave chord with chorus detune — the release. */ + private pad(t: number, freqs: number[], cutoff: number, level: number): void { + if (!this.ctx || !this.master) return + const lp = this.ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.setValueAtTime(cutoff * 0.4, t) + lp.frequency.exponentialRampToValueAtTime(cutoff, t + 6) + const g = this.ctx.createGain() + g.gain.setValueAtTime(0.0001, t) + g.gain.exponentialRampToValueAtTime(level, t + 3.2) + const oscs: OscillatorNode[] = [] + for (const f of freqs) { + for (const det of [-4, 4]) { + const o = this.ctx.createOscillator() + o.type = 'triangle' + o.frequency.value = f + o.detune.value = det + o.connect(lp) + o.start(t) + oscs.push(o) + } + } + lp.connect(g) + g.connect(this.master) + this.hold(g, oscs) + } + + /** 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 g = this.ctx.createGain() + g.gain.value = 0.05 + g.connect(this.master) + const oscs: OscillatorNode[] = [] + for (let i = 0; i < 10; 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 + eg.gain.setValueAtTime(0.0001, at) + eg.gain.exponentialRampToValueAtTime(1, at + 0.02) + eg.gain.exponentialRampToValueAtTime(0.0001, at + 1.6) + o.connect(eg); eg.connect(g) + o.start(at) + o.stop(at + 1.8) + 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 */ } } } }) + } + + // ── heartbeat ───────────────────────────────────────────────────────────── + + private scheduleBeats(): void { + if (!this.ctx || !this.master || this.bpm <= 0) return + 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.nextBeat += 60 / this.bpm + } + if (this.nextBeat < this.ctx.currentTime) this.nextBeat = this.ctx.currentTime + 0.1 + } + + 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) + 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) + o.connect(g) + g.connect(this.master) + o.start(at) + o.stop(at + 0.3) + } +} diff --git a/client/src/components/FourZero/noFearLines.ts b/client/src/components/FourZero/noFearLines.ts new file mode 100644 index 000000000..00411bcc5 --- /dev/null +++ b/client/src/components/FourZero/noFearLines.ts @@ -0,0 +1,646 @@ +// TREK 4.0.0 release moment — "KEINE ANGST". Temporary by design: this whole +// folder ships for one release and is removed afterwards, so the copy lives in a +// local dictionary instead of the shared i18n bundles (no parity churn across +// 20+ locale files for a feature with a lifespan of one version). + +export interface NoFearCopy { + beaconTitle: string + beaconSub: string + beaconCta: string + // The show's spoken lines, in order. Index maps to the show script. + lines: { + afraid: string + ofTheStranger: string + fearTool: string + hateTrade: string + butYouTraveled: string + tables: string + face: string + fences: string + notAnOpinion: string + } + anthem: string + creditTitle: string + creditBody: string + inspiredBy: string + close: string + skip: string + soundOn: string + soundOff: string +} + +const en: NoFearCopy = { + beaconTitle: 'NO FEAR', + beaconSub: 'A sign for an open world.', + beaconCta: 'Press play.', + lines: { + afraid: 'They want you to be afraid.', + ofTheStranger: 'Afraid of the stranger. Afraid of everything you don’t know.', + fearTool: 'Because fear closes borders — first on maps, then in minds.', + hateTrade: 'Fear is their tool. Hatred is their trade.', + butYouTraveled: 'But you have traveled.', + tables: 'You have eaten at foreign tables. Slept under foreign roofs. Laughed with strangers.', + 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.', + }, + anthem: 'NO FEAR', + creditTitle: 'TREK 4.0.0 — for an open world.', + creditBody: 'Against racism, hatred and fascism. Travel turns strangers into neighbors.', + inspiredBy: 'Inspired by Danger Dan — “Keine Angst”', + close: 'Carry it on', + skip: 'Skip', + soundOn: 'Sound on', + soundOff: 'Sound off', +} + +const de: NoFearCopy = { + beaconTitle: 'KEINE ANGST', + beaconSub: 'Ein Zeichen für eine offene Welt.', + beaconCta: 'Drück Play.', + lines: { + afraid: 'Sie wollen, dass du Angst hast.', + ofTheStranger: 'Angst vor dem Fremden. Angst vor allem, was du nicht kennst.', + fearTool: 'Denn Angst schließt Grenzen — erst auf Karten, dann in Köpfen.', + hateTrade: 'Angst ist ihr Werkzeug. Hass ist ihr Geschäft.', + butYouTraveled: 'Aber du bist gereist.', + tables: 'Du hast an fremden Tischen gegessen. Unter fremden Dächern geschlafen. Mit Fremden gelacht.', + 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.', + }, + anthem: 'KEINE ANGST', + creditTitle: 'TREK 4.0.0 — für eine offene Welt.', + creditBody: 'Gegen Rassismus, Hass und Faschismus. Reisen macht aus Fremden Nachbarn.', + inspiredBy: 'Inspiriert von Danger Dan — „Keine Angst“', + close: 'Weitertragen', + skip: 'Überspringen', + soundOn: 'Ton an', + soundOff: 'Ton aus', +} + +const es: NoFearCopy = { + beaconTitle: 'SIN MIEDO', + beaconSub: 'Una señal por un mundo abierto.', + beaconCta: 'Pulsa play.', + lines: { + afraid: 'Quieren que tengas miedo.', + ofTheStranger: 'Miedo al extranjero. Miedo a todo lo que no conoces.', + fearTool: 'Porque el miedo cierra fronteras — primero en los mapas, luego en las mentes.', + 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.', + 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.', + }, + 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', + skip: 'Saltar', + soundOn: 'Sonido sí', + soundOff: 'Sonido no', +} + +const fr: NoFearCopy = { + beaconTitle: 'SANS PEUR', + beaconSub: 'Un signe pour un monde ouvert.', + beaconCta: 'Appuie sur play.', + lines: { + afraid: 'Ils veulent que tu aies peur.', + ofTheStranger: 'Peur de l’étranger. Peur de tout ce que tu ne connais pas.', + fearTool: 'Car la peur ferme les frontières — d’abord sur les cartes, puis dans les têtes.', + hateTrade: 'La peur est leur outil. La haine, leur commerce.', + 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.', + notAnOpinion: 'Le racisme n’est pas une opinion. Le fascisme n’est pas une alternative.', + }, + 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.', + inspiredBy: 'Inspiré par Danger Dan — « Keine Angst »', + close: 'Fais-le vivre', + skip: 'Passer', + soundOn: 'Son activé', + soundOff: 'Son coupé', +} + +const it: NoFearCopy = { + beaconTitle: 'SENZA PAURA', + beaconSub: 'Un segno per un mondo aperto.', + beaconCta: 'Premi play.', + lines: { + afraid: 'Vogliono che tu abbia paura.', + ofTheStranger: 'Paura dello straniero. Paura di tutto ciò che non conosci.', + fearTool: 'Perché la paura chiude i confini — prima sulle mappe, poi nelle teste.', + hateTrade: 'La paura è il loro strumento. L’odio, il loro commercio.', + 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.', + notAnOpinion: 'Il razzismo non è un’opinione. Il fascismo non è un’alternativa.', + }, + 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', +} + +const nl: NoFearCopy = { + beaconTitle: 'GEEN ANGST', + beaconSub: 'Een teken voor een open wereld.', + beaconCta: 'Druk op play.', + lines: { + afraid: 'Ze willen dat je bang bent.', + ofTheStranger: 'Bang voor de vreemdeling. Bang voor alles wat je niet kent.', + fearTool: 'Want angst sluit grenzen — eerst op kaarten, dan in hoofden.', + hateTrade: 'Angst is hun gereedschap. Haat is hun handel.', + 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.', + notAnOpinion: 'Racisme is geen mening. Fascisme is geen alternatief.', + }, + 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', + skip: 'Overslaan', + soundOn: 'Geluid aan', + soundOff: 'Geluid uit', +} + +const pl: NoFearCopy = { + beaconTitle: 'BEZ STRACHU', + beaconSub: 'Znak dla otwartego świata.', + beaconCta: 'Naciśnij play.', + lines: { + afraid: 'Chcą, żebyś się bał.', + ofTheStranger: 'Bał obcego. Bał wszystkiego, czego nie znasz.', + fearTool: 'Bo strach zamyka granice — najpierw na mapach, potem w głowach.', + hateTrade: 'Strach to ich narzędzie. Nienawiść to ich interes.', + butYouTraveled: 'Ale ty podróżowałeś.', + tables: 'Jadłeś przy obcych stołach. Spałeś pod obcymi dachami. Śmiałeś się z nieznajomymi.', + 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.', + }, + 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.', + inspiredBy: 'Inspirowane utworem Danger Dan — „Keine Angst”', + close: 'Nieś to dalej', + skip: 'Pomiń', + soundOn: 'Dźwięk wł.', + soundOff: 'Dźwięk wył.', +} + +const ru: NoFearCopy = { + beaconTitle: 'БЕЗ СТРАХА', + beaconSub: 'Знак открытого мира.', + beaconCta: 'Нажми play.', + lines: { + afraid: 'Они хотят, чтобы ты боялся.', + ofTheStranger: 'Боялся чужого. Боялся всего, чего не знаешь.', + fearTool: 'Потому что страх закрывает границы — сначала на картах, потом в головах.', + hateTrade: 'Страх — их инструмент. Ненависть — их ремесло.', + butYouTraveled: 'Но ты путешествовал.', + tables: 'Ты ел за чужими столами. Спал под чужими крышами. Смеялся с незнакомцами.', + face: 'Ты знаешь: у чужого есть лицо. И оно похоже на твоё.', + fences: 'Никто, кто видел мир, не хочет снова запирать его за заборами.', + notAnOpinion: 'Расизм — не мнение. Фашизм — не альтернатива.', + }, + anthem: 'БЕЗ СТРАХА', + creditTitle: 'TREK 4.0.0 — за открытый мир.', + creditBody: 'Против расизма, ненависти и фашизма. Путешествия превращают чужих в соседей.', + inspiredBy: 'Вдохновлено Danger Dan — «Keine Angst»', + close: 'Передай дальше', + skip: 'Пропустить', + soundOn: 'Звук вкл.', + soundOff: 'Звук выкл.', +} + +const uk: NoFearCopy = { + beaconTitle: 'БЕЗ СТРАХУ', + beaconSub: 'Знак відкритого світу.', + beaconCta: 'Натисни play.', + lines: { + afraid: 'Вони хочуть, щоб ти боявся.', + ofTheStranger: 'Боявся чужого. Боявся всього, чого не знаєш.', + fearTool: 'Бо страх закриває кордони — спершу на мапах, потім у головах.', + hateTrade: 'Страх — їхній інструмент. Ненависть — їхнє ремесло.', + butYouTraveled: 'Але ти подорожував.', + tables: 'Ти їв за чужими столами. Спав під чужими дахами. Сміявся з незнайомцями.', + face: 'Ти знаєш: чуже має обличчя. І воно схоже на твоє.', + fences: 'Ніхто, хто бачив світ, не хоче знову замикати його за парканами.', + notAnOpinion: 'Расизм — не думка. Фашизм — не альтернатива.', + }, + anthem: 'БЕЗ СТРАХУ', + creditTitle: 'TREK 4.0.0 — за відкритий світ.', + creditBody: 'Проти расизму, ненависті й фашизму. Подорожі перетворюють чужих на сусідів.', + inspiredBy: 'Натхнено Danger Dan — «Keine Angst»', + close: 'Передай далі', + skip: 'Пропустити', + soundOn: 'Звук увімк.', + soundOff: 'Звук вимк.', +} + +const cs: NoFearCopy = { + beaconTitle: 'BEZE STRACHU', + beaconSub: 'Znamení pro otevřený svět.', + beaconCta: 'Stiskni play.', + lines: { + afraid: 'Chtějí, aby ses bál.', + ofTheStranger: 'Bál cizího. Bál všeho, co neznáš.', + fearTool: 'Protože strach zavírá hranice — nejdřív na mapách, pak v hlavách.', + hateTrade: 'Strach je jejich nástroj. Nenávist jejich obchod.', + butYouTraveled: 'Ale ty jsi cestoval.', + tables: 'Jedl jsi u cizích stolů. Spal pod cizími střechami. Smál se s cizinci.', + 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.', + }, + 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.', + inspiredBy: 'Inspirováno Danger Dan — „Keine Angst“', + close: 'Nes to dál', + skip: 'Přeskočit', + soundOn: 'Zvuk zap.', + soundOff: 'Zvuk vyp.', +} + +const sv: NoFearCopy = { + beaconTitle: 'INGEN RÄDSLA', + beaconSub: 'Ett tecken för en öppen värld.', + 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.', + 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.', + 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.', + }, + 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.', + inspiredBy: 'Inspirerat av Danger Dan — ”Keine Angst”', + close: 'För det vidare', + skip: 'Hoppa över', + soundOn: 'Ljud på', + soundOff: 'Ljud av', +} + +const tr: NoFearCopy = { + beaconTitle: 'KORKMA', + beaconSub: 'Açık bir dünya için bir işaret.', + beaconCta: 'Play’e bas.', + lines: { + afraid: 'Korkmanı istiyorlar.', + ofTheStranger: 'Yabancıdan korkmanı. Bilmediğin her şeyden korkmanı.', + fearTool: 'Çünkü korku sınırları kapatır — önce haritalarda, sonra zihinlerde.', + hateTrade: 'Korku onların aracı. Nefret onların ticareti.', + butYouTraveled: 'Ama sen seyahat ettin.', + 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.', + }, + 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', + skip: 'Atla', + soundOn: 'Ses açık', + soundOff: 'Ses kapalı', +} + +const gr: NoFearCopy = { + beaconTitle: 'ΧΩΡΙΣ ΦΟΒΟ', + beaconSub: 'Ένα σημάδι για έναν ανοιχτό κόσμο.', + beaconCta: 'Πάτα play.', + lines: { + afraid: 'Θέλουν να φοβάσαι.', + ofTheStranger: 'Να φοβάσαι τον ξένο. Να φοβάσαι ό,τι δεν γνωρίζεις.', + fearTool: 'Γιατί ο φόβος κλείνει σύνορα — πρώτα στους χάρτες, μετά στα μυαλά.', + hateTrade: 'Ο φόβος είναι το εργαλείο τους. Το μίσος, το εμπόριό τους.', + butYouTraveled: 'Όμως εσύ ταξίδεψες.', + tables: 'Έφαγες σε ξένα τραπέζια. Κοιμήθηκες κάτω από ξένες στέγες. Γέλασες με αγνώστους.', + face: 'Το ξέρεις: το ξένο έχει πρόσωπο. Και μοιάζει με το δικό σου.', + fences: 'Κανείς που είδε τον κόσμο δεν θέλει να τον ξανακλείσει πίσω από φράχτες.', + notAnOpinion: 'Ο ρατσισμός δεν είναι άποψη. Ο φασισμός δεν είναι εναλλακτική.', + }, + anthem: 'ΧΩΡΙΣ ΦΟΒΟ', + creditTitle: 'TREK 4.0.0 — για έναν ανοιχτό κόσμο.', + creditBody: 'Ενάντια στον ρατσισμό, το μίσος και τον φασισμό. Το ταξίδι κάνει τους ξένους γείτονες.', + inspiredBy: 'Εμπνευσμένο από τον Danger Dan — «Keine Angst»', + close: 'Δώσ’ το παρακάτω', + skip: 'Παράλειψη', + soundOn: 'Ήχος ναι', + soundOff: 'Ήχος όχι', +} + +const hu: NoFearCopy = { + beaconTitle: 'NE FÉLJ', + beaconSub: 'Jel egy nyitott világért.', + beaconCta: 'Nyomd meg a play-t.', + lines: { + afraid: 'Azt akarják, hogy félj.', + ofTheStranger: 'Félj az idegentől. Félj mindentől, amit nem ismersz.', + fearTool: 'Mert a félelem határokat zár le — előbb a térképeken, aztán a fejekben.', + hateTrade: 'A félelem az eszközük. A gyűlölet az üzletük.', + butYouTraveled: 'De te utaztál.', + tables: 'Idegen asztaloknál ettél. Idegen tetők alatt aludtál. Idegenekkel nevettél.', + 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.', + }, + 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.', + inspiredBy: 'Danger Dan ihlette — „Keine Angst”', + close: 'Add tovább', + skip: 'Kihagyás', + soundOn: 'Hang be', + soundOff: 'Hang ki', +} + +const br: NoFearCopy = { + beaconTitle: 'SEM MEDO', + beaconSub: 'Um sinal por um mundo aberto.', + beaconCta: 'Aperte 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.', + 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.', + }, + 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.', + inspiredBy: 'Inspirado em Danger Dan — “Keine Angst”', + close: 'Leve adiante', + skip: 'Pular', + soundOn: 'Som ligado', + soundOff: 'Som desligado', +} + +const ca: NoFearCopy = { + beaconTitle: 'SENSE POR', + beaconSub: 'Un senyal per un món obert.', + beaconCta: 'Prem play.', + lines: { + afraid: 'Volen que tinguis por.', + ofTheStranger: 'Por de l’estranger. Por de tot allò que no coneixes.', + fearTool: 'Perquè la por tanca fronteres — primer als mapes, després a les ments.', + hateTrade: 'La por és la seva eina. L’odi, el seu negoci.', + butYouTraveled: 'Però tu has viatjat.', + tables: 'Has menjat a taules estrangeres. Dormit sota sostres estrangers. Rigut amb desconeguts.', + 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.', + }, + 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.', + inspiredBy: 'Inspirat en Danger Dan — «Keine Angst»', + close: 'Porta-ho endavant', + skip: 'Omet', + soundOn: 'So activat', + soundOff: 'So desactivat', +} + +const id: NoFearCopy = { + beaconTitle: 'JANGAN TAKUT', + beaconSub: 'Tanda untuk dunia yang terbuka.', + beaconCta: 'Tekan play.', + lines: { + afraid: 'Mereka ingin kamu takut.', + ofTheStranger: 'Takut pada orang asing. Takut pada semua yang tidak kamu kenal.', + fearTool: 'Karena rasa takut menutup perbatasan — mula-mula di peta, lalu di kepala.', + hateTrade: 'Rasa takut adalah alat mereka. Kebencian adalah dagangan mereka.', + butYouTraveled: 'Tapi kamu sudah bepergian.', + tables: 'Kamu pernah makan di meja orang asing. Tidur di bawah atap asing. Tertawa bersama orang tak dikenal.', + 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.', + }, + anthem: 'JANGAN TAKUT', + creditTitle: 'TREK 4.0.0 — untuk dunia yang terbuka.', + creditBody: 'Melawan rasisme, kebencian, dan fasisme. Bepergian mengubah orang asing menjadi tetangga.', + inspiredBy: 'Terinspirasi oleh Danger Dan — “Keine Angst”', + close: 'Teruskan', + skip: 'Lewati', + soundOn: 'Suara aktif', + soundOff: 'Suara mati', +} + +const vi: NoFearCopy = { + beaconTitle: 'ĐỪNG SỢ', + beaconSub: 'Một dấu hiệu cho thế giới rộng mở.', + beaconCta: 'Nhấn play.', + lines: { + afraid: 'Họ muốn bạn sợ hãi.', + ofTheStranger: 'Sợ người lạ. Sợ tất cả những gì bạn không biết.', + fearTool: 'Vì nỗi sợ đóng lại biên giới — trước tiên trên bản đồ, rồi trong tâm trí.', + hateTrade: 'Nỗi sợ là công cụ của họ. Hận thù là món hàng của họ.', + butYouTraveled: 'Nhưng bạn đã đi xa.', + tables: 'Bạn đã ăn ở những bàn ăn xa lạ. Ngủ dưới những mái nhà xa lạ. Cười với những người chưa quen.', + 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ế.', + }, + 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.', + inspiredBy: 'Lấy cảm hứng từ Danger Dan — “Keine Angst”', + close: 'Lan tỏa tiếp', + skip: 'Bỏ qua', + soundOn: 'Bật âm thanh', + soundOff: 'Tắt âm thanh', +} + +const ja: NoFearCopy = { + beaconTitle: '恐れないで', + beaconSub: '開かれた世界へのしるし。', + beaconCta: '再生を押して。', + lines: { + afraid: '彼らはあなたに怯えてほしいのです。', + ofTheStranger: '見知らぬ人を恐れ、知らないものすべてを恐れるように。', + fearTool: '恐怖は国境を閉ざします — まず地図の上で、やがて心の中で。', + hateTrade: '恐怖は彼らの道具。憎しみは彼らの商売。', + butYouTraveled: 'でも、あなたは旅をしてきた。', + tables: '見知らぬ食卓で食事をし、見知らぬ屋根の下で眠り、見知らぬ人と笑い合った。', + face: 'あなたは知っている。「よそ者」には顔があり、それはあなたの顔に似ていることを。', + fences: '世界を見た人は誰も、それを再び柵の中に閉じ込めたいとは思わない。', + notAnOpinion: 'レイシズムは意見ではない。ファシズムは選択肢ではない。', + }, + anthem: '恐れないで', + creditTitle: 'TREK 4.0.0 — 開かれた世界のために。', + creditBody: 'レイシズム、憎しみ、ファシズムに抗して。旅は見知らぬ人を隣人に変える。', + inspiredBy: 'Danger Dan「Keine Angst」に着想を得て', + close: '受け継いでいく', + skip: 'スキップ', + soundOn: '音を出す', + soundOff: '音を消す', +} + +const ko: NoFearCopy = { + beaconTitle: '두려워하지 마', + beaconSub: '열린 세상을 위한 신호.', + beaconCta: '재생을 눌러요.', + lines: { + afraid: '그들은 당신이 두려워하길 바랍니다.', + ofTheStranger: '낯선 사람을 두려워하고, 알지 못하는 모든 것을 두려워하기를.', + fearTool: '두려움은 국경을 닫습니다 — 처음엔 지도에서, 그다음엔 마음속에서.', + hateTrade: '두려움은 그들의 도구이고, 증오는 그들의 장사입니다.', + butYouTraveled: '하지만 당신은 여행을 했습니다.', + tables: '낯선 식탁에서 밥을 먹고, 낯선 지붕 아래 잠들고, 낯선 사람들과 웃었습니다.', + face: '당신은 압니다. 낯선 이에게도 얼굴이 있고, 그 얼굴이 당신을 닮았다는 것을.', + fences: '세상을 본 사람이라면 누구도 그것을 다시 울타리 뒤에 가두고 싶어 하지 않습니다.', + notAnOpinion: '인종차별은 의견이 아닙니다. 파시즘은 대안이 아닙니다.', + }, + anthem: '두려워하지 마', + creditTitle: 'TREK 4.0.0 — 열린 세상을 위하여.', + creditBody: '인종차별, 증오, 파시즘에 반대하며. 여행은 낯선 사람을 이웃으로 만듭니다.', + inspiredBy: 'Danger Dan의 「Keine Angst」에서 영감을 받아', + close: '이어가기', + skip: '건너뛰기', + soundOn: '소리 켜기', + soundOff: '소리 끄기', +} + +const zh: NoFearCopy = { + beaconTitle: '别害怕', + beaconSub: '为开放世界而立的标记。', + beaconCta: '按下播放。', + lines: { + afraid: '他们想让你害怕。', + ofTheStranger: '害怕陌生人,害怕一切你不了解的东西。', + fearTool: '因为恐惧会关闭边界——先是在地图上,然后是在头脑里。', + hateTrade: '恐惧是他们的工具,仇恨是他们的生意。', + butYouTraveled: '但你旅行过。', + tables: '你曾在陌生的餐桌旁吃饭,在陌生的屋檐下入睡,与陌生人一起欢笑。', + face: '你知道:陌生人也有一张脸,而那张脸和你的很像。', + fences: '见过世界的人,没有谁愿意再把它锁回栅栏之后。', + notAnOpinion: '种族主义不是一种观点。法西斯主义不是一种选择。', + }, + anthem: '别害怕', + creditTitle: 'TREK 4.0.0 — 为了一个开放的世界。', + creditBody: '反对种族主义、仇恨与法西斯主义。旅行让陌生人成为邻居。', + inspiredBy: '灵感来自 Danger Dan——《Keine Angst》', + close: '传递下去', + skip: '跳过', + soundOn: '开启声音', + soundOff: '关闭声音', +} + +const zhTW: NoFearCopy = { + beaconTitle: '別害怕', + beaconSub: '為開放世界而立的標記。', + beaconCta: '按下播放。', + lines: { + afraid: '他們想讓你害怕。', + ofTheStranger: '害怕陌生人,害怕一切你不了解的事物。', + fearTool: '因為恐懼會關閉邊界——先是在地圖上,然後是在腦海裡。', + hateTrade: '恐懼是他們的工具,仇恨是他們的生意。', + butYouTraveled: '但你旅行過。', + tables: '你曾在陌生的餐桌旁吃飯,在陌生的屋簷下入睡,與陌生人一起歡笑。', + face: '你知道:陌生人也有一張臉,而那張臉和你的很像。', + fences: '見過世界的人,沒有誰願意再把它鎖回柵欄之後。', + notAnOpinion: '種族主義不是一種觀點。法西斯主義不是一種選項。', + }, + anthem: '別害怕', + creditTitle: 'TREK 4.0.0 — 為了一個開放的世界。', + creditBody: '反對種族主義、仇恨與法西斯主義。旅行讓陌生人成為鄰居。', + inspiredBy: '靈感來自 Danger Dan——《Keine Angst》', + close: '傳遞下去', + skip: '跳過', + soundOn: '開啟聲音', + soundOff: '關閉聲音', +} + +const ar: NoFearCopy = { + beaconTitle: 'لا تخف', + beaconSub: 'علامة من أجل عالم مفتوح.', + beaconCta: 'اضغط تشغيل.', + lines: { + afraid: 'يريدونك أن تخاف.', + ofTheStranger: 'أن تخاف من الغريب. أن تخاف من كل ما لا تعرفه.', + fearTool: 'لأن الخوف يغلق الحدود — أولًا على الخرائط، ثم في العقول.', + hateTrade: 'الخوف أداتهم. والكراهية تجارتهم.', + butYouTraveled: 'لكنك سافرت.', + tables: 'أكلت على موائد غريبة. نمت تحت سقوف غريبة. ضحكت مع غرباء.', + face: 'أنت تعرف: للغريب وجه. وهو يشبه وجهك.', + fences: 'لا أحد رأى العالم يريد أن يحبسه خلف الأسوار من جديد.', + notAnOpinion: 'العنصرية ليست رأيًا. الفاشية ليست بديلًا.', + }, + anthem: 'لا تخف', + creditTitle: 'TREK 4.0.0 — من أجل عالم مفتوح.', + creditBody: 'ضد العنصرية والكراهية والفاشية. السفر يحوّل الغرباء إلى جيران.', + inspiredBy: 'مستوحى من Danger Dan — «Keine Angst»', + close: 'احمله معك', + skip: 'تخطَّ', + soundOn: 'تشغيل الصوت', + soundOff: 'كتم الصوت', +} + +const COPY: Record = { + en, de, es, fr, it, nl, pl, ru, uk, cs, sv, tr, gr, hu, br, ca, id, vi, ja, ko, zh, ar, + 'zh-TW': zhTW, +} + +export function noFearCopy(language: string): NoFearCopy { + return COPY[language] ?? en +} + +/** + * "No fear" in every language TREK speaks — the anthem cascade. The German song + * sparked this, but the message belongs to everyone, so it closes in all 23. + */ +export const ANTHEM_CASCADE: { lang: string; text: string }[] = [ + { lang: 'de', text: 'Keine Angst' }, + { lang: 'en', text: 'No fear' }, + { lang: 'uk', text: 'Без страху' }, + { lang: 'fr', text: 'Sans peur' }, + { lang: 'es', text: 'Sin miedo' }, + { lang: 'ar', text: 'لا تخف' }, + { lang: 'it', text: 'Senza paura' }, + { lang: 'pl', text: 'Bez strachu' }, + { lang: 'tr', text: 'Korkma' }, + { lang: 'nl', text: 'Geen angst' }, + { lang: 'gr', text: 'Χωρίς φόβο' }, + { lang: 'ja', text: '恐れないで' }, + { lang: 'br', text: 'Sem medo' }, + { lang: 'ko', text: '두려워하지 마' }, + { lang: 'cs', text: 'Beze strachu' }, + { lang: 'zh', text: '别害怕' }, + { lang: 'sv', text: 'Ingen rädsla' }, + { lang: 'vi', text: 'Đừng sợ' }, + { lang: 'hu', text: 'Ne félj' }, + { lang: 'id', text: 'Jangan takut' }, + { lang: 'ca', text: 'Sense por' }, + { lang: 'ru', text: 'Без страха' }, + { lang: 'zh-TW', text: '別害怕' }, +] + +export const INSPIRED_URL = 'https://www.youtube.com/watch?v=Gg-SCpXba64' diff --git a/client/src/components/FourZero/noFearScene.ts b/client/src/components/FourZero/noFearScene.ts new file mode 100644 index 000000000..3a98b2315 --- /dev/null +++ b/client/src/components/FourZero/noFearScene.ts @@ -0,0 +1,290 @@ +// 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. +// +// 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. + +type Ring = [number, number][] + +export interface SceneState { + /** 0→1: how far the land dots have faded in. */ + land: 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: how much of the city web has grown. */ + web: number + /** 0→1: warm ambient glow of the release act. */ + warmth: number + /** 0→1: rising particles of the anthem. */ + particles: number + /** Master opacity for the whole scene. */ + opacity: number +} + +// Landmark cities on every continent — the web's anchor points. +const CITIES: [number, number][] = [ + [52.52, 13.4], [48.85, 2.35], [51.5, -0.12], [40.4, -3.7], [41.9, 12.5], [37.98, 23.7], + [41.0, 28.98], [50.45, 30.52], [52.23, 21.01], [59.33, 18.07], [64.15, -21.9], [38.72, -9.14], + [52.37, 4.9], [48.2, 16.37], [50.08, 14.44], [30.04, 31.24], [6.52, 3.38], [-1.29, 36.82], + [-33.92, 18.42], [33.57, -7.59], [9.03, 38.74], [5.56, -0.2], [25.2, 55.27], [35.69, 51.39], + [19.08, 72.88], [28.61, 77.21], [13.76, 100.5], [1.35, 103.82], [-6.21, 106.85], [21.03, 105.85], + [35.68, 139.69], [37.57, 126.98], [39.9, 116.4], [31.23, 121.47], [25.03, 121.56], [14.6, 120.98], + [-33.87, 151.21], [-36.85, 174.76], [61.22, -149.9], [49.28, -123.12], [37.77, -122.42], + [19.43, -99.13], [40.71, -74.01], [43.65, -79.38], [23.11, -82.37], [4.71, -74.07], + [-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 } +interface Particle { x: number; y: number; v: number; drift: number; size: number; seed: number } + +export class NoFearScene { + private dots: Dot[] = [] + private borders: Path2D | null = null + private arcs: Arc[] = [] + private particlesPool: Particle[] = [] + private w = 0 + private h = 0 + private ready = false + + /** Maps lon/lat to scene pixels. Set up in layout(). */ + private px = (lon: number): number => lon + private py = (lat: number): number => lat + + constructor() { + // Every city connects to a couple of far-away peers — long lines across + // many borders are the whole point. + const d2 = (i: number, j: number) => { + const [la, lo] = CITIES[i] + const [lb, lob] = CITIES[j] + return (la - lb) ** 2 + (lo - lob) ** 2 + } + const seen = new Set() + CITIES.forEach((_, i) => { + 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.sort((x, y) => x.delay - y.delay) + for (let i = 0; i < 90; i++) { + this.particlesPool.push({ + x: Math.random(), y: 1 + Math.random(), v: 0.02 + Math.random() * 0.05, + drift: (Math.random() - 0.5) * 0.02, size: 0.7 + Math.random() * 1.7, seed: Math.random() * 7, + }) + } + } + + /** Fetches the Atlas country bundle and builds dots + border paths. Safe to fail. */ + async load(width: number, height: number): Promise { + this.layout(width, height) + try { + const res = await fetch('/api/addons/atlas/countries/geo', { credentials: 'include' }) + if (!res.ok) return + const geo = await res.json() as { features?: { geometry?: { type: string; coordinates: unknown } }[] } + const rings: 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 (rings.length === 0) return + this.buildDots(rings) + this.buildBorders(rings) + this.ready = true + } catch { + // Atlas addon off / offline — the show runs without the map. + } + } + + layout(width: number, height: number): void { + 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)) + const mapW = 360 * scale + const mapH = (latTop - latBot) * 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 + } + + 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. + const cols = 168 + const rows = 84 + const off = document.createElement('canvas') + off.width = cols + off.height = rows + 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 + 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 = [] + 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, + r: 0.8 + Math.random() * 0.9, + tw: Math.random() * Math.PI * 2, + }) + } + } + } + } + + 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() + } + this.borders = path + } + + /** Border paths bake in pixel coordinates, so a resize needs a rebuild — cheap enough to redo on demand. */ + hasMap(): boolean { + return this.ready + } + + draw(c: CanvasRenderingContext2D, s: SceneState, t: number): void { + const { w, h } = this + 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) + } + c.stroke() + } + // City lights on top of the arcs. + 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 [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() + } + c.restore() + } + + // 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 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.beginPath() + c.arc(x * w, y * h, p.size, 0, Math.PI * 2) + c.fill() + } + c.restore() + } + + c.restore() + } +} diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 46c293a04..1120ba024 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -21,6 +21,7 @@ import { } from 'lucide-react' import { IcsSubscribeModal } from '../components/Planner/IcsSubscribeModal' import CollectionsWidget from '../components/Dashboard/CollectionsWidget' +import NoFearBeacon from '../components/FourZero/NoFearBeacon' // 4.0.0 release moment — remove with the FourZero folder import PluginWidgets from '../components/Plugins/PluginWidgets' import PluginFrame from '../components/Plugins/PluginFrame' import { TripCardBadges, useTripCardBadges } from '../components/Plugins/TripCardBadges' @@ -254,6 +255,7 @@ function DashboardPageDesktop(): React.ReactElement { {sidebarVisible && (