update miletruth page and remove unwanted files

This commit is contained in:
2026-06-03 13:42:12 +05:30
parent 3bad62851c
commit 6eea5636fb
153 changed files with 6089 additions and 36024 deletions

View File

@@ -0,0 +1,421 @@
"use client";
import React, { useMemo, useRef } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { RoundedBox, Line, Sparkles, Html } from "@react-three/drei";
import { EffectComposer, Bloom } from "@react-three/postprocessing";
import { KernelSize } from "postprocessing";
import * as THREE from "three";
import { STAGES, N, stagePosition, samplePath } from "./theme";
type Props = {
progress: React.RefObject<number>;
reduced?: boolean;
isMobile?: boolean;
active?: boolean;
};
const BG = "#eef1f6";
/* ---------------------------------------------------------------------------
Camera — fully scroll-driven. A fixed back/up offset from the active stage
keeps the active stage at a near-constant distance, so a constant DoF plane
keeps it sharp while past/future stages fall out of focus (Apple-style).
--------------------------------------------------------------------------- */
function CameraRig({ progress, reduced }: { progress: React.RefObject<number>; reduced: boolean }) {
const look = useRef(new THREE.Vector3(...stagePosition(0)));
useFrame((state, dt) => {
const p = progress.current ?? 0;
const idx = p * (N - 1);
const [tx, ty, tz] = samplePath(idx);
const t = state.clock.elapsedTime;
// Gentle parallax: camera slides toward the centre-line and breathes.
const camX = tx * 0.45 + Math.sin(t * 0.25) * 0.25;
const camY = ty + 1.35 + Math.sin(t * 0.35) * 0.12;
const camZ = tz + 5.4;
const k = reduced ? 1 : Math.min(1, dt * 3.2);
const cam = state.camera;
cam.position.x += (camX - cam.position.x) * k;
cam.position.y += (camY - cam.position.y) * k;
cam.position.z += (camZ - cam.position.z) * k;
look.current.x += (tx - look.current.x) * k;
look.current.y += (ty - look.current.y) * k;
look.current.z += (tz - look.current.z) * k;
cam.lookAt(look.current);
});
return null;
}
/* ---------------------------------------------------------------------------
Themed accent object floating in front of each glass stage.
--------------------------------------------------------------------------- */
function Accent({ i, color }: { i: number; color: THREE.Color }) {
const mat = (
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={1.4}
roughness={0.25}
metalness={0.2}
toneMapped={false}
/>
);
switch (i) {
case 0: // Rider happiness — soft orb
return <mesh><icosahedronGeometry args={[0.46, 1]} />{mat}</mesh>;
case 1: // Dispatch — node / octahedron
return <mesh rotation={[0.4, 0, 0.4]}><octahedronGeometry args={[0.55, 0]} />{mat}</mesh>;
case 2: // Route execution — flowing knot
return <mesh><torusKnotGeometry args={[0.34, 0.12, 96, 16]} />{mat}</mesh>;
case 3: // Fulfillment — gauge ring
return <mesh rotation={[Math.PI / 2.2, 0, 0]}><torusGeometry args={[0.46, 0.1, 24, 64]} />{mat}</mesh>;
default: // Hero — trophy (cup + stem + base)
return (
<group>
<mesh position={[0, 0.28, 0]}><sphereGeometry args={[0.34, 32, 16, 0, Math.PI * 2, 0, Math.PI / 1.7]} />{mat}</mesh>
<mesh position={[0, -0.05, 0]}><cylinderGeometry args={[0.07, 0.07, 0.34, 16]} />{mat}</mesh>
<mesh position={[0, -0.26, 0]}><cylinderGeometry args={[0.26, 0.3, 0.12, 24]} />{mat}</mesh>
</group>
);
}
}
/* ===========================================================================
STAGE 01 — a real logistics intake scene rendered on the glass panel:
orders.csv → 59 orders streaming in → AI assignment hub → 4 rider markers,
with glowing assignment beams. Labels/counters/icons are crisp drei <Html>
(real DOM, glass-styled) positioned in 3D; structure + motion stay in WebGL.
=========================================================================== */
const GREEN = "#22C55E";
const RIDERS = [
{ id: "A", veh: "EV Bike", icon: "🚲" },
{ id: "B", veh: "Auto", icon: "🛺" },
{ id: "C", veh: "Cargo Truck", icon: "🚚" },
{ id: "D", veh: "EV Van", icon: "🚐" },
];
const RIDER_X = [-1.18, -0.39, 0.39, 1.18];
const HUB = new THREE.Vector3(0, 0.14, 0.22);
/** A small order card that streams down from the source into the AI hub, on a loop. */
function OrderCard({ index }: { index: number }) {
const group = useRef<THREE.Group>(null);
const mat = useRef<THREE.MeshStandardMaterial>(null);
useFrame((state) => {
const t = (state.clock.elapsedTime * 0.55 + index * 0.27) % 1;
const o = Math.sin(t * Math.PI); // fade/scale in at the ends of the fall
if (group.current) {
group.current.position.set((index - 1.5) * 0.16, 0.78 - t * 0.62, 0.32);
group.current.scale.setScalar(0.66 + o * 0.34);
}
if (mat.current) mat.current.opacity = o * 0.95;
});
return (
<group ref={group}>
<RoundedBox args={[0.5, 0.34, 0.02]} radius={0.05} smoothness={3}>
<meshStandardMaterial ref={mat} color="#ffffff" emissive={GREEN} emissiveIntensity={0.35} transparent opacity={0.9} />
</RoundedBox>
<mesh position={[0, 0.1, 0.012]}><planeGeometry args={[0.5, 0.05]} /><meshBasicMaterial color={GREEN} toneMapped={false} transparent opacity={0.85} /></mesh>
</group>
);
}
/** Glowing assignment beam from the hub to a rider, with a travelling packet. */
function AssignBeam({ to, delay }: { to: [number, number, number]; delay: number }) {
const dot = useRef<THREE.Mesh>(null);
const b = useMemo(() => new THREE.Vector3(...to), [to]);
const points = useMemo(() => [HUB.clone(), b.clone()], [b]);
useFrame((state) => {
if (dot.current) dot.current.position.lerpVectors(HUB, b, (state.clock.elapsedTime * 0.6 + delay) % 1);
});
return (
<group>
<Line points={points} color={GREEN} lineWidth={1.4} transparent opacity={0.5} toneMapped={false} />
<mesh ref={dot}><sphereGeometry args={[0.045, 12, 12]} /><meshBasicMaterial color={GREEN} toneMapped={false} /></mesh>
</group>
);
}
/** A rider node (glowing marker) + a glass DOM chip with vehicle icon + name. */
function RiderMarker({ x, rider, register }: { x: number; rider: typeof RIDERS[number]; register: (el: HTMLElement | null) => void }) {
const pulse = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (pulse.current) {
const s = 1 + Math.sin(state.clock.elapsedTime * 2 + x) * 0.18;
pulse.current.scale.setScalar(s);
}
});
return (
<group position={[x, -0.74, 0.22]}>
<mesh><sphereGeometry args={[0.11, 24, 24]} /><meshStandardMaterial color={GREEN} emissive={GREEN} emissiveIntensity={0.9} toneMapped={false} /></mesh>
<mesh ref={pulse}><ringGeometry args={[0.15, 0.17, 32]} /><meshBasicMaterial color={GREEN} transparent opacity={0.5} toneMapped={false} side={THREE.DoubleSide} /></mesh>
<Html center distanceFactor={6.5} position={[0, -0.3, 0]} zIndexRange={[20, 0]} pointerEvents="none">
<div className="dm-st3d-chip" ref={register}>
<span className="dm-st3d-chip__ico">{rider.icon}</span>
<span className="dm-st3d-chip__txt"><b>Rider {rider.id}</b>{rider.veh}</span>
</div>
</Html>
</group>
);
}
function Stage1Scene({ progress }: { progress: React.RefObject<number> }) {
const counter = useRef<HTMLSpanElement>(null);
const labels = useRef<HTMLElement[]>([]);
const register = (el: HTMLElement | null) => { if (el && !labels.current.includes(el)) labels.current.push(el); };
useFrame((state) => {
// Live order intake — count up 0→59, hold, repeat.
if (counter.current) {
const cyc = state.clock.elapsedTime % 3.6;
counter.current.textContent = String(Math.min(59, Math.round((cyc / 2.4) * 59)));
}
// Fade the DOM labels out as the camera leaves stage 1.
const idx = (progress.current ?? 0) * (N - 1);
const op = THREE.MathUtils.clamp(1 - (idx - 0.45) / 0.6, 0, 1);
for (const el of labels.current) el.style.opacity = String(op);
});
return (
<group>
{/* Source file */}
<Html center distanceFactor={6.5} position={[0, 0.96, 0.34]} zIndexRange={[20, 0]} pointerEvents="none">
<div className="dm-st3d-file" ref={register}>📄 orders.csv</div>
</Html>
{/* Live intake counter */}
<Html center distanceFactor={6.5} position={[0, 0.62, 0.34]} zIndexRange={[20, 0]} pointerEvents="none">
<div className="dm-st3d-count" ref={register}><span ref={counter}>0</span> Orders</div>
</Html>
{/* Order cards streaming in */}
{[0, 1, 2, 3].map((j) => <OrderCard key={j} index={j} />)}
{/* AI assignment hub */}
<mesh position={[HUB.x, HUB.y, 0.16]}><icosahedronGeometry args={[0.16, 1]} /><meshStandardMaterial color={GREEN} emissive={GREEN} emissiveIntensity={1.2} toneMapped={false} /></mesh>
<Html center distanceFactor={6.5} position={[0, HUB.y, 0.36]} zIndexRange={[20, 0]} pointerEvents="none">
<div className="dm-st3d-ai" ref={register}>🤖 AI Assignment</div>
</Html>
{/* Assignment beams + rider markers */}
{RIDERS.map((r, j) => <AssignBeam key={r.id} to={[RIDER_X[j], -0.66, 0.22]} delay={j * 0.25} />)}
{RIDERS.map((r, j) => <RiderMarker key={r.id} x={RIDER_X[j]} rider={r} register={register} />)}
</group>
);
}
/* ---------------------------------------------------------------------------
One floating glassmorphism stage. Per-frame it reacts to scroll: the active
stage scales up + brightens; completed stages push back + dim.
--------------------------------------------------------------------------- */
function StageNode({ i, progress, reduced }: { i: number; progress: React.RefObject<number>; reduced: boolean }) {
const group = useRef<THREE.Group>(null);
const accentGroup = useRef<THREE.Group>(null);
const glassMat = useRef<THREE.MeshPhysicalMaterial>(null);
const haloMat = useRef<THREE.MeshBasicMaterial>(null);
const base = useMemo(() => stagePosition(i), [i]);
const color = useMemo(() => new THREE.Color(STAGES[i].theme), [i]);
const sEased = useRef(0.6);
const pushEased = useRef(0);
const oEased = useRef(0.2);
useFrame((state, dt) => {
const p = progress.current ?? 0;
const idx = p * (N - 1);
const d = idx - i; // >0 completed, <0 future, ~0 active
const ad = Math.abs(d);
const active = ad < 0.5;
const targetScale = THREE.MathUtils.clamp(1.12 - ad * 0.17, 0.62, 1.14);
const targetPush = d > 0 ? d * 2.8 : 0; // completed recede
const targetOpacity =
d > 0 ? THREE.MathUtils.clamp(0.95 - d * 0.42, 0.16, 0.95) // completed dim
: d < 0 ? THREE.MathUtils.clamp(0.95 + d * 0.28, 0.28, 0.95) // future faintly hidden
: 0.95;
const k = reduced ? 1 : Math.min(1, dt * 4);
sEased.current = THREE.MathUtils.lerp(sEased.current, targetScale, k);
pushEased.current = THREE.MathUtils.lerp(pushEased.current, targetPush, k);
oEased.current = THREE.MathUtils.lerp(oEased.current, targetOpacity, k);
const t = state.clock.elapsedTime;
if (group.current) {
group.current.scale.setScalar(sEased.current);
group.current.position.set(
base[0],
base[1] + Math.sin(t * 0.6 + i) * 0.16,
base[2] - pushEased.current,
);
group.current.rotation.y = Math.sin(t * 0.3 + i * 1.3) * 0.07;
}
if (glassMat.current) {
glassMat.current.opacity = oEased.current * 0.3;
(glassMat.current.emissive as THREE.Color).copy(color).multiplyScalar(active ? 0.55 : 0.16);
}
if (haloMat.current) {
haloMat.current.opacity = oEased.current * (active ? 0.92 : 0.4);
}
if (accentGroup.current) {
accentGroup.current.rotation.y += dt * (active ? 0.7 : 0.25);
accentGroup.current.position.y = Math.sin(t * 0.9 + i) * 0.12;
}
});
return (
<group ref={group} position={base}>
{/* Coloured halo backplate — reads as the stage glow under Bloom */}
<RoundedBox args={[3.62, 2.42, 0.05]} radius={0.22} smoothness={4} position={[0, 0, -0.05]}>
<meshBasicMaterial ref={haloMat} color={color} transparent opacity={0.8} toneMapped={false} />
</RoundedBox>
{/* Frosted glass panel */}
<RoundedBox args={[3.4, 2.2, 0.14]} radius={0.2} smoothness={4}>
<meshPhysicalMaterial
ref={glassMat}
color="#ffffff"
transparent
opacity={0.3}
roughness={0.12}
metalness={0}
clearcoat={1}
clearcoatRoughness={0.18}
ior={1.25}
reflectivity={0.45}
emissive={color}
emissiveIntensity={1}
/>
</RoundedBox>
{/* Stage 1 = a real logistics intake scene; other stages = floating accent */}
{i === 0 ? (
<Stage1Scene progress={progress} />
) : (
<group ref={accentGroup} position={[0, 0, 0.55]}>
<Accent i={i} color={color} />
</group>
)}
</group>
);
}
/* ---------------------------------------------------------------------------
Glowing connector + a light packet travelling from one stage to the next.
--------------------------------------------------------------------------- */
function Connector({ i }: { i: number }) {
const dot = useRef<THREE.Mesh>(null);
const a = useMemo(() => stagePosition(i), [i]);
const b = useMemo(() => stagePosition(i + 1), [i]);
const cA = useMemo(() => new THREE.Color(STAGES[i].theme), [i]);
const cB = useMemo(() => new THREE.Color(STAGES[i + 1].theme), [i]);
const dotColor = useMemo(() => cA.clone().lerp(cB, 0.5), [cA, cB]);
// A gentle arc between the two stages (midpoint lifted).
const points = useMemo(() => {
const mid: [number, number, number] = [
(a[0] + b[0]) / 2,
(a[1] + b[1]) / 2 + 0.9,
(a[2] + b[2]) / 2,
];
const curve = new THREE.QuadraticBezierCurve3(
new THREE.Vector3(...a),
new THREE.Vector3(...mid),
new THREE.Vector3(...b),
);
return curve.getPoints(40);
}, [a, b]);
const curve = useMemo(() => {
const mid = new THREE.Vector3(
(a[0] + b[0]) / 2,
(a[1] + b[1]) / 2 + 0.9,
(a[2] + b[2]) / 2,
);
return new THREE.QuadraticBezierCurve3(new THREE.Vector3(...a), mid, new THREE.Vector3(...b));
}, [a, b]);
useFrame((state) => {
if (!dot.current) return;
const t = (state.clock.elapsedTime * 0.32 + i * 0.27) % 1;
dot.current.position.copy(curve.getPoint(t));
});
return (
<group>
<Line points={points} color={dotColor} lineWidth={1.5} transparent opacity={0.5} toneMapped={false} />
<mesh ref={dot}>
<sphereGeometry args={[0.09, 16, 16]} />
<meshBasicMaterial color={dotColor} toneMapped={false} />
</mesh>
</group>
);
}
function Scene({ progress, reduced, isMobile }: { progress: React.RefObject<number>; reduced: boolean; isMobile: boolean }) {
return (
<>
<color attach="background" args={[BG]} />
<fog attach="fog" args={[BG, 16, 46]} />
<ambientLight intensity={0.95} />
<hemisphereLight args={["#ffffff", "#dfe4ee", 0.7]} />
<directionalLight position={[6, 10, 8]} intensity={0.85} />
<directionalLight position={[-8, 4, -4]} intensity={0.35} color="#cdd6ff" />
<CameraRig progress={progress} reduced={reduced} />
{STAGES.map((_, i) => (
<StageNode key={i} i={i} progress={progress} reduced={reduced} />
))}
{STAGES.slice(0, -1).map((_, i) => (
<Connector key={i} i={i} />
))}
{/* Ambient floating particles + a confetti-like burst near the hero stage */}
{!reduced && (
<>
<Sparkles
count={isMobile ? 50 : 110}
scale={[14, 8, N * 6.4]}
position={[0, 0, (-(N - 1) * 6.4) / 2]}
size={2.2}
speed={0.3}
opacity={0.5}
color="#9aa6c4"
/>
<Sparkles
count={isMobile ? 28 : 60}
scale={[5, 4, 4]}
position={stagePosition(N - 1)}
size={3.4}
speed={0.5}
opacity={0.9}
color="#ff9aa9"
/>
</>
)}
{!reduced && (
<EffectComposer multisampling={isMobile ? 0 : 2}>
<Bloom
mipmapBlur
intensity={isMobile ? 0.7 : 1.05}
luminanceThreshold={0.55}
luminanceSmoothing={0.06}
radius={isMobile ? 0.6 : 0.78}
kernelSize={KernelSize.MEDIUM}
/>
</EffectComposer>
)}
</>
);
}
export default function StrategyCanvas({ progress, reduced = false, isMobile = false, active = true }: Props) {
return (
<Canvas
dpr={[1, isMobile || reduced ? 1.25 : 1.5]}
camera={{ position: [0, 1.4, 5.4], fov: 48, near: 0.1, far: 90 }}
gl={{ antialias: !isMobile, powerPreference: "high-performance", alpha: false }}
frameloop={active ? "always" : "never"}
>
<Scene progress={progress} reduced={reduced} isMobile={isMobile} />
</Canvas>
);
}

