update logistices
This commit is contained in:
155
src/components/logisticsbrain/Brain.tsx
Normal file
155
src/components/logisticsbrain/Brain.tsx
Normal 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);
|
||||
360
src/components/logisticsbrain/City.tsx
Normal file
360
src/components/logisticsbrain/City.tsx
Normal 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);
|
||||
125
src/components/logisticsbrain/LogisticsBrainCanvas.tsx
Normal file
125
src/components/logisticsbrain/LogisticsBrainCanvas.tsx
Normal 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);
|
||||
457
src/components/logisticsbrain/LogisticsBrainSection.tsx
Normal file
457
src/components/logisticsbrain/LogisticsBrainSection.tsx
Normal 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 & 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 & 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'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; }
|
||||
}
|
||||
`;
|
||||
277
src/components/logisticsbrain/Network.tsx
Normal file
277
src/components/logisticsbrain/Network.tsx
Normal 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);
|
||||
401
src/components/logisticsbrain/Routes.tsx
Normal file
401
src/components/logisticsbrain/Routes.tsx
Normal 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 }}> ✓ Best</span> : null}
|
||||
{isReject ? <span style={{ color: "#fca5a5", fontWeight: 700 }}> ✕ 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);
|
||||
157
src/components/logisticsbrain/SLAClock.tsx
Normal file
157
src/components/logisticsbrain/SLAClock.tsx
Normal 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);
|
||||
33
src/components/logisticsbrain/math.ts
Normal file
33
src/components/logisticsbrain/math.ts
Normal 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);
|
||||
}
|
||||
179
src/components/logisticsbrain/theme.ts
Normal file
179
src/components/logisticsbrain/theme.ts
Normal 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] },
|
||||
];
|
||||
Reference in New Issue
Block a user