mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(dashboard): five signature text moments + widget-matched corners
- The finale condenses out of light: ~1000 particles rise from the world and gather into the anthem's letterforms before the crisp DOM text takes over; the language cascade waits for the word to settle. - Replaced fear-act lines decay letter by letter — the words of fear fall apart instead of vanishing. - The climax boom rocks the whole frame for half a second, in sync with the sub impact and the border shatter. - Hope-act lines reveal sentence by sentence, in speech rhythm. - The standing anthem breathes — a barely-there scale and glow pulse. - Beacon corners now use the dashboard tool widgets' --r-xl radius.
This commit is contained in:
@@ -5,6 +5,7 @@ import { placesApi, tripsApi } from '../../api/client'
|
||||
import apiClient from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Place, Trip } from '../../types'
|
||||
import { TextAssembly } from './noFearAssembly'
|
||||
import { NoFearAudio, type NoFearAct } from './noFearAudio'
|
||||
import { NoFearScene, type SceneState } from './noFearScene'
|
||||
import { ANTHEM_CASCADE, noFearCopy } from './noFearLines'
|
||||
@@ -79,10 +80,15 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
|
||||
const sceneRef = useRef<NoFearScene | null>(null)
|
||||
const startRef = useRef<number>(performance.now())
|
||||
const statsRef = useRef<{ line: string } | null>(null)
|
||||
const assemblyRef = useRef<TextAssembly | null>(null)
|
||||
const anthemWordRef = useRef<HTMLHeadingElement>(null)
|
||||
const [cueIdx, setCueIdx] = useState(-1)
|
||||
const [flashIdx, setFlashIdx] = useState(-1)
|
||||
const [muted, setMuted] = useState(false)
|
||||
const [anthem, setAnthem] = useState(false)
|
||||
// A fear-act line that was just replaced — rendered once more while its
|
||||
// letters decay away (the words of fear literally fall apart).
|
||||
const [ghost, setGhost] = useState<{ text: string; id: number } | null>(null)
|
||||
const reducedMotion = useMemo(() => window.matchMedia('(prefers-reduced-motion: reduce)').matches, [])
|
||||
|
||||
const skipToEnd = useCallback(() => {
|
||||
@@ -183,11 +189,25 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
|
||||
let idx = -1
|
||||
for (let i = 0; i < CUES.length; i++) if (t >= CUES[i].at) idx = i
|
||||
if (idx !== lastCue && idx >= 0) {
|
||||
// A replaced fear-act line decays letter by letter instead of vanishing.
|
||||
if (lastCue >= 0 && lastCue <= 3) {
|
||||
const prevKey = CUES[lastCue].line
|
||||
if (prevKey) {
|
||||
setGhost({ text: copy.lines[prevKey], id: lastCue })
|
||||
window.setTimeout(() => setGhost(g => (g?.id === lastCue ? null : g)), 1400)
|
||||
}
|
||||
}
|
||||
lastCue = idx
|
||||
const cue = CUES[idx]
|
||||
if (cue.act) audioRef.current?.setAct(cue.act)
|
||||
if (cue.impact) audioRef.current?.impact(cue.impact)
|
||||
if (cue.swell) audioRef.current?.swell()
|
||||
// The climax hit shakes the whole frame with the boom.
|
||||
if ((cue.impact ?? 0) >= 0.8 && overlayRef.current) {
|
||||
const el = overlayRef.current
|
||||
el.classList.add('fz-shake')
|
||||
window.setTimeout(() => el.classList.remove('fz-shake'), 700)
|
||||
}
|
||||
setCueIdx(idx)
|
||||
if (t >= ANTHEM_AT) setAnthem(true)
|
||||
}
|
||||
@@ -200,6 +220,29 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
|
||||
setFlashIdx(flash)
|
||||
}
|
||||
if (ctx) sceneRef.current?.draw(ctx, sceneAt(t), t)
|
||||
// The finale condenses out of light: once the anthem title is measurable,
|
||||
// sample its letterforms and gather rising particles into them; the real
|
||||
// DOM text takes over as they settle.
|
||||
if (ctx && cv && t >= ANTHEM_AT) {
|
||||
const word = anthemWordRef.current
|
||||
if (!assemblyRef.current && word) {
|
||||
const assembly = new TextAssembly()
|
||||
const style = window.getComputedStyle(word)
|
||||
const rect = word.getBoundingClientRect()
|
||||
assembly.init(
|
||||
word.textContent ?? '',
|
||||
`${style.fontWeight} ${style.fontSize} ${style.fontFamily}`,
|
||||
{ left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
||||
cv.clientWidth,
|
||||
cv.clientHeight,
|
||||
)
|
||||
assemblyRef.current = assembly
|
||||
}
|
||||
const progress = ramp(t, ANTHEM_AT + 0.4, ANTHEM_AT + 3.6)
|
||||
const fade = ramp(t, ANTHEM_AT + 3.4, ANTHEM_AT + 4.8)
|
||||
assemblyRef.current?.draw(ctx, progress, fade, t)
|
||||
word?.classList.toggle('fz-word-hidden', fade < 0.15)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
@@ -241,20 +284,54 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The lines. */}
|
||||
{!anthem && lineText && flashIdx < 0 && (
|
||||
<div className="fz-line-wrap" aria-live="polite">
|
||||
<p key={lineText} className={`fz-line ${hard ? 'fz-line-hard' : ''} ${cue?.soft ? 'fz-line-soft' : ''}`}>{lineText}</p>
|
||||
{/* A replaced fear line decays away letter by letter. */}
|
||||
{!anthem && ghost && (
|
||||
<div className="fz-line-wrap" aria-hidden>
|
||||
<p key={`ghost-${ghost.id}`} className="fz-line fz-line-ghost">
|
||||
{[...ghost.text].map((ch, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="fz-char-decay"
|
||||
style={{
|
||||
'--dx': `${(Math.sin(i * 37.7) * 0.5) * 52}px`,
|
||||
'--dy': `${-(12 + Math.abs(Math.sin(i * 17.3)) * 34)}px`,
|
||||
'--rot': `${Math.sin(i * 53.1) * 14}deg`,
|
||||
animationDelay: `${Math.abs(Math.sin(i * 91.7)) * 0.3}s`,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{ch === ' ' ? ' ' : ch}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The ending: the big words + the cascade through every TREK language — and it stays. */}
|
||||
{/* The lines. Hope-act lines reveal sentence by sentence, like speech. */}
|
||||
{!anthem && lineText && flashIdx < 0 && (
|
||||
<div className="fz-line-wrap" aria-live="polite">
|
||||
<p key={lineText} className={`fz-line ${hard ? 'fz-line-hard' : ''} ${cue?.soft ? 'fz-line-soft' : ''}`}>
|
||||
{cueIdx >= 6 && cueIdx <= 10 && !hard
|
||||
? lineText.split(/(?<=[.!?…])\s+/u).map((seg, i) => (
|
||||
<span key={i} className="fz-seg" style={{ animationDelay: `${i * 0.95}s` }}>{seg}{' '}</span>
|
||||
))
|
||||
: lineText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The ending: the big words condense out of rising light, then the
|
||||
cascade through every TREK language joins — and it all stays. */}
|
||||
{anthem && (
|
||||
<div className="fz-anthem">
|
||||
<h1 className="fz-anthem-word">{copy.anthem}</h1>
|
||||
<h1
|
||||
ref={anthemWordRef}
|
||||
className={`fz-anthem-word ${reducedMotion ? '' : 'fz-word-assembled fz-word-hidden'}`}
|
||||
>
|
||||
{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` }}>
|
||||
<span key={c.lang} className="fz-cascade-item" style={{ animationDelay: `${(reducedMotion ? 0.2 : 4.6) + i * 0.42}s` }}>
|
||||
{c.text}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -76,6 +76,49 @@
|
||||
to { opacity: 1; transform: translateY(0); filter: blur(0); }
|
||||
}
|
||||
|
||||
/* A replaced fear line falls apart: every letter drifts off on its own path. */
|
||||
.fz-line-ghost {
|
||||
position: absolute;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.fz-char-decay {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
animation: fzCharDecay 1.05s cubic-bezier(0.4, 0, 0.9, 0.4) both;
|
||||
}
|
||||
|
||||
@keyframes fzCharDecay {
|
||||
0% { opacity: 1; transform: translate(0, 0) rotate(0); filter: blur(0); }
|
||||
100% { opacity: 0; transform: translate(var(--dx, 0), var(--dy, -20px)) rotate(var(--rot, 0)); filter: blur(7px); }
|
||||
}
|
||||
|
||||
/* Hope-act lines arrive sentence by sentence, in speech rhythm. */
|
||||
.fz-seg {
|
||||
opacity: 0;
|
||||
animation: fzSegIn 1.5s cubic-bezier(0.19, 1, 0.22, 1) both;
|
||||
}
|
||||
|
||||
@keyframes fzSegIn {
|
||||
from { opacity: 0; filter: blur(5px); }
|
||||
to { opacity: 1; filter: blur(0); }
|
||||
}
|
||||
|
||||
/* The climax boom rocks the whole frame — short, decaying, gone. */
|
||||
.fz-shake {
|
||||
animation: fzShake 0.55s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||
}
|
||||
|
||||
@keyframes fzShake {
|
||||
0% { transform: translate(0, 0); }
|
||||
12% { transform: translate(-6px, 3px) rotate(-0.25deg); }
|
||||
28% { transform: translate(5px, -4px) rotate(0.2deg); }
|
||||
46% { transform: translate(-4px, 2px); }
|
||||
64% { transform: translate(3px, -1px); }
|
||||
82% { transform: translate(-1px, 1px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* Staccato words strobing to the heartbeat in the late fear act. */
|
||||
.fz-flash-wrap {
|
||||
position: absolute;
|
||||
@@ -149,6 +192,24 @@
|
||||
to { opacity: 1; transform: scale(1); letter-spacing: 0.06em; filter: blur(0); }
|
||||
}
|
||||
|
||||
/* Assembly mode: the DOM word skips the zoom-in (the particles ARE the
|
||||
entrance, and the zoom would skew the measured letterform targets); once the
|
||||
particles settle it fades in crisp and keeps breathing quietly forever. */
|
||||
.fz-anthem-word.fz-word-assembled {
|
||||
animation: fzAnthemBreathe 5.5s ease-in-out 5.5s infinite;
|
||||
transition: opacity 1.5s ease;
|
||||
}
|
||||
|
||||
.fz-anthem-word.fz-word-hidden {
|
||||
opacity: 0;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@keyframes fzAnthemBreathe {
|
||||
0%, 100% { transform: scale(1); text-shadow: 0 0 60px rgba(255, 200, 120, 0.35), 0 2px 30px rgba(0, 0, 0, 0.9); }
|
||||
50% { transform: scale(1.012); text-shadow: 0 0 84px rgba(255, 205, 130, 0.5), 0 2px 30px rgba(0, 0, 0, 0.9); }
|
||||
}
|
||||
|
||||
.fz-cascade {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -214,7 +275,7 @@
|
||||
display: none;
|
||||
width: 100%;
|
||||
min-height: 196px;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--r-xl, 16px); /* same corner radius as the dashboard tool widgets */
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
box-shadow: 0 12px 34px -14px rgba(0, 0, 0, 0.55), inset 0 0 0 1px rgba(255, 190, 110, 0.14);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// The finale's signature move: "KEINE ANGST" is not faded in — it CONDENSES.
|
||||
// Hundreds of light particles rise out of the world and gather into the
|
||||
// letterforms; once they settle, the real DOM title takes over for crispness.
|
||||
// The targets are sampled from an offscreen raster of the exact same text in
|
||||
// the exact same font, so the swap is seamless.
|
||||
|
||||
interface AssemblyParticle {
|
||||
sx: number
|
||||
sy: number
|
||||
tx: number
|
||||
ty: number
|
||||
delay: number
|
||||
size: number
|
||||
seed: number
|
||||
}
|
||||
|
||||
const easeOutCubic = (q: number): number => 1 - (1 - q) ** 3
|
||||
|
||||
export class TextAssembly {
|
||||
private particles: AssemblyParticle[] = []
|
||||
private ready = false
|
||||
|
||||
/**
|
||||
* Samples particle targets from the given text. `font` is a full CSS font
|
||||
* shorthand measured off the real DOM title; the box is the title's screen
|
||||
* rect so canvas particles land exactly where the DOM text will appear.
|
||||
*/
|
||||
init(text: string, font: string, box: { left: number; top: number; width: number; height: number }, screenW: number, screenH: number): void {
|
||||
const off = document.createElement('canvas')
|
||||
// Raster at a capped size — targets only need ~2px precision.
|
||||
const scale = Math.min(1, 700 / Math.max(box.width, 1))
|
||||
off.width = Math.max(Math.round(box.width * scale), 1)
|
||||
off.height = Math.max(Math.round(box.height * scale), 1)
|
||||
const c = off.getContext('2d')
|
||||
if (!c) return
|
||||
c.font = font.replace(/(\d+(?:\.\d+)?)px/, (_, n) => `${Number(n) * scale}px`)
|
||||
c.textAlign = 'center'
|
||||
c.textBaseline = 'middle'
|
||||
c.fillStyle = '#fff'
|
||||
c.fillText(text, off.width / 2, off.height / 2)
|
||||
const img = c.getImageData(0, 0, off.width, off.height).data
|
||||
const step = 3 // sample grid in raster px — ~800-1400 particles for a title
|
||||
this.particles = []
|
||||
for (let y = 0; y < off.height; y += step) {
|
||||
for (let x = 0; x < off.width; x += step) {
|
||||
if (img[(y * off.width + x) * 4 + 3] > 128) {
|
||||
const tx = box.left + x / scale
|
||||
const ty = box.top + y / scale
|
||||
// Particles rise from below and drift in from the sides — they come
|
||||
// OUT of the world, not from nowhere.
|
||||
const fromSide = Math.random() < 0.3
|
||||
this.particles.push({
|
||||
sx: fromSide ? (Math.random() < 0.5 ? -30 : screenW + 30) : Math.random() * screenW,
|
||||
sy: fromSide ? Math.random() * screenH : screenH + 20 + Math.random() * 80,
|
||||
tx,
|
||||
ty,
|
||||
delay: Math.random() * 0.45,
|
||||
size: 0.9 + Math.random() * 1.2,
|
||||
seed: Math.random() * 7,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
this.ready = true
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.ready
|
||||
}
|
||||
|
||||
/** progress 0→1 gathers, `fade` 0→1 dissolves the particles into the DOM text. */
|
||||
draw(c: CanvasRenderingContext2D, progress: number, fade: number, t: number): void {
|
||||
if (!this.ready || fade >= 1) return
|
||||
c.save()
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
for (const p of this.particles) {
|
||||
const local = easeOutCubic(Math.min(Math.max((progress - p.delay) / (1 - p.delay), 0), 1))
|
||||
if (local <= 0) continue
|
||||
const x = p.sx + (p.tx - p.sx) * local
|
||||
const y = p.sy + (p.ty - p.sy) * local
|
||||
// In flight the particle glows warm; settled it cools toward the ivory of the text.
|
||||
const settled = local > 0.98
|
||||
const flicker = 0.65 + 0.35 * Math.sin(t * 5 + p.seed)
|
||||
c.globalAlpha = (1 - fade) * (settled ? 0.9 : 0.75 * flicker)
|
||||
c.fillStyle = settled ? 'rgb(247, 240, 226)' : 'rgb(255, 205, 130)'
|
||||
c.beginPath()
|
||||
c.arc(x, y, settled ? p.size * 0.85 : p.size, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
}
|
||||
c.restore()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user