View File

@@ -0,0 +1,516 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { motion, useMotionValue, useTransform, type MotionValue } from "framer-motion";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { STAGES, N } from "./theme";
const StrategyCanvas = dynamic(() => import("./StrategyCanvas"), { ssr: false });
/** Center of each stage's scroll window (0…1). */
const CENTER = (i: number) => i / (N - 1);
/** Persistent top rail: the 5 stages, current one highlighted. */
function StageRail({ active }: { active: number }) {
return (
<div className="dm-st-rail" aria-hidden>
{STAGES.map((s, i) => {
const state = i < active ? "done" : i === active ? "current" : "todo";
return (
<React.Fragment key={s.n}>
{i > 0 && <span className={`dm-st-rail__line is-${i <= active ? "on" : "off"}`} />}
<div className={`dm-st-rail__step is-${state}`} style={{ ["--c" as string]: s.theme }}>
<span className="dm-st-rail__num">{i < active ? "✓" : s.n}</span>
<span className="dm-st-rail__title">{s.kicker}</span>
</div>
</React.Fragment>
);
})}
</div>
);
}
/** A cross-fading glass content card pinned to one side, themed per stage. */
function StageCard({
i,
scroll,
side,
children,
}: {
i: number;
scroll: MotionValue<number>;
side: "left" | "right";
children: React.ReactNode;
}) {
const c = CENTER(i);
const opacity = useTransform(scroll, [c - 0.14, c - 0.06, c + 0.06, c + 0.14], [0, 1, 1, 0]);
const y = useTransform(scroll, [c - 0.14, c - 0.05], [34, 0]);
const s = STAGES[i];
return (
<motion.div
className={`dm-st-card-story is-${side}`}
style={{ opacity, y, ["--c" as string]: s.theme }}
>
<div className="dm-st-card-story__head">
<span className="dm-st-pillar__num">{s.n}</span>
<span className="dm-st-pillar__kicker">{s.kicker}</span>
</div>
{children}
</motion.div>
);
}
/**
* "Strategy" — a premium, Apple-keynote-style 3D scroll-storytelling section.
* A single GSAP ScrollTrigger maps scroll to a normalized progress that drives
* the R3F camera through five floating glass stages while DOM glass cards
* cross-fade in lockstep. Pins via a self-managed fixed element (the site's
* fixed header + an ancestor `overflow:hidden` break CSS sticky / GSAP pin).
*/
export default function StrategySection({ connected = false }: { connected?: boolean } = {}) {
const containerRef = useRef<HTMLDivElement>(null);
const progressRef = useRef(0);
const scroll = useMotionValue(0);
const [pinState, setPinState] = useState<"before" | "pinned" | "after">("before");
const [active, setActive] = useState(0);
const [mountScene, setMountScene] = useState(false);
const [sceneActive, setSceneActive] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mqMobile = window.matchMedia("(max-width: 767px)");
const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => { setIsMobile(mqMobile.matches); setReduced(mqReduce.matches); };
sync();
mqMobile.addEventListener("change", sync);
mqReduce.addEventListener("change", sync);
return () => { mqMobile.removeEventListener("change", sync); mqReduce.removeEventListener("change", sync); };
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const mountIo = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setMountScene(true);
setSceneActive(true);
mountIo.disconnect();
}
},
{ rootMargin: "70% 0px" },
);
const activeIo = new IntersectionObserver(
(entries) => setSceneActive(entries.some((e) => e.isIntersecting)),
{ rootMargin: "10% 0px" },
);
mountIo.observe(el);
activeIo.observe(el);
return () => { mountIo.disconnect(); activeIo.disconnect(); };
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
gsap.registerPlugin(ScrollTrigger);
let lastPin: "before" | "pinned" | "after" = "before";
let lastActive = 0;
const st = ScrollTrigger.create({
trigger: el,
start: "top top",
end: "bottom bottom",
scrub: 0.5,
invalidateOnRefresh: true,
onUpdate: (self) => {
const p = self.progress;
progressRef.current = p;
scroll.set(p);
const ns = p <= 0.0002 ? "before" : p >= 0.9998 ? "after" : "pinned";
if (ns !== lastPin) { lastPin = ns; setPinState(ns); }
const na = Math.round(p * (N - 1));
if (na !== lastActive) { lastActive = na; setActive(na); }
},
});
const refresh = setTimeout(() => ScrollTrigger.refresh(), 300);
return () => { clearTimeout(refresh); st.kill(); };
}, [scroll]);
// Intro hint fades out as the journey begins.
const introOpacity = useTransform(scroll, [0, 0.03, 0.06], [1, 1, 0]);
// Persistent header fades in after the intro.
const headerOpacity = useTransform(scroll, [0.02, 0.07], [0, 1]);
return (
<section
ref={containerRef}
className={`dm-st is-${pinState}${connected ? " is-connected" : ""}`}
aria-label="Strategy — Happier Riders. Higher Fulfillment."
>
<div className="dm-st-sticky">
<div className="dm-st-card">
{mountScene && (
<div className="dm-st-canvas">
<StrategyCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive} />
</div>
)}
<div className="dm-st-ui">
{/* Persistent header */}
<motion.div className="dm-st-top" style={{ opacity: headerOpacity }}>
<div className="dm-st-eyebrow"><span className="dm-st-dot" /> MileTruth Strategy Engine</div>
<StageRail active={active} />
</motion.div>
{/* Intro hint */}
<motion.div className="dm-st-scrollhint" style={{ opacity: introOpacity }}>
<span>Scroll to follow the strategy</span>
<span className="dm-st-arrow"></span>
</motion.div>
{/* STAGE 01 — INPUT (green): orders + riders enter the system */}
<StageCard i={0} scroll={scroll} side="left">
<h3 className="dm-st-pillar__title">Orders &amp; riders enter the system</h3>
<div className="dm-st-grid2">
<div className="dm-st-tile">
<span className="dm-st-tile__ico">📄</span>
<span className="dm-st-tile__num">59</span>
<span className="dm-st-tile__lbl">Orders</span>
<span className="dm-st-tile__sub">Order intake</span>
</div>
<div className="dm-st-tile">
<span className="dm-st-tile__ico">🧑</span>
<span className="dm-st-tile__num">4</span>
<span className="dm-st-tile__lbl">Total Riders</span>
<span className="dm-st-tile__sub">Available fleet</span>
</div>
</div>
<div className="dm-st-file">
<span className="dm-st-file__ico"></span>
<span className="dm-st-file__name">orders.csv</span>
<span className="dm-st-file__meta">59 rows · uploaded</span>
<span className="dm-st-file__ok"></span>
</div>
<ul className="dm-st-riders">
{[
{ a: "A", name: "Rider A", v: "EV Bike", cap: "12 orders" },
{ a: "B", name: "Rider B", v: "Auto", cap: "18 orders" },
{ a: "C", name: "Rider C", v: "Cargo Truck", cap: "20 orders" },
{ a: "D", name: "Rider D", v: "EV Van", cap: "9 orders" },
].map((r) => (
<li key={r.a} className="dm-st-rider">
<span className="dm-st-rider__av">{r.a}<i /></span>
<span className="dm-st-rider__name">{r.name}</span>
<span className="dm-st-rider__veh">{r.v}</span>
<span className="dm-st-rider__cap">{r.cap}</span>
</li>
))}
</ul>
</StageCard>
{/* STAGE 02 — PARALLEL EXECUTION (purple): 6 strategies at once */}
<StageCard i={1} scroll={scroll} side="right">
<h3 className="dm-st-pillar__title">Six strategies, evaluated in parallel</h3>
<div className="dm-st-engines">
<div className="dm-st-engine">
<div className="dm-st-engine__head"><span className="dm-st-engine__name">Legacy Engine</span><span className="dm-st-engine__tag">Baseline</span></div>
<div className="dm-st-pills">
{["Proximity", "Balanced", "Fuel Saver"].map((p) => <span key={p} className="dm-st-pill">{p}</span>)}
</div>
</div>
<div className="dm-st-engine is-unified">
<div className="dm-st-engine__head"><span className="dm-st-engine__name">Unified Engine</span><span className="dm-st-engine__tag dm-st-engine__tag--u">MileTruth</span></div>
<div className="dm-st-pills">
{["EV Aware", "Multi Trip", "Time Aware"].map((p) => <span key={p} className="dm-st-pill dm-st-pill--u">{p}</span>)}
</div>
</div>
</div>
<p className="dm-st-foot"><span className="dm-st-livedot" /> All 6 strategies run at the same time</p>
</StageCard>
{/* STAGE 03 — SMART OPTIMIZATION (blue): validation pipeline */}
<StageCard i={2} scroll={scroll} side="left">
<h3 className="dm-st-pillar__title">Routes validated &amp; optimized</h3>
<div className="dm-st-pipe">
{[
{ ico: "⚙️", name: "VRP Optimizer", desc: "Google OR-Tools solver" },
{ ico: "🔋", name: "Battery Simulation", desc: "EV range & charging feasibility" },
{ ico: "⏱️", name: "SLA Validator", desc: "ETA vs promised window" },
].map((m, j) => (
<React.Fragment key={m.name}>
{j > 0 && <span className="dm-st-pipe__arrow"><i /></span>}
<div className="dm-st-pipe__node">
<span className="dm-st-pipe__ico">{m.ico}</span>
<span className="dm-st-pipe__name">{m.name}</span>
<span className="dm-st-pipe__desc">{m.desc}</span>
</div>
</React.Fragment>
))}
</div>
</StageCard>
{/* STAGE 04 — PERFORMANCE GRADING (orange): every strategy scored */}
<StageCard i={3} scroll={scroll} side="right">
<h3 className="dm-st-pillar__title">Every strategy is scored</h3>
<div className="dm-st-stars"><span className="dm-st-stars__on"></span><span className="dm-st-stars__off"></span><span className="dm-st-stars__txt">4.5 / 5 grade</span></div>
<ul className="dm-st-metrics">
{[
{ name: "Fulfillment Rate", score: "88%", w: 88, status: "Good", desc: "Orders delivered vs total" },
{ name: "SLA Compliance", score: "95%", w: 95, status: "Pass", desc: "On-time within window" },
{ name: "Efficiency Score", score: "92", w: 92, status: "Strong", desc: "Distance & fleet usage" },
{ name: "Battery & Route", score: "OK", w: 100, status: "Feasible", desc: "EV range respected" },
].map((m) => (
<li key={m.name} className="dm-st-metric">
<span className="dm-st-metric__name">{m.name}</span>
<span className="dm-st-metric__bar"><i style={{ width: `${m.w}%` }} /></span>
<span className="dm-st-metric__score">{m.score}</span>
<span className="dm-st-metric__status"> {m.status}</span>
<span className="dm-st-metric__desc">{m.desc}</span>
</li>
))}
</ul>
</StageCard>
{/* STAGE 05 — STRATEGY COMPARISON (red, hero): the winner */}
<StageCard i={4} scroll={scroll} side="right">
<div className="dm-st-winner">
<span className="dm-st-winner__eyebrow">Best strategy recommendation</span>
<div className="dm-st-winner__name"><span className="dm-st-trophy">🏆</span> EV Aware</div>
<span className="dm-st-winner__grade">High Performance Grade</span>
</div>
<div className="dm-st-hero">
<div className="dm-st-hero__ring">
<span className="dm-st-hero__pct">88%</span>
<span className="dm-st-hero__sub">Score</span>
</div>
<ul className="dm-st-wins">
<li><strong>52/59</strong> Orders Fulfilled</li>
<li><strong>88%</strong> Performance Score</li>
<li><strong>3</strong> SLA Violations</li>
<li><strong>A</strong> Performance Grade</li>
</ul>
</div>
</StageCard>
</div>
</div>
</div>
<style>{styles}</style>
</section>
);
}
const styles = `
.dm-st { position: relative; height: 620vh; background: transparent; }
.dm-st-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden; }
.dm-st.is-pinned .dm-st-sticky { position: fixed; top: 0; left: 0; }
.dm-st.is-after .dm-st-sticky { position: absolute; top: auto; bottom: 0; }
.dm-st-card {
position: absolute !important; inset: 16px !important;
border-radius: 28px !important; overflow: hidden !important;
background: radial-gradient(120% 100% at 50% 0%, #ffffff 0%, #eef1f6 60%, #e6eaf2 100%) !important;
border: 1px solid rgba(15,23,42,0.08) !important;
box-shadow: 0 30px 90px -34px rgba(15,23,42,0.4) !important;
box-sizing: border-box !important;
}
@media (max-width: 767px) { .dm-st-card { inset: 10px !important; border-radius: 20px !important; } }
/* Connected mode (inside Workflow 3): flatten the card's bottom so the Strategy
content card below butts directly against it — same seam as Workflow 1 & 2. */
.dm-st.is-connected .dm-st-card {
top: 16px !important; left: 16px !important; right: 16px !important; bottom: 0 !important;
border-radius: 28px 28px 0 0 !important; border-bottom: none !important;
}
@media (max-width: 767px) {
.dm-st.is-connected .dm-st-card {
top: 10px !important; left: 10px !important; right: 10px !important; bottom: 0 !important;
border-radius: 20px 20px 0 0 !important;
}
}
.dm-st-canvas { position: absolute; inset: 0; z-index: 1; }
.dm-st-canvas canvas { display: block; }
.dm-st-ui { position: absolute; inset: 0; z-index: 4; pointer-events: none;
font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif; color: #0f172a; }
/* ---- Persistent header: title + 5-stage rail ---- */
.dm-st-top { position: absolute; top: clamp(96px, 13vh, 128px); left: 0; right: 0; z-index: 5;
display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 0 16px; }
.dm-st-eyebrow { display: inline-flex; align-items: center; gap: 8px; font-size: 11px; line-height: 1.35;
letter-spacing: 0.28em; text-transform: uppercase; color: #475569; padding: 9px 18px; border-radius: 999px;
background: rgba(255,255,255,0.72); border: 1px solid rgba(15,23,42,0.08); backdrop-filter: blur(10px); white-space: nowrap; }
.dm-st-dot { width: 6px; height: 6px; border-radius: 50%; background: #6366f1; box-shadow: 0 0 10px #6366f1; }
.dm-st-rail { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; max-width: 980px; }
.dm-st-rail__step { display: inline-flex; align-items: center; gap: 7px; padding: 5px 11px; border-radius: 999px;
background: rgba(255,255,255,0.7); border: 1px solid rgba(15,23,42,0.08); backdrop-filter: blur(8px);
transition: all 0.45s cubic-bezier(0.22,1,0.36,1); }
.dm-st-rail__num { width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 800; color: #64748b; background: rgba(15,23,42,0.06); }
.dm-st-rail__title { font-size: 11px; font-weight: 600; letter-spacing: 0.02em; color: #64748b; white-space: nowrap; }
.dm-st-rail__step.is-current { background: color-mix(in srgb, var(--c) 16%, white); border-color: var(--c);
box-shadow: 0 0 22px -6px var(--c); }
.dm-st-rail__step.is-current .dm-st-rail__num { background: var(--c); color: #fff; }
.dm-st-rail__step.is-current .dm-st-rail__title { color: #0f172a; }
.dm-st-rail__step.is-done .dm-st-rail__num { background: #22C55E; color: #fff; }
.dm-st-rail__step.is-done .dm-st-rail__title { color: #334155; }
.dm-st-rail__line { width: 14px; height: 1px; background: rgba(15,23,42,0.14); margin: 0 3px; transition: background 0.45s ease; }
.dm-st-rail__line.is-on { background: var(--c, #22C55E); }
.dm-st-scrollhint { position: absolute; bottom: clamp(26px, 6vh, 60px); left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 8px; font-size: 12px; letter-spacing: 0.12em;
color: #64748b; text-transform: uppercase; text-align: center; }
.dm-st-arrow { font-size: 18px; animation: dmStBob 1.8s ease-in-out infinite; }
@keyframes dmStBob { 0%,100% { transform: translateY(0); opacity: 0.5; } 50% { transform: translateY(6px); opacity: 1; } }
/* ---- Per-stage glass content card ---- */
.dm-st-card-story { position: absolute; bottom: clamp(24px, 6vh, 64px); width: min(484px, 88vw);
pointer-events: auto; will-change: opacity, transform; padding: 20px 22px; border-radius: 20px;
background: rgba(255,255,255,0.94); border: 1px solid rgba(15,23,42,0.08);
/* backdrop blur removed — card cross-fades/translates per scroll-stage; blur was the
heaviest per-frame cost on this section. Near-opaque white keeps the glass look. */
border-top: 3px solid var(--c);
box-shadow: 0 28px 70px -34px rgba(15,23,42,0.5); }
.dm-st-card-story.is-left { left: clamp(18px, 5vw, 72px); }
.dm-st-card-story.is-right { right: clamp(18px, 5vw, 72px); }
.dm-st-card-story__head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.dm-st-pillar__num { font-size: 12px; font-weight: 800; letter-spacing: 0.1em; color: #fff;
background: var(--c); border-radius: 7px; padding: 3px 8px; }
.dm-st-pillar__kicker { font-size: clamp(11px, 1.1vw, 13px); font-weight: 700; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--c); }
.dm-st .dm-st-pillar__title { margin: 0 0 14px !important; padding: 0 !important; color: #0f172a !important;
font-weight: 700 !important; text-transform: none !important; letter-spacing: -0.015em !important;
font-size: clamp(18px, 2vw, 26px) !important; line-height: 1.16 !important; }
.dm-st .dm-st-pillar__title--hero { font-size: clamp(22px, 2.6vw, 34px) !important;
background: linear-gradient(90deg, #C01227, #E2354A) !important; -webkit-background-clip: text !important;
background-clip: text !important; -webkit-text-fill-color: transparent !important; }
.dm-st-foot { margin: 12px 0 0; font-size: clamp(12px, 1.1vw, 13.5px); line-height: 1.5; color: #475569;
display: flex; align-items: center; gap: 8px; }
.dm-st-livedot { width: 8px; height: 8px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 0 var(--c);
animation: dmStPulse 1.8s ease-out infinite; }
@keyframes dmStPulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--c) 55%, transparent); } 100% { box-shadow: 0 0 0 9px transparent; } }
/* In-scene 3D labels (drei <Html>) — crisp glass chips floating in the WebGL scene */
.dm-st3d-file, .dm-st3d-count, .dm-st3d-ai, .dm-st3d-chip {
font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif;
pointer-events: none; user-select: none; white-space: nowrap; transition: opacity 0.2s linear; }
.dm-st3d-file, .dm-st3d-ai {
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700; color: #0f172a;
background: rgba(255,255,255,0.88); border: 1px solid rgba(34,197,94,0.4); border-radius: 999px;
padding: 6px 13px; box-shadow: 0 8px 22px -12px rgba(34,197,94,0.7); backdrop-filter: blur(8px); }
.dm-st3d-count { font-size: 15px; font-weight: 800; color: #0f172a; background: rgba(255,255,255,0.9);
border: 1px solid rgba(34,197,94,0.45); border-radius: 12px; padding: 6px 14px;
box-shadow: 0 10px 26px -12px rgba(34,197,94,0.8); backdrop-filter: blur(8px); }
.dm-st3d-count span { color: #16a34a; font-size: 19px; }
.dm-st3d-chip { display: inline-flex; align-items: center; gap: 8px; background: rgba(255,255,255,0.92);
border: 1px solid rgba(34,197,94,0.4); border-radius: 12px; padding: 6px 11px;
box-shadow: 0 10px 26px -14px rgba(15,23,42,0.7); backdrop-filter: blur(8px); }
.dm-st3d-chip__ico { font-size: 17px; }
.dm-st3d-chip__txt { display: flex; flex-direction: column; line-height: 1.15; }
.dm-st3d-chip__txt b { font-size: 12.5px; font-weight: 800; color: #0f172a; }
.dm-st3d-chip__txt { font-size: 10.5px; color: #475569; }
/* STAGE 01 — Input */
.dm-st-grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 10px; }
.dm-st-tile { position: relative; background: color-mix(in srgb, var(--c) 7%, white); border: 1px solid color-mix(in srgb, var(--c) 22%, white);
border-radius: 14px; padding: 12px 14px; display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; column-gap: 10px; align-items: center; }
.dm-st-tile__ico { grid-row: 1 / 3; font-size: 24px; }
.dm-st-tile__num { font-size: 26px; font-weight: 800; color: #0f172a; line-height: 1; }
.dm-st-tile__lbl { font-size: 12px; font-weight: 700; color: #334155; }
.dm-st-tile__sub { grid-column: 1 / 3; margin-top: 4px; font-size: 10.5px; letter-spacing: 0.04em; text-transform: uppercase; color: #64748b; }
.dm-st-file { display: flex; align-items: center; gap: 9px; margin-bottom: 12px; padding: 9px 12px; border-radius: 12px;
background: rgba(15,23,42,0.04); border: 1px dashed rgba(15,23,42,0.18); }
.dm-st-file__ico { font-size: 15px; }
.dm-st-file__name { font-size: 13px; font-weight: 700; color: #0f172a; }
.dm-st-file__meta { font-size: 11.5px; color: #64748b; }
.dm-st-file__ok { margin-left: auto; font-size: 12px; font-weight: 800; color: #22C55E; }
.dm-st-riders { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.dm-st-rider { display: grid; grid-template-columns: 26px 1fr auto auto; align-items: center; gap: 9px;
padding: 6px 8px; border-radius: 10px; background: rgba(15,23,42,0.03); }
.dm-st-rider__av { position: relative; width: 26px; height: 26px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 800; color: #fff; background: linear-gradient(135deg, var(--c), color-mix(in srgb, var(--c) 60%, #1e293b)); }
.dm-st-rider__av i { position: absolute; right: -1px; bottom: -1px; width: 8px; height: 8px; border-radius: 50%; background: #22C55E; border: 2px solid #fff; }
.dm-st-rider__name { font-size: 12.5px; font-weight: 700; color: #0f172a; }
.dm-st-rider__veh { font-size: 11px; font-weight: 600; color: #475569; padding: 2px 8px; border-radius: 999px; background: rgba(15,23,42,0.06); }
.dm-st-rider__cap { font-size: 11px; color: #64748b; white-space: nowrap; }
/* STAGE 02 — Parallel execution */
.dm-st-engines { display: grid; gap: 10px; }
.dm-st-engine { padding: 11px 12px; border-radius: 14px; background: rgba(15,23,42,0.03); border: 1px solid rgba(15,23,42,0.07); }
.dm-st-engine.is-unified { background: color-mix(in srgb, var(--c) 8%, white); border-color: color-mix(in srgb, var(--c) 28%, white); }
.dm-st-engine__head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.dm-st-engine__name { font-size: 13px; font-weight: 800; color: #0f172a; }
.dm-st-engine__tag { font-size: 9.5px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b;
background: rgba(15,23,42,0.07); padding: 2px 7px; border-radius: 6px; }
.dm-st-engine__tag--u { color: #fff; background: var(--c); }
.dm-st-pills { display: flex; flex-wrap: wrap; gap: 7px; }
.dm-st-pill { font-size: 12px; font-weight: 700; color: #334155; padding: 6px 12px; border-radius: 999px;
background: rgba(255,255,255,0.9); border: 1px solid rgba(15,23,42,0.12); }
.dm-st-pill--u { color: #0f172a; background: color-mix(in srgb, var(--c) 14%, white); border-color: color-mix(in srgb, var(--c) 40%, white); }
/* STAGE 03 — Optimization pipeline */
.dm-st-pipe { display: grid; gap: 0; }
.dm-st-pipe__node { display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; column-gap: 11px; align-items: center;
padding: 11px 14px; border-radius: 14px; background: rgba(255,255,255,0.92);
border: 1px solid color-mix(in srgb, var(--c) 30%, white); box-shadow: 0 8px 22px -14px var(--c); }
.dm-st-pipe__ico { grid-row: 1 / 3; font-size: 22px; }
.dm-st-pipe__name { font-size: 13.5px; font-weight: 800; color: #0f172a; }
.dm-st-pipe__desc { font-size: 11.5px; color: #64748b; }
.dm-st-pipe__arrow { display: flex; align-items: center; justify-content: center; height: 22px; }
.dm-st-pipe__arrow i { position: relative; width: 2px; height: 22px; background: linear-gradient(180deg, color-mix(in srgb, var(--c) 50%, transparent), var(--c)); overflow: visible; }
.dm-st-pipe__arrow i::after { content: ""; position: absolute; left: 50%; top: -4px; width: 6px; height: 6px; border-radius: 50%;
background: var(--c); box-shadow: 0 0 8px var(--c); transform: translateX(-50%); animation: dmStFlow 1.4s linear infinite; }
@keyframes dmStFlow { 0% { top: -4px; opacity: 0; } 20% { opacity: 1; } 100% { top: 22px; opacity: 0; } }
/* STAGE 04 — Performance grading */
.dm-st-stars { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; }
.dm-st-stars__on { color: var(--c); letter-spacing: 2px; font-size: 16px; }
.dm-st-stars__off { color: rgba(15,23,42,0.18); font-size: 16px; }
.dm-st-stars__txt { font-size: 12px; font-weight: 700; color: #475569; margin-left: 4px; }
.dm-st-metrics { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.dm-st-metric { display: grid; grid-template-columns: 1fr 70px auto; grid-template-rows: auto auto; column-gap: 10px; row-gap: 3px; align-items: center; }
.dm-st-metric__name { font-size: 12.5px; font-weight: 700; color: #0f172a; }
.dm-st-metric__bar { grid-column: 1 / 2; grid-row: 2; height: 6px; border-radius: 999px; background: rgba(15,23,42,0.08); overflow: hidden; }
.dm-st-metric__bar i { display: block; height: 100%; border-radius: 999px; background: var(--c); }
.dm-st-metric__score { grid-column: 2; grid-row: 1 / 3; font-size: 17px; font-weight: 800; color: #0f172a; text-align: right; }
.dm-st-metric__status { grid-column: 3; grid-row: 1 / 3; font-size: 11px; font-weight: 800; color: #16a34a;
background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.3); padding: 3px 8px; border-radius: 999px; white-space: nowrap; }
.dm-st-metric__desc { grid-column: 1 / 2; grid-row: 1; font-size: 10.5px; color: #64748b; align-self: end; display: none; }
/* STAGE 05 — Strategy comparison (hero) */
.dm-st-winner { margin-bottom: 14px; }
.dm-st-winner__eyebrow { display: block; font-size: 10.5px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; color: #64748b; }
.dm-st-winner__name { display: flex; align-items: center; gap: 10px; margin: 4px 0; font-size: clamp(24px, 2.8vw, 34px); font-weight: 800; color: #0f172a; letter-spacing: -0.02em; }
.dm-st-winner__grade { display: inline-block; font-size: 11px; font-weight: 800; letter-spacing: 0.04em; color: #fff;
background: linear-gradient(90deg, #C01227, #E2354A); padding: 4px 11px; border-radius: 999px; }
.dm-st-trophy { font-size: 32px; filter: drop-shadow(0 8px 18px rgba(192,18,39,0.4)); animation: dmStFloat 3s ease-in-out infinite; }
@keyframes dmStFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
.dm-st-hero { display: flex; align-items: center; gap: 20px; }
.dm-st-hero__ring { position: relative; flex-shrink: 0; width: 96px; height: 96px; border-radius: 50%; display: flex; flex-direction: column;
align-items: center; justify-content: center; background: conic-gradient(#C01227 88%, rgba(15,23,42,0.1) 0); }
.dm-st-hero__ring::after { content: ""; position: absolute; inset: 9px; border-radius: 50%; background: #fff; }
.dm-st-hero__pct { position: relative; z-index: 1; font-size: 24px; font-weight: 800; color: #C01227; }
.dm-st-hero__sub { position: relative; z-index: 1; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: #64748b; }
.dm-st-wins { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.dm-st-wins li { font-size: 13px; color: #334155; display: flex; align-items: baseline; gap: 8px; }
.dm-st-wins strong { color: #C01227; font-weight: 800; min-width: 48px; }
@media (max-width: 1000px) {
.dm-st-rail__title { display: none; }
.dm-st-rail__step { padding: 5px 7px; }
.dm-st-rail__line { width: 9px; }
}
@media (max-width: 767px) {
.dm-st { height: 560vh; }
.dm-st-card-story { left: 0 !important; right: 0 !important; margin: 0 auto; width: calc(100% - 28px);
bottom: clamp(18px, 4vh, 40px); padding: 15px 16px; }
.dm-st-rider__cap { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.dm-st-arrow, .dm-st-trophy { animation: none !important; }
}
`;

