mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
Reference in New Issue
Block a user