feat(dashboard): 4.0.0 release moment — KEINE ANGST

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.
This commit is contained in:
Maurice
2026-07-23 23:11:38 +02:00
committed by Maurice
parent eb0dfc83c1
commit c6d96872ae
7 changed files with 1758 additions and 0 deletions
@@ -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 (
<>
<button type="button" className="fz-beacon" onClick={() => setOpen(true)}>
<span className="fz-beacon-pulse" aria-hidden />
<span
role="button"
tabIndex={0}
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) } }}
>
<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>
{open && (
<Suspense fallback={null}>
<NoFearShow onClose={() => setOpen(false)} />
</Suspense>
)}
</>
)
}
@@ -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<typeof noFearCopy>['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<HTMLCanvasElement>(null)
const audioRef = useRef<NoFearAudio | null>(null)
const sceneRef = useRef<NoFearScene | null>(null)
const startRef = useRef<number>(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 (
<div className="fz-overlay" role="dialog" aria-label={copy.beaconTitle}>
<canvas ref={canvasRef} className="fz-canvas" />
{/* Red vignette while the borders burn. */}
{!anthem && cueIdx >= 2 && cueIdx <= 3 && <div className="fz-vignette" aria-hidden />}
{/* The lines. */}
{!anthem && lineText && (
<div className="fz-line-wrap">
<p key={lineKey} className={`fz-line ${hard ? 'fz-line-hard' : ''}`}>{lineText}</p>
</div>
)}
{/* Anthem: the big words + the cascade through every TREK language. */}
{anthem && !credits && (
<div className="fz-anthem">
<h1 className="fz-anthem-word">{copy.anthem}</h1>
<div className="fz-cascade">
{ANTHEM_CASCADE.filter(c => c.lang !== language).map((c, i) => (
<span key={c.lang} className="fz-cascade-item" style={{ animationDelay: `${0.6 + i * 0.42}s` }}>
{c.text}
</span>
))}
</div>
</div>
)}
{/* Credits — stays until dismissed. */}
{credits && (
<div className="fz-credits">
<h1 className="fz-anthem-word fz-anthem-word-small">{copy.anthem}</h1>
<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">
{copy.inspiredBy}
</a>
<button type="button" className="fz-close-btn" onClick={onClose}>{copy.close}</button>
</div>
)}
{/* Chrome: mute, skip, close. */}
<div className="fz-chrome">
<button
type="button"
className="fz-chrome-btn"
onClick={() => { const m = !muted; setMuted(m); audioRef.current?.setMuted(m) }}
aria-label={muted ? copy.soundOn : copy.soundOff}
>
{muted ? <VolumeX size={15} /> : <Volume2 size={15} />}
</button>
{!credits && (
<button type="button" className="fz-chrome-btn fz-chrome-skip" onClick={skipToEnd}>
{copy.skip}
</button>
)}
<button type="button" className="fz-chrome-btn" onClick={onClose} aria-label={copy.close}>
<X size={15} />
</button>
</div>
</div>
)
}
+324
View File
@@ -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; }
}
@@ -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)
}
}
@@ -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<string, NoFearCopy> = {
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'
@@ -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<string>()
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<void> {
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()
}
}
+2
View File
@@ -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 && (
<aside className="page-sidebar">
<NoFearBeacon /> {/* 4.0.0 release moment — remove with the FourZero folder */}
{showCurrency && <CurrencyTool />}
{showCollections && <CollectionsWidget onOpen={() => navigate('/collections')} />}
{showTimezones && <TimezoneTool locale={locale} />}