View File

@@ -0,0 +1,67 @@
import { STAGES, N, Z_SPACING, X_OFFSET, stagePosition, samplePath } from "./theme";
describe("strategy/theme — stage data", () => {
it("exposes five stages and a matching N", () => {
expect(STAGES).toHaveLength(5);
expect(N).toBe(5);
});
it("has unique, well-formed stage descriptors", () => {
const keys = STAGES.map((s) => s.key);
expect(new Set(keys).size).toBe(keys.length);
for (const s of STAGES) {
expect(s.n).toMatch(/^\d{2}$/);
expect(s.theme).toMatch(/^#[0-9A-Fa-f]{6}$/);
expect(s.kicker.length).toBeGreaterThan(0);
}
});
});
describe("strategy/theme — stagePosition()", () => {
it("alternates x sign by parity and steps z by -Z_SPACING", () => {
expect(stagePosition(0)).toEqual([-X_OFFSET, -0.45, -0]);
expect(stagePosition(1)).toEqual([X_OFFSET, 0.55, -Z_SPACING]);
expect(stagePosition(2)).toEqual([-X_OFFSET, -0.45, -2 * Z_SPACING]);
expect(stagePosition(3)).toEqual([X_OFFSET, 0.55, -3 * Z_SPACING]);
});
it("places even stages left and odd stages right", () => {
for (let i = 0; i < N; i++) {
const [x] = stagePosition(i);
if (i % 2 === 0) expect(x).toBeLessThan(0);
else expect(x).toBeGreaterThan(0);
}
});
});
describe("strategy/theme — samplePath()", () => {
it("equals stagePosition() at integer indices", () => {
for (let i = 0; i < N; i++) {
const sampled = samplePath(i);
const exact = stagePosition(i);
sampled.forEach((v, k) => expect(v).toBeCloseTo(exact[k], 10));
}
});
it("linearly interpolates between adjacent stages", () => {
const [x, y, z] = samplePath(0.5);
expect(x).toBeCloseTo(0, 10); // midpoint of -2.5 and +2.5
expect(y).toBeCloseTo(0.05, 10); // midpoint of -0.45 and 0.55
expect(z).toBeCloseTo(-Z_SPACING / 2, 10);
});
it("clamps indices below 0 to the first stage", () => {
expect(samplePath(-3)).toEqual(stagePosition(0));
});
it("clamps indices above N-1 to the last stage", () => {
expect(samplePath(99)).toEqual(stagePosition(N - 1));
});
it("never produces NaN across a fine sweep", () => {
for (let p = 0; p <= 1.0001; p += 0.05) {
const idx = p * (N - 1);
for (const v of samplePath(idx)) expect(Number.isNaN(v)).toBe(false);
}
});
});

View File

@@ -0,0 +1,43 @@
// Shared data + geometry for the "Strategy" 3D scroll-storytelling section.
// Five themed stages laid out along a zig-zag path in 3D. The camera travels
// stage-to-stage as a normalized scroll progress (0→1) advances.
export type StageTheme = {
n: string; // "01"
key: string;
kicker: string; // short label for the step rail
theme: string; // hex accent
};
export const STAGES: StageTheme[] = [
{ n: "01", key: "input", kicker: "Input", theme: "#22C55E" }, // green
{ n: "02", key: "parallel", kicker: "Parallel Execution", theme: "#8B5CF6" }, // purple
{ n: "03", key: "optimize", kicker: "Smart Optimization", theme: "#3B82F6" }, // blue
{ n: "04", key: "grading", kicker: "Performance Grading", theme: "#F59E0B" }, // orange
{ n: "05", key: "winner", kicker: "Strategy Comparison", theme: "#C01227" }, // red — hero
];
export const N = STAGES.length;
// Zig-zag layout constants (world units).
export const Z_SPACING = 6.4;
export const X_OFFSET = 2.5;
/** Resting world position of stage `i` along the zig-zag path. */
export function stagePosition(i: number): [number, number, number] {
const x = (i % 2 === 0 ? -1 : 1) * X_OFFSET;
const y = i % 2 === 0 ? -0.45 : 0.55;
const z = -i * Z_SPACING;
return [x, y, z];
}
/** Continuous position sampled at a fractional stage index (lerp between stages). */
export function samplePath(idx: number): [number, number, number] {
const clamped = Math.max(0, Math.min(N - 1, idx));
const f = Math.floor(clamped);
const c = Math.min(N - 1, f + 1);
const t = clamped - f;
const a = stagePosition(f);
const b = stagePosition(c);
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}