update logistices

This commit is contained in:
2026-06-02 23:59:51 +05:30
parent bae2fa0daa
commit 3bad62851c
32 changed files with 2305 additions and 1222 deletions

View File

@@ -0,0 +1,155 @@
"use client";
import React, { useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
import { C, BRAIN_Y, P } from "./theme";
import { clamp01, damp, lerp, seeded, smoothstep } from "./math";
type Props = {
progress: React.RefObject<number>;
reduced?: boolean;
isMobile?: boolean;
};
/** Evenly distributed points on a unit sphere (Fibonacci spiral). */
function fibonacciSphere(n: number, radiusJitter = 0): Float32Array {
const arr = new Float32Array(n * 3);
const golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < n; i++) {
const y = 1 - (i / (n - 1)) * 2;
const r = Math.sqrt(1 - y * y);
const theta = golden * i;
const jitter = 1 + (seeded(i * 1.7) - 0.5) * radiusJitter;
arr[i * 3] = Math.cos(theta) * r * jitter;
arr[i * 3 + 1] = y * jitter;
arr[i * 3 + 2] = Math.sin(theta) * r * jitter;
}
return arr;
}
/**
* The central "Logistics Brain": a glowing icosahedral core wrapped in two
* counter-rotating wireframe shells and surrounded by thousands of orbiting
* particles. It births into the scene, breathes continuously, recedes slightly
* for the eagle-eye network pass and pulses brighter for the finale.
*/
function Brain({ progress, isMobile = false }: Props) {
const group = useRef<THREE.Group>(null);
const coreA = useRef<THREE.Mesh>(null);
const coreB = useRef<THREE.Mesh>(null);
const haloRef = useRef<THREE.Mesh>(null);
const haloMat = useRef<THREE.MeshBasicMaterial>(null);
const shellPts = useRef<THREE.Points>(null);
const orbitA = useRef<THREE.Points>(null);
const orbitB = useRef<THREE.Points>(null);
const eased = useRef(0);
const shellCount = isMobile ? 700 : 1500;
const orbitCount = isMobile ? 900 : 2200;
const shellPos = useMemo(() => fibonacciSphere(shellCount, 0.18), [shellCount]);
const orbitAPos = useMemo(() => fibonacciSphere(orbitCount, 0.55), [orbitCount]);
const orbitBPos = useMemo(() => fibonacciSphere(Math.floor(orbitCount * 0.6), 0.7), [orbitCount]);
useFrame((state, dt) => {
const p = progress.current ?? 0;
eased.current = damp(eased.current, p, 3, dt);
const e = eased.current;
const t = state.clock.elapsedTime;
const g = group.current;
if (!g) return;
// Birth: scale up from nothing across the first beat.
const birth = smoothstep(0, P.routes, e);
// Recede a touch while the camera is overhead studying the network.
const recede = smoothstep(P.network, P.network + 0.06, e) * (1 - smoothstep(P.ecosystem, P.finale, e));
// Finale: swell + brighten as the whole system lights up.
const finale = smoothstep(P.finale, 1, e);
const breathe = 1 + Math.sin(t * 0.9) * 0.03;
// Base factor keeps the "brain" small — it's a depot/control beacon over the
// map centre now, not the hero object. The map + routes carry the story.
const scale = 0.5 * lerp(0.001, 1, birth) * (1 - recede * 0.32) * (1 + finale * 0.18) * breathe;
g.scale.setScalar(scale);
g.position.y = BRAIN_Y + Math.sin(t * 0.6) * 0.18;
g.rotation.y = t * 0.12;
if (coreA.current) coreA.current.rotation.set(t * 0.18, t * 0.24, 0);
if (coreB.current) coreB.current.rotation.set(-t * 0.22, t * 0.16, t * 0.1);
if (shellPts.current) shellPts.current.rotation.y = t * 0.06;
if (orbitA.current) {
orbitA.current.rotation.y = t * 0.35;
orbitA.current.rotation.x = Math.sin(t * 0.2) * 0.3;
}
if (orbitB.current) {
orbitB.current.rotation.y = -t * 0.28;
orbitB.current.rotation.z = t * 0.18;
}
if (haloRef.current) {
const hs = 1 + Math.sin(t * 1.6) * 0.06 + finale * 0.4;
haloRef.current.scale.setScalar(hs);
}
if (haloMat.current) {
haloMat.current.opacity = clamp01(0.22 + Math.sin(t * 1.6) * 0.05 + finale * 0.25) * birth;
}
});
return (
<group ref={group} position={[0, BRAIN_Y, 0]}>
{/* Soft inner glow */}
<mesh>
<sphereGeometry args={[0.95, 24, 24]} />
<meshBasicMaterial color={C.blue} transparent opacity={0.18} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
{/* Bright core */}
<mesh>
<sphereGeometry args={[0.42, 24, 24]} />
<meshBasicMaterial color={C.white} transparent opacity={0.9} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
{/* Counter-rotating wireframe shells = the "folds" of the brain */}
<mesh ref={coreA}>
<icosahedronGeometry args={[1.35, 1]} />
<meshBasicMaterial color={C.cyan} wireframe transparent opacity={0.55} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
<mesh ref={coreB}>
<icosahedronGeometry args={[1.7, 2]} />
<meshBasicMaterial color={C.violet} wireframe transparent opacity={0.32} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
{/* Dense synapse points hugging the core */}
<points ref={shellPts}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[shellPos, 3]} />
</bufferGeometry>
<pointsMaterial size={isMobile ? 0.05 : 0.045} color={C.sky} transparent opacity={0.85} sizeAttenuation blending={THREE.AdditiveBlending} depthWrite={false} />
</points>
{/* Two orbiting particle clouds (kept tight so they don't clutter the map) */}
<points ref={orbitA} scale={1.6}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[orbitAPos, 3]} />
</bufferGeometry>
<pointsMaterial size={0.04} color={C.cyan} transparent opacity={0.6} sizeAttenuation blending={THREE.AdditiveBlending} depthWrite={false} />
</points>
<points ref={orbitB} scale={2.0}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[orbitBPos, 3]} />
</bufferGeometry>
<pointsMaterial size={0.035} color={C.purple} transparent opacity={0.45} sizeAttenuation blending={THREE.AdditiveBlending} depthWrite={false} />
</points>
{/* Pulsing halo ring */}
<mesh ref={haloRef} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[2.1, 2.25, 64]} />
<meshBasicMaterial ref={haloMat} color={C.cyan} transparent opacity={0.22} side={THREE.DoubleSide} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
</group>
);
}
export default React.memo(Brain);

View File

