diff --git a/src/animations/AnimationProvider.tsx b/src/animations/AnimationProvider.tsx index 7d71241..4b24af9 100644 --- a/src/animations/AnimationProvider.tsx +++ b/src/animations/AnimationProvider.tsx @@ -16,7 +16,7 @@ export default function AnimationProvider({ children }: { children: React.ReactN const initDecorativeBlocks = () => { // Clean up previous block triggers to avoid duplicates ScrollTrigger.getAll().forEach((t) => { - if (t.vars && (t.vars as any).id === "block-deco") { + if (t.vars && (t.vars as { id?: string }).id === "block-deco") { t.kill(); } }); diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index 654960d..f04a5c2 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -1,6 +1,5 @@ import React from "react"; import ContactsHero from "@/components/sections/ContactsHero"; -import ContactForm from "@/components/sections/ContactForm"; import ContactMap from "@/components/sections/ContactMap"; export const metadata = { @@ -15,7 +14,6 @@ export default function ContactPage() {
-
diff --git a/src/app/miletruth/page.tsx b/src/app/miletruth/page.tsx index ff47d0d..d79c4bd 100644 --- a/src/app/miletruth/page.tsx +++ b/src/app/miletruth/page.tsx @@ -3,7 +3,7 @@ import MileTruthHero from "../../components/sections/MileTruthHero"; import Workflow1 from "../../components/sections/Workflow1"; import Workflow2 from "../../components/sections/Workflow2"; import Workflow3 from "../../components/sections/Workflow3"; -import PerformanceSection from "../../components/performance/PerformanceSection"; +import LogisticsBrainSection from "../../components/logisticsbrain/LogisticsBrainSection"; export const metadata = { title: "MileTruth – Doormile", @@ -20,7 +20,7 @@ export default function MileTruthPage() { - + diff --git a/src/components/logisticsbrain/Brain.tsx b/src/components/logisticsbrain/Brain.tsx new file mode 100644 index 0000000..29514f5 --- /dev/null +++ b/src/components/logisticsbrain/Brain.tsx @@ -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; + 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(null); + const coreA = useRef(null); + const coreB = useRef(null); + const haloRef = useRef(null); + const haloMat = useRef(null); + const shellPts = useRef(null); + const orbitA = useRef(null); + const orbitB = useRef(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 ( + + {/* Soft inner glow */} + + + + + + {/* Bright core */} + + + + + + {/* Counter-rotating wireframe shells = the "folds" of the brain */} + + + + + + + + + + {/* Dense synapse points hugging the core */} + + + + + + + + {/* Two orbiting particle clouds (kept tight so they don't clutter the map) */} + + + + + + + + + + + + + + {/* Pulsing halo ring */} + + + + + + ); +} + +export default React.memo(Brain); diff --git a/src/components/logisticsbrain/City.tsx b/src/components/logisticsbrain/City.tsx new file mode 100644 index 0000000..92965d3 --- /dev/null +++ b/src/components/logisticsbrain/City.tsx @@ -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; + 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(null); + const bodies = useRef(null); + const tiers = useRef(null); + const caps = useRef(null); + const masts = useRef(null); + const lights = useRef(null); + const roadsMat = useRef(null); + const ring1 = useRef(null); + const ring2 = useRef(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 ", `#include \n${WIN_VERT}`) + .replace("#include ", `#include \n${WIN_VERT_BODY}`); + shader.fragmentShader = shader.fragmentShader + .replace("#include ", `#include \n${WIN_FRAG}`) + .replace("#include ", `#include \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 ( + + {/* Energy grid floor */} + + + + + {/* Radial energy roads */} + + + + + {/* Foundation pulse rings beneath the brain */} + + + + + + + + + + {/* Skyline (rises from the floor) */} + + {/* Building bodies — windowed facade shader */} + + {/* Setback tiers — same facade shader */} + {data.tierList.length > 0 && ( + + )} + {/* Glowing rooftop caps */} + + + + + {/* Rooftop antenna masts */} + {data.tallList.length > 0 && ( + + + + + )} + {/* Aviation lights on the masts */} + {data.tallList.length > 0 && ( + + + + + )} + + + ); +} + +export default React.memo(City); diff --git a/src/components/logisticsbrain/LogisticsBrainCanvas.tsx b/src/components/logisticsbrain/LogisticsBrainCanvas.tsx new file mode 100644 index 0000000..5a284d5 --- /dev/null +++ b/src/components/logisticsbrain/LogisticsBrainCanvas.tsx @@ -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; + 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 }) { + 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 ( + + + + + + + + + + + + + + + + + {!reduced && ( + + + + + )} + + ); +} + +export default React.memo(LogisticsBrainCanvas); diff --git a/src/components/logisticsbrain/LogisticsBrainSection.tsx b/src/components/logisticsbrain/LogisticsBrainSection.tsx new file mode 100644 index 0000000..c7e99c7 --- /dev/null +++ b/src/components/logisticsbrain/LogisticsBrainSection.tsx @@ -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 }) { + 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 ( +
+ {ENGINE_STEPS.map((s, i) => { + const state = i < active ? "done" : i === active ? "current" : "todo"; + return ( + + {i > 0 && } +
+ {i < active ? "✓" : s.n} + {s.title} +
+
+ ); + })} +
+ ); +} + +/** One cross-fading workflow card pinned to the lower-left. */ +function StoryCard({ + opacity, + y, + num, + kicker, + title, + children, +}: { + opacity: MotionValue; + y: MotionValue; + num: string; + kicker: string; + title: string; + children?: React.ReactNode; +}) { + return ( + +
+ {num} + {kicker} +
+

{title}

+ {children} +
+ ); +} + +/** + * "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(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 ( +
+
+
+ {mountScene && ( +
+ +
+ )} +
+ + +
+ {/* Persistent header: what this is + where we are in the workflow */} + +
+ MileTruth Routing Engine +
+ +
+ + + Scroll to see how every delivery is planned + + + + {/* STEP 01 — Generate Routes */} + +
+ {STRATEGIES.map((s) => ( + {s} + ))} +
+

6 different ways to deliver all 59 orders — generated in milliseconds.

+
+ + {/* STEP 02 — Check Constraints (the EV paradox) */} + +
    + {CONSTRAINT_LIST.map((c) => ( +
  • + {c.icon} + {c.label} + {c.note} +
  • + ))} +
+

59/59 delivered vs 34/59 when battery limits are ignored

+
+ + {/* STEP 03 — Score & Compare (the leaderboard) */} + +
    + {STRATEGY_SCORES.map((s) => ( +
  • + {s.name}{s.win && WINNER} + + {s.score} +
  • + ))} +
+
+ + {/* STEP 04 — Guarantee On-Time */} + +
+ ⏱️ On-time only + ✕ Late plan → dropped +
+

We only keep plans that hit every promised delivery window.

+
+ + {/* STEP 05 — Pick & Dispatch */} + +
✓ Multi-Trip selected — lowest cost, zero delays
+
+ EV Bikes + Autos + Cargo Trucks +
+
+ + {/* STEP 06 — Results: KPI cards */} + + +
+ /59 + Orders Delivered +
+
+ 0 + SLA Misses +
+
+ % + Cost Saved +
+
+ + + + MileTruth + + + This isn't just software.
+ This is your logistics brain. +
+
+
+
+
+ +
+ ); +} + +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; } +} +`; diff --git a/src/components/logisticsbrain/Network.tsx b/src/components/logisticsbrain/Network.tsx new file mode 100644 index 0000000..21338b0 --- /dev/null +++ b/src/components/logisticsbrain/Network.tsx @@ -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; + 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(null); + const whMats = useRef<(THREE.MeshStandardMaterial | null)[]>([]); + const linksMat = useRef(null); + const flow = useRef(null); + const fleet = useRef(null); + const markers = useRef(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(() => { + 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 ( + + {/* Connection lattice */} + + + + + {/* Delivery nodes */} + + + + + {/* Flow particles (the algorithm computing) */} + + + + + {/* Delivery markers */} + + + + + + {/* Autonomous fleet */} + + + + + + {/* Warehouses */} + + {whPos.map((w, i) => ( + + + + { whMats.current[i] = el; }} + color="#0b1426" + emissive={C.blue} + emissiveIntensity={0.3} + metalness={0.4} + roughness={0.5} + /> + + + + + + + ))} + + + ); +} + +export default React.memo(Network); diff --git a/src/components/logisticsbrain/Routes.tsx b/src/components/logisticsbrain/Routes.tsx new file mode 100644 index 0000000..cf1cc25 --- /dev/null +++ b/src/components/logisticsbrain/Routes.tsx @@ -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; + 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(null); + const battFill = useRef(null); + const battMat = useRef(null); + const station = useRef(null); + const evRedMat = useRef(null); + const evGreenMat = useRef(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(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 ( + + {/* Six competing route simulations */} + {tubes.map((geom, i) => ( + + + { tubeMats.current[i] = el; }} + color={ROUTE_COLORS[i]} + toneMapped={false} + transparent + opacity={0} + blending={THREE.AdditiveBlending} + depthWrite={false} + /> + + { packets.current[i] = el; }}> + + + + + ))} + + {/* 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 ( + +
{ 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", + }} + > + + {STRATEGIES[i]} + {ROUTE_SCORES[i]} + {isWinner ?  ✓ Best : null} + {isReject ?  ✕ Over range : null} +
+ + ); + })} + + {/* EV failed route (red) */} + + + + {/* EV optimized route (green) */} + + + + + {/* Charging station */} + + + + + + + + + + + + + + + + {/* Recharge hub label (the "Kitchen / Recharge" in the EV paradox) */} + +
+ + Recharge Hub +
+ + + {/* EV scooter */} + + + + + + + + + + + + + + {[-0.16, 0.16].map((x, i) => ( + + + + + ))} + {/* Battery indicator floating above */} + + + + + + + + + + + + + {/* 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) => ( + { dispatch.current[k] = el; }} visible={false}> + + + + ))} +
+ ); +} + +export default React.memo(Routes); diff --git a/src/components/logisticsbrain/SLAClock.tsx b/src/components/logisticsbrain/SLAClock.tsx new file mode 100644 index 0000000..bd75b0d --- /dev/null +++ b/src/components/logisticsbrain/SLAClock.tsx @@ -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; + 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(null); + const hand = useRef(null); + const sweepMat = useRef(null); + const faceMats = useRef([]); + const burst = useRef(null); + const redMat = useRef(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 ( + + {/* Holographic clock */} + + {/* Outer ring */} + + + + + {/* Inner ring */} + + + + + {/* Tick marks */} + {Array.from({ length: 12 }).map((_, i) => { + const a = (i / 12) * Math.PI * 2; + return ( + + + { 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} + /> + + ); + })} + {/* Sweeping hand */} + + + + + {/* Hub */} + + + + + + + {/* Delayed route + dissolve burst */} + + + + + + + + ); +} + +export default React.memo(SLAClock); diff --git a/src/components/logisticsbrain/math.ts b/src/components/logisticsbrain/math.ts new file mode 100644 index 0000000..ee5aca5 --- /dev/null +++ b/src/components/logisticsbrain/math.ts @@ -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); +} diff --git a/src/components/logisticsbrain/theme.ts b/src/components/logisticsbrain/theme.ts new file mode 100644 index 0000000..8d27a69 --- /dev/null +++ b/src/components/logisticsbrain/theme.ts @@ -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 = { + 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] }, +]; diff --git a/src/components/optimization/AICore.tsx b/src/components/optimization/AICore.tsx index 4aa25f5..e63f919 100644 --- a/src/components/optimization/AICore.tsx +++ b/src/components/optimization/AICore.tsx @@ -112,7 +112,7 @@ const CalculationBeamMemo = React.memo(CalculationBeam); * a particle shell and neural links. It powers up during the AI-scan phase, * fires an expanding radar scan, then a route-optimization burst. */ -function AICore({ progress, reduced = false }: Props) { +function AICore({ progress }: Props) { const root = useRef(null); const hubOffsets = useMemo(() => { diff --git a/src/components/optimization/HologramCity.tsx b/src/components/optimization/HologramCity.tsx index cb24050..a3ae5db 100644 --- a/src/components/optimization/HologramCity.tsx +++ b/src/components/optimization/HologramCity.tsx @@ -26,7 +26,7 @@ const clusterCenters = Array.from({ length: clusterCount }, (_, c) => { return new THREE.Vector3(cx, 0.1, cz); }); -function HologramCity({ progress, reduced = false }: Props) { +function HologramCity({ progress }: Props) { const mainWarehouseRef = useRef(null); const radarRefs = useRef<(THREE.Group | null)[]>([]); const corridorMats = useRef<(THREE.LineBasicMaterial | null)[]>([]); diff --git a/src/components/optimization/OptimizationCanvas.tsx b/src/components/optimization/OptimizationCanvas.tsx index c654582..8a1ee5c 100644 --- a/src/components/optimization/OptimizationCanvas.tsx +++ b/src/components/optimization/OptimizationCanvas.tsx @@ -1,12 +1,11 @@ "use client"; -import React, { useMemo, useRef } from "react"; +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 { COLORS } from "./constants"; -import { damp, lerp, seeded } from "./math"; +import { damp, lerp } from "./math"; import HologramCity from "./HologramCity"; import RouteSystem from "./RouteSystem"; import VehicleFleet from "./VehicleFleet"; diff --git a/src/components/optimization/OptimizationSection.tsx b/src/components/optimization/OptimizationSection.tsx index cfbe9a2..087ccdd 100644 --- a/src/components/optimization/OptimizationSection.tsx +++ b/src/components/optimization/OptimizationSection.tsx @@ -451,19 +451,22 @@ const styles = ` top: 110px !important; left: 40px !important; right: 40px !important; - bottom: 24px !important; - border-radius: 35px !important; + bottom: 0 !important; + /* flat bottom + flush to container so the Performance card butts directly + against it, reading as one continuous container (home-page technique) */ + border-radius: 42px 42px 0 0 !important; overflow: hidden !important; - background: linear-gradient(165deg, #06101f 0%, #020617 35%, #040d1c 70%, #030a18 100%) !important; - border: 1.5px solid ${rgba("#ffffff", 0.08)} !important; - box-shadow: - 0 0 0 1px ${rgba(COLORS.cyan, 0.04)}, - 0 4px 30px -4px rgba(0, 0, 0, 0.7), - 0 20px 80px -20px rgba(0, 0, 0, 0.6), - 0 0 120px -30px ${rgba(COLORS.cyan, 0.08)}, - inset 0 1px 0 ${rgba("#ffffff", 0.06)}, - inset 0 -1px 0 ${rgba("#ffffff", 0.02)} !important; - box-sizing: border-box !important; + // background: linear-gradient(165deg, #06101f 0%, #020617 35%, #040d1c 70%, #030a18 100%) !important; + // border: 0px solid ${rgba("#ffffff", 0.08)} !important; + border-bottom: none !important; + // box-shadow: + // 0 0 0 1px ${rgba(COLORS.cyan, 0.04)}, + // 0 4px 30px -4px rgba(0, 0, 0, 0.7), + // 0 20px 80px -20px rgba(0, 0, 0, 0.6), + // 0 0 120px -30px ${rgba(COLORS.cyan, 0.08)}, + // inset 0 1px 0 ${rgba("#ffffff", 0.06)}, + // inset 0 -1px 0 ${rgba("#ffffff", 0.02)} !important; + // box-sizing: border-box !important; } /* Animated subtle grid pattern */ .dm-opt-card::before { @@ -493,8 +496,8 @@ const styles = ` top: 96px !important; left: 20px !important; right: 20px !important; - bottom: 16px !important; - border-radius: 42px !important; + bottom: 0 !important; + border-radius: 42px 42px 0 0 !important; } } @media (max-width: 767px) { @@ -502,8 +505,8 @@ const styles = ` top: 86px !important; left: 10px !important; right: 10px !important; - bottom: 10px !important; - border-radius: 28px !important; + bottom: 0 !important; + border-radius: 28px 28px 0 0 !important; } } diff --git a/src/components/optimization/VehicleFleet.tsx b/src/components/optimization/VehicleFleet.tsx index 20a3333..18833d7 100644 --- a/src/components/optimization/VehicleFleet.tsx +++ b/src/components/optimization/VehicleFleet.tsx @@ -120,15 +120,6 @@ function VehicleFleet({ progress, reduced = false }: Props) { const chaosProgress = useRef([]); const optProgress = useRef([]); - // Re-sync on length change (not just when empty) so a fleet-size edit / HMR - // doesn't leave indices reading `undefined` and crashing getPointAt(). - if (chaosProgress.current.length !== chaosFleet.length) { - chaosProgress.current = chaosFleet.map((v) => v.offset); - } - if (optProgress.current.length !== optFleet.length) { - optProgress.current = optFleet.map((v) => v.offset); - } - const place = ( group: THREE.Group | null, def: VehicleDef, @@ -191,6 +182,15 @@ function VehicleFleet({ progress, reduced = false }: Props) { }; useFrame((state, dt) => { + // Re-sync on length change (not just when empty) so a fleet-size edit / HMR + // doesn't leave indices reading `undefined` and crashing getPointAt(). + if (chaosProgress.current.length !== chaosFleet.length) { + chaosProgress.current = chaosFleet.map((v) => v.offset); + } + if (optProgress.current.length !== optFleet.length) { + optProgress.current = optFleet.map((v) => v.offset); + } + const p = progress.current ?? 0; const t = state.clock.elapsedTime; const safeDt = Math.min(0.06, dt); diff --git a/src/components/performance/PerformanceCanvas.tsx b/src/components/performance/PerformanceCanvas.tsx deleted file mode 100644 index bc496bb..0000000 --- a/src/components/performance/PerformanceCanvas.tsx +++ /dev/null @@ -1,464 +0,0 @@ -"use client"; - -import React, { useMemo, useRef } from "react"; -import { Canvas, useFrame } from "@react-three/fiber"; -import { Html } from "@react-three/drei"; -import { EffectComposer, Bloom, Vignette } from "@react-three/postprocessing"; -import { KernelSize } from "postprocessing"; -import * as THREE from "three"; -import { buildPerfRoutes, GATEWAY, LEFT_C, RIGHT_C, ROUTE_Y } from "./perfRoutes"; -import { SplineRider, VType } from "./Vehicles"; - -type Props = { - progress: React.RefObject; - reduced?: boolean; - isMobile?: boolean; - active?: boolean; -}; - -// Local math helpers (self-contained; avoids fragile cross-folder const imports). -const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); -const lerp = (a: number, b: number, t: number) => a + (b - a) * t; -const smoothstep = (e0: number, e1: number, x: number) => { - const t = clamp01((x - e0) / (e1 - e0 || 1)); - return t * t * (3 - 2 * t); -}; -const damp = (cur: number, target: number, lambda: number, dt: number) => - lerp(cur, target, 1 - Math.exp(-lambda * dt)); -const seeded = (i: number) => { - const x = Math.sin(i * 127.1 + 311.7) * 43758.5453; - return x - Math.floor(x); -}; - -// Grounded "operational" palette — white / gray / green / orange / red. No cyan/blue/purple. -const C = { - bg: "#14171c", - ground: "#262b33", - road: "#2b3038", - bldA: "#4b5563", - bldB: "#6b7280", - bldC: "#9ca3af", - white: "#e5e7eb", - red: "#ef4444", - orange: "#f97316", - amber: "#fbbf24", - green: "#22c55e", - green2: "#4ade80", - steel: "#475569", -}; - -/* ───────────── Camera: grounded city flythrough (not orbit) ───────────── */ -type Key = { p: number; pos: [number, number, number]; look: [number, number, number] }; -const KEYS: Key[] = [ - { p: 0.0, pos: [0, 9, 20], look: [0, 0.8, 0] }, // wide — framed on BOTH districts' content (instant contrast) - { p: 0.28, pos: [-13, 3.4, 8.5], look: [-8, 1.0, 0] }, // road-level, chaotic left - { p: 0.5, pos: [0, 6.5, 9], look: [0, 2.2, -1] }, // transformation divide - { p: 0.72, pos: [13, 3.4, 8.5], look: [8, 1.0, 0] }, // road-level, optimized right - { p: 1.0, pos: [0, 15, 12], look: [0, 0.2, 4] }, // top-down logistics overview -]; -function sampleKeys(e: number) { - let a = KEYS[0], b = KEYS[KEYS.length - 1]; - for (let i = 0; i < KEYS.length - 1; i++) { - if (e >= KEYS[i].p && e <= KEYS[i + 1].p) { a = KEYS[i]; b = KEYS[i + 1]; break; } - } - const k = smoothstep(0, 1, (e - a.p) / ((b.p - a.p) || 1)); - return { - pos: [lerp(a.pos[0], b.pos[0], k), lerp(a.pos[1], b.pos[1], k), lerp(a.pos[2], b.pos[2], k)] as const, - look: [lerp(a.look[0], b.look[0], k), lerp(a.look[1], b.look[1], k), lerp(a.look[2], b.look[2], k)] as const, - }; -} -function CameraRig({ progress }: { progress: React.RefObject }) { - const eased = useRef(0); - const look = useRef(new THREE.Vector3(0, 1.5, 4)); - useFrame((state, dt) => { - eased.current = damp(eased.current, clamp01(progress.current ?? 0), 2.4, dt); - const { pos, look: lk } = sampleKeys(eased.current); - const t = state.clock.elapsedTime; - state.camera.position.set(pos[0] + Math.sin(t * 0.25) * 0.25, pos[1] + Math.sin(t * 0.4) * 0.12, pos[2]); - look.current.lerp(new THREE.Vector3(lk[0], lk[1], lk[2]), 0.12); - state.camera.lookAt(look.current); - }); - return null; -} - -/* ───────────── Roads (flat asphalt ribbons on the ground) ───────────── */ -const _p = new THREE.Vector3(), _t = new THREE.Vector3(); -function roadRibbon(curve: THREE.CatmullRomCurve3, width: number, segs = 130) { - const half = width / 2; - const pos: number[] = []; - const idx: number[] = []; - for (let i = 0; i <= segs; i++) { - const u = (i / segs) % 1; - curve.getPointAt(u, _p); - curve.getTangentAt(u, _t); - const nx = -_t.z, nz = _t.x; - const len = Math.hypot(nx, nz) || 1; - pos.push(_p.x + (nx / len) * half, ROUTE_Y, _p.z + (nz / len) * half); - pos.push(_p.x - (nx / len) * half, ROUTE_Y, _p.z - (nz / len) * half); - } - for (let i = 0; i < segs; i++) { - const a = i * 2, b = i * 2 + 1, c = i * 2 + 2, d = i * 2 + 3; - idx.push(a, b, c, b, d, c); - } - const g = new THREE.BufferGeometry(); - g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); - g.setIndex(idx); - g.computeVertexNormals(); - return g; -} -function Roads({ routes, edge, width }: { routes: { curve: THREE.CatmullRomCurve3 }[]; edge: string; width: number }) { - const geos = useMemo(() => routes.map((r) => roadRibbon(r.curve, width)), [routes, width]); - return ( - - {geos.map((g, i) => ( - - - - ))} - - ); -} - -/* Flowing fleet/data pulses riding the roads. */ -const PULSE_TMP = new THREE.Vector3(); -function RoadPulses({ routes, color, per = 3 }: { routes: { curve: THREE.CatmullRomCurve3 }[]; color: string; per?: number }) { - const count = routes.length * per; - const refs = useRef<(THREE.Mesh | null)[]>([]); - const offs = useMemo(() => Array.from({ length: count }, (_, i) => seeded(i * 7 + 3)), [count]); - useFrame((state) => { - const t = state.clock.elapsedTime; - for (let i = 0; i < count; i++) { - const m = refs.current[i]; - if (!m) continue; - const ri = Math.floor(i / per); - const u = (offs[i] + t * (0.04 + seeded(i) * 0.03)) % 1; - routes[ri].curve.getPointAt(u, PULSE_TMP); - m.position.set(PULSE_TMP.x, ROUTE_Y + 0.12, PULSE_TMP.z); - (m.material as THREE.MeshBasicMaterial).opacity = 0.6 + Math.sin(t * 6 + i) * 0.35; - } - }); - return ( - - {Array.from({ length: count }, (_, i) => ( - { refs.current[i] = el; }}> - - - - ))} - - ); -} - -/* ───────────── City buildings ───────────── */ -type Bld = { x: number; z: number; w: number; d: number; h: number; side: number; tone: number; accent: boolean }; -function Buildings() { - const blds = useMemo(() => { - const arr: Bld[] = []; - for (let gx = -15; gx <= 15; gx += 2.6) { - for (let gz = -12.5; gz <= 12.5; gz += 2.6) { - if (Math.abs(gx) < 1.8) continue; // keep gateway band clear - const center = gx < 0 ? LEFT_C : RIGHT_C; - const dCenter = Math.hypot(gx - center.x, gz - center.z); - if (dCenter < 6.4) continue; // keep road area clear - if (gz > 7.2 && Math.abs(gx) < 6.5) continue; // keep KPI tower area clear - if (seeded(gx * 7.3 + gz * 1.7 + 50) < 0.28) continue; // streets / gaps - const jx = (seeded(gx + gz) - 0.5) * 0.6; - const jz = (seeded(gx * 2 + gz) - 0.5) * 0.6; - arr.push({ - x: gx + jx, z: gz + jz, - w: 1.2 + seeded(gx * 1.1 + gz) * 0.8, - d: 1.2 + seeded(gx + gz * 1.3) * 0.8, - h: 1.1 + seeded(gx * 3 + gz * 5) * 3.6, - side: gx < 0 ? -1 : 1, - tone: seeded(gx * 9 + gz * 4), - accent: seeded(gx * 4 + gz * 11) > 0.62, - }); - } - } - return arr; - }, []); - - return ( - - {blds.map((b, i) => { - const bodyColor = b.tone < 0.4 ? C.bldA : b.tone < 0.75 ? C.bldB : (b.side > 0 ? C.white : C.bldC); - const accent = b.side < 0 ? (b.tone > 0.5 ? C.orange : C.red) : C.green; - return ( - - - - - - {b.accent && ( - - - - - )} - - ); - })} - - ); -} - -/* ───────────── Warehouse / dispatch depot ───────────── */ -function Warehouse({ x, z, accent }: { x: number; z: number; accent: string }) { - return ( - - - - - - {/* sloped roof slab */} - - - - - {/* roller doors */} - {[-1.3, 0, 1.3].map((o, i) => ( - - - - - ))} - - ); -} - -/* ───────────── Ground delivery zones + congestion heat ───────────── */ -function GroundMarks() { - const heat = useRef<(THREE.Mesh | null)[]>([]); - useFrame((state) => { - const t = state.clock.elapsedTime; - heat.current.forEach((m, i) => { - if (m) (m.material as THREE.MeshBasicMaterial).opacity = 0.18 + Math.sin(t * 2 + i) * 0.1; - }); - }); - const leftZones = useMemo(() => Array.from({ length: 4 }, (_, i) => { - const a = (i / 4) * Math.PI * 2 + 0.5; - return [LEFT_C.x + Math.cos(a) * 4.4, LEFT_C.z + Math.sin(a) * 4.4] as [number, number]; - }), []); - const rightZones = useMemo(() => Array.from({ length: 4 }, (_, i) => { - const a = (i / 4) * Math.PI * 2 + 0.5; - return [RIGHT_C.x + Math.cos(a) * 4.4, RIGHT_C.z + Math.sin(a) * 4.4] as [number, number]; - }), []); - return ( - - {/* left congestion heat patches (red/orange) */} - {leftZones.map(([x, z], i) => ( - { heat.current[i] = el; }} rotation={[-Math.PI / 2, 0, 0]} position={[x, 0.04, z]}> - - - - ))} - {/* right optimized coverage zones (green rings) */} - {rightZones.map(([x, z], i) => ( - - - - - ))} - - ); -} - -/* ───────────── Central transformation divider (NOT an AI engine) ───────────── - A clean glowing seam between the two worlds + a light "optimization wave" that - sweeps left→right, literally showing the chaotic side being transformed. */ -function TransformDivider() { - const sweep = useRef(null); - const seam = useRef(null); - useFrame((state) => { - const t = state.clock.elapsedTime; - if (sweep.current) { - const u = (t * 0.16) % 1; - sweep.current.position.x = lerp(-6, 6, u); - (sweep.current.material as THREE.MeshBasicMaterial).opacity = Math.sin(u * Math.PI) * 0.3; - } - if (seam.current) seam.current.opacity = 0.4 + Math.sin(t * 2) * 0.12; - }); - return ( - - {/* ground seam marking before | after */} - - - - - {/* vertical seam light */} - - - - - {/* sweeping optimization wave crossing the city */} - - - - - - ); -} - -/* Big number that counts up once on mount (drei Html children are normal DOM). */ -function CountUp({ value, decimals = 0, suffix = "" }: { value: number; decimals?: number; suffix?: string }) { - const [n, setN] = React.useState(0); - React.useEffect(() => { - let raf = 0; - let start: number | null = null; - const dur = 1700; - const tick = (now: number) => { - if (start === null) start = now; - const k = Math.min(1, (now - start) / dur); - setN(value * (1 - Math.pow(1 - k, 3))); - if (k < 1) raf = requestAnimationFrame(tick); - }; - raf = requestAnimationFrame(tick); - return () => cancelAnimationFrame(raf); - }, [value]); - return <>{n.toFixed(decimals)}{suffix}; -} - -/* ───────────── KPI performance towers (grow with scroll) + BIG counting numbers ───────────── */ -type Kpi = { name: string; value: number; decimals?: number; suffix: string; h: number; x: number }; -const KPIS: Kpi[] = [ - { name: "Faster Deliveries", value: 32, suffix: "%", h: 5.4, x: -5.1 }, - { name: "Lower Op. Cost", value: 18, suffix: "%", h: 4.4, x: -1.7 }, - { name: "SLA Success", value: 99.2, decimals: 1, suffix: "%", h: 5.7, x: 1.7 }, - { name: "Less Fuel Used", value: 24, suffix: "%", h: 4.0, x: 5.1 }, -]; -function KpiTowers({ progress }: { progress: React.RefObject }) { - const refs = useRef<(THREE.Group | null)[]>([]); - const Z = 12.5; - useFrame(() => { - const p = clamp01(progress.current ?? 0); - KPIS.forEach((_, i) => { - const g = refs.current[i]; - if (!g) return; - const k = smoothstep(0.12 + i * 0.07, 0.55 + i * 0.07, p); - g.scale.y = 0.02 + k * 0.98; - }); - }); - return ( - - {KPIS.map((kpi, i) => ( - - - - - - { refs.current[i] = el; }}> - - - - - - - - - - {/* BIG, immediately-readable counting readout integrated on the tower */} - -
-
- -
-
{kpi.name}
-
- -
- ))} -
- ); -} - -/* ───────────── Fleet (spline-locked, drives on the roads) ───────────── */ -const TYPES: VType[] = ["truck", "van", "auto", "bike"]; -function Fleets() { - const { chaotic, optimized } = useMemo(() => buildPerfRoutes(), []); - const badBodies = [C.steel, "#7f1d1d", "#9a3412"]; - const goodBodies = [C.white, C.bldC, "#166534"]; - return ( - - {chaotic.map((r, i) => ( - - ))} - {optimized.map((r, i) => ( - - ))} - - ); -} - -/* ───────────── Scene ───────────── */ -function Scene({ progress, reduced, isMobile }: { progress: React.RefObject; reduced: boolean; isMobile: boolean }) { - const { chaotic, optimized } = useMemo(() => buildPerfRoutes(), []); - return ( - <> - - - - - - - {/* ground + faint street grid */} - - - - - - - {/* bold per-side atmosphere wash — instant "chaos vs efficiency" read */} - - - - - - - - - - - - {!isMobile && } - {!isMobile && } - - - - - - - - - - - {!reduced && ( - - {/* light bloom — only bright emissive (lights, pulses, gateway, tower caps) glow */} - - - - )} - - ); -} - -function PerformanceCanvas({ progress, reduced = false, isMobile = false, active = true }: Props) { - return ( - - - - - ); -} - -export default React.memo(PerformanceCanvas); diff --git a/src/components/performance/PerformanceSection.tsx b/src/components/performance/PerformanceSection.tsx deleted file mode 100644 index 3584b71..0000000 --- a/src/components/performance/PerformanceSection.tsx +++ /dev/null @@ -1,189 +0,0 @@ -"use client"; - -import React, { useEffect, useRef, useState } from "react"; -import dynamic from "next/dynamic"; -import { motion, useMotionValue, useTransform } from "framer-motion"; -import gsap from "gsap"; -import { ScrollTrigger } from "gsap/ScrollTrigger"; - -const PerformanceCanvas = dynamic(() => import("./PerformanceCanvas"), { ssr: false }); - -export default function PerformanceSection() { - const containerRef = useRef(null); - const progressRef = useRef(0); - const scroll = useMotionValue(0); - - const [pinState, setPinState] = useState<"before" | "pinned" | "after">("before"); - 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"; - const st = ScrollTrigger.create({ - trigger: el, - start: "top top", - end: "bottom bottom", - scrub: 0.4, - 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 refresh = setTimeout(() => ScrollTrigger.refresh(), 300); - return () => { clearTimeout(refresh); st.kill(); }; - }, [scroll]); - - const beforeOpacity = useTransform(scroll, [0.1, 0.3, 0.46], [0.4, 1, 0.32]); - const afterOpacity = useTransform(scroll, [0.6, 0.74, 0.95], [0.32, 1, 0.7]); - const stageA = useTransform(scroll, [0, 0.4], [1, 0]); - const stageB = useTransform(scroll, [0.4, 0.55, 0.65], [0, 1, 0]); - const stageC = useTransform(scroll, [0.65, 0.85], [0, 1]); - - return ( -
-
-
-
- {mountScene && ( -
- -
- )} -
- -
-
- - Results & Impact - - - What MileTruth Delivers - - - From congested traditional dispatch to a lean optimized fleet — the measurable business results across a live delivery city. - - -
- Traditional dispatch - Transformation gateway - Optimized network -
-
- - - Traditional Dispatch - Congestion · long routes · fuel waste · delays - - - MileTruth Optimized - Clean corridors · organized fleet · faster coverage - -
-
-
- -
- ); -} - -const styles = ` -.dm-perf { position: relative; height: 250vh; background: transparent; margin-bottom: 120px; } -.dm-perf-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden; } -.dm-perf.is-pinned .dm-perf-sticky { position: fixed; top: 0; left: 0; } -.dm-perf.is-after .dm-perf-sticky { position: absolute; top: auto; bottom: 0; } - -.dm-perf-card { - position: absolute !important; top: 110px !important; left: 40px !important; right: 40px !important; bottom: 24px !important; - border-radius: 60px !important; overflow: hidden !important; - background: linear-gradient(168deg, #1b1f26 0%, #15181d 45%, #101216 100%) !important; - border: 1.5px solid rgba(255,255,255,0.08) !important; - box-shadow: 0 4px 30px -4px rgba(0,0,0,0.7), 0 20px 80px -20px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.05) !important; - box-sizing: border-box !important; -} -@media (max-width: 1024px) { .dm-perf-card { top: 96px !important; left: 20px !important; right: 20px !important; bottom: 16px !important; border-radius: 42px !important; } } -@media (max-width: 767px) { .dm-perf-card { top: 86px !important; left: 10px !important; right: 10px !important; bottom: 10px !important; border-radius: 28px !important; } } - -.dm-perf-backdrop { position: absolute; inset: 0; z-index: 0; - background: radial-gradient(55% 50% at 20% 60%, rgba(239,68,68,0.07) 0%, transparent 60%), - radial-gradient(55% 50% at 80% 60%, rgba(34,197,94,0.08) 0%, transparent 60%); } -.dm-perf-canvas { position: absolute; inset: 0; z-index: 1; } -.dm-perf-canvas canvas { display: block; } -.dm-perf-vignette { position: absolute; inset: 0; z-index: 2; pointer-events: none; - background: radial-gradient(125% 105% at 50% 46%, transparent 56%, rgba(8,9,12,0.86) 100%), - linear-gradient(180deg, rgba(8,9,12,0.5) 0%, transparent 20%, transparent 66%, rgba(8,9,12,0.9) 100%); } - -.dm-perf-ui { position: absolute; inset: 0; z-index: 4; pointer-events: none; - font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif; } - -.dm-perf-head { position: absolute; top: clamp(18px, 3.4vh, 40px); left: 50%; transform: translateX(-50%); width: min(700px, 92vw); text-align: center; } -.dm-perf-eyebrow { display: inline-flex; align-items: center; gap: 7px; font-size: 11px; letter-spacing: 0.24em; text-transform: uppercase; color: #4ade80; - padding: 5px 14px; border-radius: 999px; background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.28); backdrop-filter: blur(8px); } -.dm-perf-dot { width: 6px; height: 6px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 10px #22c55e; } -.dm-perf .dm-perf-head h2 { margin: 10px 0 6px !important; padding: 0 !important; color: #F8FAFC !important; font-weight: 700 !important; text-transform: none !important; - font-size: clamp(22px, 2.6vw, 38px) !important; line-height: 1.08 !important; letter-spacing: -0.015em !important; } -.dm-perf .dm-perf-head p { margin: 0 auto !important; padding: 0 !important; color: rgba(226,232,240,0.66) !important; max-width: 500px; font-size: clamp(11px, 1vw, 13.5px) !important; line-height: 1.45 !important; } - -.dm-perf-status { display: inline-flex; align-items: center; gap: 16px; margin-top: 12px; min-height: 18px; } -.dm-perf-status__item { position: relative; display: inline-flex; align-items: center; gap: 7px; font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase; color: #E2E8F0; font-weight: 600; } -.dm-perf-status__item:not(:first-child) { position: absolute; left: 50%; transform: translateX(-50%); white-space: nowrap; } -.dm-perf-status__dot { width: 6px; height: 6px; border-radius: 50%; } -.dm-perf-status__dot--red { background: #ef4444; box-shadow: 0 0 10px #ef4444; } -.dm-perf-status__dot--amber { background: #fbbf24; box-shadow: 0 0 10px #fbbf24; } -.dm-perf-status__dot--green { background: #22c55e; box-shadow: 0 0 10px #22c55e; } - -.dm-perf-label { position: absolute; top: 44%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 4px; } -.dm-perf-label--before { left: clamp(16px, 4vw, 60px); text-align: left; } -.dm-perf-label--after { right: clamp(16px, 4vw, 60px); text-align: right; align-items: flex-end; } -.dm-perf-label__tag { font-size: clamp(17px, 2vw, 28px); font-weight: 800; letter-spacing: -0.02em; color: #f87171; text-shadow: 0 0 22px rgba(239,68,68,0.45); } -.dm-perf-label__tag--good { color: #4ade80; text-shadow: 0 0 22px rgba(34,197,94,0.5); } -.dm-perf-label__sub { font-size: 10.5px; letter-spacing: 0.05em; color: rgba(226,232,240,0.6); max-width: 180px; } - -@media (max-width: 767px) { - .dm-perf { height: 220vh; } - .dm-perf-label__sub { display: none; } - .dm-perf-label__tag { font-size: 15px; } - .dm-perf-head h2 { font-size: 22px; } - .dm-perf-status { gap: 10px; } -} -`; diff --git a/src/components/performance/Vehicles.tsx b/src/components/performance/Vehicles.tsx deleted file mode 100644 index f1a7ba0..0000000 --- a/src/components/performance/Vehicles.tsx +++ /dev/null @@ -1,183 +0,0 @@ -"use client"; - -import React, { useRef } from "react"; -import { useFrame } from "@react-three/fiber"; -import * as THREE from "three"; - -export type VType = "bike" | "auto" | "van" | "truck"; - -const TMP_POS = new THREE.Vector3(); -const TMP_TAN = new THREE.Vector3(); - -/* ── Low-poly procedural vehicle bodies (modelled facing +Z) ────────────── - Body uses a lit standard material; accent strips + lights are emissive so - they pick up bloom. Wheels are a shared group that spins. */ - -function Wheels({ positions, radius = 0.12, spinRef }: { - positions: [number, number, number][]; - radius?: number; - spinRef: React.RefObject; -}) { - return ( - - {positions.map((p, i) => ( - - - - - ))} - - ); -} - -function VehicleBody({ type, body, accent }: { type: VType; body: string; accent: string }) { - const wheels = useRef(null); - // spin the wheels for a sense of motion - useFrame((_, dt) => { - if (wheels.current) wheels.current.rotation.x += dt * 9; - }); - - const std = (color: string, e = 0.15) => ( - - ); - const glow = (color: string) => ( - - ); - - if (type === "bike") { - return ( - - - - {std(body, 0.2)} - - - - {std("#1e293b", 0.1)} - - - - {glow(accent)} - - - - {glow("#ffffff")} - - - - ); - } - - if (type === "auto") { - // three-wheeled auto-rickshaw - return ( - - - - {std(body, 0.18)} - - - - {std("#0b1220", 0.05)} - - - - {glow(accent)} - - {glow("#ffffff")} - {glow("#ffffff")} - - - ); - } - - if (type === "van") { - return ( - - - - {std(body, 0.16)} - - - - {std("#1e293b", 0.05)} - - - - {glow(accent)} - - {glow("#ffffff")} - {glow("#ffffff")} - - - ); - } - - // truck — cab + long box trailer - return ( - - - - {std(body, 0.18)} - - - - {std("#cbd5e1", 0.08)} - - - - {glow(accent)} - - {glow("#ffffff")} - {glow("#ffffff")} - - - ); -} - -export const VehicleBodyMemo = React.memo(VehicleBody); - -/** - * Locks a vehicle perfectly to a spline. Position = curve.getPointAt(u), - * heading = curve.getTangentAt(u). No drifting, no off-route movement. - */ -export function SplineRider({ - curve, - speed, - offset, - type, - body, - accent, -}: { - curve: THREE.CatmullRomCurve3; - speed: number; - offset: number; - type: VType; - body: string; - accent: string; -}) { - const ref = useRef(null); - const u = useRef(offset); - - useFrame((_, dt) => { - const g = ref.current; - if (!g) return; - u.current = (u.current + dt * speed) % 1; - curve.getPointAt(u.current, TMP_POS); - curve.getTangentAt(u.current, TMP_TAN); - g.position.copy(TMP_POS); - g.rotation.y = Math.atan2(TMP_TAN.x, TMP_TAN.z); - // gentle pitch so it follows slope without leaving the path - g.rotation.x = -Math.asin(THREE.MathUtils.clamp(TMP_TAN.y, -0.6, 0.6)) * 0.5; - }); - - return ( - - - - ); -} diff --git a/src/components/performance/perfRoutes.ts b/src/components/performance/perfRoutes.ts deleted file mode 100644 index b95c0a3..0000000 --- a/src/components/performance/perfRoutes.ts +++ /dev/null @@ -1,69 +0,0 @@ -import * as THREE from "three"; -import { seeded } from "../optimization/math"; - -/** - * Ground-level ROAD network for the Performance "Results & Impact" city. - * - LEFT district (x < 0): traditional dispatch — tangled, overlapping roads. - * - RIGHT district (x > 0): MileTruth optimized — clean, organized corridors. - * All curves are CLOSED and flat on the ground so fleet vehicles drive on the - * roads continuously (spline-locked, no end-of-curve teleport). - */ - -export const ROUTE_Y = 0.05; // roads sit on the ground -export const LEFT_C = new THREE.Vector3(-8, 0, 0); -export const RIGHT_C = new THREE.Vector3(8, 0, 0); -export const GATEWAY = new THREE.Vector3(0, 0, 0); // central transformation gateway - -export type PerfRoute = { curve: THREE.CatmullRomCurve3 }; - -function closedFlat(points: THREE.Vector3[]): THREE.CatmullRomCurve3 { - const flat = points.map((p) => new THREE.Vector3(p.x, ROUTE_Y, p.z)); - return new THREE.CatmullRomCurve3(flat, true, "catmullrom", 0.5); -} - -let cache: { chaotic: PerfRoute[]; optimized: PerfRoute[] } | null = null; - -export function buildPerfRoutes() { - if (cache) return cache; - - // --- LEFT: tangled, overlapping traffic loops ---------------------------- - const chaotic: PerfRoute[] = []; - for (let r = 0; r < 5; r++) { - const pts: THREE.Vector3[] = []; - const n = 6 + Math.floor(seeded(r * 13 + 1) * 3); - let ang = seeded(r * 13 + 2) * Math.PI * 2; - for (let s = 0; s < n; s++) { - ang += (seeded(r * 13 + s * 3 + 3) - 0.5) * 2.7; // erratic detours - const rad = 1.8 + seeded(r * 7 + s + 4) * 4.2; - const x = LEFT_C.x + Math.cos(ang) * rad + (seeded(r * 5 + s) - 0.5) * 2.2; - const z = LEFT_C.z + Math.sin(ang) * rad + (seeded(r * 9 + s) - 0.5) * 2.2; - pts.push(new THREE.Vector3(x, 0, z)); - } - chaotic.push({ curve: closedFlat(pts) }); - } - - // --- RIGHT: clean organized delivery corridors, one per zone ------------- - const optimized: PerfRoute[] = []; - const zones: [number, number][] = [ - [0, 0], - [2.8, 2.6], - [-2.8, 2.6], - [2.8, -2.6], - [-2.8, -2.6], - ]; - zones.forEach(([ox, oz], r) => { - const pts: THREE.Vector3[] = []; - const n = 6; - const rad = r === 0 ? 4.8 : 1.5; - for (let s = 0; s < n; s++) { - const a = (s / n) * Math.PI * 2 + r * 0.3; - const x = RIGHT_C.x + ox + Math.cos(a) * rad; - const z = RIGHT_C.z + oz + Math.sin(a) * rad; - pts.push(new THREE.Vector3(x, 0, z)); - } - optimized.push({ curve: closedFlat(pts) }); - }); - - cache = { chaotic, optimized }; - return cache; -} diff --git a/src/components/sections/ConnectedLogistics.tsx b/src/components/sections/ConnectedLogistics.tsx index ba96cc0..7011bfc 100644 --- a/src/components/sections/ConnectedLogistics.tsx +++ b/src/components/sections/ConnectedLogistics.tsx @@ -90,7 +90,7 @@ export default function ConnectedLogistics() {
-

Detect SLA risks hours before they become problems. Act, don't react.

+

Detect SLA risks hours before they become problems. Act, don't react.

diff --git a/src/components/sections/ContactForm.tsx b/src/components/sections/ContactForm.tsx index 97f8885..d80690a 100644 --- a/src/components/sections/ContactForm.tsx +++ b/src/components/sections/ContactForm.tsx @@ -120,6 +120,72 @@ export default function ContactForm() { return (
+