/* global React */
/*
* PortariaScene — wide cinematic SVG scene driven by scroll progress (0..1).
*
* Phases:
* P0 0.00–0.10 establish — calm portaria, gate down, doorman idle
* P1 0.10–0.32 chaos — cars arrive and stack up
* P2 0.32–0.42 peak — doorman overwhelmed
* P3 0.42–0.50 reset — cars fade away
* P4 0.50–0.60 Luna enters from above
* P5 0.60–0.72 Luna installs camera on the pole
* P6 0.72–0.80 system online — beam connects camera ↔ booth
* P7 0.80–1.00 solved — cars return; smooth flow, scans, decisions (loops forever)
*/
const { useState, useEffect, useRef } = React;
const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const smooth = (t) => t * t * (3 - 2 * t);
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
const easeIn = (t) => t * t * t;
const rng = (p, a, b) => clamp((p - a) / (b - a), 0, 1);
/* ─────────────────── Car (sedan side-view, facing LEFT) ───────────────────
* Local coords: width 200 × height ~80 (body 10..60, wheels to 78)
* Origin (0,0) = top-left bounding box. Front bumper is on the LEFT.
*/
function Car({ x, y = 600, color = "#cfd6e6", opacity = 1, scanned = false, plate = null, decision = null }) {
return (
{/* shadow under car */}
{/* body silhouette: front-left hood → windshield → roof → rear window → trunk → rear bumper */}
{/* lower-body shading */}
{/* windows (windshield + rear) */}
{/* B-pillar separating windshield from rear window */}
{/* roof highlight (subtle gloss) */}
{/* door line */}
{/* door handle */}
{/* wheels (with arch shadows) */}
{/* front headlight (LEFT side — car faces left) */}
{/* front plate */}
{/* tail light (right) */}
{/* ───── Scan overlay (Luna's identifier) ───── */}
{scanned && (
{/* dashed framing */}
{/* corner brackets (legs 18px each) */}
{[
{ x: 0, y: 2, dx: 1, dy: 1 },
{ x: 200, y: 2, dx: -1, dy: 1 },
{ x: 0, y: 78, dx: 1, dy: -1 },
{ x: 200, y: 78, dx: -1, dy: -1 },
].map((c, i) => (
))}
{/* center crosshair on the windshield */}
{/* connector dot above */}
{/* PLATE readout — left */}
{plate && (
PLACA
{plate}
)}
{/* STATUS / decision — right */}
{decision && (() => {
const ok = decision === 'AUTORIZADO';
const visit = decision === 'VISITANTE';
const stroke = ok ? '#5fd29a' : visit ? '#f4c34d' : '#E74C3C';
const tint = ok ? 'rgba(95,210,154,0.12)' : visit ? 'rgba(244,195,77,0.12)' : 'rgba(231,76,60,0.14)';
return (
STATUS
{decision}
);
})()}
)}
);
}
/* ─────────────────── Guard booth ─────────────────── */
function GuardBooth({ doormanState = 'calm', linked = false }) {
return (
{/* booth shadow on ground */}
{/* booth body */}
{/* roof */}
{/* window frame */}
{/* horizontal window slats */}
{/* inside glow */}
{/* doorman head & shoulders */}
{/* shoulders / uniform */}
{/* tie / collar */}
{/* head */}
{/* hair */}
{/* cap brim */}
{/* eyes — vary with state */}
{/* mouth */}
{/* sweat for stressed */}
{doormanState === 'stressed' && (
)}
{/* link indicator above booth */}
{linked && (
)}
);
}
function Eyes({ state }) {
if (state === 'stressed') {
return (
);
}
if (state === 'focused') {
return (
);
}
if (state === 'happy') {
return (
);
}
return (
);
}
function Mouth({ state }) {
if (state === 'stressed') {
return ;
}
if (state === 'focused') {
return ;
}
if (state === 'happy') {
return ;
}
return ;
}
/* ─────────────────── Gate (cancela) ─────────────────── */
function Cancela({ openness = 0 }) {
const angle = -openness * 78; // 0 = horizontal, -78 = up
return (
{/* base/post */}
{/* pivot */}
{/* arm */}
{[20, 60, 100, 140, 180].map((px) => (
))}
{/* small light on post */}
0.1 ? '#5fd29a' : '#E74C3C'}>
);
}
/* ─────────────────── Camera on pole ─────────────────── */
function CameraPole({ progress = 0, beamOn = false }) {
// progress 0..1 — 0 nothing, 0.4 pole appears, 0.7 camera appears, 1 fully connected
const poleH = lerp(0, 360, smooth(clamp(progress / 0.5, 0, 1)));
const camOpacity = clamp((progress - 0.45) / 0.25, 0, 1);
const ringOpacity = clamp((progress - 0.7) / 0.3, 0, 1);
return (
{/* base */}
{progress > 0 && (
)}
{/* pole */}
{/* horizontal arm */}
{camOpacity > 0 && (
)}
{/* camera head */}
{camOpacity > 0 && (
{beamOn && }
{/* status LED */}
)}
{/* scan ring emanating */}
{beamOn && (
)}
);
}
/* ─────────────────── Background skyline + grid ─────────────────── */
function SceneBackground() {
return (
{/* sky */}
{/* distant building silhouettes */}
{/* tiny windows on buildings */}
{Array.from({ length: 60 }).map((_, i) => {
const x = 810 + (i * 13) % 780;
const y = 440 + Math.floor(i / 20) * 30 + ((i * 7) % 3) * 10;
return ;
})}
{/* horizon glow */}
{/* ground */}
{/* ground edge line */}
{/* condo boundary wall on far left */}
{/* wall texture - horizontal bricks */}
{Array.from({ length: 8 }).map((_, i) => (
))}
{/* small windows */}
{/* driveway markings */}
{Array.from({ length: 12 }).map((_, i) => (
))}
);
}
/* ─────────────────── Mini Luna for the scene ─────────────────── */
function SceneLuna({ x, y, size = 140, state = 'happy', floating = true }) {
return (
{floating && (
)}
{floating && (
)}
);
}
/* ─────────────────── Connection beam: camera ↔ booth ─────────────────── */
function ConnectionBeam({ strength = 0, poleTop = 340 }) {
// beam from camera (~257, poleTop) curving to top of booth (~120, 488)
if (strength <= 0) return null;
const camX = 257, camY = poleTop;
const bX = 120, bY = 490;
const ctrlX = (camX + bX) / 2, ctrlY = Math.min(camY, bY) - 60;
const d = `M${camX} ${camY} Q${ctrlX} ${ctrlY} ${bX} ${bY}`;
return (
{/* small data packets */}
);
}
/* ─────────────────── Main scene ─────────────────── */
function PortariaScene({ progress = 0 }) {
const p = progress;
// ─── Infinite loop timer for solved-phase cars ───
// One full car cycle = 0.20 progress units × 26 000 ms = 5 200 ms.
// We start the RAF timer the first time p crosses 0.78 and keep it running
// forever, so the cars never stop even when the timeline pauses at 1.0.
const CYCLE_MS = 5200;
const [loopT, setLoopT] = useState(0);
const timerRef = useRef({ started: false, startAt: 0 });
const inSolved = p >= 0.78;
useEffect(() => {
if (!inSolved) {
timerRef.current.started = false;
return;
}
if (!timerRef.current.started) {
timerRef.current.started = true;
const initialT = ((p - 0.78) * 5) % 1;
timerRef.current.startAt = performance.now() - initialT * CYCLE_MS;
}
let active = true;
let rafId;
const tick = (now) => {
if (!active) return;
setLoopT(((now - timerRef.current.startAt) % CYCLE_MS) / CYCLE_MS);
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => { active = false; cancelAnimationFrame(rafId); };
}, [inSolved]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Doorman state by phase ───
let doormanState = 'calm';
if (p > 0.18 && p < 0.45) doormanState = 'stressed';
else if (p > 0.50 && p < 0.72) doormanState = 'calm';
else if (p >= 0.72) doormanState = 'focused';
if (p > 0.95) doormanState = 'happy';
// ─── Gate openness ───
// closed by default; in solved phase, the gate opens for each passing car
let gateOpen = 0;
// In P7, oscillate based on the lead car's position
// We'll compute this inline below based on solved cars
// ─── Cars: define a set with spawn + queue position ───
// Chaos phase queue (cars come in but don't pass)
const chaosTargets = [320, 540, 760, 980, 1200]; // x of car LEFT edge when stopped
const chaosCars = chaosTargets.map((target, i) => {
const spawn = 0.10 + i * 0.045;
const stopBy = spawn + 0.07;
const t = smooth(rng(p, spawn, stopBy));
let x = lerp(1700, target, t);
// fade-out in P3
let opacity = 1;
if (p > 0.42) {
const t2 = rng(p, 0.42, 0.50);
x = lerp(x, x + 60, smooth(t2));
opacity = 1 - smooth(t2);
}
if (p > 0.50) opacity = 0;
return { x, y: 600, color: ['#cf3c3c', '#d9c265', '#8da7c4', '#5b6b85', '#a06f4a'][i], opacity };
});
// Solved phase — a slow loop of 3 cars passing through
// Use the portion of progress in [0.80, 1.0] to drive a continuous loop
const solvedActive = clamp((p - 0.78) / 0.22, 0, 1);
// loopT now comes from the internal RAF timer (see top of component) so it
// keeps cycling even after progress freezes at 1.0.
// Three cars staggered by 1/3 of loop
const solvedCarDefs = [
{ color: '#2E75B6', plate: 'BRA·2A18', decision: 'AUTORIZADO', offset: 0.0 },
{ color: '#d9c265', plate: 'JLU·5C92', decision: 'AUTORIZADO', offset: 0.33 },
{ color: '#7c4dff', plate: 'XKR·9F31', decision: 'VISITANTE', offset: 0.66 },
];
// Solved cars: each follows the same loop but offset. They appear from x=1700, slow at the gate (~x=320) for scan, then accelerate through to x=-300.
const solvedCars = solvedCarDefs.map((def, i) => {
let lt = (loopT + def.offset) % 1;
// 0.00–0.55: drive in from 1700 to 320 (easeOut)
// 0.55–0.70: scan dwell at 320
// 0.70–1.00: drive through to -300 (easeIn)
let x;
let scanning = false;
let showDecision = null;
if (lt < 0.55) {
const t = lt / 0.55;
x = lerp(1700, 320, easeOut(t));
} else if (lt < 0.70) {
x = 320;
scanning = true;
showDecision = def.decision;
} else {
const t = (lt - 0.70) / 0.30;
x = lerp(320, -300, easeIn(t));
scanning = lt < 0.78; // keep brackets briefly as it leaves
showDecision = lt < 0.78 ? def.decision : null;
}
return {
x, y: 600, color: def.color,
opacity: solvedActive * (x > -250 && x < 1700 ? 1 : 0),
scanned: scanning,
plate: def.plate,
decision: showDecision,
};
});
// Compute gate openness based on whether any solved car is in the gate zone
if (p > 0.78) {
// open gate when a car is near 320 or passing through
let maxOpen = 0;
solvedCars.forEach((c) => {
// open when car between x=400 and x=-100
if (c.x < 450 && c.x > -250 && c.opacity > 0) {
// scale: smooth opening as car approaches scan, full open while passing
let openness = 0;
if (c.x > 320) openness = 1 - (c.x - 320) / 130; // approaching
else if (c.x > -100) openness = 1; // passing
else openness = 1 + (c.x + 100) / 150; // closing after
openness = clamp(openness, 0, 1);
if (openness > maxOpen) maxOpen = openness;
}
});
gateOpen = maxOpen;
}
// ─── Luna in scene ───
// P4 (0.50-0.60): Luna flies in from top to position above pole
// P5 (0.60-0.72): Luna stays near pole, "installs" the camera
// P6 (0.72-0.80): Luna drifts to upper-right (overseeing)
let lunaX = null, lunaY = null, lunaSize = 0, lunaState = 'happy';
if (p > 0.48 && p < 0.85) {
const enterT = smooth(rng(p, 0.48, 0.60));
// P4 enter: from above
const startX = 800, startY = -200;
const installX = 250, installY = 240;
const overseeX = 1280, overseeY = 80;
lunaSize = lerp(80, 160, enterT);
if (p < 0.72) {
lunaX = lerp(startX, installX, enterT);
lunaY = lerp(startY, installY, enterT);
lunaState = p < 0.62 ? 'idle' : 'happy';
} else {
const moveT = smooth(rng(p, 0.72, 0.82));
lunaX = lerp(installX, overseeX, moveT);
lunaY = lerp(installY, overseeY, moveT);
lunaSize = lerp(160, 100, moveT);
lunaState = 'happy';
}
// exit fade (after 0.85)
}
if (p >= 0.85) {
// Luna stays in upper right but smaller
lunaX = 1280;
lunaY = 80;
lunaSize = 100;
lunaState = 'happy';
}
// ─── Camera install progress: P5 ───
// The pole and camera grow during P5 (0.55..0.72) — but actually we want them to appear AS Luna installs
const cameraProgress = clamp((p - 0.58) / 0.18, 0, 1);
const beamOn = p > 0.72;
const beamStrength = clamp((p - 0.72) / 0.08, 0, 1);
const poleTop = 720 - lerp(0, 360, smooth(cameraProgress));
// ─── Scene camera transform (subtle zoom/pan) ───
// Slight zoom-in during chaos (peak 0.42), pull back during install (0.65), zoom in for solved (0.9)
let zoom = 1, panY = 0;
if (p < 0.42) {
zoom = lerp(1.0, 1.08, smooth(rng(p, 0.10, 0.42)));
} else if (p < 0.60) {
zoom = lerp(1.08, 1.0, smooth(rng(p, 0.42, 0.60)));
} else if (p < 0.78) {
zoom = lerp(1.0, 0.96, smooth(rng(p, 0.60, 0.78)));
panY = lerp(0, -20, smooth(rng(p, 0.60, 0.78)));
} else {
zoom = lerp(0.96, 1.04, smooth(rng(p, 0.78, 1.0)));
panY = lerp(-20, 0, smooth(rng(p, 0.78, 1.0)));
}
const sceneTransform = `translate(0, ${panY}) scale(${zoom})`;
// ─── Chaos "alert" overlay (speed lines + red vignette) ───
const chaosIntensity = clamp((p - 0.20) / 0.18, 0, 1) * (1 - clamp((p - 0.42) / 0.08, 0, 1));
return (
);
}
window.PortariaScene = PortariaScene;