@@ -0,0 +1,360 @@
"use client";
import React, { useLayoutEffect, useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
import { C, CITY_RADIUS, P, ROUTE_COLORS } from "./theme";
import { damp, lerp, seeded, smoothstep } from "./math";
type Props = {
progress: React.RefObject<number>;
reduced?: boolean;
isMobile?: boolean;
};
const dummy = new THREE.Object3D();
const tmpColor = new THREE.Color();
type Building = {
x: number;
z: number;
w: number;
d: number;
h: number; // total height
bodyH: number;
rot: number;
ci: number;
tier: boolean;
tall: boolean;
};
const WIN_VERT = `
attribute vec3 aSize;
attribute float aSeed;
varying vec2 vWinUv;
varying vec3 vWinNrm;
varying vec3 vWinSize;
varying float vWinSeed;
`;
const WIN_VERT_BODY = `
vWinUv = uv;
vWinNrm = normal;
vWinSize = aSize;
vWinSeed = aSeed;
`;
const WIN_FRAG = `
uniform float uTime;
varying vec2 vWinUv;
varying vec3 vWinNrm;
varying vec3 vWinSize;
varying float vWinSeed;
float h11(float p){ p = fract(p*0.1031); p *= p+33.33; p *= p+p; return fract(p); }
float h21(vec2 p){ vec3 p3 = fract(vec3(p.xyx)*0.1031); p3 += dot(p3, p3.yzx+33.33); return fract((p3.x+p3.y)*p3.z); }
`;
const WIN_FRAG_BODY = `
// facade tint varies per building — dark neutral charcoal (brand: near-black)
vec3 baseA = vec3(0.040, 0.040, 0.048);
vec3 baseB = vec3(0.065, 0.058, 0.066);
diffuseColor.rgb = mix(baseA, baseB, h11(vWinSeed*1.7));
// windows only on the four vertical faces (skip roof/floor)
float isVert = 1.0 - step(0.5, abs(vWinNrm.y));
vec2 faceDim = abs(vWinNrm.x) > 0.5 ? vec2(vWinSize.z, vWinSize.y) : vec2(vWinSize.x, vWinSize.y);
vec2 cells = max(vec2(1.0), floor(faceDim / 0.52));
vec2 g = vWinUv * cells;
vec2 id = floor(g);
vec2 f = fract(g);
float m = 0.16; // mullion margin
float pane = step(m, f.x) * step(f.x, 1.0 - m) * step(m, f.y) * step(f.y, 1.0 - m);
float rnd = h21(id + vWinSeed * 37.0);
float lit = step(0.68, rnd); // ~32% of windows lit (dimmer skyline backdrop)
float toggle = step(0.97, h21(id * 1.31 + vWinSeed * 5.0 + floor(uTime * 0.5)));
lit = clamp(lit + toggle, 0.0, 1.0); // a few flick on/off over time
float flick = 0.9 + 0.1 * sin(uTime * 2.0 + rnd * 30.0);
// Brand-tinted windows: mostly warm white with occasional brand-red panes.
vec3 warmWhite = vec3(1.0, 0.88, 0.80);
vec3 brandRed = vec3(0.82, 0.18, 0.24);
vec3 wcol = mix(warmWhite, brandRed, step(0.7, h21(id * 0.7 + vWinSeed)));
float glow = isVert * pane * lit * flick;
totalEmissiveRadiance += wcol * glow * 0.85;
// dark mullion grid + dimmer unlit glass
diffuseColor.rgb *= (0.42 + 0.58 * pane);
`;
/**
* The futuristic logistics city. Buildings are instanced boxes, but a custom
* shader paints procedural apartment windows on their facades (lit/unlit panes,
* mullion grid, per-building variation, gentle night flicker) so they read as
* real architecture rather than blank boxes. Taller towers get a setback tier
* and a rooftop antenna with an aviation light. The whole skyline rises out of
* the ground during the birth beat.
*/
function City({ progress, isMobile = false }: Props) {
const cityGroup = useRef<THREE.Group>(null);
const bodies = useRef<THREE.InstancedMesh>(null);
const tiers = useRef<THREE.InstancedMesh>(null);
const caps = useRef<THREE.InstancedMesh>(null);
const masts = useRef<THREE.InstancedMesh>(null);
const lights = useRef<THREE.InstancedMesh>(null);
const roadsMat = useRef<THREE.LineBasicMaterial>(null);
const ring1 = useRef<THREE.Mesh>(null);
const ring2 = useRef<THREE.Mesh>(null);
const shaderRef = useRef<{ uniforms: { uTime: { value: number } } } | null>(null);
const eased = useRef(0);
const count = isMobile ? 20 : 34;
const data = useMemo(() => {
const buildings: Building[] = [];
// Inner clear radius — buildings form a dim perimeter ring only, so the whole
// centre of the map (depot, delivery nodes, candidate routes, vehicles) stays
// open and reads as the routing engine, not a city the camera flies through.
const INNER = 12.5;
for (let i = 0; i < count; i++) {
const angle = seeded(i * 3.1 + 0.5) * Math.PI * 2;
const radius = INNER + seeded(i * 1.7 + 2.2) * (CITY_RADIUS - INNER);
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
const w = 0.8 + seeded(i * 5.3) * 1.7;
const d = 0.8 + seeded(i * 2.9 + 9) * 1.7;
const h = 1.6 + seeded(i * 7.7 + 4) * 3.2;
const ci = Math.floor(seeded(i * 11.1) * ROUTE_COLORS.length);
const tier = h > 3.4 && seeded(i * 13.3) > 0.42;
const bodyH = tier ? h * 0.62 : h;
const rot = (Math.floor(seeded(i * 17.7) * 4) * Math.PI) / 8 + seeded(i * 4.1) * 0.12;
const tall = h > 5;
buildings.push({ x, z, w, d, h, bodyH, rot, ci, tier, tall });
}
const tierList = buildings.filter((b) => b.tier);
const tallList = buildings.filter((b) => b.tall);
// Per-instance shader attributes (world dimensions + a stable seed).
const bodyGeom = new THREE.BoxGeometry(1, 1, 1);
const bSize = new Float32Array(count * 3);
const bSeed = new Float32Array(count);
buildings.forEach((b, i) => {
bSize[i * 3] = b.w; bSize[i * 3 + 1] = b.bodyH; bSize[i * 3 + 2] = b.d;
bSeed[i] = seeded(i * 23.1) * 10;
});
bodyGeom.setAttribute("aSize", new THREE.InstancedBufferAttribute(bSize, 3));
bodyGeom.setAttribute("aSeed", new THREE.InstancedBufferAttribute(bSeed, 1));
const tierGeom = new THREE.BoxGeometry(1, 1, 1);
const tSize = new Float32Array(Math.max(1, tierList.length) * 3);
const tSeed = new Float32Array(Math.max(1, tierList.length));
tierList.forEach((b, i) => {
const tw = b.w * 0.64, td = b.d * 0.64, th = b.h - b.bodyH;
tSize[i * 3] = tw; tSize[i * 3 + 1] = th; tSize[i * 3 + 2] = td;
tSeed[i] = seeded(i * 29.7) * 10;
});
tierGeom.setAttribute("aSize", new THREE.InstancedBufferAttribute(tSize, 3));
tierGeom.setAttribute("aSeed", new THREE.InstancedBufferAttribute(tSeed, 1));
return { buildings, tierList, tallList, bodyGeom, tierGeom };
}, [count]);
// Shared facade material with the procedural window shader.
const facadeMat = useMemo(() => {
const mat = new THREE.MeshStandardMaterial({
color: "#0c1226",
emissive: "#000000",
metalness: 0.35,
roughness: 0.62,
});
mat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = { value: 0 };
shaderRef.current = shader as unknown as { uniforms: { uTime: { value: number } } };
shader.vertexShader = shader.vertexShader
.replace("#include <common>", `#include <common>\n${WIN_VERT}`)
.replace("#include <begin_vertex>", `#include <begin_vertex>\n${WIN_VERT_BODY}`);
shader.fragmentShader = shader.fragmentShader
.replace("#include <common>", `#include <common>\n${WIN_FRAG}`)
.replace("#include <emissivemap_fragment>", `#include <emissivemap_fragment>\n${WIN_FRAG_BODY}`);
};
return mat;
}, []);
const roadGeom = useMemo(() => {
const pts: number[] = [];
const y = 0.04;
// Radial avenues out from the centre.
const spokes = 12;
for (let i = 0; i < spokes; i++) {
const a = (i / spokes) * Math.PI * 2;
const r0 = 1.0;
const r1 = CITY_RADIUS * (0.72 + seeded(i * 4.4) * 0.26);
pts.push(Math.cos(a) * r0, y, Math.sin(a) * r0);
pts.push(Math.cos(a) * r1, y, Math.sin(a) * r1);
}
// Concentric ring roads so the ground reads as a real street grid.
const rings = [3.5, 7, 11, 15];
const seg = 64;
for (const r of rings) {
for (let i = 0; i < seg; i++) {
const a0 = (i / seg) * Math.PI * 2;
const a1 = ((i + 1) / seg) * Math.PI * 2;
pts.push(Math.cos(a0) * r, y, Math.sin(a0) * r);
pts.push(Math.cos(a1) * r, y, Math.sin(a1) * r);
}
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
return g;
}, []);
useLayoutEffect(() => {
const { buildings, tierList, tallList } = data;
if (bodies.current) {
buildings.forEach((b, i) => {
dummy.position.set(b.x, b.bodyH / 2, b.z);
dummy.rotation.set(0, b.rot, 0);
dummy.scale.set(b.w, b.bodyH, b.d);
dummy.updateMatrix();
bodies.current!.setMatrixAt(i, dummy.matrix);
});
bodies.current.instanceMatrix.needsUpdate = true;
}
if (tiers.current) {
tierList.forEach((b, i) => {
const th = b.h - b.bodyH;
dummy.position.set(b.x, b.bodyH + th / 2, b.z);
dummy.rotation.set(0, b.rot, 0);
dummy.scale.set(b.w * 0.64, th, b.d * 0.64);
dummy.updateMatrix();
tiers.current!.setMatrixAt(i, dummy.matrix);
});
tiers.current.instanceMatrix.needsUpdate = true;
}
if (caps.current) {
buildings.forEach((b, i) => {
const top = b.h;
const cw = b.tier ? b.w * 0.64 : b.w;
const cd = b.tier ? b.d * 0.64 : b.d;
dummy.position.set(b.x, top + 0.03, b.z);
dummy.rotation.set(0, b.rot, 0);
dummy.scale.set(cw * 1.02, 0.07, cd * 1.02);
dummy.updateMatrix();
caps.current!.setMatrixAt(i, dummy.matrix);
caps.current!.setColorAt(i, tmpColor.set(ROUTE_COLORS[b.ci]));
});
caps.current.instanceMatrix.needsUpdate = true;
if (caps.current.instanceColor) caps.current.instanceColor.needsUpdate = true;
}
if (masts.current && lights.current) {
tallList.forEach((b, i) => {
dummy.position.set(b.x, b.h + 0.34, b.z);
dummy.rotation.set(0, 0, 0);
dummy.scale.set(0.05, 0.68, 0.05);
dummy.updateMatrix();
masts.current!.setMatrixAt(i, dummy.matrix);
dummy.position.set(b.x, b.h + 0.7, b.z);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
lights.current!.setMatrixAt(i, dummy.matrix);
});
masts.current.instanceMatrix.needsUpdate = true;
lights.current.instanceMatrix.needsUpdate = true;
}
}, [data]);
useFrame((state, dt) => {
const p = progress.current ?? 0;
eased.current = damp(eased.current, p, 3, dt);
const e = eased.current;
const t = state.clock.elapsedTime;
if (shaderRef.current) shaderRef.current.uniforms.uTime.value = t;
// City rises from the floor during the birth beat.
const rise = smoothstep(0.01, P.routes + 0.02, e);
if (cityGroup.current) cityGroup.current.scale.y = lerp(0.001, 1, rise);
// Aviation lights — gentle glow pulse (kept soft to avoid an unnatural
// perfectly-synchronized hard blink across all masts).
if (lights.current) {
const m = lights.current.material as THREE.MeshBasicMaterial;
m.opacity = (0.5 + 0.3 * Math.sin(t * 2.2)) * rise;
}
if (roadsMat.current) {
roadsMat.current.opacity = lerp(0, 0.6, smoothstep(0.02, P.routes, e)) * (0.78 + Math.sin(t * 2.2) * 0.22);
}
const ringPulse = smoothstep(0.0, P.routes, e);
if (ring1.current) {
const s = 1 + ((t * 0.35) % 1) * 6;
ring1.current.scale.set(s, s, s);
(ring1.current.material as THREE.MeshBasicMaterial).opacity = (1 - ((t * 0.35) % 1)) * 0.28 * ringPulse;
}
if (ring2.current) {
const s = 1 + ((t * 0.35 + 0.5) % 1) * 6;
ring2.current.scale.set(s, s, s);
(ring2.current.material as THREE.MeshBasicMaterial).opacity = (1 - ((t * 0.35 + 0.5) % 1)) * 0.28 * ringPulse;
}
});
return (
<group>
{/* Energy grid floor */}
<gridHelper args={[CITY_RADIUS * 2.6, 60, C.blue, C.blue]} position={[0, 0, 0]}>
<lineBasicMaterial attach="material" color={C.blue} transparent opacity={0.11} depthWrite={false} />
</gridHelper>
{/* Radial energy roads */}
<lineSegments geometry={roadGeom}>
<lineBasicMaterial ref={roadsMat} color={C.cyan} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
</lineSegments>
{/* Foundation pulse rings beneath the brain */}
<mesh ref={ring1} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.03, 0]}>
<ringGeometry args={[1.0, 1.12, 48]} />
<meshBasicMaterial color={C.cyan} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
<mesh ref={ring2} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.03, 0]}>
<ringGeometry args={[1.0, 1.12, 48]} />
<meshBasicMaterial color={C.violet} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
{/* Skyline (rises from the floor) */}
<group ref={cityGroup}>
{/* Building bodies — windowed facade shader */}
<instancedMesh ref={bodies} args={[data.bodyGeom, facadeMat, data.buildings.length]} />
{/* Setback tiers — same facade shader */}
{data.tierList.length > 0 && (
<instancedMesh ref={tiers} args={[data.tierGeom, facadeMat, data.tierList.length]} />
)}
{/* Glowing rooftop caps */}
<instancedMesh ref={caps} args={[undefined, undefined, data.buildings.length]}>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial toneMapped={false} transparent opacity={0.9} blending={THREE.AdditiveBlending} />
</instancedMesh>
{/* Rooftop antenna masts */}
{data.tallList.length > 0 && (
<instancedMesh ref={masts} args={[undefined, undefined, data.tallList.length]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#11182c" metalness={0.7} roughness={0.4} />
</instancedMesh>
)}
{/* Aviation lights on the masts */}
{data.tallList.length > 0 && (
<instancedMesh ref={lights} args={[undefined, undefined, data.tallList.length]}>
<sphereGeometry args={[0.07, 8, 8]} />
<meshBasicMaterial color={C.red} toneMapped={false} transparent opacity={0.8} blending={THREE.AdditiveBlending} depthWrite={false} />
</instancedMesh>
)}
</group>
</group>
);
}
export default React.memo(City);

View File

@@ -0,0 +1,125 @@
"use client";
import React, { useRef } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { EffectComposer, Bloom, Vignette } from "@react-three/postprocessing";
import { KernelSize } from "postprocessing";
import * as THREE from "three";
import { C, WAYPOINTS } from "./theme";
import { clamp01, damp, lerp, smoothstep } from "./math";
import Brain from "./Brain";
import City from "./City";
import Routes from "./Routes";
import Network from "./Network";
import SLAClock from "./SLAClock";
type Props = {
progress: React.RefObject<number>;
reduced?: boolean;
isMobile?: boolean;
active?: boolean;
};
const posTarget = new THREE.Vector3();
const lookTarget = new THREE.Vector3();
/**
* Cinematic camera that flies along the waypoint spline by scroll progress.
* Adjacent waypoints are smoothstep-interpolated, then the camera exponentially
* damps toward the result so even fast/flung scrolls glide instead of snapping.
*/
function CameraRig({ progress }: { progress: React.RefObject<number> }) {
const lookCurrent = useRef(new THREE.Vector3(0, 6, 0));
const inited = useRef(false);
useFrame((state, dt) => {
const p = clamp01(progress.current ?? 0);
// Locate the active waypoint segment.
let i = 0;
for (let k = 0; k < WAYPOINTS.length - 1; k++) {
if (p >= WAYPOINTS[k].at && p <= WAYPOINTS[k + 1].at) { i = k; break; }
if (p > WAYPOINTS[WAYPOINTS.length - 1].at) i = WAYPOINTS.length - 2;
}
const a = WAYPOINTS[i];
const b = WAYPOINTS[i + 1];
const span = b.at - a.at || 1;
const lt = smoothstep(0, 1, clamp01((p - a.at) / span));
posTarget.set(
lerp(a.pos[0], b.pos[0], lt),
lerp(a.pos[1], b.pos[1], lt),
lerp(a.pos[2], b.pos[2], lt),
);
lookTarget.set(
lerp(a.look[0], b.look[0], lt),
lerp(a.look[1], b.look[1], lt),
lerp(a.look[2], b.look[2], lt),
);
// Very subtle idle breathing so the shot isn't dead-static — kept tiny (~70%
// smaller than before) so the camera reads as a fixed map view, not a fly-through.
const t = state.clock.elapsedTime;
posTarget.x += Math.sin(t * 0.16) * 0.14;
posTarget.y += Math.sin(t * 0.21) * 0.07;
const cam = state.camera;
if (!inited.current) {
cam.position.copy(posTarget);
lookCurrent.current.copy(lookTarget);
inited.current = true;
} else {
cam.position.x = damp(cam.position.x, posTarget.x, 2.6, dt);
cam.position.y = damp(cam.position.y, posTarget.y, 2.6, dt);
cam.position.z = damp(cam.position.z, posTarget.z, 2.6, dt);
lookCurrent.current.x = damp(lookCurrent.current.x, lookTarget.x, 3, dt);
lookCurrent.current.y = damp(lookCurrent.current.y, lookTarget.y, 3, dt);
lookCurrent.current.z = damp(lookCurrent.current.z, lookTarget.z, 3, dt);
}
cam.lookAt(lookCurrent.current);
});
return null;
}
function LogisticsBrainCanvas({ progress, reduced = false, isMobile = false, active = true }: Props) {
return (
<Canvas
flat
dpr={[1, isMobile || reduced ? 1.3 : 1.7]}
camera={{ position: WAYPOINTS[0].pos, fov: 52, near: 0.1, far: 200 }}
gl={{ antialias: !isMobile, powerPreference: "high-performance", alpha: false }}
frameloop={active ? "always" : "never"}
>
<color attach="background" args={[C.bg]} />
<fog attach="fog" args={[C.bg, 40, 100]} />
<ambientLight intensity={0.55} />
<directionalLight position={[8, 18, 10]} intensity={0.7} color={C.sky} />
<pointLight position={[-10, 8, -8]} intensity={40} distance={60} color={C.purple} />
<pointLight position={[0, 9, 0]} intensity={30} distance={40} color={C.cyan} />
<CameraRig progress={progress} />
<City progress={progress} reduced={reduced} isMobile={isMobile} />
<Routes progress={progress} reduced={reduced} isMobile={isMobile} />
<Network progress={progress} reduced={reduced} isMobile={isMobile} />
<SLAClock progress={progress} reduced={reduced} isMobile={isMobile} />
<Brain progress={progress} reduced={reduced} isMobile={isMobile} />
{!reduced && (
<EffectComposer multisampling={isMobile ? 0 : 2}>
<Bloom
mipmapBlur
intensity={isMobile ? 0.9 : 1.25}
luminanceThreshold={0.12}
luminanceSmoothing={0.045}
radius={isMobile ? 0.65 : 0.82}
kernelSize={KernelSize.MEDIUM}
/>
<Vignette eskil={false} offset={0.22} darkness={0.62} />
</EffectComposer>
)}
</Canvas>
);
}
export default React.memo(LogisticsBrainCanvas);

View File

@@ -0,0 +1,457 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { motion, useMotionValue, useMotionValueEvent, useTransform, type MotionValue } from "framer-motion";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { P, STRATEGIES, ENGINE_STEPS, CONSTRAINT_LIST, STRATEGY_SCORES } from "./theme";
const LogisticsBrainCanvas = dynamic(() => import("./LogisticsBrainCanvas"), { ssr: false });
/** Rounds a MotionValue to an integer for the animated stat counters. */
function Counter({ mv }: { mv: MotionValue<number> }) {
const [v, setV] = useState(0);
useMotionValueEvent(mv, "change", (x) => setV(Math.round(x)));
return <>{v}</>;
}
/** Active step index from scroll progress (1 before the engine starts). */
function stepFromProgress(p: number): number {
let s = -1;
for (let i = 0; i < ENGINE_STEPS.length; i++) if (p >= ENGINE_STEPS[i].at) s = i;
return s;
}
/** Persistent top rail: the 6 engine steps, with the current one highlighted. */
function StepRail({ active }: { active: number }) {
return (
<div className="dm-lb-rail" aria-hidden>
{ENGINE_STEPS.map((s, i) => {
const state = i < active ? "done" : i === active ? "current" : "todo";
return (
<React.Fragment key={s.n}>
{i > 0 && <span className={`dm-lb-rail__line is-${i <= active ? "on" : "off"}`} />}
<div className={`dm-lb-rail__step is-${state}`}>
<span className="dm-lb-rail__num">{i < active ? "✓" : s.n}</span>
<span className="dm-lb-rail__title">{s.title}</span>
</div>
</React.Fragment>
);
})}
</div>
);
}
/** One cross-fading workflow card pinned to the lower-left. */
function StoryCard({
opacity,
y,
num,
kicker,
title,
children,
}: {
opacity: MotionValue<number>;
y: MotionValue<number>;
num: string;
kicker: string;
title: string;
children?: React.ReactNode;
}) {
return (
<motion.div className="dm-lb-card-story" style={{ opacity, y }}>
<div className="dm-lb-card-story__head">
<span className="dm-lb-pillar__num">{num}</span>
<span className="dm-lb-pillar__kicker">{kicker}</span>
</div>
<h3 className="dm-lb-pillar__title">{title}</h3>
{children}
</motion.div>
);
}
/**
* "Logistics Brain" — one sticky, fullscreen, scroll-driven cinematic WebGL
* section. A single GSAP ScrollTrigger maps scroll position to a normalized
* progress value that drives the R3F scene, the camera spline and this overlay
* in lockstep, so the whole thing reads as one continuous shot.
*/
export default function LogisticsBrainSection() {
const containerRef = useRef<HTMLDivElement>(null);
const progressRef = useRef(0);
const scroll = useMotionValue(0);
const [pinState, setPinState] = useState<"before" | "pinned" | "after">("before");
const [step, setStep] = useState(-1);
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: "120% 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 lastStep = -1;
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 nstep = stepFromProgress(p);
if (nstep !== lastStep) { lastStep = nstep; setStep(nstep); }
},
});
const refresh = setTimeout(() => ScrollTrigger.refresh(), 300);
return () => { clearTimeout(refresh); st.kill(); };
}, [scroll]);
// Overlay transforms.
const introOpacity = useTransform(scroll, [0, 0.04, 0.1], [1, 1, 0]);
// Persistent header (title + step rail) fades in after the intro and out at the finale.
const headerOpacity = useTransform(scroll, [0.04, 0.1, P.finale - 0.04, P.finale + 0.02], [0, 1, 1, 0]);
// Per-beat kinetic-typography pillars (cross-fade across each story beat).
const p1o = useTransform(scroll, [0.135, 0.165, 0.255, 0.275], [0, 1, 1, 0]);
const p1y = useTransform(scroll, [0.135, 0.175], [26, 0]);
const p2o = useTransform(scroll, [0.29, 0.32, 0.415, 0.435], [0, 1, 1, 0]);
const p2y = useTransform(scroll, [0.29, 0.33], [26, 0]);
const p3o = useTransform(scroll, [0.45, 0.48, 0.575, 0.595], [0, 1, 1, 0]);
const p3y = useTransform(scroll, [0.45, 0.49], [26, 0]);
const p4o = useTransform(scroll, [0.61, 0.64, 0.715, 0.735], [0, 1, 1, 0]);
const p4y = useTransform(scroll, [0.61, 0.65], [26, 0]);
const p5o = useTransform(scroll, [0.75, 0.78, 0.855, 0.875], [0, 1, 1, 0]);
const p5y = useTransform(scroll, [0.75, 0.79], [26, 0]);
// Readability scrim behind the lower-left story pillars. The bright street-level
// city (esp. the EV beat) leaves the text with no contrast, so we darken the
// bottom-left corner across all pillar beats and fade it out for the intro hint
// and the centered finale.
const scrimOpacity = useTransform(scroll, [0.08, 0.13, 0.84, P.finale], [0, 1, 1, 0]);
const finaleOpacity = useTransform(scroll, [P.finale - 0.02, P.finale + 0.04], [0, 1]);
const finaleY = useTransform(scroll, [P.finale - 0.02, P.finale + 0.06], [40, 0]);
const taglineOpacity = useTransform(scroll, [P.finale + 0.04, P.finale + 0.1], [0, 1]);
const orders = useTransform(scroll, [P.finale, 0.97], [0, 59]);
const cost = useTransform(scroll, [P.finale, 0.97], [0, 18]);
return (
<section ref={containerRef} className={`dm-lb is-${pinState}`} aria-label="Logistics Brain — one intelligent system">
<div className="dm-lb-sticky">
<div className="dm-lb-card">
{mountScene && (
<div className="dm-lb-canvas">
<LogisticsBrainCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive} />
</div>
)}
<div className="dm-lb-vignette" aria-hidden />
<motion.div className="dm-lb-scrim" style={{ opacity: scrimOpacity }} aria-hidden />
<div className="dm-lb-ui">
{/* Persistent header: what this is + where we are in the workflow */}
<motion.div className="dm-lb-top" style={{ opacity: headerOpacity }}>
<div className="dm-lb-eyebrow">
<span className="dm-lb-dot" /> MileTruth Routing Engine
</div>
<StepRail active={step} />
</motion.div>
<motion.div className="dm-lb-scrollhint" style={{ opacity: introOpacity }}>
<span>Scroll to see how every delivery is planned</span>
<span className="dm-lb-arrow"></span>
</motion.div>
{/* STEP 01 — Generate Routes */}
<StoryCard opacity={p1o} y={p1y} num="01" kicker="Generate Routes" title="We create many delivery plans at once">
<div className="dm-lb-chips">
{STRATEGIES.map((s) => (
<span key={s} className="dm-lb-chip">{s}</span>
))}
</div>
<p className="dm-lb-pillar__foot">6 different ways to deliver all 59 orders generated in milliseconds.</p>
</StoryCard>
{/* STEP 02 — Check Constraints (the EV paradox) */}
<StoryCard opacity={p2o} y={p2y} num="02" kicker="Check Constraints" title="Every plan must respect real-world limits">
<ul className="dm-lb-constraints">
{CONSTRAINT_LIST.map((c) => (
<li key={c.label}>
<span className="dm-lb-constraints__icon">{c.icon}</span>
<span className="dm-lb-constraints__label">{c.label}</span>
<span className="dm-lb-constraints__note">{c.note}</span>
</li>
))}
</ul>
<p className="dm-lb-pillar__stat"><strong>59/59</strong> delivered <em>vs 34/59 when battery limits are ignored</em></p>
</StoryCard>
{/* STEP 03 — Score & Compare (the leaderboard) */}
<StoryCard opacity={p3o} y={p3y} num="03" kicker="Score &amp; Compare" title="Each plan is scored by total delivery cost">
<ul className="dm-lb-board">
{STRATEGY_SCORES.map((s) => (
<li key={s.name} className={s.win ? "is-win" : ""}>
<span className="dm-lb-board__name">{s.name}{s.win && <span className="dm-lb-board__tag">WINNER</span>}</span>
<span className="dm-lb-board__track"><span className="dm-lb-board__fill" style={{ width: `${s.score}%` }} /></span>
<span className="dm-lb-board__score">{s.score}</span>
</li>
))}
</ul>
</StoryCard>
{/* STEP 04 — Guarantee On-Time */}
<StoryCard opacity={p4o} y={p4y} num="04" kicker="Guarantee On-Time" title="Any plan even 1 minute late is rejected">
<div className="dm-lb-sla">
<span className="dm-lb-sla__badge"> On-time only</span>
<span className="dm-lb-sla__x"> Late plan dropped</span>
</div>
<p className="dm-lb-pillar__foot">We only keep plans that hit every promised delivery window.</p>
</StoryCard>
{/* STEP 05 — Pick & Dispatch */}
<StoryCard opacity={p5o} y={p5y} num="05" kicker="Pick &amp; Dispatch" title="The winning plan is sent to the fleet">
<div className="dm-lb-winner"> Multi-Trip selected lowest cost, zero delays</div>
<div className="dm-lb-chips">
<span className="dm-lb-chip">EV Bikes</span>
<span className="dm-lb-chip">Autos</span>
<span className="dm-lb-chip">Cargo Trucks</span>
</div>
</StoryCard>
{/* STEP 06 — Results: KPI cards */}
<motion.div className="dm-lb-finale" style={{ opacity: finaleOpacity }}>
<motion.div className="dm-lb-kpis" style={{ y: finaleY }}>
<div className="dm-lb-kpi">
<span className="dm-lb-kpi__num"><Counter mv={orders} />/59</span>
<span className="dm-lb-kpi__label">Orders Delivered</span>
</div>
<div className="dm-lb-kpi dm-lb-kpi--green">
<span className="dm-lb-kpi__num">0</span>
<span className="dm-lb-kpi__label">SLA Misses</span>
</div>
<div className="dm-lb-kpi">
<span className="dm-lb-kpi__num"><Counter mv={cost} />%</span>
<span className="dm-lb-kpi__label">Cost Saved</span>
</div>
</motion.div>
<motion.div className="dm-lb-logo" style={{ opacity: taglineOpacity }}>
<span className="dm-lb-logo__mark" />
MileTruth
</motion.div>
<motion.p className="dm-lb-tagline" style={{ opacity: taglineOpacity }}>
This isn&apos;t just software.<br />
<strong>This is your logistics brain.</strong>
</motion.p>
</motion.div>
</div>
</div>
</div>
<style>{styles}</style>
</section>
);
}
const styles = `
.dm-lb { position: relative; height: 640vh; background: transparent; }
.dm-lb-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden; }
.dm-lb.is-pinned .dm-lb-sticky { position: fixed; top: 0; left: 0; }
.dm-lb.is-after .dm-lb-sticky { position: absolute; top: auto; bottom: 0; }
.dm-lb-card {
position: absolute !important; inset: 16px !important;
border-radius: 28px !important; overflow: hidden !important;
background: radial-gradient(120% 100% at 50% 0%, #12090c 0%, #0a070a 55%, #060507 100%) !important;
border: 1px solid rgba(192,18,39,0.16) !important;
box-shadow: 0 30px 90px -30px rgba(0,0,0,0.85), inset 0 1px 0 rgba(255,255,255,0.04) !important;
box-sizing: border-box !important;
}
@media (max-width: 767px) { .dm-lb-card { inset: 10px !important; border-radius: 20px !important; } }
.dm-lb-canvas { position: absolute; inset: 0; z-index: 1; }
.dm-lb-canvas canvas { display: block; }
.dm-lb-vignette { position: absolute; inset: 0; z-index: 2; pointer-events: none;
background: radial-gradient(130% 110% at 50% 45%, transparent 58%, rgba(3,4,10,0.9) 100%),
linear-gradient(180deg, rgba(3,4,10,0.55) 0%, transparent 18%, transparent 70%, rgba(3,4,10,0.92) 100%); }
/* Lower-left readability scrim — keeps the story pillars legible over the bright
street-level skyline. Anchored to the bottom-left corner where the pillars sit. */
.dm-lb-scrim { position: absolute; inset: 0; z-index: 3; pointer-events: none;
background:
linear-gradient(to top right, rgba(3,4,10,0.94) 0%, rgba(3,4,10,0.74) 20%, rgba(3,4,10,0.34) 40%, transparent 60%),
linear-gradient(0deg, rgba(3,4,10,0.6) 0%, transparent 38%); }
@media (max-width: 767px) {
.dm-lb-scrim { background: linear-gradient(0deg, rgba(3,4,10,0.92) 0%, rgba(3,4,10,0.55) 28%, transparent 52%); }
}
.dm-lb-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: #eaf2ff; }
/* ---- Persistent header: title + 6-step engine rail ---- */
.dm-lb-top { position: absolute; top: clamp(16px, 3.5vh, 34px); left: 0; right: 0;
display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 0 16px; }
.dm-lb-eyebrow {
display: inline-flex; align-items: center; gap: 8px; font-size: 11px; letter-spacing: 0.28em; text-transform: uppercase;
color: #F2667A; padding: 6px 16px; border-radius: 999px; background: rgba(192,18,39,0.10);
border: 1px solid rgba(226,53,66,0.32); backdrop-filter: blur(8px); white-space: nowrap; }
.dm-lb-dot { width: 6px; height: 6px; border-radius: 50%; background: #E2354A; box-shadow: 0 0 10px #E2354A; }
.dm-lb-rail { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; max-width: 940px; }
.dm-lb-rail__step { display: inline-flex; align-items: center; gap: 7px; padding: 5px 11px; border-radius: 999px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
backdrop-filter: blur(6px); transition: all 0.45s cubic-bezier(0.22,1,0.36,1); }
.dm-lb-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: rgba(234,242,255,0.6); background: rgba(255,255,255,0.08); }
.dm-lb-rail__title { font-size: 11px; font-weight: 600; letter-spacing: 0.03em; color: rgba(234,242,255,0.55); white-space: nowrap; }
.dm-lb-rail__step.is-current { background: rgba(192,18,39,0.18); border-color: rgba(226,53,66,0.55); box-shadow: 0 0 22px -6px rgba(226,53,66,0.7); }
.dm-lb-rail__step.is-current .dm-lb-rail__num { background: linear-gradient(135deg,#E2354A,#C01227); color: #fff; }
.dm-lb-rail__step.is-current .dm-lb-rail__title { color: #fff; }
.dm-lb-rail__step.is-done .dm-lb-rail__num { background: #22C55E; color: #04130a; }
.dm-lb-rail__step.is-done .dm-lb-rail__title { color: rgba(234,242,255,0.78); }
.dm-lb-rail__line { width: 14px; height: 1px; background: rgba(255,255,255,0.12); margin: 0 3px; transition: background 0.45s ease; }
.dm-lb-rail__line.is-on { background: linear-gradient(90deg,#22C55E,#E2354A); }
.dm-lb-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: rgba(240,228,230,0.72); text-transform: uppercase; text-align: center; }
.dm-lb-arrow { font-size: 18px; animation: dmLbBob 1.8s ease-in-out infinite; }
@keyframes dmLbBob { 0%,100% { transform: translateY(0); opacity: 0.5; } 50% { transform: translateY(6px); opacity: 1; } }
/* ---- Lower-left workflow card (glass panel, cross-fades per step) ---- */
.dm-lb-card-story { position: absolute; left: clamp(18px, 4vw, 56px); bottom: clamp(26px, 7vh, 64px);
width: min(440px, 84vw); pointer-events: auto; will-change: opacity, transform;
padding: 18px 20px; border-radius: 18px;
background: rgba(14,8,10,0.6); border: 1px solid rgba(226,53,66,0.22);
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
box-shadow: 0 24px 64px -30px rgba(0,0,0,0.92); }
.dm-lb-card-story__head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.dm-lb-pillar__num { font-size: 12px; font-weight: 700; letter-spacing: 0.1em; color: #ffffff;
background: linear-gradient(135deg, #E2354A, #C01227); border-radius: 7px; padding: 3px 8px; }
.dm-lb-pillar__kicker { font-size: clamp(11px, 1.1vw, 13px); font-weight: 700; letter-spacing: 0.18em;
text-transform: uppercase; color: #F2667A; }
.dm-lb .dm-lb-pillar__title { margin: 0 0 12px !important; padding: 0 !important; color: #fbf5f6 !important;
font-weight: 700 !important; text-transform: none !important; letter-spacing: -0.015em !important;
font-size: clamp(17px, 1.9vw, 24px) !important; line-height: 1.18 !important;
text-shadow: 0 0 30px rgba(192,18,39,0.3) !important; }
.dm-lb-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.dm-lb-chip { font-size: 11.5px; font-weight: 600; letter-spacing: 0.02em; color: #f1dadd;
padding: 4px 11px; border-radius: 999px; background: rgba(192,18,39,0.12);
border: 1px solid rgba(226,53,66,0.30); white-space: nowrap; }
.dm-lb-pillar__foot { margin: 0; font-size: clamp(12px, 1.1vw, 13.5px); line-height: 1.45; color: rgba(236,224,226,0.72); }
.dm-lb-pillar__stat { margin: 6px 0 0; font-size: clamp(12.5px, 1.2vw, 15px); color: rgba(236,224,226,0.78); }
.dm-lb-pillar__stat strong { color: #4ade80; font-weight: 800; font-size: 1.25em; text-shadow: 0 0 20px rgba(34,197,94,0.5); }
.dm-lb-pillar__stat em { font-style: normal; color: rgba(230,218,220,0.55); }
/* Constraints checklist (step 02) */
.dm-lb-constraints { list-style: none; margin: 0 0 10px; padding: 0; display: grid; gap: 7px; }
.dm-lb-constraints li { display: flex; align-items: center; gap: 9px; }
.dm-lb-constraints__icon { font-size: 14px; width: 20px; text-align: center; }
.dm-lb-constraints__label { font-size: 13px; font-weight: 700; color: #fbeff0; min-width: 84px; }
.dm-lb-constraints__note { font-size: 12px; color: rgba(232,222,224,0.6); }
/* Scored leaderboard (step 03) */
.dm-lb-board { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.dm-lb-board li { display: grid; grid-template-columns: 104px 1fr 26px; align-items: center; gap: 9px; }
.dm-lb-board__name { font-size: 11.5px; font-weight: 600; color: rgba(234,226,228,0.68); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.dm-lb-board li.is-win .dm-lb-board__name { color: #fff; font-weight: 800; }
.dm-lb-board__tag { font-size: 8px; font-weight: 800; letter-spacing: 0.08em; color: #fff;
background: linear-gradient(135deg,#E2354A,#C01227); padding: 2px 5px; border-radius: 5px; }
.dm-lb-board__track { height: 7px; border-radius: 999px; background: rgba(255,255,255,0.08); overflow: hidden; }
.dm-lb-board__fill { display: block; height: 100%; border-radius: 999px; background: rgba(150,150,165,0.5); }
.dm-lb-board li.is-win .dm-lb-board__fill { background: linear-gradient(90deg,#E2354A,#C01227); box-shadow: 0 0 12px rgba(226,53,66,0.6); }
.dm-lb-board__score { font-size: 12px; font-weight: 700; color: rgba(234,226,228,0.68); text-align: right; }
.dm-lb-board li.is-win .dm-lb-board__score { color: #fff; }
/* SLA badges (step 04) */
.dm-lb-sla { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
.dm-lb-sla__badge { font-size: 12px; font-weight: 700; color: #86efac; background: rgba(34,197,94,0.1);
border: 1px solid rgba(34,197,94,0.32); padding: 6px 12px; border-radius: 999px; }
.dm-lb-sla__x { font-size: 12px; font-weight: 700; color: #fca5a5; background: rgba(239,68,68,0.1);
border: 1px solid rgba(239,68,68,0.32); padding: 6px 12px; border-radius: 999px; }
/* Winner banner (step 05) */
.dm-lb-winner { font-size: 13.5px; font-weight: 700; color: #fff; margin-bottom: 10px; padding: 9px 13px; border-radius: 12px;
background: linear-gradient(135deg, rgba(192,18,39,0.24), rgba(34,197,94,0.16)); border: 1px solid rgba(226,53,66,0.4); }
/* ---- Finale: KPI cards ---- */
.dm-lb-finale { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 0 20px; }
.dm-lb-kpis { display: flex; gap: clamp(14px, 2.4vw, 28px); margin-bottom: clamp(28px, 6vh, 56px); flex-wrap: wrap; justify-content: center; }
.dm-lb-kpi { display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: clamp(150px, 18vw, 210px);
padding: 22px 26px; border-radius: 18px; background: rgba(16,9,11,0.6); border: 1px solid rgba(226,53,66,0.28);
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); box-shadow: 0 24px 60px -28px rgba(0,0,0,0.9); }
.dm-lb-kpi--green { border-color: rgba(34,197,94,0.4); }
.dm-lb-kpi__num { font-size: clamp(38px, 5.5vw, 72px); font-weight: 800; line-height: 1; letter-spacing: -0.03em;
color: #fff; text-shadow: 0 0 32px rgba(226,53,66,0.55), 0 0 12px rgba(192,18,39,0.5); }
.dm-lb-kpi--green .dm-lb-kpi__num { color: #4ade80; text-shadow: 0 0 32px rgba(34,197,94,0.6); }
.dm-lb-kpi__label { font-size: clamp(10px, 1.1vw, 13px); letter-spacing: 0.14em; text-transform: uppercase; color: rgba(232,222,224,0.62); }
.dm-lb-logo { display: inline-flex; align-items: center; gap: 12px; font-size: clamp(22px, 3vw, 40px); font-weight: 800;
letter-spacing: -0.01em; color: #fff; margin-bottom: 18px; text-shadow: 0 0 30px rgba(192,18,39,0.55); }
.dm-lb-logo__mark { width: clamp(20px, 2.4vw, 30px); height: clamp(20px, 2.4vw, 30px); border-radius: 8px;
background: conic-gradient(from 140deg, #E2354A, #C01227, #8A0E1F, #C8102E, #E2354A);
box-shadow: 0 0 28px rgba(192,18,39,0.75); }
.dm-lb-tagline { margin: 0; font-size: clamp(15px, 1.9vw, 24px); line-height: 1.4; font-weight: 400;
color: rgba(240,224,226,0.78); letter-spacing: 0.02em; }
.dm-lb-tagline strong { display: inline-block; margin-top: 4px; font-weight: 700; color: #fff;
background: linear-gradient(90deg, #E2354A, #C01227, #C8102E); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
text-transform: uppercase; letter-spacing: 0.06em; }
/* Hide the step titles on narrower screens so the rail stays a single tidy row of numbers. */
@media (max-width: 1000px) {
.dm-lb-rail__title { display: none; }
.dm-lb-rail__step { padding: 5px 7px; }
.dm-lb-rail__line { width: 9px; }
}
@media (max-width: 767px) {
.dm-lb { height: 540vh; }
.dm-lb-kpis { gap: 12px; }
.dm-lb-kpi { min-width: 96px; padding: 14px 14px; }
.dm-lb-card-story { left: 0; right: 0; margin: 0 auto; width: calc(100% - 28px); bottom: clamp(20px, 5vh, 44px); padding: 14px 16px; }
.dm-lb-board li { grid-template-columns: 88px 1fr 24px; }
.dm-lb-constraints__note { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.dm-lb-arrow { animation: none !important; }
}
`;

View File

@@ -0,0 +1,277 @@
"use client";
import React, { useLayoutEffect, useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
import { C, CITY_RADIUS, P } from "./theme";
import { clamp01, damp, lerp, seeded, smoothstep } from "./math";
type Props = {
progress: React.RefObject<number>;
reduced?: boolean;
isMobile?: boolean;
};
const dummy = new THREE.Object3D();
const cBlue = new THREE.Color(C.blue);
const cGreen = new THREE.Color(C.green);
const tmpC = new THREE.Color();
const tmpV = new THREE.Vector3();
type Seg = { a: THREE.Vector3; b: THREE.Vector3 };
/**
* The live logistics network revealed at the eagle-eye pass: warehouses, a field
* of delivery nodes, the connection lattice between them, glow particles flowing
* through the routes (the algorithm computing), and an autonomous fleet that
* leaves the warehouses and lights up delivery markers as it completes runs.
*/
function Network({ progress, isMobile = false }: Props) {
const eased = useRef(0);
const warehouses = useRef<THREE.Group>(null);
const whMats = useRef<(THREE.MeshStandardMaterial | null)[]>([]);
const linksMat = useRef<THREE.LineBasicMaterial>(null);
const flow = useRef<THREE.Points>(null);
const fleet = useRef<THREE.InstancedMesh>(null);
const markers = useRef<THREE.InstancedMesh>(null);
const nodeCount = isMobile ? 40 : 70;
const flowCount = isMobile ? 70 : 150;
const fleetCount = isMobile ? 12 : 24;
// Warehouse anchors on a mid ring.
const whPos = useMemo(
() =>
Array.from({ length: 5 }, (_, i) => {
const a = (i / 5) * Math.PI * 2 + 0.6;
const r = 6.5;
return new THREE.Vector3(Math.cos(a) * r, 0, Math.sin(a) * r);
}),
[],
);
// Scattered delivery nodes.
const nodes = useMemo(
() =>
Array.from({ length: nodeCount }, (_, i) => {
const a = seeded(i * 3.3 + 1) * Math.PI * 2;
const r = 3 + seeded(i * 1.9 + 5) * (CITY_RADIUS - 2);
return new THREE.Vector3(Math.cos(a) * r, 0.12, Math.sin(a) * r);
}),
[nodeCount],
);
// Connection segments: each node to its nearest warehouse.
const segs = useMemo<Seg[]>(() => {
return nodes.map((n) => {
let best = whPos[0];
let bestD = Infinity;
for (const w of whPos) {
const d = n.distanceToSquared(w);
if (d < bestD) { bestD = d; best = w; }
}
return { a: new THREE.Vector3(best.x, 0.2, best.z), b: n.clone() };
});
}, [nodes, whPos]);
// Link geometry (one line per segment).
const linkGeom = useMemo(() => {
const arr: number[] = [];
for (const s of segs) arr.push(s.a.x, s.a.y, s.a.z, s.b.x, s.b.y, s.b.z);
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(arr, 3));
return g;
}, [segs]);
// Node point cloud geometry.
const nodeGeom = useMemo(() => {
const arr = new Float32Array(nodes.length * 3);
nodes.forEach((n, i) => { arr[i * 3] = n.x; arr[i * 3 + 1] = n.y; arr[i * 3 + 2] = n.z; });
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(arr, 3));
return g;
}, [nodes]);
// Flow particles: each rides a random segment.
const flowState = useMemo(
() =>
Array.from({ length: flowCount }, (_, i) => ({
seg: Math.floor(seeded(i * 4.7) * segs.length),
speed: 0.12 + seeded(i * 2.1) * 0.25,
phase: seeded(i * 8.3),
})),
[flowCount, segs.length],
);
const flowGeom = useMemo(() => {
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(new Float32Array(flowCount * 3), 3));
return g;
}, [flowCount]);
// Fleet: each vehicle rides a segment outward and loops.
const fleetState = useMemo(
() =>
Array.from({ length: fleetCount }, (_, i) => ({
seg: Math.floor(seeded(i * 5.9 + 3) * segs.length),
speed: 0.06 + seeded(i * 3.7) * 0.08,
phase: seeded(i * 6.6),
kind: Math.floor(seeded(i * 9.2) * 3), // 0 bike, 1 auto, 2 truck
})),
[fleetCount, segs.length],
);
useLayoutEffect(() => {
// Static delivery-marker pillars at each node.
if (markers.current) {
nodes.forEach((n, i) => {
dummy.position.set(n.x, 0.18, n.z);
dummy.scale.set(0.06, 0.36, 0.06);
dummy.rotation.set(0, 0, 0);
dummy.updateMatrix();
markers.current!.setMatrixAt(i, dummy.matrix);
markers.current!.setColorAt(i, cBlue);
});
markers.current.instanceMatrix.needsUpdate = true;
if (markers.current.instanceColor) markers.current.instanceColor.needsUpdate = true;
}
// Fleet base colour by kind.
if (fleet.current) {
fleetState.forEach((v, i) => {
dummy.position.set(0, -50, 0);
dummy.updateMatrix();
fleet.current!.setMatrixAt(i, dummy.matrix);
const col = v.kind === 0 ? C.cyan : v.kind === 1 ? C.violet : C.sky;
fleet.current!.setColorAt(i, tmpC.set(col));
});
fleet.current.instanceMatrix.needsUpdate = true;
if (fleet.current.instanceColor) fleet.current.instanceColor.needsUpdate = true;
}
}, [nodes, fleetState]);
useFrame((state, dt) => {
const p = progress.current ?? 0;
eased.current = damp(eased.current, p, 3, dt);
const e = eased.current;
const t = state.clock.elapsedTime;
// Delivery locations appear early (step 1) so the map is populated before the
// candidate routes are drawn; the lattice/markers strengthen through the run.
const reveal = smoothstep(P.routes - 0.02, P.routes + 0.06, e);
const eco = smoothstep(P.ecosystem - 0.03, P.ecosystem + 0.06, e);
const finaleGlow = smoothstep(P.finale, 1, e);
// Warehouses activate during the ecosystem beat.
whMats.current.forEach((m, i) => {
if (m) m.emissiveIntensity = lerp(0.3, 1.8, eco) * (0.85 + Math.sin(t * 2 + i) * 0.15);
});
if (warehouses.current) warehouses.current.visible = reveal > 0.02;
// Connection lattice.
if (linksMat.current) {
linksMat.current.opacity = (0.05 + 0.16 * reveal + 0.1 * finaleGlow) * (0.8 + Math.sin(t * 1.5) * 0.2);
}
// Flow particles streaming along the lattice.
if (flow.current) {
const attr = flow.current.geometry.getAttribute("position") as THREE.BufferAttribute;
for (let i = 0; i < flowState.length; i++) {
const f = flowState[i];
const s = segs[f.seg];
const frac = ((t * f.speed + f.phase) % 1 + 1) % 1;
tmpV.copy(s.a).lerp(s.b, frac);
attr.setXYZ(i, tmpV.x, tmpV.y + 0.05, tmpV.z);
}
attr.needsUpdate = true;
(flow.current.material as THREE.PointsMaterial).opacity = clamp01(0.2 + reveal * 0.8);
}
// Fleet rolls out during the ecosystem beat; markers light green as runs complete.
if (fleet.current) {
for (let i = 0; i < fleetState.length; i++) {
const v = fleetState[i];
const s = segs[v.seg];
const frac = ((t * v.speed + v.phase) % 1 + 1) % 1;
tmpV.copy(s.a).lerp(s.b, frac);
const scaleK = eco * (v.kind === 2 ? 1.4 : v.kind === 1 ? 1.1 : 0.85);
dummy.position.set(tmpV.x, 0.16, tmpV.z);
// orient along the segment
const dir = Math.atan2(s.b.z - s.a.z, s.b.x - s.a.x);
dummy.rotation.set(0, -dir, 0);
dummy.scale.set(0.34 * scaleK + 0.0001, 0.16 * scaleK + 0.0001, 0.2 * scaleK + 0.0001);
dummy.updateMatrix();
fleet.current.setMatrixAt(i, dummy.matrix);
}
fleet.current.instanceMatrix.needsUpdate = true;
fleet.current.visible = eco > 0.02;
}
// Delivery markers illuminate progressively (blue → green).
if (markers.current) {
for (let i = 0; i < nodes.length; i++) {
const thr = (i / nodes.length) * 0.9;
const lit = smoothstep(thr, thr + 0.08, eco) * (0.6 + Math.sin(t * 3 + i) * 0.4);
tmpC.copy(cBlue).lerp(cGreen, smoothstep(thr, thr + 0.08, eco));
markers.current.setColorAt(i, tmpC.multiplyScalar(0.3 + lit));
}
if (markers.current.instanceColor) markers.current.instanceColor.needsUpdate = true;
(markers.current.material as THREE.MeshBasicMaterial).opacity = clamp01(0.15 + reveal * 0.5 + eco * 0.4);
}
});
return (
<group>
{/* Connection lattice */}
<lineSegments geometry={linkGeom}>
<lineBasicMaterial ref={linksMat} color={C.blue} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
</lineSegments>
{/* Delivery nodes */}
<points geometry={nodeGeom}>
<pointsMaterial size={isMobile ? 0.12 : 0.1} color={C.sky} transparent opacity={0.6} sizeAttenuation blending={THREE.AdditiveBlending} depthWrite={false} />
</points>
{/* Flow particles (the algorithm computing) */}
<points ref={flow} geometry={flowGeom}>
<pointsMaterial size={isMobile ? 0.11 : 0.09} color={C.cyan} transparent opacity={0} sizeAttenuation blending={THREE.AdditiveBlending} depthWrite={false} />
</points>
{/* Delivery markers */}
<instancedMesh ref={markers} args={[undefined, undefined, nodeCount]}>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial toneMapped={false} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
</instancedMesh>
{/* Autonomous fleet */}
<instancedMesh ref={fleet} args={[undefined, undefined, fleetCount]} visible={false}>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial toneMapped={false} blending={THREE.AdditiveBlending} />
</instancedMesh>
{/* Warehouses */}
<group ref={warehouses} visible={false}>
{whPos.map((w, i) => (
<group key={i} position={[w.x, 0, w.z]}>
<mesh position={[0, 0.5, 0]}>
<boxGeometry args={[1.6, 1, 1.3]} />
<meshStandardMaterial
ref={(el) => { whMats.current[i] = el; }}
color="#0b1426"
emissive={C.blue}
emissiveIntensity={0.3}
metalness={0.4}
roughness={0.5}
/>
</mesh>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.03, 0]}>
<ringGeometry args={[1.2, 1.4, 36]} />
<meshBasicMaterial color={C.cyan} toneMapped={false} transparent opacity={0.4} side={THREE.DoubleSide} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
</group>
))}
</group>
</group>
);
}
export default React.memo(Network);

View File

@@ -0,0 +1,401 @@
"use client";
import React, { useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { Html } from "@react-three/drei";
import * as THREE from "three";
import { C, P, ROUTE_COLORS, STRATEGIES, WINNER_INDEX } from "./theme";
import { clamp01, damp, lerp, seeded, smoothstep } from "./math";
/** Score per candidate, index-aligned to STRATEGIES. Multi-Trip(0) wins; EV-Aware(4) is rejected. */
const ROUTE_SCORES = [98, 76, 84, 68, 58, 90];
const REJECT_INDEX = 4; // EV-Aware — fails the battery/range constraint
const labelBase: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "6px",
padding: "4px 11px",
borderRadius: "999px",
background: "rgba(7,11,22,0.72)",
border: "1px solid rgba(255,255,255,0.16)",
color: "#eaf2ff",
fontSize: "11px",
fontWeight: 600,
letterSpacing: "0.04em",
whiteSpace: "nowrap",
backdropFilter: "blur(6px)",
WebkitBackdropFilter: "blur(6px)",
fontFamily: "var(--font-space-grotesk), system-ui, sans-serif",
opacity: 0,
pointerEvents: "none",
willChange: "opacity",
};
type Props = {
progress: React.RefObject<number>;
reduced?: boolean;
isMobile?: boolean;
};
const WINNER = WINNER_INDEX; // the surviving optimal route (Multi-Trip)
const EV_MID = (P.ev + P.network) / 2; // moment the battery fails / recalc fires
const green = new THREE.Color(C.green);
const amber = new THREE.Color(C.amber);
const red = new THREE.Color(C.red);
const tmp = new THREE.Color();
/**
* Ground-hugging candidate route from the depot out to a delivery cluster. Each
* route kinks through an intermediate stop (so it reads as a real plan visiting
* locations, not a straight ray) but stays low on the map plane — the camera is
* a near-static top-down view, so the routes ARE the story.
*/
function makeRouteCurve(i: number): THREE.CatmullRomCurve3 {
const angle = (i / ROUTE_COLORS.length) * Math.PI * 2 + 0.4;
const radius = 9 + seeded(i * 6.1) * 4;
const end = new THREE.Vector3(Math.cos(angle) * radius, 0.16, Math.sin(angle) * radius);
// intermediate stop, offset sideways so each candidate takes a visibly different path
const side = (seeded(i * 3.7) - 0.5) * 5;
const midA = new THREE.Vector3(
Math.cos(angle) * radius * 0.4 + Math.cos(angle + Math.PI / 2) * side,
0.18,
Math.sin(angle) * radius * 0.4 + Math.sin(angle + Math.PI / 2) * side,
);
const start = new THREE.Vector3(0, 0.2, 0);
return new THREE.CatmullRomCurve3([start, midA, end]);
}
/**
* Six competing delivery strategies projected by the brain. They race packets
* through the city; the losers fade out and the optimal route keeps glowing.
* Then the EV beat: a scooter runs a ground route, its battery drains until the
* route is invalid, the brain recalculates, a charging station rises and a new
* green optimized route lights up.
*/
function Routes({ progress }: Props) {
const eased = useRef(0);
const tubeMats = useRef<(THREE.MeshBasicMaterial | null)[]>([]);
const packets = useRef<(THREE.Mesh | null)[]>([]);
const scooter = useRef<THREE.Group>(null);
const battFill = useRef<THREE.Mesh>(null);
const battMat = useRef<THREE.MeshBasicMaterial>(null);
const station = useRef<THREE.Group>(null);
const evRedMat = useRef<THREE.MeshBasicMaterial>(null);
const evGreenMat = useRef<THREE.MeshBasicMaterial>(null);
const curves = useMemo(() => ROUTE_COLORS.map((_, i) => makeRouteCurve(i)), []);
const tubes = useMemo(
() => curves.map((c) => new THREE.TubeGeometry(c, 60, 0.05, 8, false)),
[curves],
);
// EV ground path + its red (failed) and green (optimized) variants.
const evCurve = useMemo(
() =>
new THREE.CatmullRomCurve3([
new THREE.Vector3(-11, 0.3, 7),
new THREE.Vector3(-4, 0.3, 3),
new THREE.Vector3(2.6, 0.3, -1.2),
new THREE.Vector3(9.5, 0.3, -6.5),
]),
[],
);
const evTube = useMemo(() => new THREE.TubeGeometry(evCurve, 70, 0.07, 8, false), [evCurve]);
const evGreenTube = useMemo(() => {
const c = new THREE.CatmullRomCurve3([
new THREE.Vector3(-11, 0.3, 7),
new THREE.Vector3(-3.5, 0.3, 1.5),
new THREE.Vector3(2.6, 0.6, -1.2), // routes via the charging station
new THREE.Vector3(6.5, 0.3, -3),
new THREE.Vector3(9.5, 0.3, -6.5),
]);
return new THREE.TubeGeometry(c, 80, 0.08, 8, false);
}, []);
const stationPos = useMemo(() => new THREE.Vector3(2.6, 0, -1.2), []);
const scratch = useMemo(() => new THREE.Vector3(), []);
// Per-candidate base colours (winner red, others grey) so we can tint the
// rejected candidate toward red each frame as it's flagged invalid.
const baseColors = useMemo(() => ROUTE_COLORS.map((c) => new THREE.Color(c)), []);
const rejectRed = useMemo(() => new THREE.Color(C.red), []);
// Vehicles dispatched along the WINNING route at the dispatch beat.
const dispatch = useRef<(THREE.Mesh | null)[]>([]);
// 3D label anchors near each route's far end + the recharge hub.
const labelRefs = useRef<(HTMLDivElement | null)[]>([]);
const stationLabelRef = useRef<HTMLDivElement>(null);
const labelPos = useMemo(
() =>
curves.map((c) => {
const v = c.getPointAt(0.82).clone();
v.y += 1.0;
return v;
}),
[curves],
);
useFrame((state, dt) => {
const p = progress.current ?? 0;
eased.current = damp(eased.current, p, 3.2, dt);
const e = eased.current;
const t = state.clock.elapsedTime;
// Beat gates — drive the whole "why this route was chosen" story.
const appear = smoothstep(P.routes - 0.03, P.routes + 0.05, e); // candidates draw in
const racing = smoothstep(P.routes, P.routes + 0.05, e);
const reject = smoothstep(P.ev, P.ev + 0.05, e); // invalid candidate flagged
const losersGone = smoothstep(P.sla, P.ecosystem, e); // lower-scoring routes eliminated
const winnerBoost = smoothstep(P.ecosystem, P.ecosystem + 0.06, e); // winner glows stronger
const dispatchT = clamp01((e - P.ecosystem) / (1 - P.ecosystem)); // vehicles roll out along winner
const endFade = smoothstep(0.97, 1.0, e);
// Candidate routes: all draw in → one flagged invalid → losers eliminated → winner persists & glows.
for (let i = 0; i < curves.length; i++) {
const isWinner = i === WINNER;
const isReject = i === REJECT_INDEX;
const loserFade = isReject ? Math.max(losersGone, reject * 0.55) : losersGone;
const mat = tubeMats.current[i];
if (mat) {
if (isReject) mat.color.copy(baseColors[i]).lerp(rejectRed, reject);
let o = 0.55 * appear;
if (isWinner) o *= (0.72 + Math.sin(t * 3) * 0.28) * (1 + winnerBoost * 0.9);
else o *= 1 - loserFade;
mat.opacity = clamp01(o) * (1 - endFade);
}
const pk = packets.current[i];
if (pk) {
const frac = ((t * (0.16 + i * 0.02) + seeded(i * 9)) % 1 + 1) % 1;
curves[i].getPointAt(frac, scratch);
pk.position.copy(scratch);
const vis = (isWinner ? 1 : 1 - loserFade) * racing * (1 - endFade);
pk.scale.setScalar(0.0001 + vis * (isWinner ? 0.18 : 0.1));
}
}
// Floating score labels — appear at the SCORE beat; the rejected one shows its ✕
// earlier (constraints beat); winner keeps its label to the end.
for (let i = 0; i < labelRefs.current.length; i++) {
const el = labelRefs.current[i];
if (!el) continue;
const isW = i === WINNER;
const isReject = i === REJECT_INDEX;
const show = isReject
? smoothstep(P.ev, P.ev + 0.04, e)
: smoothstep(P.network, P.network + 0.04, e);
const fade = isW ? endFade : (isReject ? Math.max(losersGone, 0) : losersGone);
const o = show * (1 - fade);
el.style.opacity = o.toFixed(3);
el.style.display = o < 0.02 ? "none" : "flex";
}
// Dispatch: vehicles travel the winning route once it's selected.
if (dispatch.current.length) {
const wc = curves[WINNER];
for (let k = 0; k < dispatch.current.length; k++) {
const m = dispatch.current[k];
if (!m) continue;
const on = dispatchT > 0.02;
m.visible = on;
if (!on) continue;
const frac = ((t * 0.14 + k * 0.34) % 1 + 1) % 1;
wc.getPointAt(frac, scratch);
m.position.set(scratch.x, 0.3, scratch.z);
wc.getPointAt(Math.min(0.999, frac + 0.02), scratch);
m.lookAt(scratch.x, 0.3, scratch.z);
m.scale.setScalar(0.0001 + smoothstep(0, 0.12, dispatchT) * (1 - endFade));
}
}
// ---- EV beat ----
const evIn = smoothstep(P.ev - 0.02, P.ev + 0.03, e);
const evOut = 1 - smoothstep(P.network + 0.02, P.network + 0.1, e);
const recalc = smoothstep(EV_MID - 0.015, EV_MID + 0.03, e);
const evActive = evIn * evOut;
// Scooter travels the full ground path across the beat.
const drive = clamp01((e - P.ev) / (P.network - P.ev));
if (scooter.current) {
// After recalc the scooter follows the optimized green path.
const curve = recalc > 0.5 ? evGreenTube.parameters.path : evCurve;
curve.getPointAt(clamp01(drive), scratch);
scooter.current.position.set(scratch.x, 0.32, scratch.z);
// face direction of travel
curve.getPointAt(clamp01(drive + 0.01), scratch);
scooter.current.lookAt(scratch.x, 0.32, scratch.z);
scooter.current.visible = evActive > 0.02;
scooter.current.scale.setScalar(0.0001 + evActive);
}
// Battery: drains to near-empty, then refills after recalc/charging.
const drain = clamp01((e - P.ev) / (EV_MID - P.ev));
const level = recalc > 0 ? lerp(0.14, 1, recalc) : clamp01(1 - drain * 0.92);
if (battFill.current) {
battFill.current.scale.x = 0.02 + level * 0.98;
battFill.current.position.x = -0.18 * (1 - level); // shrink from the right
}
if (battMat.current) {
if (level > 0.5) tmp.copy(amber).lerp(green, (level - 0.5) * 2);
else tmp.copy(red).lerp(amber, level * 2);
battMat.current.color.copy(tmp);
}
// Failed (red) route fades in as battery dies, then dissolves after recalc.
if (evRedMat.current) {
const fail = smoothstep(EV_MID - 0.06, EV_MID, e);
evRedMat.current.opacity = evActive * (0.55 * Math.max(evIn * 0.4, fail)) * (1 - recalc) * (0.7 + Math.sin(t * 5) * 0.3);
}
// Optimized green route lights up after recalc.
if (evGreenMat.current) {
evGreenMat.current.opacity = evActive * recalc * (0.65 + Math.sin(t * 3) * 0.25);
}
// Charging station rises from the ground at recalc.
if (station.current) {
station.current.scale.y = 0.0001 + recalc;
station.current.visible = recalc > 0.01 && evOut > 0.02;
}
if (stationLabelRef.current) {
const o = recalc * evOut;
stationLabelRef.current.style.opacity = o.toFixed(3);
stationLabelRef.current.style.display = o < 0.02 ? "none" : "flex";
}
});
return (
<group>
{/* Six competing route simulations */}
{tubes.map((geom, i) => (
<group key={i}>
<mesh geometry={geom}>
<meshBasicMaterial
ref={(el) => { tubeMats.current[i] = el; }}
color={ROUTE_COLORS[i]}
toneMapped={false}
transparent
opacity={0}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
<mesh ref={(el) => { packets.current[i] = el; }}>
<sphereGeometry args={[1, 12, 12]} />
<meshBasicMaterial color={C.white} toneMapped={false} transparent opacity={0.95} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
</group>
))}
{/* Floating score labels above each candidate route — winner / rejected called out */}
{labelPos.map((pos, i) => {
const isWinner = i === WINNER;
const isReject = i === REJECT_INDEX;
const dotColor = isWinner || isReject ? C.red : ROUTE_COLORS[i];
return (
<Html key={`lbl${i}`} position={[pos.x, pos.y, pos.z]} center zIndexRange={[30, 0]} style={{ pointerEvents: "none" }}>
<div
ref={(el) => { labelRefs.current[i] = el; }}
style={{
...labelBase,
border: isWinner ? "1px solid rgba(226,53,66,0.85)" : isReject ? "1px solid rgba(239,68,68,0.7)" : labelBase.border,
background: isWinner ? "rgba(28,8,11,0.85)" : labelBase.background,
boxShadow: isWinner ? "0 0 22px rgba(192,18,39,0.6)" : "none",
}}
>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: dotColor, boxShadow: `0 0 8px ${dotColor}` }} />
{STRATEGIES[i]}
<span style={{ fontWeight: 800, color: isReject ? "#fca5a5" : "#fff", marginLeft: 2 }}>{ROUTE_SCORES[i]}</span>
{isWinner ? <span style={{ color: "#4ade80", fontWeight: 700 }}>&nbsp; Best</span> : null}
{isReject ? <span style={{ color: "#fca5a5", fontWeight: 700 }}>&nbsp; Over range</span> : null}
</div>
</Html>
);
})}
{/* EV failed route (red) */}
<mesh geometry={evTube}>
<meshBasicMaterial ref={evRedMat} color={C.red} toneMapped={false} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
{/* EV optimized route (green) */}
<mesh geometry={evGreenTube}>
<meshBasicMaterial ref={evGreenMat} color={C.green} toneMapped={false} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
{/* Charging station */}
<group ref={station} position={stationPos} visible={false}>
<mesh position={[0, 0.55, 0]}>
<boxGeometry args={[0.32, 1.1, 0.32]} />
<meshStandardMaterial color="#0e1b14" emissive={C.green} emissiveIntensity={0.6} metalness={0.4} roughness={0.5} />
</mesh>
<mesh position={[0, 1.18, 0]}>
<boxGeometry args={[0.5, 0.18, 0.42]} />
<meshBasicMaterial color={C.green} toneMapped={false} transparent opacity={0.9} blending={THREE.AdditiveBlending} />
</mesh>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.03, 0]}>
<ringGeometry args={[0.5, 0.62, 40]} />
<meshBasicMaterial color={C.green} toneMapped={false} transparent opacity={0.7} side={THREE.DoubleSide} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
</group>
{/* Recharge hub label (the "Kitchen / Recharge" in the EV paradox) */}
<Html position={[stationPos.x, 1.7, stationPos.z]} center zIndexRange={[30, 0]} style={{ pointerEvents: "none" }}>
<div ref={stationLabelRef} style={{ ...labelBase, border: "1px solid rgba(34,197,94,0.65)", boxShadow: "0 0 18px rgba(34,197,94,0.45)" }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: C.green, boxShadow: `0 0 8px ${C.green}` }} />
Recharge Hub
</div>
</Html>
{/* EV scooter */}
<group ref={scooter} visible={false}>
<mesh position={[0, 0.12, 0]}>
<boxGeometry args={[0.5, 0.16, 0.22]} />
<meshStandardMaterial color="#11203a" emissive={C.cyan} emissiveIntensity={0.5} metalness={0.5} roughness={0.4} />
</mesh>
<mesh position={[0.18, 0.26, 0]}>
<boxGeometry args={[0.06, 0.28, 0.12]} />
<meshStandardMaterial color="#0d1830" emissive={C.cyan} emissiveIntensity={0.4} metalness={0.5} roughness={0.4} />
</mesh>
<mesh position={[0.3, 0.14, 0]}>
<sphereGeometry args={[0.06, 10, 10]} />
<meshBasicMaterial color={C.white} toneMapped={false} />
</mesh>
{[-0.16, 0.16].map((x, i) => (
<mesh key={i} position={[x, 0.05, 0]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.09, 0.09, 0.05, 14]} />
<meshStandardMaterial color="#05070d" metalness={0.6} roughness={0.5} />
</mesh>
))}
{/* Battery indicator floating above */}
<group position={[0, 0.62, 0]}>
<mesh>
<boxGeometry args={[0.42, 0.12, 0.02]} />
<meshBasicMaterial color="#0a0f1c" toneMapped={false} transparent opacity={0.85} />
</mesh>
<mesh ref={battFill} position={[0, 0, 0.012]}>
<boxGeometry args={[0.38, 0.08, 0.02]} />
<meshBasicMaterial ref={battMat} color={C.green} toneMapped={false} blending={THREE.AdditiveBlending} />
</mesh>
</group>
</group>
{/* Dispatch fleet — bike, auto, truck rolling along the WINNING route */}
{[
{ w: 0.34, h: 0.16, d: 0.2, col: C.sky }, // bike
{ w: 0.46, h: 0.22, d: 0.26, col: C.white }, // auto
{ w: 0.6, h: 0.3, d: 0.32, col: C.red }, // truck
].map((v, k) => (
<mesh key={`disp${k}`} ref={(el) => { dispatch.current[k] = el; }} visible={false}>
<boxGeometry args={[v.w, v.h, v.d]} />
<meshStandardMaterial color="#12060a" emissive={v.col} emissiveIntensity={0.7} metalness={0.5} roughness={0.4} />
</mesh>
))}
</group>
);
}
export default React.memo(Routes);

View File

@@ -0,0 +1,157 @@
"use client";
import React, { useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
import { C, P } from "./theme";
import { clamp01, damp, lerp, seeded, smoothstep } from "./math";
type Props = {
progress: React.RefObject<number>;
reduced?: boolean;
isMobile?: boolean;
};
/**
* A giant holographic SLA clock that rises out of the city. As it sweeps,
* delayed routes flash red and dissolve into a burst of particles — the brain
* rejecting them — while the reliable routes are left glowing.
*/
function SLAClock({ progress, isMobile = false }: Props) {
const eased = useRef(0);
const group = useRef<THREE.Group>(null);
const hand = useRef<THREE.Mesh>(null);
const sweepMat = useRef<THREE.MeshBasicMaterial>(null);
const faceMats = useRef<THREE.MeshBasicMaterial[]>([]);
const burst = useRef<THREE.Points>(null);
const redMat = useRef<THREE.MeshBasicMaterial>(null);
const burstCount = isMobile ? 90 : 180;
// Random outward directions for the dissolve burst.
const burstDirs = useMemo(() => {
const arr: THREE.Vector3[] = [];
for (let i = 0; i < burstCount; i++) {
const a = seeded(i * 2.7) * Math.PI * 2;
const up = 0.3 + seeded(i * 5.1) * 1.4;
const sp = 0.4 + seeded(i * 3.3) * 1.0;
arr.push(new THREE.Vector3(Math.cos(a) * sp, up, Math.sin(a) * sp));
}
return arr;
}, [burstCount]);
const burstGeom = useMemo(() => {
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(new Float32Array(burstCount * 3), 3));
return g;
}, [burstCount]);
// A "delayed" route arc that gets rejected.
const redTube = useMemo(() => {
const c = new THREE.CatmullRomCurve3([
new THREE.Vector3(-6, 0.3, 5),
new THREE.Vector3(-2, 1.6, 1),
new THREE.Vector3(3, 0.3, -4),
]);
return new THREE.TubeGeometry(c, 50, 0.07, 8, false);
}, []);
const burstOrigin = useMemo(() => new THREE.Vector3(-2, 0.6, 1), []);
useFrame((state, dt) => {
const p = progress.current ?? 0;
eased.current = damp(eased.current, p, 3, dt);
const e = eased.current;
const t = state.clock.elapsedTime;
const slaIn = smoothstep(P.sla - 0.03, P.sla + 0.05, e);
const slaOut = 1 - smoothstep(P.ecosystem, P.ecosystem + 0.08, e);
const active = slaIn * slaOut;
if (group.current) {
group.current.visible = active > 0.02;
group.current.position.y = lerp(2.6, 4.6, slaIn);
group.current.scale.setScalar(0.0001 + active * 1.5);
group.current.rotation.z = Math.sin(t * 0.3) * 0.02;
}
if (hand.current) hand.current.rotation.z = -t * 1.4;
if (sweepMat.current) sweepMat.current.opacity = active * (0.6 + Math.sin(t * 4) * 0.2);
faceMats.current.forEach((m) => { if (m) m.opacity = active * 0.8; });
// Delayed route flashes red then dissolves.
const flash = smoothstep(P.sla + 0.02, P.sla + 0.08, e);
const dissolve = ((t * 0.45) % 1);
if (redMat.current) {
redMat.current.opacity = active * flash * (1 - dissolve) * (0.6 + Math.sin(t * 6) * 0.4);
}
if (burst.current) {
const attr = burst.current.geometry.getAttribute("position") as THREE.BufferAttribute;
const r = dissolve * 3.4;
for (let i = 0; i < burstDirs.length; i++) {
const d = burstDirs[i];
attr.setXYZ(
i,
burstOrigin.x + d.x * r,
burstOrigin.y + d.y * r - dissolve * dissolve * 1.2,
burstOrigin.z + d.z * r,
);
}
attr.needsUpdate = true;
(burst.current.material as THREE.PointsMaterial).opacity = clamp01(active * flash * (1 - dissolve));
}
});
return (
<group>
{/* Holographic clock */}
<group ref={group} position={[0, 7, 0]} visible={false}>
{/* Outer ring */}
<mesh>
<torusGeometry args={[1, 0.03, 12, 64]} />
<meshBasicMaterial color={C.cyan} toneMapped={false} transparent opacity={0.8} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
{/* Inner ring */}
<mesh>
<torusGeometry args={[0.78, 0.012, 10, 64]} />
<meshBasicMaterial color={C.blue} toneMapped={false} transparent opacity={0.5} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
{/* Tick marks */}
{Array.from({ length: 12 }).map((_, i) => {
const a = (i / 12) * Math.PI * 2;
return (
<mesh key={i} position={[Math.cos(a) * 0.88, Math.sin(a) * 0.88, 0]} rotation={[0, 0, a]}>
<boxGeometry args={[0.1, 0.02, 0.02]} />
<meshBasicMaterial
ref={(el) => { if (el) faceMats.current[i] = el; }}
color={i % 3 === 0 ? C.white : C.cyan}
toneMapped={false}
transparent
opacity={0.8}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
);
})}
{/* Sweeping hand */}
<mesh ref={hand} position={[0, 0, 0.02]}>
<boxGeometry args={[0.04, 1.5, 0.02]} />
<meshBasicMaterial ref={sweepMat} color={C.green} toneMapped={false} transparent opacity={0.7} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
{/* Hub */}
<mesh>
<sphereGeometry args={[0.07, 16, 16]} />
<meshBasicMaterial color={C.white} toneMapped={false} />
</mesh>
</group>
{/* Delayed route + dissolve burst */}
<mesh geometry={redTube}>
<meshBasicMaterial ref={redMat} color={C.red} toneMapped={false} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
</mesh>
<points ref={burst} geometry={burstGeom}>
<pointsMaterial size={isMobile ? 0.12 : 0.1} color={C.red} transparent opacity={0} sizeAttenuation blending={THREE.AdditiveBlending} depthWrite={false} />
</points>
</group>
);
}
export default React.memo(SLAClock);

View File

@@ -0,0 +1,33 @@
/**
* Frame-rate-independent math helpers for the Logistics Brain scene.
*
* NOTE: these are intentionally local `function` declarations rather than
* importing from `../optimization/math`. Under Turbopack, cross-folder imports
* of that module's `const`-arrow exports (`lerp`, `clamp01`) resolved to
* `undefined` at runtime ("lerp is not defined" thrown every frame) even though
* they type-check and build fine. Keeping the section self-contained avoids it.
*/
export function clamp01(v: number): number {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function smoothstep(edge0: number, edge1: number, x: number): number {
const t = clamp01((x - edge0) / (edge1 - edge0 || 1));
return t * t * (3 - 2 * t);
}
/** Exponential damp toward a target — `lambda` ~ responsiveness. */
export function damp(current: number, target: number, lambda: number, dt: number): number {
return lerp(current, target, 1 - Math.exp(-lambda * dt));
}
/** Deterministic pseudo-random in [0,1) — stable across SSR/render. */
export function seeded(i: number): number {
const x = Math.sin(i * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
}

View File

@@ -0,0 +1,179 @@
/**
* Shared design tokens, scroll-phase thresholds and the cinematic camera path
* for the "Logistics Brain" scroll-storytelling section.
*
* The whole experience is driven by ONE normalized scroll progress value
* (0 → 1) shared between GSAP ScrollTrigger, the R3F render loop and the
* Framer-Motion HTML overlay — so every layer stays perfectly in sync and the
* journey reads as one continuous shot with no scene cuts.
*/
/**
* Doormile brand palette: near-black + brand red, with green reserved for the
* "delivered / optimized" success state.
*
* NOTE ON TOKEN NAMES: the keys below (cyan/blue/violet/…) are the original
* cool-palette slot names, kept ONLY so the many `C.cyan`-style references in
* the scene files don't all have to change. Their *values* are now brand reds
* and neutral steels — read the inline comment, not the key name. New code
* should prefer the semantic aliases (red / redBright / redSoft / steel).
*/
export const C = {
bg: "#08080c", // near-black scene background
red: "#C01227", // PRIMARY brand red
cyan: "#E2354A", // redBright — main glowing red accent (roads, rings, brain, scooter)
blue: "#7E1420", // crimson — dim deep-red structural glow (grid, links, soft glows)
sky: "#F2667A", // redSoft — light coral highlight points
magenta: "#C8102E", // secondary brand red
violet: "#3c3c46", // steel — neutral grey structure (losing routes, secondary shell)
purple: "#6a6a76", // steelLight — lighter neutral grey
steel: "#43434d", // losing-route grey
gray: "#7c7c86", // losing-route grey (lighter)
green: "#22C55E", // success / delivered (unchanged)
amber: "#F59E0B", // transitional (battery mid-charge) (unchanged)
white: "#FFFFFF",
} as const;
/**
* Palette for the six competing route simulations. Index 0 (Multi-Trip) is the
* winner and gets the brand red so it stands out; the five losing strategies
* are neutral greys that recede, then fade out entirely.
*/
export const ROUTE_COLORS = [
C.red, // 0 — the eventual winner (brand red)
C.steel,
C.gray,
C.steel,
C.gray,
C.steel,
] as const;
/**
* The six strategies the "Parallel Universe Engine" benchmarks (index-aligned
* to ROUTE_COLORS). Index 0 = Multi-Trip is the winner — it's also what solves
* the EV paradox (59/59 vs 34/59), tying the routes beat to the EV beat.
*/
export const STRATEGIES = [
"Multi-Trip",
"Proximity",
"Balanced",
"Fuel Saver",
"EV-Aware",
"Time-Aware",
] as const;
export const WINNER_INDEX = 0;
/** The constraints the VRP solver optimizes together (Mathematical Precision). */
export const CONSTRAINTS = ["Capacity", "Distance", "Battery", "Traffic", "SLA"] as const;
/** Plain-language constraints shown in the EV / constraints beat. */
export const CONSTRAINT_LIST = [
{ icon: "🔋", label: "Battery", note: "EV range & recharge stops" },
{ icon: "📍", label: "Distance", note: "Total km per route" },
{ icon: "📦", label: "Capacity", note: "Orders each vehicle can carry" },
{ icon: "⏱️", label: "Time / SLA", note: "Promised delivery windows" },
] as const;
/**
* Scored leaderboard for the "Score & Compare" beat. Higher = better total
* delivery cost/outcome. Multi-Trip wins; EV-Aware (the naive baseline) scores
* worst — it's the one that only delivered 34/59 in the EV paradox beat.
*/
export const STRATEGY_SCORES: { name: string; score: number; win?: boolean }[] = [
{ name: "Multi-Trip", score: 98, win: true },
{ name: "Time-Aware", score: 90 },
{ name: "Balanced", score: 84 },
{ name: "Proximity", score: 76 },
{ name: "Fuel Saver", score: 68 },
{ name: "EV-Aware", score: 58 },
];
/** Layout anchors (world units). */
export const BRAIN_Y = 3.0; // depot beacon hovers low over the map centre
export const CITY_RADIUS = 19;
/**
* Scroll-phase thresholds (normalized 0 → 1). These are *story beats*, not hard
* cuts — every element cross-fades across a window around its beat so the scene
* morphs continuously.
*/
export const P = {
birth: 0.0, // glowing brain + city form, energy roads grow
routes: 0.13, // six route simulations race through the city
ev: 0.28, // EV battery drains → recalc → charging station → green route
network: 0.44, // camera rises to eagle-eye; full live network
sla: 0.6, // holographic SLA clock; delayed routes dissolve
ecosystem: 0.74, // pull back; warehouses + fleet come alive
finale: 0.88, // fly up; stats + logo + tagline reveal
} as const;
export type PhaseKey = keyof typeof P;
/**
* The six steps of the routing engine, in plain language — the spine of the
* on-screen story. The persistent step rail and the per-beat content cards both
* read from this so a non-technical viewer can follow the workflow. `at` is the
* scroll-progress threshold where each step becomes the active one.
*/
export const ENGINE_STEPS = [
{ n: "01", at: P.routes, title: "Generate Routes", caption: "Many delivery plans created at once" },
{ n: "02", at: P.ev, title: "Check Constraints", caption: "Battery, distance, capacity & time" },
{ n: "03", at: P.network, title: "Score & Compare", caption: "Every plan ranked by total cost" },
{ n: "04", at: P.sla, title: "Guarantee On-Time", caption: "Late plans rejected automatically" },
{ n: "05", at: P.ecosystem, title: "Pick & Dispatch", caption: "Best plan sent to the fleet" },
{ n: "06", at: P.finale, title: "Delivered", caption: "Real business results" },
] as const;
export const PHASE_CAPTIONS: Record<PhaseKey, string> = {
birth: "Initializing the logistics brain",
routes: "Simulating delivery strategies",
ev: "Recalculating around EV constraints",
network: "Mapping the live delivery network",
sla: "Enforcing on-time SLA reliability",
ecosystem: "Autonomous fleet in motion",
finale: "",
};
export function captionFor(p: number): string {
if (p >= P.finale) return PHASE_CAPTIONS.finale;
if (p >= P.ecosystem) return PHASE_CAPTIONS.ecosystem;
if (p >= P.sla) return PHASE_CAPTIONS.sla;
if (p >= P.network) return PHASE_CAPTIONS.network;
if (p >= P.ev) return PHASE_CAPTIONS.ev;
if (p >= P.routes) return PHASE_CAPTIONS.routes;
return PHASE_CAPTIONS.birth;
}
/**
* Camera waypoints sampled by scroll progress. Each has an eye position and a
* look-at target. The rig lerps between adjacent waypoints (smoothstep eased)
* and then exponentially damps toward the result — giving a smooth spline-like
* dolly with no jerk on fast scroll.
*/
export type Waypoint = {
at: number;
pos: [number, number, number];
look: [number, number, number];
};
/**
* NEAR-STATIC MAP VIEW. The camera holds a consistent elevated 3/4 "control
* room" angle on the depot/map the whole way through — only small, slow shifts
* between beats (≈70% less travel than a cinematic fly-through). The story is
* told by the OBJECTS on the map (nodes, candidate routes, scores, vehicles),
* not by camera moves. Keep these positions clustered; large deltas reintroduce
* the "city fly-through" feel we're removing.
*/
export const WAYPOINTS: Waypoint[] = [
{ at: 0.0, pos: [0, 20, 27], look: [0, 1.4, 0] }, // establish the map
{ at: 0.13, pos: [-2.5, 20, 26.5], look: [-0.5, 1.2, 0] }, // routes generate
{ at: 0.28, pos: [3, 18.5, 25.5], look: [0.6, 0.8, -0.8] }, // constraints — ease toward street
{ at: 0.44, pos: [0, 23, 24.5], look: [0, 1.0, 0] }, // score — lift slightly to read labels
{ at: 0.6, pos: [2.5, 21, 25.5], look: [0, 1.2, 0] }, // SLA — small lateral drift
{ at: 0.74, pos: [-2, 20, 26.5], look: [0, 1.2, 0] }, // dispatch
{ at: 0.88, pos: [0, 22, 25.5], look: [0, 1.8, 0] }, // results
{ at: 1.0, pos: [0, 21.5, 25.5], look: [0, 2.0, 0] },
];