update logistices
This commit is contained in:
@@ -16,7 +16,7 @@ export default function AnimationProvider({ children }: { children: React.ReactN
|
|||||||
const initDecorativeBlocks = () => {
|
const initDecorativeBlocks = () => {
|
||||||
// Clean up previous block triggers to avoid duplicates
|
// Clean up previous block triggers to avoid duplicates
|
||||||
ScrollTrigger.getAll().forEach((t) => {
|
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();
|
t.kill();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ContactsHero from "@/components/sections/ContactsHero";
|
import ContactsHero from "@/components/sections/ContactsHero";
|
||||||
import ContactForm from "@/components/sections/ContactForm";
|
|
||||||
import ContactMap from "@/components/sections/ContactMap";
|
import ContactMap from "@/components/sections/ContactMap";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@@ -15,7 +14,6 @@ export default function ContactPage() {
|
|||||||
<div className="content-inner">
|
<div className="content-inner">
|
||||||
<div data-elementor-type="wp-page" data-elementor-id="41" className="elementor elementor-41">
|
<div data-elementor-type="wp-page" data-elementor-id="41" className="elementor elementor-41">
|
||||||
<ContactsHero />
|
<ContactsHero />
|
||||||
<ContactForm />
|
|
||||||
<ContactMap />
|
<ContactMap />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import MileTruthHero from "../../components/sections/MileTruthHero";
|
|||||||
import Workflow1 from "../../components/sections/Workflow1";
|
import Workflow1 from "../../components/sections/Workflow1";
|
||||||
import Workflow2 from "../../components/sections/Workflow2";
|
import Workflow2 from "../../components/sections/Workflow2";
|
||||||
import Workflow3 from "../../components/sections/Workflow3";
|
import Workflow3 from "../../components/sections/Workflow3";
|
||||||
import PerformanceSection from "../../components/performance/PerformanceSection";
|
import LogisticsBrainSection from "../../components/logisticsbrain/LogisticsBrainSection";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "MileTruth – Doormile",
|
title: "MileTruth – Doormile",
|
||||||
@@ -20,7 +20,7 @@ export default function MileTruthPage() {
|
|||||||
<Workflow1 />
|
<Workflow1 />
|
||||||
<Workflow2 />
|
<Workflow2 />
|
||||||
<Workflow3 />
|
<Workflow3 />
|
||||||
<PerformanceSection />
|
<LogisticsBrainSection />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
155
src/components/logisticsbrain/Brain.tsx
Normal file
155
src/components/logisticsbrain/Brain.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useRef } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { C, BRAIN_Y, P } from "./theme";
|
||||||
|
import { clamp01, damp, lerp, seeded, smoothstep } from "./math";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
progress: React.RefObject<number>;
|
||||||
|
reduced?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Evenly distributed points on a unit sphere (Fibonacci spiral). */
|
||||||
|
function fibonacciSphere(n: number, radiusJitter = 0): Float32Array {
|
||||||
|
const arr = new Float32Array(n * 3);
|
||||||
|
const golden = Math.PI * (3 - Math.sqrt(5));
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const y = 1 - (i / (n - 1)) * 2;
|
||||||
|
const r = Math.sqrt(1 - y * y);
|
||||||
|
const theta = golden * i;
|
||||||
|
const jitter = 1 + (seeded(i * 1.7) - 0.5) * radiusJitter;
|
||||||
|
arr[i * 3] = Math.cos(theta) * r * jitter;
|
||||||
|
arr[i * 3 + 1] = y * jitter;
|
||||||
|
arr[i * 3 + 2] = Math.sin(theta) * r * jitter;
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The central "Logistics Brain": a glowing icosahedral core wrapped in two
|
||||||
|
* counter-rotating wireframe shells and surrounded by thousands of orbiting
|
||||||
|
* particles. It births into the scene, breathes continuously, recedes slightly
|
||||||
|
* for the eagle-eye network pass and pulses brighter for the finale.
|
||||||
|
*/
|
||||||
|
function Brain({ progress, isMobile = false }: Props) {
|
||||||
|
const group = useRef<THREE.Group>(null);
|
||||||
|
const coreA = useRef<THREE.Mesh>(null);
|
||||||
|
const coreB = useRef<THREE.Mesh>(null);
|
||||||
|
const haloRef = useRef<THREE.Mesh>(null);
|
||||||
|
const haloMat = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const shellPts = useRef<THREE.Points>(null);
|
||||||
|
const orbitA = useRef<THREE.Points>(null);
|
||||||
|
const orbitB = useRef<THREE.Points>(null);
|
||||||
|
const eased = useRef(0);
|
||||||
|
|
||||||
|
const shellCount = isMobile ? 700 : 1500;
|
||||||
|
const orbitCount = isMobile ? 900 : 2200;
|
||||||
|
|
||||||
|
const shellPos = useMemo(() => fibonacciSphere(shellCount, 0.18), [shellCount]);
|
||||||
|
const orbitAPos = useMemo(() => fibonacciSphere(orbitCount, 0.55), [orbitCount]);
|
||||||
|
const orbitBPos = useMemo(() => fibonacciSphere(Math.floor(orbitCount * 0.6), 0.7), [orbitCount]);
|
||||||
|
|
||||||
|
useFrame((state, dt) => {
|
||||||
|
const p = progress.current ?? 0;
|
||||||
|
eased.current = damp(eased.current, p, 3, dt);
|
||||||
|
const e = eased.current;
|
||||||
|
const t = state.clock.elapsedTime;
|
||||||
|
|
||||||
|
const g = group.current;
|
||||||
|
if (!g) return;
|
||||||
|
|
||||||
|
// Birth: scale up from nothing across the first beat.
|
||||||
|
const birth = smoothstep(0, P.routes, e);
|
||||||
|
// Recede a touch while the camera is overhead studying the network.
|
||||||
|
const recede = smoothstep(P.network, P.network + 0.06, e) * (1 - smoothstep(P.ecosystem, P.finale, e));
|
||||||
|
// Finale: swell + brighten as the whole system lights up.
|
||||||
|
const finale = smoothstep(P.finale, 1, e);
|
||||||
|
|
||||||
|
const breathe = 1 + Math.sin(t * 0.9) * 0.03;
|
||||||
|
// Base factor keeps the "brain" small — it's a depot/control beacon over the
|
||||||
|
// map centre now, not the hero object. The map + routes carry the story.
|
||||||
|
const scale = 0.5 * lerp(0.001, 1, birth) * (1 - recede * 0.32) * (1 + finale * 0.18) * breathe;
|
||||||
|
g.scale.setScalar(scale);
|
||||||
|
g.position.y = BRAIN_Y + Math.sin(t * 0.6) * 0.18;
|
||||||
|
g.rotation.y = t * 0.12;
|
||||||
|
|
||||||
|
if (coreA.current) coreA.current.rotation.set(t * 0.18, t * 0.24, 0);
|
||||||
|
if (coreB.current) coreB.current.rotation.set(-t * 0.22, t * 0.16, t * 0.1);
|
||||||
|
if (shellPts.current) shellPts.current.rotation.y = t * 0.06;
|
||||||
|
if (orbitA.current) {
|
||||||
|
orbitA.current.rotation.y = t * 0.35;
|
||||||
|
orbitA.current.rotation.x = Math.sin(t * 0.2) * 0.3;
|
||||||
|
}
|
||||||
|
if (orbitB.current) {
|
||||||
|
orbitB.current.rotation.y = -t * 0.28;
|
||||||
|
orbitB.current.rotation.z = t * 0.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haloRef.current) {
|
||||||
|
const hs = 1 + Math.sin(t * 1.6) * 0.06 + finale * 0.4;
|
||||||
|
haloRef.current.scale.setScalar(hs);
|
||||||
|
}
|
||||||
|
if (haloMat.current) {
|
||||||
|
haloMat.current.opacity = clamp01(0.22 + Math.sin(t * 1.6) * 0.05 + finale * 0.25) * birth;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={group} position={[0, BRAIN_Y, 0]}>
|
||||||
|
{/* Soft inner glow */}
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[0.95, 24, 24]} />
|
||||||
|
<meshBasicMaterial color={C.blue} transparent opacity={0.18} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Bright core */}
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[0.42, 24, 24]} />
|
||||||
|
<meshBasicMaterial color={C.white} transparent opacity={0.9} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Counter-rotating wireframe shells = the "folds" of the brain */}
|
||||||
|
<mesh ref={coreA}>
|
||||||
|
<icosahedronGeometry args={[1.35, 1]} />
|
||||||
|
<meshBasicMaterial color={C.cyan} wireframe transparent opacity={0.55} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={coreB}>
|
||||||
|
<icosahedronGeometry args={[1.7, 2]} />
|
||||||
|
<meshBasicMaterial color={C.violet} wireframe transparent opacity={0.32} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Dense synapse points hugging the core */}
|
||||||
|
<points ref={shellPts}>
|
||||||
|
<bufferGeometry>
|
||||||
|
<bufferAttribute attach="attributes-position" args={[shellPos, 3]} />
|
||||||
|
</bufferGeometry>
|
||||||
|
<pointsMaterial size={isMobile ? 0.05 : 0.045} color={C.sky} transparent opacity={0.85} sizeAttenuation blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</points>
|
||||||
|
|
||||||
|
{/* Two orbiting particle clouds (kept tight so they don't clutter the map) */}
|
||||||
|
<points ref={orbitA} scale={1.6}>
|
||||||
|
<bufferGeometry>
|
||||||
|
<bufferAttribute attach="attributes-position" args={[orbitAPos, 3]} />
|
||||||
|
</bufferGeometry>
|
||||||
|
<pointsMaterial size={0.04} color={C.cyan} transparent opacity={0.6} sizeAttenuation blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</points>
|
||||||
|
<points ref={orbitB} scale={2.0}>
|
||||||
|
<bufferGeometry>
|
||||||
|
<bufferAttribute attach="attributes-position" args={[orbitBPos, 3]} />
|
||||||
|
</bufferGeometry>
|
||||||
|
<pointsMaterial size={0.035} color={C.purple} transparent opacity={0.45} sizeAttenuation blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</points>
|
||||||
|
|
||||||
|
{/* Pulsing halo ring */}
|
||||||
|
<mesh ref={haloRef} rotation={[Math.PI / 2, 0, 0]}>
|
||||||
|
<ringGeometry args={[2.1, 2.25, 64]} />
|
||||||
|
<meshBasicMaterial ref={haloMat} color={C.cyan} transparent opacity={0.22} side={THREE.DoubleSide} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(Brain);
|
||||||
360
src/components/logisticsbrain/City.tsx
Normal file
360
src/components/logisticsbrain/City.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useLayoutEffect, useMemo, useRef } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { C, CITY_RADIUS, P, ROUTE_COLORS } from "./theme";
|
||||||
|
import { damp, lerp, seeded, smoothstep } from "./math";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
progress: React.RefObject<number>;
|
||||||
|
reduced?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dummy = new THREE.Object3D();
|
||||||
|
const tmpColor = new THREE.Color();
|
||||||
|
|
||||||
|
type Building = {
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
w: number;
|
||||||
|
d: number;
|
||||||
|
h: number; // total height
|
||||||
|
bodyH: number;
|
||||||
|
rot: number;
|
||||||
|
ci: number;
|
||||||
|
tier: boolean;
|
||||||
|
tall: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WIN_VERT = `
|
||||||
|
attribute vec3 aSize;
|
||||||
|
attribute float aSeed;
|
||||||
|
varying vec2 vWinUv;
|
||||||
|
varying vec3 vWinNrm;
|
||||||
|
varying vec3 vWinSize;
|
||||||
|
varying float vWinSeed;
|
||||||
|
`;
|
||||||
|
const WIN_VERT_BODY = `
|
||||||
|
vWinUv = uv;
|
||||||
|
vWinNrm = normal;
|
||||||
|
vWinSize = aSize;
|
||||||
|
vWinSeed = aSeed;
|
||||||
|
`;
|
||||||
|
const WIN_FRAG = `
|
||||||
|
uniform float uTime;
|
||||||
|
varying vec2 vWinUv;
|
||||||
|
varying vec3 vWinNrm;
|
||||||
|
varying vec3 vWinSize;
|
||||||
|
varying float vWinSeed;
|
||||||
|
float h11(float p){ p = fract(p*0.1031); p *= p+33.33; p *= p+p; return fract(p); }
|
||||||
|
float h21(vec2 p){ vec3 p3 = fract(vec3(p.xyx)*0.1031); p3 += dot(p3, p3.yzx+33.33); return fract((p3.x+p3.y)*p3.z); }
|
||||||
|
`;
|
||||||
|
const WIN_FRAG_BODY = `
|
||||||
|
// facade tint varies per building — dark neutral charcoal (brand: near-black)
|
||||||
|
vec3 baseA = vec3(0.040, 0.040, 0.048);
|
||||||
|
vec3 baseB = vec3(0.065, 0.058, 0.066);
|
||||||
|
diffuseColor.rgb = mix(baseA, baseB, h11(vWinSeed*1.7));
|
||||||
|
|
||||||
|
// windows only on the four vertical faces (skip roof/floor)
|
||||||
|
float isVert = 1.0 - step(0.5, abs(vWinNrm.y));
|
||||||
|
vec2 faceDim = abs(vWinNrm.x) > 0.5 ? vec2(vWinSize.z, vWinSize.y) : vec2(vWinSize.x, vWinSize.y);
|
||||||
|
vec2 cells = max(vec2(1.0), floor(faceDim / 0.52));
|
||||||
|
vec2 g = vWinUv * cells;
|
||||||
|
vec2 id = floor(g);
|
||||||
|
vec2 f = fract(g);
|
||||||
|
float m = 0.16; // mullion margin
|
||||||
|
float pane = step(m, f.x) * step(f.x, 1.0 - m) * step(m, f.y) * step(f.y, 1.0 - m);
|
||||||
|
|
||||||
|
float rnd = h21(id + vWinSeed * 37.0);
|
||||||
|
float lit = step(0.68, rnd); // ~32% of windows lit (dimmer skyline backdrop)
|
||||||
|
float toggle = step(0.97, h21(id * 1.31 + vWinSeed * 5.0 + floor(uTime * 0.5)));
|
||||||
|
lit = clamp(lit + toggle, 0.0, 1.0); // a few flick on/off over time
|
||||||
|
float flick = 0.9 + 0.1 * sin(uTime * 2.0 + rnd * 30.0);
|
||||||
|
|
||||||
|
// Brand-tinted windows: mostly warm white with occasional brand-red panes.
|
||||||
|
vec3 warmWhite = vec3(1.0, 0.88, 0.80);
|
||||||
|
vec3 brandRed = vec3(0.82, 0.18, 0.24);
|
||||||
|
vec3 wcol = mix(warmWhite, brandRed, step(0.7, h21(id * 0.7 + vWinSeed)));
|
||||||
|
|
||||||
|
float glow = isVert * pane * lit * flick;
|
||||||
|
totalEmissiveRadiance += wcol * glow * 0.85;
|
||||||
|
|
||||||
|
// dark mullion grid + dimmer unlit glass
|
||||||
|
diffuseColor.rgb *= (0.42 + 0.58 * pane);
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The futuristic logistics city. Buildings are instanced boxes, but a custom
|
||||||
|
* shader paints procedural apartment windows on their facades (lit/unlit panes,
|
||||||
|
* mullion grid, per-building variation, gentle night flicker) so they read as
|
||||||
|
* real architecture rather than blank boxes. Taller towers get a setback tier
|
||||||
|
* and a rooftop antenna with an aviation light. The whole skyline rises out of
|
||||||
|
* the ground during the birth beat.
|
||||||
|
*/
|
||||||
|
function City({ progress, isMobile = false }: Props) {
|
||||||
|
const cityGroup = useRef<THREE.Group>(null);
|
||||||
|
const bodies = useRef<THREE.InstancedMesh>(null);
|
||||||
|
const tiers = useRef<THREE.InstancedMesh>(null);
|
||||||
|
const caps = useRef<THREE.InstancedMesh>(null);
|
||||||
|
const masts = useRef<THREE.InstancedMesh>(null);
|
||||||
|
const lights = useRef<THREE.InstancedMesh>(null);
|
||||||
|
const roadsMat = useRef<THREE.LineBasicMaterial>(null);
|
||||||
|
const ring1 = useRef<THREE.Mesh>(null);
|
||||||
|
const ring2 = useRef<THREE.Mesh>(null);
|
||||||
|
const shaderRef = useRef<{ uniforms: { uTime: { value: number } } } | null>(null);
|
||||||
|
const eased = useRef(0);
|
||||||
|
|
||||||
|
const count = isMobile ? 20 : 34;
|
||||||
|
|
||||||
|
const data = useMemo(() => {
|
||||||
|
const buildings: Building[] = [];
|
||||||
|
// Inner clear radius — buildings form a dim perimeter ring only, so the whole
|
||||||
|
// centre of the map (depot, delivery nodes, candidate routes, vehicles) stays
|
||||||
|
// open and reads as the routing engine, not a city the camera flies through.
|
||||||
|
const INNER = 12.5;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const angle = seeded(i * 3.1 + 0.5) * Math.PI * 2;
|
||||||
|
const radius = INNER + seeded(i * 1.7 + 2.2) * (CITY_RADIUS - INNER);
|
||||||
|
const x = Math.cos(angle) * radius;
|
||||||
|
const z = Math.sin(angle) * radius;
|
||||||
|
const w = 0.8 + seeded(i * 5.3) * 1.7;
|
||||||
|
const d = 0.8 + seeded(i * 2.9 + 9) * 1.7;
|
||||||
|
const h = 1.6 + seeded(i * 7.7 + 4) * 3.2;
|
||||||
|
const ci = Math.floor(seeded(i * 11.1) * ROUTE_COLORS.length);
|
||||||
|
const tier = h > 3.4 && seeded(i * 13.3) > 0.42;
|
||||||
|
const bodyH = tier ? h * 0.62 : h;
|
||||||
|
const rot = (Math.floor(seeded(i * 17.7) * 4) * Math.PI) / 8 + seeded(i * 4.1) * 0.12;
|
||||||
|
const tall = h > 5;
|
||||||
|
buildings.push({ x, z, w, d, h, bodyH, rot, ci, tier, tall });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tierList = buildings.filter((b) => b.tier);
|
||||||
|
const tallList = buildings.filter((b) => b.tall);
|
||||||
|
|
||||||
|
// Per-instance shader attributes (world dimensions + a stable seed).
|
||||||
|
const bodyGeom = new THREE.BoxGeometry(1, 1, 1);
|
||||||
|
const bSize = new Float32Array(count * 3);
|
||||||
|
const bSeed = new Float32Array(count);
|
||||||
|
buildings.forEach((b, i) => {
|
||||||
|
bSize[i * 3] = b.w; bSize[i * 3 + 1] = b.bodyH; bSize[i * 3 + 2] = b.d;
|
||||||
|
bSeed[i] = seeded(i * 23.1) * 10;
|
||||||
|
});
|
||||||
|
bodyGeom.setAttribute("aSize", new THREE.InstancedBufferAttribute(bSize, 3));
|
||||||
|
bodyGeom.setAttribute("aSeed", new THREE.InstancedBufferAttribute(bSeed, 1));
|
||||||
|
|
||||||
|
const tierGeom = new THREE.BoxGeometry(1, 1, 1);
|
||||||
|
const tSize = new Float32Array(Math.max(1, tierList.length) * 3);
|
||||||
|
const tSeed = new Float32Array(Math.max(1, tierList.length));
|
||||||
|
tierList.forEach((b, i) => {
|
||||||
|
const tw = b.w * 0.64, td = b.d * 0.64, th = b.h - b.bodyH;
|
||||||
|
tSize[i * 3] = tw; tSize[i * 3 + 1] = th; tSize[i * 3 + 2] = td;
|
||||||
|
tSeed[i] = seeded(i * 29.7) * 10;
|
||||||
|
});
|
||||||
|
tierGeom.setAttribute("aSize", new THREE.InstancedBufferAttribute(tSize, 3));
|
||||||
|
tierGeom.setAttribute("aSeed", new THREE.InstancedBufferAttribute(tSeed, 1));
|
||||||
|
|
||||||
|
return { buildings, tierList, tallList, bodyGeom, tierGeom };
|
||||||
|
}, [count]);
|
||||||
|
|
||||||
|
// Shared facade material with the procedural window shader.
|
||||||
|
const facadeMat = useMemo(() => {
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
color: "#0c1226",
|
||||||
|
emissive: "#000000",
|
||||||
|
metalness: 0.35,
|
||||||
|
roughness: 0.62,
|
||||||
|
});
|
||||||
|
mat.onBeforeCompile = (shader) => {
|
||||||
|
shader.uniforms.uTime = { value: 0 };
|
||||||
|
shaderRef.current = shader as unknown as { uniforms: { uTime: { value: number } } };
|
||||||
|
shader.vertexShader = shader.vertexShader
|
||||||
|
.replace("#include <common>", `#include <common>\n${WIN_VERT}`)
|
||||||
|
.replace("#include <begin_vertex>", `#include <begin_vertex>\n${WIN_VERT_BODY}`);
|
||||||
|
shader.fragmentShader = shader.fragmentShader
|
||||||
|
.replace("#include <common>", `#include <common>\n${WIN_FRAG}`)
|
||||||
|
.replace("#include <emissivemap_fragment>", `#include <emissivemap_fragment>\n${WIN_FRAG_BODY}`);
|
||||||
|
};
|
||||||
|
return mat;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const roadGeom = useMemo(() => {
|
||||||
|
const pts: number[] = [];
|
||||||
|
const y = 0.04;
|
||||||
|
// Radial avenues out from the centre.
|
||||||
|
const spokes = 12;
|
||||||
|
for (let i = 0; i < spokes; i++) {
|
||||||
|
const a = (i / spokes) * Math.PI * 2;
|
||||||
|
const r0 = 1.0;
|
||||||
|
const r1 = CITY_RADIUS * (0.72 + seeded(i * 4.4) * 0.26);
|
||||||
|
pts.push(Math.cos(a) * r0, y, Math.sin(a) * r0);
|
||||||
|
pts.push(Math.cos(a) * r1, y, Math.sin(a) * r1);
|
||||||
|
}
|
||||||
|
// Concentric ring roads so the ground reads as a real street grid.
|
||||||
|
const rings = [3.5, 7, 11, 15];
|
||||||
|
const seg = 64;
|
||||||
|
for (const r of rings) {
|
||||||
|
for (let i = 0; i < seg; i++) {
|
||||||
|
const a0 = (i / seg) * Math.PI * 2;
|
||||||
|
const a1 = ((i + 1) / seg) * Math.PI * 2;
|
||||||
|
pts.push(Math.cos(a0) * r, y, Math.sin(a0) * r);
|
||||||
|
pts.push(Math.cos(a1) * r, y, Math.sin(a1) * r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const g = new THREE.BufferGeometry();
|
||||||
|
g.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
|
||||||
|
return g;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const { buildings, tierList, tallList } = data;
|
||||||
|
|
||||||
|
if (bodies.current) {
|
||||||
|
buildings.forEach((b, i) => {
|
||||||
|
dummy.position.set(b.x, b.bodyH / 2, b.z);
|
||||||
|
dummy.rotation.set(0, b.rot, 0);
|
||||||
|
dummy.scale.set(b.w, b.bodyH, b.d);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
bodies.current!.setMatrixAt(i, dummy.matrix);
|
||||||
|
});
|
||||||
|
bodies.current.instanceMatrix.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tiers.current) {
|
||||||
|
tierList.forEach((b, i) => {
|
||||||
|
const th = b.h - b.bodyH;
|
||||||
|
dummy.position.set(b.x, b.bodyH + th / 2, b.z);
|
||||||
|
dummy.rotation.set(0, b.rot, 0);
|
||||||
|
dummy.scale.set(b.w * 0.64, th, b.d * 0.64);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
tiers.current!.setMatrixAt(i, dummy.matrix);
|
||||||
|
});
|
||||||
|
tiers.current.instanceMatrix.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (caps.current) {
|
||||||
|
buildings.forEach((b, i) => {
|
||||||
|
const top = b.h;
|
||||||
|
const cw = b.tier ? b.w * 0.64 : b.w;
|
||||||
|
const cd = b.tier ? b.d * 0.64 : b.d;
|
||||||
|
dummy.position.set(b.x, top + 0.03, b.z);
|
||||||
|
dummy.rotation.set(0, b.rot, 0);
|
||||||
|
dummy.scale.set(cw * 1.02, 0.07, cd * 1.02);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
caps.current!.setMatrixAt(i, dummy.matrix);
|
||||||
|
caps.current!.setColorAt(i, tmpColor.set(ROUTE_COLORS[b.ci]));
|
||||||
|
});
|
||||||
|
caps.current.instanceMatrix.needsUpdate = true;
|
||||||
|
if (caps.current.instanceColor) caps.current.instanceColor.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (masts.current && lights.current) {
|
||||||
|
tallList.forEach((b, i) => {
|
||||||
|
dummy.position.set(b.x, b.h + 0.34, b.z);
|
||||||
|
dummy.rotation.set(0, 0, 0);
|
||||||
|
dummy.scale.set(0.05, 0.68, 0.05);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
masts.current!.setMatrixAt(i, dummy.matrix);
|
||||||
|
|
||||||
|
dummy.position.set(b.x, b.h + 0.7, b.z);
|
||||||
|
dummy.scale.set(1, 1, 1);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
lights.current!.setMatrixAt(i, dummy.matrix);
|
||||||
|
});
|
||||||
|
masts.current.instanceMatrix.needsUpdate = true;
|
||||||
|
lights.current.instanceMatrix.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useFrame((state, dt) => {
|
||||||
|
const p = progress.current ?? 0;
|
||||||
|
eased.current = damp(eased.current, p, 3, dt);
|
||||||
|
const e = eased.current;
|
||||||
|
const t = state.clock.elapsedTime;
|
||||||
|
|
||||||
|
if (shaderRef.current) shaderRef.current.uniforms.uTime.value = t;
|
||||||
|
|
||||||
|
// City rises from the floor during the birth beat.
|
||||||
|
const rise = smoothstep(0.01, P.routes + 0.02, e);
|
||||||
|
if (cityGroup.current) cityGroup.current.scale.y = lerp(0.001, 1, rise);
|
||||||
|
|
||||||
|
// Aviation lights — gentle glow pulse (kept soft to avoid an unnatural
|
||||||
|
// perfectly-synchronized hard blink across all masts).
|
||||||
|
if (lights.current) {
|
||||||
|
const m = lights.current.material as THREE.MeshBasicMaterial;
|
||||||
|
m.opacity = (0.5 + 0.3 * Math.sin(t * 2.2)) * rise;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roadsMat.current) {
|
||||||
|
roadsMat.current.opacity = lerp(0, 0.6, smoothstep(0.02, P.routes, e)) * (0.78 + Math.sin(t * 2.2) * 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ringPulse = smoothstep(0.0, P.routes, e);
|
||||||
|
if (ring1.current) {
|
||||||
|
const s = 1 + ((t * 0.35) % 1) * 6;
|
||||||
|
ring1.current.scale.set(s, s, s);
|
||||||
|
(ring1.current.material as THREE.MeshBasicMaterial).opacity = (1 - ((t * 0.35) % 1)) * 0.28 * ringPulse;
|
||||||
|
}
|
||||||
|
if (ring2.current) {
|
||||||
|
const s = 1 + ((t * 0.35 + 0.5) % 1) * 6;
|
||||||
|
ring2.current.scale.set(s, s, s);
|
||||||
|
(ring2.current.material as THREE.MeshBasicMaterial).opacity = (1 - ((t * 0.35 + 0.5) % 1)) * 0.28 * ringPulse;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Energy grid floor */}
|
||||||
|
<gridHelper args={[CITY_RADIUS * 2.6, 60, C.blue, C.blue]} position={[0, 0, 0]}>
|
||||||
|
<lineBasicMaterial attach="material" color={C.blue} transparent opacity={0.11} depthWrite={false} />
|
||||||
|
</gridHelper>
|
||||||
|
|
||||||
|
{/* Radial energy roads */}
|
||||||
|
<lineSegments geometry={roadGeom}>
|
||||||
|
<lineBasicMaterial ref={roadsMat} color={C.cyan} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</lineSegments>
|
||||||
|
|
||||||
|
{/* Foundation pulse rings beneath the brain */}
|
||||||
|
<mesh ref={ring1} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.03, 0]}>
|
||||||
|
<ringGeometry args={[1.0, 1.12, 48]} />
|
||||||
|
<meshBasicMaterial color={C.cyan} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={ring2} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.03, 0]}>
|
||||||
|
<ringGeometry args={[1.0, 1.12, 48]} />
|
||||||
|
<meshBasicMaterial color={C.violet} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Skyline (rises from the floor) */}
|
||||||
|
<group ref={cityGroup}>
|
||||||
|
{/* Building bodies — windowed facade shader */}
|
||||||
|
<instancedMesh ref={bodies} args={[data.bodyGeom, facadeMat, data.buildings.length]} />
|
||||||
|
{/* Setback tiers — same facade shader */}
|
||||||
|
{data.tierList.length > 0 && (
|
||||||
|
<instancedMesh ref={tiers} args={[data.tierGeom, facadeMat, data.tierList.length]} />
|
||||||
|
)}
|
||||||
|
{/* Glowing rooftop caps */}
|
||||||
|
<instancedMesh ref={caps} args={[undefined, undefined, data.buildings.length]}>
|
||||||
|
<boxGeometry args={[1, 1, 1]} />
|
||||||
|
<meshBasicMaterial toneMapped={false} transparent opacity={0.9} blending={THREE.AdditiveBlending} />
|
||||||
|
</instancedMesh>
|
||||||
|
{/* Rooftop antenna masts */}
|
||||||
|
{data.tallList.length > 0 && (
|
||||||
|
<instancedMesh ref={masts} args={[undefined, undefined, data.tallList.length]}>
|
||||||
|
<boxGeometry args={[1, 1, 1]} />
|
||||||
|
<meshStandardMaterial color="#11182c" metalness={0.7} roughness={0.4} />
|
||||||
|
</instancedMesh>
|
||||||
|
)}
|
||||||
|
{/* Aviation lights on the masts */}
|
||||||
|
{data.tallList.length > 0 && (
|
||||||
|
<instancedMesh ref={lights} args={[undefined, undefined, data.tallList.length]}>
|
||||||
|
<sphereGeometry args={[0.07, 8, 8]} />
|
||||||
|
<meshBasicMaterial color={C.red} toneMapped={false} transparent opacity={0.8} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</instancedMesh>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(City);
|
||||||
125
src/components/logisticsbrain/LogisticsBrainCanvas.tsx
Normal file
125
src/components/logisticsbrain/LogisticsBrainCanvas.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useRef } from "react";
|
||||||
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
|
import { EffectComposer, Bloom, Vignette } from "@react-three/postprocessing";
|
||||||
|
import { KernelSize } from "postprocessing";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { C, WAYPOINTS } from "./theme";
|
||||||
|
import { clamp01, damp, lerp, smoothstep } from "./math";
|
||||||
|
import Brain from "./Brain";
|
||||||
|
import City from "./City";
|
||||||
|
import Routes from "./Routes";
|
||||||
|
import Network from "./Network";
|
||||||
|
import SLAClock from "./SLAClock";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
progress: React.RefObject<number>;
|
||||||
|
reduced?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const posTarget = new THREE.Vector3();
|
||||||
|
const lookTarget = new THREE.Vector3();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cinematic camera that flies along the waypoint spline by scroll progress.
|
||||||
|
* Adjacent waypoints are smoothstep-interpolated, then the camera exponentially
|
||||||
|
* damps toward the result so even fast/flung scrolls glide instead of snapping.
|
||||||
|
*/
|
||||||
|
function CameraRig({ progress }: { progress: React.RefObject<number> }) {
|
||||||
|
const lookCurrent = useRef(new THREE.Vector3(0, 6, 0));
|
||||||
|
const inited = useRef(false);
|
||||||
|
|
||||||
|
useFrame((state, dt) => {
|
||||||
|
const p = clamp01(progress.current ?? 0);
|
||||||
|
|
||||||
|
// Locate the active waypoint segment.
|
||||||
|
let i = 0;
|
||||||
|
for (let k = 0; k < WAYPOINTS.length - 1; k++) {
|
||||||
|
if (p >= WAYPOINTS[k].at && p <= WAYPOINTS[k + 1].at) { i = k; break; }
|
||||||
|
if (p > WAYPOINTS[WAYPOINTS.length - 1].at) i = WAYPOINTS.length - 2;
|
||||||
|
}
|
||||||
|
const a = WAYPOINTS[i];
|
||||||
|
const b = WAYPOINTS[i + 1];
|
||||||
|
const span = b.at - a.at || 1;
|
||||||
|
const lt = smoothstep(0, 1, clamp01((p - a.at) / span));
|
||||||
|
|
||||||
|
posTarget.set(
|
||||||
|
lerp(a.pos[0], b.pos[0], lt),
|
||||||
|
lerp(a.pos[1], b.pos[1], lt),
|
||||||
|
lerp(a.pos[2], b.pos[2], lt),
|
||||||
|
);
|
||||||
|
lookTarget.set(
|
||||||
|
lerp(a.look[0], b.look[0], lt),
|
||||||
|
lerp(a.look[1], b.look[1], lt),
|
||||||
|
lerp(a.look[2], b.look[2], lt),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Very subtle idle breathing so the shot isn't dead-static — kept tiny (~70%
|
||||||
|
// smaller than before) so the camera reads as a fixed map view, not a fly-through.
|
||||||
|
const t = state.clock.elapsedTime;
|
||||||
|
posTarget.x += Math.sin(t * 0.16) * 0.14;
|
||||||
|
posTarget.y += Math.sin(t * 0.21) * 0.07;
|
||||||
|
|
||||||
|
const cam = state.camera;
|
||||||
|
if (!inited.current) {
|
||||||
|
cam.position.copy(posTarget);
|
||||||
|
lookCurrent.current.copy(lookTarget);
|
||||||
|
inited.current = true;
|
||||||
|
} else {
|
||||||
|
cam.position.x = damp(cam.position.x, posTarget.x, 2.6, dt);
|
||||||
|
cam.position.y = damp(cam.position.y, posTarget.y, 2.6, dt);
|
||||||
|
cam.position.z = damp(cam.position.z, posTarget.z, 2.6, dt);
|
||||||
|
lookCurrent.current.x = damp(lookCurrent.current.x, lookTarget.x, 3, dt);
|
||||||
|
lookCurrent.current.y = damp(lookCurrent.current.y, lookTarget.y, 3, dt);
|
||||||
|
lookCurrent.current.z = damp(lookCurrent.current.z, lookTarget.z, 3, dt);
|
||||||
|
}
|
||||||
|
cam.lookAt(lookCurrent.current);
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogisticsBrainCanvas({ progress, reduced = false, isMobile = false, active = true }: Props) {
|
||||||
|
return (
|
||||||
|
<Canvas
|
||||||
|
flat
|
||||||
|
dpr={[1, isMobile || reduced ? 1.3 : 1.7]}
|
||||||
|
camera={{ position: WAYPOINTS[0].pos, fov: 52, near: 0.1, far: 200 }}
|
||||||
|
gl={{ antialias: !isMobile, powerPreference: "high-performance", alpha: false }}
|
||||||
|
frameloop={active ? "always" : "never"}
|
||||||
|
>
|
||||||
|
<color attach="background" args={[C.bg]} />
|
||||||
|
<fog attach="fog" args={[C.bg, 40, 100]} />
|
||||||
|
|
||||||
|
<ambientLight intensity={0.55} />
|
||||||
|
<directionalLight position={[8, 18, 10]} intensity={0.7} color={C.sky} />
|
||||||
|
<pointLight position={[-10, 8, -8]} intensity={40} distance={60} color={C.purple} />
|
||||||
|
<pointLight position={[0, 9, 0]} intensity={30} distance={40} color={C.cyan} />
|
||||||
|
|
||||||
|
<CameraRig progress={progress} />
|
||||||
|
<City progress={progress} reduced={reduced} isMobile={isMobile} />
|
||||||
|
<Routes progress={progress} reduced={reduced} isMobile={isMobile} />
|
||||||
|
<Network progress={progress} reduced={reduced} isMobile={isMobile} />
|
||||||
|
<SLAClock progress={progress} reduced={reduced} isMobile={isMobile} />
|
||||||
|
<Brain progress={progress} reduced={reduced} isMobile={isMobile} />
|
||||||
|
|
||||||
|
{!reduced && (
|
||||||
|
<EffectComposer multisampling={isMobile ? 0 : 2}>
|
||||||
|
<Bloom
|
||||||
|
mipmapBlur
|
||||||
|
intensity={isMobile ? 0.9 : 1.25}
|
||||||
|
luminanceThreshold={0.12}
|
||||||
|
luminanceSmoothing={0.045}
|
||||||
|
radius={isMobile ? 0.65 : 0.82}
|
||||||
|
kernelSize={KernelSize.MEDIUM}
|
||||||
|
/>
|
||||||
|
<Vignette eskil={false} offset={0.22} darkness={0.62} />
|
||||||
|
</EffectComposer>
|
||||||
|
)}
|
||||||
|
</Canvas>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(LogisticsBrainCanvas);
|
||||||
457
src/components/logisticsbrain/LogisticsBrainSection.tsx
Normal file
457
src/components/logisticsbrain/LogisticsBrainSection.tsx
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { motion, useMotionValue, useMotionValueEvent, useTransform, type MotionValue } from "framer-motion";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
|
import { P, STRATEGIES, ENGINE_STEPS, CONSTRAINT_LIST, STRATEGY_SCORES } from "./theme";
|
||||||
|
|
||||||
|
const LogisticsBrainCanvas = dynamic(() => import("./LogisticsBrainCanvas"), { ssr: false });
|
||||||
|
|
||||||
|
/** Rounds a MotionValue to an integer for the animated stat counters. */
|
||||||
|
function Counter({ mv }: { mv: MotionValue<number> }) {
|
||||||
|
const [v, setV] = useState(0);
|
||||||
|
useMotionValueEvent(mv, "change", (x) => setV(Math.round(x)));
|
||||||
|
return <>{v}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active step index from scroll progress (−1 before the engine starts). */
|
||||||
|
function stepFromProgress(p: number): number {
|
||||||
|
let s = -1;
|
||||||
|
for (let i = 0; i < ENGINE_STEPS.length; i++) if (p >= ENGINE_STEPS[i].at) s = i;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persistent top rail: the 6 engine steps, with the current one highlighted. */
|
||||||
|
function StepRail({ active }: { active: number }) {
|
||||||
|
return (
|
||||||
|
<div className="dm-lb-rail" aria-hidden>
|
||||||
|
{ENGINE_STEPS.map((s, i) => {
|
||||||
|
const state = i < active ? "done" : i === active ? "current" : "todo";
|
||||||
|
return (
|
||||||
|
<React.Fragment key={s.n}>
|
||||||
|
{i > 0 && <span className={`dm-lb-rail__line is-${i <= active ? "on" : "off"}`} />}
|
||||||
|
<div className={`dm-lb-rail__step is-${state}`}>
|
||||||
|
<span className="dm-lb-rail__num">{i < active ? "✓" : s.n}</span>
|
||||||
|
<span className="dm-lb-rail__title">{s.title}</span>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One cross-fading workflow card pinned to the lower-left. */
|
||||||
|
function StoryCard({
|
||||||
|
opacity,
|
||||||
|
y,
|
||||||
|
num,
|
||||||
|
kicker,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
opacity: MotionValue<number>;
|
||||||
|
y: MotionValue<number>;
|
||||||
|
num: string;
|
||||||
|
kicker: string;
|
||||||
|
title: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div className="dm-lb-card-story" style={{ opacity, y }}>
|
||||||
|
<div className="dm-lb-card-story__head">
|
||||||
|
<span className="dm-lb-pillar__num">{num}</span>
|
||||||
|
<span className="dm-lb-pillar__kicker">{kicker}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="dm-lb-pillar__title">{title}</h3>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Logistics Brain" — one sticky, fullscreen, scroll-driven cinematic WebGL
|
||||||
|
* section. A single GSAP ScrollTrigger maps scroll position to a normalized
|
||||||
|
* progress value that drives the R3F scene, the camera spline and this overlay
|
||||||
|
* in lockstep, so the whole thing reads as one continuous shot.
|
||||||
|
*/
|
||||||
|
export default function LogisticsBrainSection() {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const progressRef = useRef(0);
|
||||||
|
const scroll = useMotionValue(0);
|
||||||
|
|
||||||
|
const [pinState, setPinState] = useState<"before" | "pinned" | "after">("before");
|
||||||
|
const [step, setStep] = useState(-1);
|
||||||
|
const [mountScene, setMountScene] = useState(false);
|
||||||
|
const [sceneActive, setSceneActive] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [reduced, setReduced] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mqMobile = window.matchMedia("(max-width: 767px)");
|
||||||
|
const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
const sync = () => { setIsMobile(mqMobile.matches); setReduced(mqReduce.matches); };
|
||||||
|
sync();
|
||||||
|
mqMobile.addEventListener("change", sync);
|
||||||
|
mqReduce.addEventListener("change", sync);
|
||||||
|
return () => { mqMobile.removeEventListener("change", sync); mqReduce.removeEventListener("change", sync); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const mountIo = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((e) => e.isIntersecting)) {
|
||||||
|
setMountScene(true);
|
||||||
|
setSceneActive(true);
|
||||||
|
mountIo.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "120% 0px" },
|
||||||
|
);
|
||||||
|
const activeIo = new IntersectionObserver(
|
||||||
|
(entries) => setSceneActive(entries.some((e) => e.isIntersecting)),
|
||||||
|
{ rootMargin: "10% 0px" },
|
||||||
|
);
|
||||||
|
mountIo.observe(el);
|
||||||
|
activeIo.observe(el);
|
||||||
|
return () => { mountIo.disconnect(); activeIo.disconnect(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
let lastPin: "before" | "pinned" | "after" = "before";
|
||||||
|
let lastStep = -1;
|
||||||
|
const st = ScrollTrigger.create({
|
||||||
|
trigger: el,
|
||||||
|
start: "top top",
|
||||||
|
end: "bottom bottom",
|
||||||
|
scrub: 0.5,
|
||||||
|
invalidateOnRefresh: true,
|
||||||
|
onUpdate: (self) => {
|
||||||
|
const p = self.progress;
|
||||||
|
progressRef.current = p;
|
||||||
|
scroll.set(p);
|
||||||
|
const ns = p <= 0.0002 ? "before" : p >= 0.9998 ? "after" : "pinned";
|
||||||
|
if (ns !== lastPin) { lastPin = ns; setPinState(ns); }
|
||||||
|
const nstep = stepFromProgress(p);
|
||||||
|
if (nstep !== lastStep) { lastStep = nstep; setStep(nstep); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const refresh = setTimeout(() => ScrollTrigger.refresh(), 300);
|
||||||
|
return () => { clearTimeout(refresh); st.kill(); };
|
||||||
|
}, [scroll]);
|
||||||
|
|
||||||
|
// Overlay transforms.
|
||||||
|
const introOpacity = useTransform(scroll, [0, 0.04, 0.1], [1, 1, 0]);
|
||||||
|
// Persistent header (title + step rail) fades in after the intro and out at the finale.
|
||||||
|
const headerOpacity = useTransform(scroll, [0.04, 0.1, P.finale - 0.04, P.finale + 0.02], [0, 1, 1, 0]);
|
||||||
|
|
||||||
|
// Per-beat kinetic-typography pillars (cross-fade across each story beat).
|
||||||
|
const p1o = useTransform(scroll, [0.135, 0.165, 0.255, 0.275], [0, 1, 1, 0]);
|
||||||
|
const p1y = useTransform(scroll, [0.135, 0.175], [26, 0]);
|
||||||
|
const p2o = useTransform(scroll, [0.29, 0.32, 0.415, 0.435], [0, 1, 1, 0]);
|
||||||
|
const p2y = useTransform(scroll, [0.29, 0.33], [26, 0]);
|
||||||
|
const p3o = useTransform(scroll, [0.45, 0.48, 0.575, 0.595], [0, 1, 1, 0]);
|
||||||
|
const p3y = useTransform(scroll, [0.45, 0.49], [26, 0]);
|
||||||
|
const p4o = useTransform(scroll, [0.61, 0.64, 0.715, 0.735], [0, 1, 1, 0]);
|
||||||
|
const p4y = useTransform(scroll, [0.61, 0.65], [26, 0]);
|
||||||
|
const p5o = useTransform(scroll, [0.75, 0.78, 0.855, 0.875], [0, 1, 1, 0]);
|
||||||
|
const p5y = useTransform(scroll, [0.75, 0.79], [26, 0]);
|
||||||
|
|
||||||
|
// Readability scrim behind the lower-left story pillars. The bright street-level
|
||||||
|
// city (esp. the EV beat) leaves the text with no contrast, so we darken the
|
||||||
|
// bottom-left corner across all pillar beats and fade it out for the intro hint
|
||||||
|
// and the centered finale.
|
||||||
|
const scrimOpacity = useTransform(scroll, [0.08, 0.13, 0.84, P.finale], [0, 1, 1, 0]);
|
||||||
|
|
||||||
|
const finaleOpacity = useTransform(scroll, [P.finale - 0.02, P.finale + 0.04], [0, 1]);
|
||||||
|
const finaleY = useTransform(scroll, [P.finale - 0.02, P.finale + 0.06], [40, 0]);
|
||||||
|
const taglineOpacity = useTransform(scroll, [P.finale + 0.04, P.finale + 0.1], [0, 1]);
|
||||||
|
|
||||||
|
const orders = useTransform(scroll, [P.finale, 0.97], [0, 59]);
|
||||||
|
const cost = useTransform(scroll, [P.finale, 0.97], [0, 18]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={containerRef} className={`dm-lb is-${pinState}`} aria-label="Logistics Brain — one intelligent system">
|
||||||
|
<div className="dm-lb-sticky">
|
||||||
|
<div className="dm-lb-card">
|
||||||
|
{mountScene && (
|
||||||
|
<div className="dm-lb-canvas">
|
||||||
|
<LogisticsBrainCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="dm-lb-vignette" aria-hidden />
|
||||||
|
<motion.div className="dm-lb-scrim" style={{ opacity: scrimOpacity }} aria-hidden />
|
||||||
|
|
||||||
|
<div className="dm-lb-ui">
|
||||||
|
{/* Persistent header: what this is + where we are in the workflow */}
|
||||||
|
<motion.div className="dm-lb-top" style={{ opacity: headerOpacity }}>
|
||||||
|
<div className="dm-lb-eyebrow">
|
||||||
|
<span className="dm-lb-dot" /> MileTruth Routing Engine
|
||||||
|
</div>
|
||||||
|
<StepRail active={step} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div className="dm-lb-scrollhint" style={{ opacity: introOpacity }}>
|
||||||
|
<span>Scroll to see how every delivery is planned</span>
|
||||||
|
<span className="dm-lb-arrow">↓</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* STEP 01 — Generate Routes */}
|
||||||
|
<StoryCard opacity={p1o} y={p1y} num="01" kicker="Generate Routes" title="We create many delivery plans at once">
|
||||||
|
<div className="dm-lb-chips">
|
||||||
|
{STRATEGIES.map((s) => (
|
||||||
|
<span key={s} className="dm-lb-chip">{s}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="dm-lb-pillar__foot">6 different ways to deliver all 59 orders — generated in milliseconds.</p>
|
||||||
|
</StoryCard>
|
||||||
|
|
||||||
|
{/* STEP 02 — Check Constraints (the EV paradox) */}
|
||||||
|
<StoryCard opacity={p2o} y={p2y} num="02" kicker="Check Constraints" title="Every plan must respect real-world limits">
|
||||||
|
<ul className="dm-lb-constraints">
|
||||||
|
{CONSTRAINT_LIST.map((c) => (
|
||||||
|
<li key={c.label}>
|
||||||
|
<span className="dm-lb-constraints__icon">{c.icon}</span>
|
||||||
|
<span className="dm-lb-constraints__label">{c.label}</span>
|
||||||
|
<span className="dm-lb-constraints__note">{c.note}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="dm-lb-pillar__stat"><strong>59/59</strong> delivered <em>vs 34/59 when battery limits are ignored</em></p>
|
||||||
|
</StoryCard>
|
||||||
|
|
||||||
|
{/* STEP 03 — Score & Compare (the leaderboard) */}
|
||||||
|
<StoryCard opacity={p3o} y={p3y} num="03" kicker="Score & Compare" title="Each plan is scored by total delivery cost">
|
||||||
|
<ul className="dm-lb-board">
|
||||||
|
{STRATEGY_SCORES.map((s) => (
|
||||||
|
<li key={s.name} className={s.win ? "is-win" : ""}>
|
||||||
|
<span className="dm-lb-board__name">{s.name}{s.win && <span className="dm-lb-board__tag">WINNER</span>}</span>
|
||||||
|
<span className="dm-lb-board__track"><span className="dm-lb-board__fill" style={{ width: `${s.score}%` }} /></span>
|
||||||
|
<span className="dm-lb-board__score">{s.score}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</StoryCard>
|
||||||
|
|
||||||
|
{/* STEP 04 — Guarantee On-Time */}
|
||||||
|
<StoryCard opacity={p4o} y={p4y} num="04" kicker="Guarantee On-Time" title="Any plan even 1 minute late is rejected">
|
||||||
|
<div className="dm-lb-sla">
|
||||||
|
<span className="dm-lb-sla__badge">⏱️ On-time only</span>
|
||||||
|
<span className="dm-lb-sla__x">✕ Late plan → dropped</span>
|
||||||
|
</div>
|
||||||
|
<p className="dm-lb-pillar__foot">We only keep plans that hit every promised delivery window.</p>
|
||||||
|
</StoryCard>
|
||||||
|
|
||||||
|
{/* STEP 05 — Pick & Dispatch */}
|
||||||
|
<StoryCard opacity={p5o} y={p5y} num="05" kicker="Pick & Dispatch" title="The winning plan is sent to the fleet">
|
||||||
|
<div className="dm-lb-winner">✓ Multi-Trip selected — lowest cost, zero delays</div>
|
||||||
|
<div className="dm-lb-chips">
|
||||||
|
<span className="dm-lb-chip">EV Bikes</span>
|
||||||
|
<span className="dm-lb-chip">Autos</span>
|
||||||
|
<span className="dm-lb-chip">Cargo Trucks</span>
|
||||||
|
</div>
|
||||||
|
</StoryCard>
|
||||||
|
|
||||||
|
{/* STEP 06 — Results: KPI cards */}
|
||||||
|
<motion.div className="dm-lb-finale" style={{ opacity: finaleOpacity }}>
|
||||||
|
<motion.div className="dm-lb-kpis" style={{ y: finaleY }}>
|
||||||
|
<div className="dm-lb-kpi">
|
||||||
|
<span className="dm-lb-kpi__num"><Counter mv={orders} />/59</span>
|
||||||
|
<span className="dm-lb-kpi__label">Orders Delivered</span>
|
||||||
|
</div>
|
||||||
|
<div className="dm-lb-kpi dm-lb-kpi--green">
|
||||||
|
<span className="dm-lb-kpi__num">0</span>
|
||||||
|
<span className="dm-lb-kpi__label">SLA Misses</span>
|
||||||
|
</div>
|
||||||
|
<div className="dm-lb-kpi">
|
||||||
|
<span className="dm-lb-kpi__num"><Counter mv={cost} />%</span>
|
||||||
|
<span className="dm-lb-kpi__label">Cost Saved</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div className="dm-lb-logo" style={{ opacity: taglineOpacity }}>
|
||||||
|
<span className="dm-lb-logo__mark" />
|
||||||
|
MileTruth
|
||||||
|
</motion.div>
|
||||||
|
<motion.p className="dm-lb-tagline" style={{ opacity: taglineOpacity }}>
|
||||||
|
This isn't just software.<br />
|
||||||
|
<strong>This is your logistics brain.</strong>
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>{styles}</style>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = `
|
||||||
|
.dm-lb { position: relative; height: 640vh; background: transparent; }
|
||||||
|
.dm-lb-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden; }
|
||||||
|
.dm-lb.is-pinned .dm-lb-sticky { position: fixed; top: 0; left: 0; }
|
||||||
|
.dm-lb.is-after .dm-lb-sticky { position: absolute; top: auto; bottom: 0; }
|
||||||
|
|
||||||
|
.dm-lb-card {
|
||||||
|
position: absolute !important; inset: 16px !important;
|
||||||
|
border-radius: 28px !important; overflow: hidden !important;
|
||||||
|
background: radial-gradient(120% 100% at 50% 0%, #12090c 0%, #0a070a 55%, #060507 100%) !important;
|
||||||
|
border: 1px solid rgba(192,18,39,0.16) !important;
|
||||||
|
box-shadow: 0 30px 90px -30px rgba(0,0,0,0.85), inset 0 1px 0 rgba(255,255,255,0.04) !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) { .dm-lb-card { inset: 10px !important; border-radius: 20px !important; } }
|
||||||
|
|
||||||
|
.dm-lb-canvas { position: absolute; inset: 0; z-index: 1; }
|
||||||
|
.dm-lb-canvas canvas { display: block; }
|
||||||
|
.dm-lb-vignette { position: absolute; inset: 0; z-index: 2; pointer-events: none;
|
||||||
|
background: radial-gradient(130% 110% at 50% 45%, transparent 58%, rgba(3,4,10,0.9) 100%),
|
||||||
|
linear-gradient(180deg, rgba(3,4,10,0.55) 0%, transparent 18%, transparent 70%, rgba(3,4,10,0.92) 100%); }
|
||||||
|
|
||||||
|
/* Lower-left readability scrim — keeps the story pillars legible over the bright
|
||||||
|
street-level skyline. Anchored to the bottom-left corner where the pillars sit. */
|
||||||
|
.dm-lb-scrim { position: absolute; inset: 0; z-index: 3; pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(to top right, rgba(3,4,10,0.94) 0%, rgba(3,4,10,0.74) 20%, rgba(3,4,10,0.34) 40%, transparent 60%),
|
||||||
|
linear-gradient(0deg, rgba(3,4,10,0.6) 0%, transparent 38%); }
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.dm-lb-scrim { background: linear-gradient(0deg, rgba(3,4,10,0.92) 0%, rgba(3,4,10,0.55) 28%, transparent 52%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-lb-ui { position: absolute; inset: 0; z-index: 4; pointer-events: none;
|
||||||
|
font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif; color: #eaf2ff; }
|
||||||
|
|
||||||
|
/* ---- Persistent header: title + 6-step engine rail ---- */
|
||||||
|
.dm-lb-top { position: absolute; top: clamp(16px, 3.5vh, 34px); left: 0; right: 0;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 0 16px; }
|
||||||
|
.dm-lb-eyebrow {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px; font-size: 11px; letter-spacing: 0.28em; text-transform: uppercase;
|
||||||
|
color: #F2667A; padding: 6px 16px; border-radius: 999px; background: rgba(192,18,39,0.10);
|
||||||
|
border: 1px solid rgba(226,53,66,0.32); backdrop-filter: blur(8px); white-space: nowrap; }
|
||||||
|
.dm-lb-dot { width: 6px; height: 6px; border-radius: 50%; background: #E2354A; box-shadow: 0 0 10px #E2354A; }
|
||||||
|
|
||||||
|
.dm-lb-rail { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; max-width: 940px; }
|
||||||
|
.dm-lb-rail__step { display: inline-flex; align-items: center; gap: 7px; padding: 5px 11px; border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
backdrop-filter: blur(6px); transition: all 0.45s cubic-bezier(0.22,1,0.36,1); }
|
||||||
|
.dm-lb-rail__num { width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 10px; font-weight: 800; color: rgba(234,242,255,0.6); background: rgba(255,255,255,0.08); }
|
||||||
|
.dm-lb-rail__title { font-size: 11px; font-weight: 600; letter-spacing: 0.03em; color: rgba(234,242,255,0.55); white-space: nowrap; }
|
||||||
|
.dm-lb-rail__step.is-current { background: rgba(192,18,39,0.18); border-color: rgba(226,53,66,0.55); box-shadow: 0 0 22px -6px rgba(226,53,66,0.7); }
|
||||||
|
.dm-lb-rail__step.is-current .dm-lb-rail__num { background: linear-gradient(135deg,#E2354A,#C01227); color: #fff; }
|
||||||
|
.dm-lb-rail__step.is-current .dm-lb-rail__title { color: #fff; }
|
||||||
|
.dm-lb-rail__step.is-done .dm-lb-rail__num { background: #22C55E; color: #04130a; }
|
||||||
|
.dm-lb-rail__step.is-done .dm-lb-rail__title { color: rgba(234,242,255,0.78); }
|
||||||
|
.dm-lb-rail__line { width: 14px; height: 1px; background: rgba(255,255,255,0.12); margin: 0 3px; transition: background 0.45s ease; }
|
||||||
|
.dm-lb-rail__line.is-on { background: linear-gradient(90deg,#22C55E,#E2354A); }
|
||||||
|
|
||||||
|
.dm-lb-scrollhint { position: absolute; bottom: clamp(26px, 6vh, 60px); left: 50%; transform: translateX(-50%);
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 8px; font-size: 12px; letter-spacing: 0.12em;
|
||||||
|
color: rgba(240,228,230,0.72); text-transform: uppercase; text-align: center; }
|
||||||
|
.dm-lb-arrow { font-size: 18px; animation: dmLbBob 1.8s ease-in-out infinite; }
|
||||||
|
@keyframes dmLbBob { 0%,100% { transform: translateY(0); opacity: 0.5; } 50% { transform: translateY(6px); opacity: 1; } }
|
||||||
|
|
||||||
|
/* ---- Lower-left workflow card (glass panel, cross-fades per step) ---- */
|
||||||
|
.dm-lb-card-story { position: absolute; left: clamp(18px, 4vw, 56px); bottom: clamp(26px, 7vh, 64px);
|
||||||
|
width: min(440px, 84vw); pointer-events: auto; will-change: opacity, transform;
|
||||||
|
padding: 18px 20px; border-radius: 18px;
|
||||||
|
background: rgba(14,8,10,0.6); border: 1px solid rgba(226,53,66,0.22);
|
||||||
|
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
||||||
|
box-shadow: 0 24px 64px -30px rgba(0,0,0,0.92); }
|
||||||
|
.dm-lb-card-story__head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||||
|
.dm-lb-pillar__num { font-size: 12px; font-weight: 700; letter-spacing: 0.1em; color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, #E2354A, #C01227); border-radius: 7px; padding: 3px 8px; }
|
||||||
|
.dm-lb-pillar__kicker { font-size: clamp(11px, 1.1vw, 13px); font-weight: 700; letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase; color: #F2667A; }
|
||||||
|
.dm-lb .dm-lb-pillar__title { margin: 0 0 12px !important; padding: 0 !important; color: #fbf5f6 !important;
|
||||||
|
font-weight: 700 !important; text-transform: none !important; letter-spacing: -0.015em !important;
|
||||||
|
font-size: clamp(17px, 1.9vw, 24px) !important; line-height: 1.18 !important;
|
||||||
|
text-shadow: 0 0 30px rgba(192,18,39,0.3) !important; }
|
||||||
|
.dm-lb-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
|
||||||
|
.dm-lb-chip { font-size: 11.5px; font-weight: 600; letter-spacing: 0.02em; color: #f1dadd;
|
||||||
|
padding: 4px 11px; border-radius: 999px; background: rgba(192,18,39,0.12);
|
||||||
|
border: 1px solid rgba(226,53,66,0.30); white-space: nowrap; }
|
||||||
|
.dm-lb-pillar__foot { margin: 0; font-size: clamp(12px, 1.1vw, 13.5px); line-height: 1.45; color: rgba(236,224,226,0.72); }
|
||||||
|
.dm-lb-pillar__stat { margin: 6px 0 0; font-size: clamp(12.5px, 1.2vw, 15px); color: rgba(236,224,226,0.78); }
|
||||||
|
.dm-lb-pillar__stat strong { color: #4ade80; font-weight: 800; font-size: 1.25em; text-shadow: 0 0 20px rgba(34,197,94,0.5); }
|
||||||
|
.dm-lb-pillar__stat em { font-style: normal; color: rgba(230,218,220,0.55); }
|
||||||
|
|
||||||
|
/* Constraints checklist (step 02) */
|
||||||
|
.dm-lb-constraints { list-style: none; margin: 0 0 10px; padding: 0; display: grid; gap: 7px; }
|
||||||
|
.dm-lb-constraints li { display: flex; align-items: center; gap: 9px; }
|
||||||
|
.dm-lb-constraints__icon { font-size: 14px; width: 20px; text-align: center; }
|
||||||
|
.dm-lb-constraints__label { font-size: 13px; font-weight: 700; color: #fbeff0; min-width: 84px; }
|
||||||
|
.dm-lb-constraints__note { font-size: 12px; color: rgba(232,222,224,0.6); }
|
||||||
|
|
||||||
|
/* Scored leaderboard (step 03) */
|
||||||
|
.dm-lb-board { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
||||||
|
.dm-lb-board li { display: grid; grid-template-columns: 104px 1fr 26px; align-items: center; gap: 9px; }
|
||||||
|
.dm-lb-board__name { font-size: 11.5px; font-weight: 600; color: rgba(234,226,228,0.68); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||||
|
.dm-lb-board li.is-win .dm-lb-board__name { color: #fff; font-weight: 800; }
|
||||||
|
.dm-lb-board__tag { font-size: 8px; font-weight: 800; letter-spacing: 0.08em; color: #fff;
|
||||||
|
background: linear-gradient(135deg,#E2354A,#C01227); padding: 2px 5px; border-radius: 5px; }
|
||||||
|
.dm-lb-board__track { height: 7px; border-radius: 999px; background: rgba(255,255,255,0.08); overflow: hidden; }
|
||||||
|
.dm-lb-board__fill { display: block; height: 100%; border-radius: 999px; background: rgba(150,150,165,0.5); }
|
||||||
|
.dm-lb-board li.is-win .dm-lb-board__fill { background: linear-gradient(90deg,#E2354A,#C01227); box-shadow: 0 0 12px rgba(226,53,66,0.6); }
|
||||||
|
.dm-lb-board__score { font-size: 12px; font-weight: 700; color: rgba(234,226,228,0.68); text-align: right; }
|
||||||
|
.dm-lb-board li.is-win .dm-lb-board__score { color: #fff; }
|
||||||
|
|
||||||
|
/* SLA badges (step 04) */
|
||||||
|
.dm-lb-sla { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||||
|
.dm-lb-sla__badge { font-size: 12px; font-weight: 700; color: #86efac; background: rgba(34,197,94,0.1);
|
||||||
|
border: 1px solid rgba(34,197,94,0.32); padding: 6px 12px; border-radius: 999px; }
|
||||||
|
.dm-lb-sla__x { font-size: 12px; font-weight: 700; color: #fca5a5; background: rgba(239,68,68,0.1);
|
||||||
|
border: 1px solid rgba(239,68,68,0.32); padding: 6px 12px; border-radius: 999px; }
|
||||||
|
|
||||||
|
/* Winner banner (step 05) */
|
||||||
|
.dm-lb-winner { font-size: 13.5px; font-weight: 700; color: #fff; margin-bottom: 10px; padding: 9px 13px; border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, rgba(192,18,39,0.24), rgba(34,197,94,0.16)); border: 1px solid rgba(226,53,66,0.4); }
|
||||||
|
|
||||||
|
/* ---- Finale: KPI cards ---- */
|
||||||
|
.dm-lb-finale { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 0 20px; }
|
||||||
|
.dm-lb-kpis { display: flex; gap: clamp(14px, 2.4vw, 28px); margin-bottom: clamp(28px, 6vh, 56px); flex-wrap: wrap; justify-content: center; }
|
||||||
|
.dm-lb-kpi { display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: clamp(150px, 18vw, 210px);
|
||||||
|
padding: 22px 26px; border-radius: 18px; background: rgba(16,9,11,0.6); border: 1px solid rgba(226,53,66,0.28);
|
||||||
|
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); box-shadow: 0 24px 60px -28px rgba(0,0,0,0.9); }
|
||||||
|
.dm-lb-kpi--green { border-color: rgba(34,197,94,0.4); }
|
||||||
|
.dm-lb-kpi__num { font-size: clamp(38px, 5.5vw, 72px); font-weight: 800; line-height: 1; letter-spacing: -0.03em;
|
||||||
|
color: #fff; text-shadow: 0 0 32px rgba(226,53,66,0.55), 0 0 12px rgba(192,18,39,0.5); }
|
||||||
|
.dm-lb-kpi--green .dm-lb-kpi__num { color: #4ade80; text-shadow: 0 0 32px rgba(34,197,94,0.6); }
|
||||||
|
.dm-lb-kpi__label { font-size: clamp(10px, 1.1vw, 13px); letter-spacing: 0.14em; text-transform: uppercase; color: rgba(232,222,224,0.62); }
|
||||||
|
|
||||||
|
.dm-lb-logo { display: inline-flex; align-items: center; gap: 12px; font-size: clamp(22px, 3vw, 40px); font-weight: 800;
|
||||||
|
letter-spacing: -0.01em; color: #fff; margin-bottom: 18px; text-shadow: 0 0 30px rgba(192,18,39,0.55); }
|
||||||
|
.dm-lb-logo__mark { width: clamp(20px, 2.4vw, 30px); height: clamp(20px, 2.4vw, 30px); border-radius: 8px;
|
||||||
|
background: conic-gradient(from 140deg, #E2354A, #C01227, #8A0E1F, #C8102E, #E2354A);
|
||||||
|
box-shadow: 0 0 28px rgba(192,18,39,0.75); }
|
||||||
|
.dm-lb-tagline { margin: 0; font-size: clamp(15px, 1.9vw, 24px); line-height: 1.4; font-weight: 400;
|
||||||
|
color: rgba(240,224,226,0.78); letter-spacing: 0.02em; }
|
||||||
|
.dm-lb-tagline strong { display: inline-block; margin-top: 4px; font-weight: 700; color: #fff;
|
||||||
|
background: linear-gradient(90deg, #E2354A, #C01227, #C8102E); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
|
||||||
|
/* Hide the step titles on narrower screens so the rail stays a single tidy row of numbers. */
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.dm-lb-rail__title { display: none; }
|
||||||
|
.dm-lb-rail__step { padding: 5px 7px; }
|
||||||
|
.dm-lb-rail__line { width: 9px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.dm-lb { height: 540vh; }
|
||||||
|
.dm-lb-kpis { gap: 12px; }
|
||||||
|
.dm-lb-kpi { min-width: 96px; padding: 14px 14px; }
|
||||||
|
.dm-lb-card-story { left: 0; right: 0; margin: 0 auto; width: calc(100% - 28px); bottom: clamp(20px, 5vh, 44px); padding: 14px 16px; }
|
||||||
|
.dm-lb-board li { grid-template-columns: 88px 1fr 24px; }
|
||||||
|
.dm-lb-constraints__note { display: none; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dm-lb-arrow { animation: none !important; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
277
src/components/logisticsbrain/Network.tsx
Normal file
277
src/components/logisticsbrain/Network.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useLayoutEffect, useMemo, useRef } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { C, CITY_RADIUS, P } from "./theme";
|
||||||
|
import { clamp01, damp, lerp, seeded, smoothstep } from "./math";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
progress: React.RefObject<number>;
|
||||||
|
reduced?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dummy = new THREE.Object3D();
|
||||||
|
const cBlue = new THREE.Color(C.blue);
|
||||||
|
const cGreen = new THREE.Color(C.green);
|
||||||
|
const tmpC = new THREE.Color();
|
||||||
|
const tmpV = new THREE.Vector3();
|
||||||
|
|
||||||
|
type Seg = { a: THREE.Vector3; b: THREE.Vector3 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live logistics network revealed at the eagle-eye pass: warehouses, a field
|
||||||
|
* of delivery nodes, the connection lattice between them, glow particles flowing
|
||||||
|
* through the routes (the algorithm computing), and an autonomous fleet that
|
||||||
|
* leaves the warehouses and lights up delivery markers as it completes runs.
|
||||||
|
*/
|
||||||
|
function Network({ progress, isMobile = false }: Props) {
|
||||||
|
const eased = useRef(0);
|
||||||
|
|
||||||
|
const warehouses = useRef<THREE.Group>(null);
|
||||||
|
const whMats = useRef<(THREE.MeshStandardMaterial | null)[]>([]);
|
||||||
|
const linksMat = useRef<THREE.LineBasicMaterial>(null);
|
||||||
|
const flow = useRef<THREE.Points>(null);
|
||||||
|
const fleet = useRef<THREE.InstancedMesh>(null);
|
||||||
|
const markers = useRef<THREE.InstancedMesh>(null);
|
||||||
|
|
||||||
|
const nodeCount = isMobile ? 40 : 70;
|
||||||
|
const flowCount = isMobile ? 70 : 150;
|
||||||
|
const fleetCount = isMobile ? 12 : 24;
|
||||||
|
|
||||||
|
// Warehouse anchors on a mid ring.
|
||||||
|
const whPos = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const a = (i / 5) * Math.PI * 2 + 0.6;
|
||||||
|
const r = 6.5;
|
||||||
|
return new THREE.Vector3(Math.cos(a) * r, 0, Math.sin(a) * r);
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scattered delivery nodes.
|
||||||
|
const nodes = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: nodeCount }, (_, i) => {
|
||||||
|
const a = seeded(i * 3.3 + 1) * Math.PI * 2;
|
||||||
|
const r = 3 + seeded(i * 1.9 + 5) * (CITY_RADIUS - 2);
|
||||||
|
return new THREE.Vector3(Math.cos(a) * r, 0.12, Math.sin(a) * r);
|
||||||
|
}),
|
||||||
|
[nodeCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Connection segments: each node to its nearest warehouse.
|
||||||
|
const segs = useMemo<Seg[]>(() => {
|
||||||
|
return nodes.map((n) => {
|
||||||
|
let best = whPos[0];
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (const w of whPos) {
|
||||||
|
const d = n.distanceToSquared(w);
|
||||||
|
if (d < bestD) { bestD = d; best = w; }
|
||||||
|
}
|
||||||
|
return { a: new THREE.Vector3(best.x, 0.2, best.z), b: n.clone() };
|
||||||
|
});
|
||||||
|
}, [nodes, whPos]);
|
||||||
|
|
||||||
|
// Link geometry (one line per segment).
|
||||||
|
const linkGeom = useMemo(() => {
|
||||||
|
const arr: number[] = [];
|
||||||
|
for (const s of segs) arr.push(s.a.x, s.a.y, s.a.z, s.b.x, s.b.y, s.b.z);
|
||||||
|
const g = new THREE.BufferGeometry();
|
||||||
|
g.setAttribute("position", new THREE.Float32BufferAttribute(arr, 3));
|
||||||
|
return g;
|
||||||
|
}, [segs]);
|
||||||
|
|
||||||
|
// Node point cloud geometry.
|
||||||
|
const nodeGeom = useMemo(() => {
|
||||||
|
const arr = new Float32Array(nodes.length * 3);
|
||||||
|
nodes.forEach((n, i) => { arr[i * 3] = n.x; arr[i * 3 + 1] = n.y; arr[i * 3 + 2] = n.z; });
|
||||||
|
const g = new THREE.BufferGeometry();
|
||||||
|
g.setAttribute("position", new THREE.BufferAttribute(arr, 3));
|
||||||
|
return g;
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
|
// Flow particles: each rides a random segment.
|
||||||
|
const flowState = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: flowCount }, (_, i) => ({
|
||||||
|
seg: Math.floor(seeded(i * 4.7) * segs.length),
|
||||||
|
speed: 0.12 + seeded(i * 2.1) * 0.25,
|
||||||
|
phase: seeded(i * 8.3),
|
||||||
|
})),
|
||||||
|
[flowCount, segs.length],
|
||||||
|
);
|
||||||
|
const flowGeom = useMemo(() => {
|
||||||
|
const g = new THREE.BufferGeometry();
|
||||||
|
g.setAttribute("position", new THREE.BufferAttribute(new Float32Array(flowCount * 3), 3));
|
||||||
|
return g;
|
||||||
|
}, [flowCount]);
|
||||||
|
|
||||||
|
// Fleet: each vehicle rides a segment outward and loops.
|
||||||
|
const fleetState = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: fleetCount }, (_, i) => ({
|
||||||
|
seg: Math.floor(seeded(i * 5.9 + 3) * segs.length),
|
||||||
|
speed: 0.06 + seeded(i * 3.7) * 0.08,
|
||||||
|
phase: seeded(i * 6.6),
|
||||||
|
kind: Math.floor(seeded(i * 9.2) * 3), // 0 bike, 1 auto, 2 truck
|
||||||
|
})),
|
||||||
|
[fleetCount, segs.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
// Static delivery-marker pillars at each node.
|
||||||
|
if (markers.current) {
|
||||||
|
nodes.forEach((n, i) => {
|
||||||
|
dummy.position.set(n.x, 0.18, n.z);
|
||||||
|
dummy.scale.set(0.06, 0.36, 0.06);
|
||||||
|
dummy.rotation.set(0, 0, 0);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
markers.current!.setMatrixAt(i, dummy.matrix);
|
||||||
|
markers.current!.setColorAt(i, cBlue);
|
||||||
|
});
|
||||||
|
markers.current.instanceMatrix.needsUpdate = true;
|
||||||
|
if (markers.current.instanceColor) markers.current.instanceColor.needsUpdate = true;
|
||||||
|
}
|
||||||
|
// Fleet base colour by kind.
|
||||||
|
if (fleet.current) {
|
||||||
|
fleetState.forEach((v, i) => {
|
||||||
|
dummy.position.set(0, -50, 0);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
fleet.current!.setMatrixAt(i, dummy.matrix);
|
||||||
|
const col = v.kind === 0 ? C.cyan : v.kind === 1 ? C.violet : C.sky;
|
||||||
|
fleet.current!.setColorAt(i, tmpC.set(col));
|
||||||
|
});
|
||||||
|
fleet.current.instanceMatrix.needsUpdate = true;
|
||||||
|
if (fleet.current.instanceColor) fleet.current.instanceColor.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}, [nodes, fleetState]);
|
||||||
|
|
||||||
|
useFrame((state, dt) => {
|
||||||
|
const p = progress.current ?? 0;
|
||||||
|
eased.current = damp(eased.current, p, 3, dt);
|
||||||
|
const e = eased.current;
|
||||||
|
const t = state.clock.elapsedTime;
|
||||||
|
|
||||||
|
// Delivery locations appear early (step 1) so the map is populated before the
|
||||||
|
// candidate routes are drawn; the lattice/markers strengthen through the run.
|
||||||
|
const reveal = smoothstep(P.routes - 0.02, P.routes + 0.06, e);
|
||||||
|
const eco = smoothstep(P.ecosystem - 0.03, P.ecosystem + 0.06, e);
|
||||||
|
const finaleGlow = smoothstep(P.finale, 1, e);
|
||||||
|
|
||||||
|
// Warehouses activate during the ecosystem beat.
|
||||||
|
whMats.current.forEach((m, i) => {
|
||||||
|
if (m) m.emissiveIntensity = lerp(0.3, 1.8, eco) * (0.85 + Math.sin(t * 2 + i) * 0.15);
|
||||||
|
});
|
||||||
|
if (warehouses.current) warehouses.current.visible = reveal > 0.02;
|
||||||
|
|
||||||
|
// Connection lattice.
|
||||||
|
if (linksMat.current) {
|
||||||
|
linksMat.current.opacity = (0.05 + 0.16 * reveal + 0.1 * finaleGlow) * (0.8 + Math.sin(t * 1.5) * 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flow particles streaming along the lattice.
|
||||||
|
if (flow.current) {
|
||||||
|
const attr = flow.current.geometry.getAttribute("position") as THREE.BufferAttribute;
|
||||||
|
for (let i = 0; i < flowState.length; i++) {
|
||||||
|
const f = flowState[i];
|
||||||
|
const s = segs[f.seg];
|
||||||
|
const frac = ((t * f.speed + f.phase) % 1 + 1) % 1;
|
||||||
|
tmpV.copy(s.a).lerp(s.b, frac);
|
||||||
|
attr.setXYZ(i, tmpV.x, tmpV.y + 0.05, tmpV.z);
|
||||||
|
}
|
||||||
|
attr.needsUpdate = true;
|
||||||
|
(flow.current.material as THREE.PointsMaterial).opacity = clamp01(0.2 + reveal * 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fleet rolls out during the ecosystem beat; markers light green as runs complete.
|
||||||
|
if (fleet.current) {
|
||||||
|
for (let i = 0; i < fleetState.length; i++) {
|
||||||
|
const v = fleetState[i];
|
||||||
|
const s = segs[v.seg];
|
||||||
|
const frac = ((t * v.speed + v.phase) % 1 + 1) % 1;
|
||||||
|
tmpV.copy(s.a).lerp(s.b, frac);
|
||||||
|
const scaleK = eco * (v.kind === 2 ? 1.4 : v.kind === 1 ? 1.1 : 0.85);
|
||||||
|
dummy.position.set(tmpV.x, 0.16, tmpV.z);
|
||||||
|
// orient along the segment
|
||||||
|
const dir = Math.atan2(s.b.z - s.a.z, s.b.x - s.a.x);
|
||||||
|
dummy.rotation.set(0, -dir, 0);
|
||||||
|
dummy.scale.set(0.34 * scaleK + 0.0001, 0.16 * scaleK + 0.0001, 0.2 * scaleK + 0.0001);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
fleet.current.setMatrixAt(i, dummy.matrix);
|
||||||
|
}
|
||||||
|
fleet.current.instanceMatrix.needsUpdate = true;
|
||||||
|
fleet.current.visible = eco > 0.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery markers illuminate progressively (blue → green).
|
||||||
|
if (markers.current) {
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
const thr = (i / nodes.length) * 0.9;
|
||||||
|
const lit = smoothstep(thr, thr + 0.08, eco) * (0.6 + Math.sin(t * 3 + i) * 0.4);
|
||||||
|
tmpC.copy(cBlue).lerp(cGreen, smoothstep(thr, thr + 0.08, eco));
|
||||||
|
markers.current.setColorAt(i, tmpC.multiplyScalar(0.3 + lit));
|
||||||
|
}
|
||||||
|
if (markers.current.instanceColor) markers.current.instanceColor.needsUpdate = true;
|
||||||
|
(markers.current.material as THREE.MeshBasicMaterial).opacity = clamp01(0.15 + reveal * 0.5 + eco * 0.4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Connection lattice */}
|
||||||
|
<lineSegments geometry={linkGeom}>
|
||||||
|
<lineBasicMaterial ref={linksMat} color={C.blue} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</lineSegments>
|
||||||
|
|
||||||
|
{/* Delivery nodes */}
|
||||||
|
<points geometry={nodeGeom}>
|
||||||
|
<pointsMaterial size={isMobile ? 0.12 : 0.1} color={C.sky} transparent opacity={0.6} sizeAttenuation blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</points>
|
||||||
|
|
||||||
|
{/* Flow particles (the algorithm computing) */}
|
||||||
|
<points ref={flow} geometry={flowGeom}>
|
||||||
|
<pointsMaterial size={isMobile ? 0.11 : 0.09} color={C.cyan} transparent opacity={0} sizeAttenuation blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</points>
|
||||||
|
|
||||||
|
{/* Delivery markers */}
|
||||||
|
<instancedMesh ref={markers} args={[undefined, undefined, nodeCount]}>
|
||||||
|
<boxGeometry args={[1, 1, 1]} />
|
||||||
|
<meshBasicMaterial toneMapped={false} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</instancedMesh>
|
||||||
|
|
||||||
|
{/* Autonomous fleet */}
|
||||||
|
<instancedMesh ref={fleet} args={[undefined, undefined, fleetCount]} visible={false}>
|
||||||
|
<boxGeometry args={[1, 1, 1]} />
|
||||||
|
<meshBasicMaterial toneMapped={false} blending={THREE.AdditiveBlending} />
|
||||||
|
</instancedMesh>
|
||||||
|
|
||||||
|
{/* Warehouses */}
|
||||||
|
<group ref={warehouses} visible={false}>
|
||||||
|
{whPos.map((w, i) => (
|
||||||
|
<group key={i} position={[w.x, 0, w.z]}>
|
||||||
|
<mesh position={[0, 0.5, 0]}>
|
||||||
|
<boxGeometry args={[1.6, 1, 1.3]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
ref={(el) => { whMats.current[i] = el; }}
|
||||||
|
color="#0b1426"
|
||||||
|
emissive={C.blue}
|
||||||
|
emissiveIntensity={0.3}
|
||||||
|
metalness={0.4}
|
||||||
|
roughness={0.5}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.03, 0]}>
|
||||||
|
<ringGeometry args={[1.2, 1.4, 36]} />
|
||||||
|
<meshBasicMaterial color={C.cyan} toneMapped={false} transparent opacity={0.4} side={THREE.DoubleSide} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(Network);
|
||||||
401
src/components/logisticsbrain/Routes.tsx
Normal file
401
src/components/logisticsbrain/Routes.tsx
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useRef } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { Html } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { C, P, ROUTE_COLORS, STRATEGIES, WINNER_INDEX } from "./theme";
|
||||||
|
import { clamp01, damp, lerp, seeded, smoothstep } from "./math";
|
||||||
|
|
||||||
|
/** Score per candidate, index-aligned to STRATEGIES. Multi-Trip(0) wins; EV-Aware(4) is rejected. */
|
||||||
|
const ROUTE_SCORES = [98, 76, 84, 68, 58, 90];
|
||||||
|
const REJECT_INDEX = 4; // EV-Aware — fails the battery/range constraint
|
||||||
|
|
||||||
|
const labelBase: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "4px 11px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
background: "rgba(7,11,22,0.72)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.16)",
|
||||||
|
color: "#eaf2ff",
|
||||||
|
fontSize: "11px",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
backdropFilter: "blur(6px)",
|
||||||
|
WebkitBackdropFilter: "blur(6px)",
|
||||||
|
fontFamily: "var(--font-space-grotesk), system-ui, sans-serif",
|
||||||
|
opacity: 0,
|
||||||
|
pointerEvents: "none",
|
||||||
|
willChange: "opacity",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
progress: React.RefObject<number>;
|
||||||
|
reduced?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WINNER = WINNER_INDEX; // the surviving optimal route (Multi-Trip)
|
||||||
|
const EV_MID = (P.ev + P.network) / 2; // moment the battery fails / recalc fires
|
||||||
|
|
||||||
|
const green = new THREE.Color(C.green);
|
||||||
|
const amber = new THREE.Color(C.amber);
|
||||||
|
const red = new THREE.Color(C.red);
|
||||||
|
const tmp = new THREE.Color();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ground-hugging candidate route from the depot out to a delivery cluster. Each
|
||||||
|
* route kinks through an intermediate stop (so it reads as a real plan visiting
|
||||||
|
* locations, not a straight ray) but stays low on the map plane — the camera is
|
||||||
|
* a near-static top-down view, so the routes ARE the story.
|
||||||
|
*/
|
||||||
|
function makeRouteCurve(i: number): THREE.CatmullRomCurve3 {
|
||||||
|
const angle = (i / ROUTE_COLORS.length) * Math.PI * 2 + 0.4;
|
||||||
|
const radius = 9 + seeded(i * 6.1) * 4;
|
||||||
|
const end = new THREE.Vector3(Math.cos(angle) * radius, 0.16, Math.sin(angle) * radius);
|
||||||
|
// intermediate stop, offset sideways so each candidate takes a visibly different path
|
||||||
|
const side = (seeded(i * 3.7) - 0.5) * 5;
|
||||||
|
const midA = new THREE.Vector3(
|
||||||
|
Math.cos(angle) * radius * 0.4 + Math.cos(angle + Math.PI / 2) * side,
|
||||||
|
0.18,
|
||||||
|
Math.sin(angle) * radius * 0.4 + Math.sin(angle + Math.PI / 2) * side,
|
||||||
|
);
|
||||||
|
const start = new THREE.Vector3(0, 0.2, 0);
|
||||||
|
return new THREE.CatmullRomCurve3([start, midA, end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Six competing delivery strategies projected by the brain. They race packets
|
||||||
|
* through the city; the losers fade out and the optimal route keeps glowing.
|
||||||
|
* Then the EV beat: a scooter runs a ground route, its battery drains until the
|
||||||
|
* route is invalid, the brain recalculates, a charging station rises and a new
|
||||||
|
* green optimized route lights up.
|
||||||
|
*/
|
||||||
|
function Routes({ progress }: Props) {
|
||||||
|
const eased = useRef(0);
|
||||||
|
|
||||||
|
const tubeMats = useRef<(THREE.MeshBasicMaterial | null)[]>([]);
|
||||||
|
const packets = useRef<(THREE.Mesh | null)[]>([]);
|
||||||
|
|
||||||
|
const scooter = useRef<THREE.Group>(null);
|
||||||
|
const battFill = useRef<THREE.Mesh>(null);
|
||||||
|
const battMat = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const station = useRef<THREE.Group>(null);
|
||||||
|
const evRedMat = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const evGreenMat = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
|
||||||
|
const curves = useMemo(() => ROUTE_COLORS.map((_, i) => makeRouteCurve(i)), []);
|
||||||
|
const tubes = useMemo(
|
||||||
|
() => curves.map((c) => new THREE.TubeGeometry(c, 60, 0.05, 8, false)),
|
||||||
|
[curves],
|
||||||
|
);
|
||||||
|
|
||||||
|
// EV ground path + its red (failed) and green (optimized) variants.
|
||||||
|
const evCurve = useMemo(
|
||||||
|
() =>
|
||||||
|
new THREE.CatmullRomCurve3([
|
||||||
|
new THREE.Vector3(-11, 0.3, 7),
|
||||||
|
new THREE.Vector3(-4, 0.3, 3),
|
||||||
|
new THREE.Vector3(2.6, 0.3, -1.2),
|
||||||
|
new THREE.Vector3(9.5, 0.3, -6.5),
|
||||||
|
]),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const evTube = useMemo(() => new THREE.TubeGeometry(evCurve, 70, 0.07, 8, false), [evCurve]);
|
||||||
|
const evGreenTube = useMemo(() => {
|
||||||
|
const c = new THREE.CatmullRomCurve3([
|
||||||
|
new THREE.Vector3(-11, 0.3, 7),
|
||||||
|
new THREE.Vector3(-3.5, 0.3, 1.5),
|
||||||
|
new THREE.Vector3(2.6, 0.6, -1.2), // routes via the charging station
|
||||||
|
new THREE.Vector3(6.5, 0.3, -3),
|
||||||
|
new THREE.Vector3(9.5, 0.3, -6.5),
|
||||||
|
]);
|
||||||
|
return new THREE.TubeGeometry(c, 80, 0.08, 8, false);
|
||||||
|
}, []);
|
||||||
|
const stationPos = useMemo(() => new THREE.Vector3(2.6, 0, -1.2), []);
|
||||||
|
|
||||||
|
const scratch = useMemo(() => new THREE.Vector3(), []);
|
||||||
|
|
||||||
|
// Per-candidate base colours (winner red, others grey) so we can tint the
|
||||||
|
// rejected candidate toward red each frame as it's flagged invalid.
|
||||||
|
const baseColors = useMemo(() => ROUTE_COLORS.map((c) => new THREE.Color(c)), []);
|
||||||
|
const rejectRed = useMemo(() => new THREE.Color(C.red), []);
|
||||||
|
|
||||||
|
// Vehicles dispatched along the WINNING route at the dispatch beat.
|
||||||
|
const dispatch = useRef<(THREE.Mesh | null)[]>([]);
|
||||||
|
|
||||||
|
// 3D label anchors near each route's far end + the recharge hub.
|
||||||
|
const labelRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
const stationLabelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const labelPos = useMemo(
|
||||||
|
() =>
|
||||||
|
curves.map((c) => {
|
||||||
|
const v = c.getPointAt(0.82).clone();
|
||||||
|
v.y += 1.0;
|
||||||
|
return v;
|
||||||
|
}),
|
||||||
|
[curves],
|
||||||
|
);
|
||||||
|
|
||||||
|
useFrame((state, dt) => {
|
||||||
|
const p = progress.current ?? 0;
|
||||||
|
eased.current = damp(eased.current, p, 3.2, dt);
|
||||||
|
const e = eased.current;
|
||||||
|
const t = state.clock.elapsedTime;
|
||||||
|
|
||||||
|
// Beat gates — drive the whole "why this route was chosen" story.
|
||||||
|
const appear = smoothstep(P.routes - 0.03, P.routes + 0.05, e); // candidates draw in
|
||||||
|
const racing = smoothstep(P.routes, P.routes + 0.05, e);
|
||||||
|
const reject = smoothstep(P.ev, P.ev + 0.05, e); // invalid candidate flagged
|
||||||
|
const losersGone = smoothstep(P.sla, P.ecosystem, e); // lower-scoring routes eliminated
|
||||||
|
const winnerBoost = smoothstep(P.ecosystem, P.ecosystem + 0.06, e); // winner glows stronger
|
||||||
|
const dispatchT = clamp01((e - P.ecosystem) / (1 - P.ecosystem)); // vehicles roll out along winner
|
||||||
|
const endFade = smoothstep(0.97, 1.0, e);
|
||||||
|
|
||||||
|
// Candidate routes: all draw in → one flagged invalid → losers eliminated → winner persists & glows.
|
||||||
|
for (let i = 0; i < curves.length; i++) {
|
||||||
|
const isWinner = i === WINNER;
|
||||||
|
const isReject = i === REJECT_INDEX;
|
||||||
|
const loserFade = isReject ? Math.max(losersGone, reject * 0.55) : losersGone;
|
||||||
|
|
||||||
|
const mat = tubeMats.current[i];
|
||||||
|
if (mat) {
|
||||||
|
if (isReject) mat.color.copy(baseColors[i]).lerp(rejectRed, reject);
|
||||||
|
let o = 0.55 * appear;
|
||||||
|
if (isWinner) o *= (0.72 + Math.sin(t * 3) * 0.28) * (1 + winnerBoost * 0.9);
|
||||||
|
else o *= 1 - loserFade;
|
||||||
|
mat.opacity = clamp01(o) * (1 - endFade);
|
||||||
|
}
|
||||||
|
const pk = packets.current[i];
|
||||||
|
if (pk) {
|
||||||
|
const frac = ((t * (0.16 + i * 0.02) + seeded(i * 9)) % 1 + 1) % 1;
|
||||||
|
curves[i].getPointAt(frac, scratch);
|
||||||
|
pk.position.copy(scratch);
|
||||||
|
const vis = (isWinner ? 1 : 1 - loserFade) * racing * (1 - endFade);
|
||||||
|
pk.scale.setScalar(0.0001 + vis * (isWinner ? 0.18 : 0.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating score labels — appear at the SCORE beat; the rejected one shows its ✕
|
||||||
|
// earlier (constraints beat); winner keeps its label to the end.
|
||||||
|
for (let i = 0; i < labelRefs.current.length; i++) {
|
||||||
|
const el = labelRefs.current[i];
|
||||||
|
if (!el) continue;
|
||||||
|
const isW = i === WINNER;
|
||||||
|
const isReject = i === REJECT_INDEX;
|
||||||
|
const show = isReject
|
||||||
|
? smoothstep(P.ev, P.ev + 0.04, e)
|
||||||
|
: smoothstep(P.network, P.network + 0.04, e);
|
||||||
|
const fade = isW ? endFade : (isReject ? Math.max(losersGone, 0) : losersGone);
|
||||||
|
const o = show * (1 - fade);
|
||||||
|
el.style.opacity = o.toFixed(3);
|
||||||
|
el.style.display = o < 0.02 ? "none" : "flex";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch: vehicles travel the winning route once it's selected.
|
||||||
|
if (dispatch.current.length) {
|
||||||
|
const wc = curves[WINNER];
|
||||||
|
for (let k = 0; k < dispatch.current.length; k++) {
|
||||||
|
const m = dispatch.current[k];
|
||||||
|
if (!m) continue;
|
||||||
|
const on = dispatchT > 0.02;
|
||||||
|
m.visible = on;
|
||||||
|
if (!on) continue;
|
||||||
|
const frac = ((t * 0.14 + k * 0.34) % 1 + 1) % 1;
|
||||||
|
wc.getPointAt(frac, scratch);
|
||||||
|
m.position.set(scratch.x, 0.3, scratch.z);
|
||||||
|
wc.getPointAt(Math.min(0.999, frac + 0.02), scratch);
|
||||||
|
m.lookAt(scratch.x, 0.3, scratch.z);
|
||||||
|
m.scale.setScalar(0.0001 + smoothstep(0, 0.12, dispatchT) * (1 - endFade));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- EV beat ----
|
||||||
|
const evIn = smoothstep(P.ev - 0.02, P.ev + 0.03, e);
|
||||||
|
const evOut = 1 - smoothstep(P.network + 0.02, P.network + 0.1, e);
|
||||||
|
const recalc = smoothstep(EV_MID - 0.015, EV_MID + 0.03, e);
|
||||||
|
const evActive = evIn * evOut;
|
||||||
|
|
||||||
|
// Scooter travels the full ground path across the beat.
|
||||||
|
const drive = clamp01((e - P.ev) / (P.network - P.ev));
|
||||||
|
if (scooter.current) {
|
||||||
|
// After recalc the scooter follows the optimized green path.
|
||||||
|
const curve = recalc > 0.5 ? evGreenTube.parameters.path : evCurve;
|
||||||
|
curve.getPointAt(clamp01(drive), scratch);
|
||||||
|
scooter.current.position.set(scratch.x, 0.32, scratch.z);
|
||||||
|
// face direction of travel
|
||||||
|
curve.getPointAt(clamp01(drive + 0.01), scratch);
|
||||||
|
scooter.current.lookAt(scratch.x, 0.32, scratch.z);
|
||||||
|
scooter.current.visible = evActive > 0.02;
|
||||||
|
scooter.current.scale.setScalar(0.0001 + evActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Battery: drains to near-empty, then refills after recalc/charging.
|
||||||
|
const drain = clamp01((e - P.ev) / (EV_MID - P.ev));
|
||||||
|
const level = recalc > 0 ? lerp(0.14, 1, recalc) : clamp01(1 - drain * 0.92);
|
||||||
|
if (battFill.current) {
|
||||||
|
battFill.current.scale.x = 0.02 + level * 0.98;
|
||||||
|
battFill.current.position.x = -0.18 * (1 - level); // shrink from the right
|
||||||
|
}
|
||||||
|
if (battMat.current) {
|
||||||
|
if (level > 0.5) tmp.copy(amber).lerp(green, (level - 0.5) * 2);
|
||||||
|
else tmp.copy(red).lerp(amber, level * 2);
|
||||||
|
battMat.current.color.copy(tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed (red) route fades in as battery dies, then dissolves after recalc.
|
||||||
|
if (evRedMat.current) {
|
||||||
|
const fail = smoothstep(EV_MID - 0.06, EV_MID, e);
|
||||||
|
evRedMat.current.opacity = evActive * (0.55 * Math.max(evIn * 0.4, fail)) * (1 - recalc) * (0.7 + Math.sin(t * 5) * 0.3);
|
||||||
|
}
|
||||||
|
// Optimized green route lights up after recalc.
|
||||||
|
if (evGreenMat.current) {
|
||||||
|
evGreenMat.current.opacity = evActive * recalc * (0.65 + Math.sin(t * 3) * 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charging station rises from the ground at recalc.
|
||||||
|
if (station.current) {
|
||||||
|
station.current.scale.y = 0.0001 + recalc;
|
||||||
|
station.current.visible = recalc > 0.01 && evOut > 0.02;
|
||||||
|
}
|
||||||
|
if (stationLabelRef.current) {
|
||||||
|
const o = recalc * evOut;
|
||||||
|
stationLabelRef.current.style.opacity = o.toFixed(3);
|
||||||
|
stationLabelRef.current.style.display = o < 0.02 ? "none" : "flex";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Six competing route simulations */}
|
||||||
|
{tubes.map((geom, i) => (
|
||||||
|
<group key={i}>
|
||||||
|
<mesh geometry={geom}>
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={(el) => { tubeMats.current[i] = el; }}
|
||||||
|
color={ROUTE_COLORS[i]}
|
||||||
|
toneMapped={false}
|
||||||
|
transparent
|
||||||
|
opacity={0}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
depthWrite={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={(el) => { packets.current[i] = el; }}>
|
||||||
|
<sphereGeometry args={[1, 12, 12]} />
|
||||||
|
<meshBasicMaterial color={C.white} toneMapped={false} transparent opacity={0.95} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Floating score labels above each candidate route — winner / rejected called out */}
|
||||||
|
{labelPos.map((pos, i) => {
|
||||||
|
const isWinner = i === WINNER;
|
||||||
|
const isReject = i === REJECT_INDEX;
|
||||||
|
const dotColor = isWinner || isReject ? C.red : ROUTE_COLORS[i];
|
||||||
|
return (
|
||||||
|
<Html key={`lbl${i}`} position={[pos.x, pos.y, pos.z]} center zIndexRange={[30, 0]} style={{ pointerEvents: "none" }}>
|
||||||
|
<div
|
||||||
|
ref={(el) => { labelRefs.current[i] = el; }}
|
||||||
|
style={{
|
||||||
|
...labelBase,
|
||||||
|
border: isWinner ? "1px solid rgba(226,53,66,0.85)" : isReject ? "1px solid rgba(239,68,68,0.7)" : labelBase.border,
|
||||||
|
background: isWinner ? "rgba(28,8,11,0.85)" : labelBase.background,
|
||||||
|
boxShadow: isWinner ? "0 0 22px rgba(192,18,39,0.6)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: dotColor, boxShadow: `0 0 8px ${dotColor}` }} />
|
||||||
|
{STRATEGIES[i]}
|
||||||
|
<span style={{ fontWeight: 800, color: isReject ? "#fca5a5" : "#fff", marginLeft: 2 }}>{ROUTE_SCORES[i]}</span>
|
||||||
|
{isWinner ? <span style={{ color: "#4ade80", fontWeight: 700 }}> ✓ Best</span> : null}
|
||||||
|
{isReject ? <span style={{ color: "#fca5a5", fontWeight: 700 }}> ✕ Over range</span> : null}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* EV failed route (red) */}
|
||||||
|
<mesh geometry={evTube}>
|
||||||
|
<meshBasicMaterial ref={evRedMat} color={C.red} toneMapped={false} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
{/* EV optimized route (green) */}
|
||||||
|
<mesh geometry={evGreenTube}>
|
||||||
|
<meshBasicMaterial ref={evGreenMat} color={C.green} toneMapped={false} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Charging station */}
|
||||||
|
<group ref={station} position={stationPos} visible={false}>
|
||||||
|
<mesh position={[0, 0.55, 0]}>
|
||||||
|
<boxGeometry args={[0.32, 1.1, 0.32]} />
|
||||||
|
<meshStandardMaterial color="#0e1b14" emissive={C.green} emissiveIntensity={0.6} metalness={0.4} roughness={0.5} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 1.18, 0]}>
|
||||||
|
<boxGeometry args={[0.5, 0.18, 0.42]} />
|
||||||
|
<meshBasicMaterial color={C.green} toneMapped={false} transparent opacity={0.9} blending={THREE.AdditiveBlending} />
|
||||||
|
</mesh>
|
||||||
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.03, 0]}>
|
||||||
|
<ringGeometry args={[0.5, 0.62, 40]} />
|
||||||
|
<meshBasicMaterial color={C.green} toneMapped={false} transparent opacity={0.7} side={THREE.DoubleSide} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* Recharge hub label (the "Kitchen / Recharge" in the EV paradox) */}
|
||||||
|
<Html position={[stationPos.x, 1.7, stationPos.z]} center zIndexRange={[30, 0]} style={{ pointerEvents: "none" }}>
|
||||||
|
<div ref={stationLabelRef} style={{ ...labelBase, border: "1px solid rgba(34,197,94,0.65)", boxShadow: "0 0 18px rgba(34,197,94,0.45)" }}>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: C.green, boxShadow: `0 0 8px ${C.green}` }} />
|
||||||
|
Recharge Hub
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
|
||||||
|
{/* EV scooter */}
|
||||||
|
<group ref={scooter} visible={false}>
|
||||||
|
<mesh position={[0, 0.12, 0]}>
|
||||||
|
<boxGeometry args={[0.5, 0.16, 0.22]} />
|
||||||
|
<meshStandardMaterial color="#11203a" emissive={C.cyan} emissiveIntensity={0.5} metalness={0.5} roughness={0.4} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.18, 0.26, 0]}>
|
||||||
|
<boxGeometry args={[0.06, 0.28, 0.12]} />
|
||||||
|
<meshStandardMaterial color="#0d1830" emissive={C.cyan} emissiveIntensity={0.4} metalness={0.5} roughness={0.4} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.3, 0.14, 0]}>
|
||||||
|
<sphereGeometry args={[0.06, 10, 10]} />
|
||||||
|
<meshBasicMaterial color={C.white} toneMapped={false} />
|
||||||
|
</mesh>
|
||||||
|
{[-0.16, 0.16].map((x, i) => (
|
||||||
|
<mesh key={i} position={[x, 0.05, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||||
|
<cylinderGeometry args={[0.09, 0.09, 0.05, 14]} />
|
||||||
|
<meshStandardMaterial color="#05070d" metalness={0.6} roughness={0.5} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
{/* Battery indicator floating above */}
|
||||||
|
<group position={[0, 0.62, 0]}>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.42, 0.12, 0.02]} />
|
||||||
|
<meshBasicMaterial color="#0a0f1c" toneMapped={false} transparent opacity={0.85} />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={battFill} position={[0, 0, 0.012]}>
|
||||||
|
<boxGeometry args={[0.38, 0.08, 0.02]} />
|
||||||
|
<meshBasicMaterial ref={battMat} color={C.green} toneMapped={false} blending={THREE.AdditiveBlending} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* Dispatch fleet — bike, auto, truck rolling along the WINNING route */}
|
||||||
|
{[
|
||||||
|
{ w: 0.34, h: 0.16, d: 0.2, col: C.sky }, // bike
|
||||||
|
{ w: 0.46, h: 0.22, d: 0.26, col: C.white }, // auto
|
||||||
|
{ w: 0.6, h: 0.3, d: 0.32, col: C.red }, // truck
|
||||||
|
].map((v, k) => (
|
||||||
|
<mesh key={`disp${k}`} ref={(el) => { dispatch.current[k] = el; }} visible={false}>
|
||||||
|
<boxGeometry args={[v.w, v.h, v.d]} />
|
||||||
|
<meshStandardMaterial color="#12060a" emissive={v.col} emissiveIntensity={0.7} metalness={0.5} roughness={0.4} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(Routes);
|
||||||
157
src/components/logisticsbrain/SLAClock.tsx
Normal file
157
src/components/logisticsbrain/SLAClock.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useRef } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { C, P } from "./theme";
|
||||||
|
import { clamp01, damp, lerp, seeded, smoothstep } from "./math";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
progress: React.RefObject<number>;
|
||||||
|
reduced?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A giant holographic SLA clock that rises out of the city. As it sweeps,
|
||||||
|
* delayed routes flash red and dissolve into a burst of particles — the brain
|
||||||
|
* rejecting them — while the reliable routes are left glowing.
|
||||||
|
*/
|
||||||
|
function SLAClock({ progress, isMobile = false }: Props) {
|
||||||
|
const eased = useRef(0);
|
||||||
|
const group = useRef<THREE.Group>(null);
|
||||||
|
const hand = useRef<THREE.Mesh>(null);
|
||||||
|
const sweepMat = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const faceMats = useRef<THREE.MeshBasicMaterial[]>([]);
|
||||||
|
const burst = useRef<THREE.Points>(null);
|
||||||
|
const redMat = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
|
||||||
|
const burstCount = isMobile ? 90 : 180;
|
||||||
|
|
||||||
|
// Random outward directions for the dissolve burst.
|
||||||
|
const burstDirs = useMemo(() => {
|
||||||
|
const arr: THREE.Vector3[] = [];
|
||||||
|
for (let i = 0; i < burstCount; i++) {
|
||||||
|
const a = seeded(i * 2.7) * Math.PI * 2;
|
||||||
|
const up = 0.3 + seeded(i * 5.1) * 1.4;
|
||||||
|
const sp = 0.4 + seeded(i * 3.3) * 1.0;
|
||||||
|
arr.push(new THREE.Vector3(Math.cos(a) * sp, up, Math.sin(a) * sp));
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, [burstCount]);
|
||||||
|
const burstGeom = useMemo(() => {
|
||||||
|
const g = new THREE.BufferGeometry();
|
||||||
|
g.setAttribute("position", new THREE.BufferAttribute(new Float32Array(burstCount * 3), 3));
|
||||||
|
return g;
|
||||||
|
}, [burstCount]);
|
||||||
|
|
||||||
|
// A "delayed" route arc that gets rejected.
|
||||||
|
const redTube = useMemo(() => {
|
||||||
|
const c = new THREE.CatmullRomCurve3([
|
||||||
|
new THREE.Vector3(-6, 0.3, 5),
|
||||||
|
new THREE.Vector3(-2, 1.6, 1),
|
||||||
|
new THREE.Vector3(3, 0.3, -4),
|
||||||
|
]);
|
||||||
|
return new THREE.TubeGeometry(c, 50, 0.07, 8, false);
|
||||||
|
}, []);
|
||||||
|
const burstOrigin = useMemo(() => new THREE.Vector3(-2, 0.6, 1), []);
|
||||||
|
|
||||||
|
useFrame((state, dt) => {
|
||||||
|
const p = progress.current ?? 0;
|
||||||
|
eased.current = damp(eased.current, p, 3, dt);
|
||||||
|
const e = eased.current;
|
||||||
|
const t = state.clock.elapsedTime;
|
||||||
|
|
||||||
|
const slaIn = smoothstep(P.sla - 0.03, P.sla + 0.05, e);
|
||||||
|
const slaOut = 1 - smoothstep(P.ecosystem, P.ecosystem + 0.08, e);
|
||||||
|
const active = slaIn * slaOut;
|
||||||
|
|
||||||
|
if (group.current) {
|
||||||
|
group.current.visible = active > 0.02;
|
||||||
|
group.current.position.y = lerp(2.6, 4.6, slaIn);
|
||||||
|
group.current.scale.setScalar(0.0001 + active * 1.5);
|
||||||
|
group.current.rotation.z = Math.sin(t * 0.3) * 0.02;
|
||||||
|
}
|
||||||
|
if (hand.current) hand.current.rotation.z = -t * 1.4;
|
||||||
|
if (sweepMat.current) sweepMat.current.opacity = active * (0.6 + Math.sin(t * 4) * 0.2);
|
||||||
|
faceMats.current.forEach((m) => { if (m) m.opacity = active * 0.8; });
|
||||||
|
|
||||||
|
// Delayed route flashes red then dissolves.
|
||||||
|
const flash = smoothstep(P.sla + 0.02, P.sla + 0.08, e);
|
||||||
|
const dissolve = ((t * 0.45) % 1);
|
||||||
|
if (redMat.current) {
|
||||||
|
redMat.current.opacity = active * flash * (1 - dissolve) * (0.6 + Math.sin(t * 6) * 0.4);
|
||||||
|
}
|
||||||
|
if (burst.current) {
|
||||||
|
const attr = burst.current.geometry.getAttribute("position") as THREE.BufferAttribute;
|
||||||
|
const r = dissolve * 3.4;
|
||||||
|
for (let i = 0; i < burstDirs.length; i++) {
|
||||||
|
const d = burstDirs[i];
|
||||||
|
attr.setXYZ(
|
||||||
|
i,
|
||||||
|
burstOrigin.x + d.x * r,
|
||||||
|
burstOrigin.y + d.y * r - dissolve * dissolve * 1.2,
|
||||||
|
burstOrigin.z + d.z * r,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
attr.needsUpdate = true;
|
||||||
|
(burst.current.material as THREE.PointsMaterial).opacity = clamp01(active * flash * (1 - dissolve));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Holographic clock */}
|
||||||
|
<group ref={group} position={[0, 7, 0]} visible={false}>
|
||||||
|
{/* Outer ring */}
|
||||||
|
<mesh>
|
||||||
|
<torusGeometry args={[1, 0.03, 12, 64]} />
|
||||||
|
<meshBasicMaterial color={C.cyan} toneMapped={false} transparent opacity={0.8} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
{/* Inner ring */}
|
||||||
|
<mesh>
|
||||||
|
<torusGeometry args={[0.78, 0.012, 10, 64]} />
|
||||||
|
<meshBasicMaterial color={C.blue} toneMapped={false} transparent opacity={0.5} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
{/* Tick marks */}
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => {
|
||||||
|
const a = (i / 12) * Math.PI * 2;
|
||||||
|
return (
|
||||||
|
<mesh key={i} position={[Math.cos(a) * 0.88, Math.sin(a) * 0.88, 0]} rotation={[0, 0, a]}>
|
||||||
|
<boxGeometry args={[0.1, 0.02, 0.02]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={(el) => { if (el) faceMats.current[i] = el; }}
|
||||||
|
color={i % 3 === 0 ? C.white : C.cyan}
|
||||||
|
toneMapped={false}
|
||||||
|
transparent
|
||||||
|
opacity={0.8}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
depthWrite={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Sweeping hand */}
|
||||||
|
<mesh ref={hand} position={[0, 0, 0.02]}>
|
||||||
|
<boxGeometry args={[0.04, 1.5, 0.02]} />
|
||||||
|
<meshBasicMaterial ref={sweepMat} color={C.green} toneMapped={false} transparent opacity={0.7} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
{/* Hub */}
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[0.07, 16, 16]} />
|
||||||
|
<meshBasicMaterial color={C.white} toneMapped={false} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* Delayed route + dissolve burst */}
|
||||||
|
<mesh geometry={redTube}>
|
||||||
|
<meshBasicMaterial ref={redMat} color={C.red} toneMapped={false} transparent opacity={0} blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
<points ref={burst} geometry={burstGeom}>
|
||||||
|
<pointsMaterial size={isMobile ? 0.12 : 0.1} color={C.red} transparent opacity={0} sizeAttenuation blending={THREE.AdditiveBlending} depthWrite={false} />
|
||||||
|
</points>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(SLAClock);
|
||||||
33
src/components/logisticsbrain/math.ts
Normal file
33
src/components/logisticsbrain/math.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Frame-rate-independent math helpers for the Logistics Brain scene.
|
||||||
|
*
|
||||||
|
* NOTE: these are intentionally local `function` declarations rather than
|
||||||
|
* importing from `../optimization/math`. Under Turbopack, cross-folder imports
|
||||||
|
* of that module's `const`-arrow exports (`lerp`, `clamp01`) resolved to
|
||||||
|
* `undefined` at runtime ("lerp is not defined" thrown every frame) even though
|
||||||
|
* they type-check and build fine. Keeping the section self-contained avoids it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function clamp01(v: number): number {
|
||||||
|
return v < 0 ? 0 : v > 1 ? 1 : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lerp(a: number, b: number, t: number): number {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function smoothstep(edge0: number, edge1: number, x: number): number {
|
||||||
|
const t = clamp01((x - edge0) / (edge1 - edge0 || 1));
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exponential damp toward a target — `lambda` ~ responsiveness. */
|
||||||
|
export function damp(current: number, target: number, lambda: number, dt: number): number {
|
||||||
|
return lerp(current, target, 1 - Math.exp(-lambda * dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic pseudo-random in [0,1) — stable across SSR/render. */
|
||||||
|
export function seeded(i: number): number {
|
||||||
|
const x = Math.sin(i * 127.1 + 311.7) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
}
|
||||||
179
src/components/logisticsbrain/theme.ts
Normal file
179
src/components/logisticsbrain/theme.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Shared design tokens, scroll-phase thresholds and the cinematic camera path
|
||||||
|
* for the "Logistics Brain" scroll-storytelling section.
|
||||||
|
*
|
||||||
|
* The whole experience is driven by ONE normalized scroll progress value
|
||||||
|
* (0 → 1) shared between GSAP ScrollTrigger, the R3F render loop and the
|
||||||
|
* Framer-Motion HTML overlay — so every layer stays perfectly in sync and the
|
||||||
|
* journey reads as one continuous shot with no scene cuts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doormile brand palette: near-black + brand red, with green reserved for the
|
||||||
|
* "delivered / optimized" success state.
|
||||||
|
*
|
||||||
|
* NOTE ON TOKEN NAMES: the keys below (cyan/blue/violet/…) are the original
|
||||||
|
* cool-palette slot names, kept ONLY so the many `C.cyan`-style references in
|
||||||
|
* the scene files don't all have to change. Their *values* are now brand reds
|
||||||
|
* and neutral steels — read the inline comment, not the key name. New code
|
||||||
|
* should prefer the semantic aliases (red / redBright / redSoft / steel).
|
||||||
|
*/
|
||||||
|
export const C = {
|
||||||
|
bg: "#08080c", // near-black scene background
|
||||||
|
|
||||||
|
red: "#C01227", // PRIMARY brand red
|
||||||
|
cyan: "#E2354A", // redBright — main glowing red accent (roads, rings, brain, scooter)
|
||||||
|
blue: "#7E1420", // crimson — dim deep-red structural glow (grid, links, soft glows)
|
||||||
|
sky: "#F2667A", // redSoft — light coral highlight points
|
||||||
|
magenta: "#C8102E", // secondary brand red
|
||||||
|
violet: "#3c3c46", // steel — neutral grey structure (losing routes, secondary shell)
|
||||||
|
purple: "#6a6a76", // steelLight — lighter neutral grey
|
||||||
|
|
||||||
|
steel: "#43434d", // losing-route grey
|
||||||
|
gray: "#7c7c86", // losing-route grey (lighter)
|
||||||
|
|
||||||
|
green: "#22C55E", // success / delivered (unchanged)
|
||||||
|
amber: "#F59E0B", // transitional (battery mid-charge) (unchanged)
|
||||||
|
white: "#FFFFFF",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Palette for the six competing route simulations. Index 0 (Multi-Trip) is the
|
||||||
|
* winner and gets the brand red so it stands out; the five losing strategies
|
||||||
|
* are neutral greys that recede, then fade out entirely.
|
||||||
|
*/
|
||||||
|
export const ROUTE_COLORS = [
|
||||||
|
C.red, // 0 — the eventual winner (brand red)
|
||||||
|
C.steel,
|
||||||
|
C.gray,
|
||||||
|
C.steel,
|
||||||
|
C.gray,
|
||||||
|
C.steel,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The six strategies the "Parallel Universe Engine" benchmarks (index-aligned
|
||||||
|
* to ROUTE_COLORS). Index 0 = Multi-Trip is the winner — it's also what solves
|
||||||
|
* the EV paradox (59/59 vs 34/59), tying the routes beat to the EV beat.
|
||||||
|
*/
|
||||||
|
export const STRATEGIES = [
|
||||||
|
"Multi-Trip",
|
||||||
|
"Proximity",
|
||||||
|
"Balanced",
|
||||||
|
"Fuel Saver",
|
||||||
|
"EV-Aware",
|
||||||
|
"Time-Aware",
|
||||||
|
] as const;
|
||||||
|
export const WINNER_INDEX = 0;
|
||||||
|
|
||||||
|
/** The constraints the VRP solver optimizes together (Mathematical Precision). */
|
||||||
|
export const CONSTRAINTS = ["Capacity", "Distance", "Battery", "Traffic", "SLA"] as const;
|
||||||
|
|
||||||
|
/** Plain-language constraints shown in the EV / constraints beat. */
|
||||||
|
export const CONSTRAINT_LIST = [
|
||||||
|
{ icon: "🔋", label: "Battery", note: "EV range & recharge stops" },
|
||||||
|
{ icon: "📍", label: "Distance", note: "Total km per route" },
|
||||||
|
{ icon: "📦", label: "Capacity", note: "Orders each vehicle can carry" },
|
||||||
|
{ icon: "⏱️", label: "Time / SLA", note: "Promised delivery windows" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scored leaderboard for the "Score & Compare" beat. Higher = better total
|
||||||
|
* delivery cost/outcome. Multi-Trip wins; EV-Aware (the naive baseline) scores
|
||||||
|
* worst — it's the one that only delivered 34/59 in the EV paradox beat.
|
||||||
|
*/
|
||||||
|
export const STRATEGY_SCORES: { name: string; score: number; win?: boolean }[] = [
|
||||||
|
{ name: "Multi-Trip", score: 98, win: true },
|
||||||
|
{ name: "Time-Aware", score: 90 },
|
||||||
|
{ name: "Balanced", score: 84 },
|
||||||
|
{ name: "Proximity", score: 76 },
|
||||||
|
{ name: "Fuel Saver", score: 68 },
|
||||||
|
{ name: "EV-Aware", score: 58 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Layout anchors (world units). */
|
||||||
|
export const BRAIN_Y = 3.0; // depot beacon hovers low over the map centre
|
||||||
|
export const CITY_RADIUS = 19;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll-phase thresholds (normalized 0 → 1). These are *story beats*, not hard
|
||||||
|
* cuts — every element cross-fades across a window around its beat so the scene
|
||||||
|
* morphs continuously.
|
||||||
|
*/
|
||||||
|
export const P = {
|
||||||
|
birth: 0.0, // glowing brain + city form, energy roads grow
|
||||||
|
routes: 0.13, // six route simulations race through the city
|
||||||
|
ev: 0.28, // EV battery drains → recalc → charging station → green route
|
||||||
|
network: 0.44, // camera rises to eagle-eye; full live network
|
||||||
|
sla: 0.6, // holographic SLA clock; delayed routes dissolve
|
||||||
|
ecosystem: 0.74, // pull back; warehouses + fleet come alive
|
||||||
|
finale: 0.88, // fly up; stats + logo + tagline reveal
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PhaseKey = keyof typeof P;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The six steps of the routing engine, in plain language — the spine of the
|
||||||
|
* on-screen story. The persistent step rail and the per-beat content cards both
|
||||||
|
* read from this so a non-technical viewer can follow the workflow. `at` is the
|
||||||
|
* scroll-progress threshold where each step becomes the active one.
|
||||||
|
*/
|
||||||
|
export const ENGINE_STEPS = [
|
||||||
|
{ n: "01", at: P.routes, title: "Generate Routes", caption: "Many delivery plans created at once" },
|
||||||
|
{ n: "02", at: P.ev, title: "Check Constraints", caption: "Battery, distance, capacity & time" },
|
||||||
|
{ n: "03", at: P.network, title: "Score & Compare", caption: "Every plan ranked by total cost" },
|
||||||
|
{ n: "04", at: P.sla, title: "Guarantee On-Time", caption: "Late plans rejected automatically" },
|
||||||
|
{ n: "05", at: P.ecosystem, title: "Pick & Dispatch", caption: "Best plan sent to the fleet" },
|
||||||
|
{ n: "06", at: P.finale, title: "Delivered", caption: "Real business results" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const PHASE_CAPTIONS: Record<PhaseKey, string> = {
|
||||||
|
birth: "Initializing the logistics brain",
|
||||||
|
routes: "Simulating delivery strategies",
|
||||||
|
ev: "Recalculating around EV constraints",
|
||||||
|
network: "Mapping the live delivery network",
|
||||||
|
sla: "Enforcing on-time SLA reliability",
|
||||||
|
ecosystem: "Autonomous fleet in motion",
|
||||||
|
finale: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function captionFor(p: number): string {
|
||||||
|
if (p >= P.finale) return PHASE_CAPTIONS.finale;
|
||||||
|
if (p >= P.ecosystem) return PHASE_CAPTIONS.ecosystem;
|
||||||
|
if (p >= P.sla) return PHASE_CAPTIONS.sla;
|
||||||
|
if (p >= P.network) return PHASE_CAPTIONS.network;
|
||||||
|
if (p >= P.ev) return PHASE_CAPTIONS.ev;
|
||||||
|
if (p >= P.routes) return PHASE_CAPTIONS.routes;
|
||||||
|
return PHASE_CAPTIONS.birth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Camera waypoints sampled by scroll progress. Each has an eye position and a
|
||||||
|
* look-at target. The rig lerps between adjacent waypoints (smoothstep eased)
|
||||||
|
* and then exponentially damps toward the result — giving a smooth spline-like
|
||||||
|
* dolly with no jerk on fast scroll.
|
||||||
|
*/
|
||||||
|
export type Waypoint = {
|
||||||
|
at: number;
|
||||||
|
pos: [number, number, number];
|
||||||
|
look: [number, number, number];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NEAR-STATIC MAP VIEW. The camera holds a consistent elevated 3/4 "control
|
||||||
|
* room" angle on the depot/map the whole way through — only small, slow shifts
|
||||||
|
* between beats (≈70% less travel than a cinematic fly-through). The story is
|
||||||
|
* told by the OBJECTS on the map (nodes, candidate routes, scores, vehicles),
|
||||||
|
* not by camera moves. Keep these positions clustered; large deltas reintroduce
|
||||||
|
* the "city fly-through" feel we're removing.
|
||||||
|
*/
|
||||||
|
export const WAYPOINTS: Waypoint[] = [
|
||||||
|
{ at: 0.0, pos: [0, 20, 27], look: [0, 1.4, 0] }, // establish the map
|
||||||
|
{ at: 0.13, pos: [-2.5, 20, 26.5], look: [-0.5, 1.2, 0] }, // routes generate
|
||||||
|
{ at: 0.28, pos: [3, 18.5, 25.5], look: [0.6, 0.8, -0.8] }, // constraints — ease toward street
|
||||||
|
{ at: 0.44, pos: [0, 23, 24.5], look: [0, 1.0, 0] }, // score — lift slightly to read labels
|
||||||
|
{ at: 0.6, pos: [2.5, 21, 25.5], look: [0, 1.2, 0] }, // SLA — small lateral drift
|
||||||
|
{ at: 0.74, pos: [-2, 20, 26.5], look: [0, 1.2, 0] }, // dispatch
|
||||||
|
{ at: 0.88, pos: [0, 22, 25.5], look: [0, 1.8, 0] }, // results
|
||||||
|
{ at: 1.0, pos: [0, 21.5, 25.5], look: [0, 2.0, 0] },
|
||||||
|
];
|
||||||
@@ -112,7 +112,7 @@ const CalculationBeamMemo = React.memo(CalculationBeam);
|
|||||||
* a particle shell and neural links. It powers up during the AI-scan phase,
|
* a particle shell and neural links. It powers up during the AI-scan phase,
|
||||||
* fires an expanding radar scan, then a route-optimization burst.
|
* fires an expanding radar scan, then a route-optimization burst.
|
||||||
*/
|
*/
|
||||||
function AICore({ progress, reduced = false }: Props) {
|
function AICore({ progress }: Props) {
|
||||||
const root = useRef<THREE.Group>(null);
|
const root = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
const hubOffsets = useMemo(() => {
|
const hubOffsets = useMemo(() => {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const clusterCenters = Array.from({ length: clusterCount }, (_, c) => {
|
|||||||
return new THREE.Vector3(cx, 0.1, cz);
|
return new THREE.Vector3(cx, 0.1, cz);
|
||||||
});
|
});
|
||||||
|
|
||||||
function HologramCity({ progress, reduced = false }: Props) {
|
function HologramCity({ progress }: Props) {
|
||||||
const mainWarehouseRef = useRef<THREE.Group>(null);
|
const mainWarehouseRef = useRef<THREE.Group>(null);
|
||||||
const radarRefs = useRef<(THREE.Group | null)[]>([]);
|
const radarRefs = useRef<(THREE.Group | null)[]>([]);
|
||||||
const corridorMats = useRef<(THREE.LineBasicMaterial | null)[]>([]);
|
const corridorMats = useRef<(THREE.LineBasicMaterial | null)[]>([]);
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo, useRef } from "react";
|
import React, { useRef } from "react";
|
||||||
import { Canvas, useFrame } from "@react-three/fiber";
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
import { EffectComposer, Bloom, Vignette } from "@react-three/postprocessing";
|
import { EffectComposer, Bloom, Vignette } from "@react-three/postprocessing";
|
||||||
import { KernelSize } from "postprocessing";
|
import { KernelSize } from "postprocessing";
|
||||||
import * as THREE from "three";
|
|
||||||
import { COLORS } from "./constants";
|
import { COLORS } from "./constants";
|
||||||
import { damp, lerp, seeded } from "./math";
|
import { damp, lerp } from "./math";
|
||||||
import HologramCity from "./HologramCity";
|
import HologramCity from "./HologramCity";
|
||||||
import RouteSystem from "./RouteSystem";
|
import RouteSystem from "./RouteSystem";
|
||||||
import VehicleFleet from "./VehicleFleet";
|
import VehicleFleet from "./VehicleFleet";
|
||||||
|
|||||||
@@ -451,19 +451,22 @@ const styles = `
|
|||||||
top: 110px !important;
|
top: 110px !important;
|
||||||
left: 40px !important;
|
left: 40px !important;
|
||||||
right: 40px !important;
|
right: 40px !important;
|
||||||
bottom: 24px !important;
|
bottom: 0 !important;
|
||||||
border-radius: 35px !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;
|
overflow: hidden !important;
|
||||||
background: linear-gradient(165deg, #06101f 0%, #020617 35%, #040d1c 70%, #030a18 100%) !important;
|
// background: linear-gradient(165deg, #06101f 0%, #020617 35%, #040d1c 70%, #030a18 100%) !important;
|
||||||
border: 1.5px solid ${rgba("#ffffff", 0.08)} !important;
|
// border: 0px solid ${rgba("#ffffff", 0.08)} !important;
|
||||||
box-shadow:
|
border-bottom: none !important;
|
||||||
0 0 0 1px ${rgba(COLORS.cyan, 0.04)},
|
// box-shadow:
|
||||||
0 4px 30px -4px rgba(0, 0, 0, 0.7),
|
// 0 0 0 1px ${rgba(COLORS.cyan, 0.04)},
|
||||||
0 20px 80px -20px rgba(0, 0, 0, 0.6),
|
// 0 4px 30px -4px rgba(0, 0, 0, 0.7),
|
||||||
0 0 120px -30px ${rgba(COLORS.cyan, 0.08)},
|
// 0 20px 80px -20px rgba(0, 0, 0, 0.6),
|
||||||
inset 0 1px 0 ${rgba("#ffffff", 0.06)},
|
// 0 0 120px -30px ${rgba(COLORS.cyan, 0.08)},
|
||||||
inset 0 -1px 0 ${rgba("#ffffff", 0.02)} !important;
|
// inset 0 1px 0 ${rgba("#ffffff", 0.06)},
|
||||||
box-sizing: border-box !important;
|
// inset 0 -1px 0 ${rgba("#ffffff", 0.02)} !important;
|
||||||
|
// box-sizing: border-box !important;
|
||||||
}
|
}
|
||||||
/* Animated subtle grid pattern */
|
/* Animated subtle grid pattern */
|
||||||
.dm-opt-card::before {
|
.dm-opt-card::before {
|
||||||
@@ -493,8 +496,8 @@ const styles = `
|
|||||||
top: 96px !important;
|
top: 96px !important;
|
||||||
left: 20px !important;
|
left: 20px !important;
|
||||||
right: 20px !important;
|
right: 20px !important;
|
||||||
bottom: 16px !important;
|
bottom: 0 !important;
|
||||||
border-radius: 42px !important;
|
border-radius: 42px 42px 0 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
@@ -502,8 +505,8 @@ const styles = `
|
|||||||
top: 86px !important;
|
top: 86px !important;
|
||||||
left: 10px !important;
|
left: 10px !important;
|
||||||
right: 10px !important;
|
right: 10px !important;
|
||||||
bottom: 10px !important;
|
bottom: 0 !important;
|
||||||
border-radius: 28px !important;
|
border-radius: 28px 28px 0 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,15 +120,6 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
|||||||
const chaosProgress = useRef<number[]>([]);
|
const chaosProgress = useRef<number[]>([]);
|
||||||
const optProgress = useRef<number[]>([]);
|
const optProgress = useRef<number[]>([]);
|
||||||
|
|
||||||
// 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 = (
|
const place = (
|
||||||
group: THREE.Group | null,
|
group: THREE.Group | null,
|
||||||
def: VehicleDef,
|
def: VehicleDef,
|
||||||
@@ -191,6 +182,15 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useFrame((state, dt) => {
|
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 p = progress.current ?? 0;
|
||||||
const t = state.clock.elapsedTime;
|
const t = state.clock.elapsedTime;
|
||||||
const safeDt = Math.min(0.06, dt);
|
const safeDt = Math.min(0.06, dt);
|
||||||
|
|||||||
@@ -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<number>;
|
|
||||||
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<number> }) {
|
|
||||||
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 (
|
|
||||||
<group>
|
|
||||||
{geos.map((g, i) => (
|
|
||||||
<mesh key={i} geometry={g}>
|
|
||||||
<meshStandardMaterial color={C.road} emissive={edge} emissiveIntensity={0.14} roughness={0.85} metalness={0.1} />
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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 (
|
|
||||||
<group>
|
|
||||||
{Array.from({ length: count }, (_, i) => (
|
|
||||||
<mesh key={i} ref={(el) => { refs.current[i] = el; }}>
|
|
||||||
<sphereGeometry args={[0.08, 8, 8]} />
|
|
||||||
<meshBasicMaterial color={color} transparent depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────────── 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<Bld[]>(() => {
|
|
||||||
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 (
|
|
||||||
<group>
|
|
||||||
{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 (
|
|
||||||
<group key={i} position={[b.x, 0, b.z]}>
|
|
||||||
<mesh position={[0, b.h / 2, 0]}>
|
|
||||||
<boxGeometry args={[b.w, b.h, b.d]} />
|
|
||||||
<meshStandardMaterial color={bodyColor} roughness={0.8} metalness={0.15} />
|
|
||||||
</mesh>
|
|
||||||
{b.accent && (
|
|
||||||
<mesh position={[0, b.h - 0.18, b.d / 2 + 0.001]}>
|
|
||||||
<planeGeometry args={[b.w * 0.82, 0.12]} />
|
|
||||||
<meshBasicMaterial color={accent} toneMapped={false} />
|
|
||||||
</mesh>
|
|
||||||
)}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────────── Warehouse / dispatch depot ───────────── */
|
|
||||||
function Warehouse({ x, z, accent }: { x: number; z: number; accent: string }) {
|
|
||||||
return (
|
|
||||||
<group position={[x, 0, z]}>
|
|
||||||
<mesh position={[0, 0.9, 0]}>
|
|
||||||
<boxGeometry args={[4.2, 1.8, 2.6]} />
|
|
||||||
<meshStandardMaterial color={C.bldB} roughness={0.8} metalness={0.2} />
|
|
||||||
</mesh>
|
|
||||||
{/* sloped roof slab */}
|
|
||||||
<mesh position={[0, 1.85, 0]}>
|
|
||||||
<boxGeometry args={[4.3, 0.12, 2.7]} />
|
|
||||||
<meshStandardMaterial color={C.steel} roughness={0.6} metalness={0.4} />
|
|
||||||
</mesh>
|
|
||||||
{/* roller doors */}
|
|
||||||
{[-1.3, 0, 1.3].map((o, i) => (
|
|
||||||
<mesh key={i} position={[o, 0.55, 1.31]}>
|
|
||||||
<boxGeometry args={[0.9, 1.0, 0.05]} />
|
|
||||||
<meshStandardMaterial color={C.bldA} emissive={accent} emissiveIntensity={0.25} roughness={0.7} />
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────────── 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 (
|
|
||||||
<group>
|
|
||||||
{/* left congestion heat patches (red/orange) */}
|
|
||||||
{leftZones.map(([x, z], i) => (
|
|
||||||
<mesh key={`h${i}`} ref={(el) => { heat.current[i] = el; }} rotation={[-Math.PI / 2, 0, 0]} position={[x, 0.04, z]}>
|
|
||||||
<circleGeometry args={[1.7, 32]} />
|
|
||||||
<meshBasicMaterial color={i % 2 ? C.orange : C.red} transparent opacity={0.2} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
{/* right optimized coverage zones (green rings) */}
|
|
||||||
{rightZones.map(([x, z], i) => (
|
|
||||||
<mesh key={`z${i}`} rotation={[-Math.PI / 2, 0, 0]} position={[x, 0.04, z]}>
|
|
||||||
<ringGeometry args={[1.3, 1.55, 36]} />
|
|
||||||
<meshBasicMaterial color={C.green} transparent opacity={0.5} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────────── 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<THREE.Mesh>(null);
|
|
||||||
const seam = useRef<THREE.MeshBasicMaterial>(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 (
|
|
||||||
<group position={[GATEWAY.x, 0, GATEWAY.z]}>
|
|
||||||
{/* ground seam marking before | after */}
|
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.06, 0]}>
|
|
||||||
<planeGeometry args={[0.2, 26]} />
|
|
||||||
<meshBasicMaterial ref={seam} color={C.green2} transparent opacity={0.4} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
|
||||||
</mesh>
|
|
||||||
{/* vertical seam light */}
|
|
||||||
<mesh position={[0, 2.2, 0]}>
|
|
||||||
<boxGeometry args={[0.06, 4.4, 0.06]} />
|
|
||||||
<meshBasicMaterial color={C.white} transparent opacity={0.5} blending={THREE.AdditiveBlending} toneMapped={false} />
|
|
||||||
</mesh>
|
|
||||||
{/* sweeping optimization wave crossing the city */}
|
|
||||||
<mesh ref={sweep} rotation={[0, Math.PI / 2, 0]} position={[0, 2, 0]}>
|
|
||||||
<planeGeometry args={[22, 4.2]} />
|
|
||||||
<meshBasicMaterial color={C.green2} transparent opacity={0.2} side={THREE.DoubleSide} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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<number> }) {
|
|
||||||
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 (
|
|
||||||
<group>
|
|
||||||
{KPIS.map((kpi, i) => (
|
|
||||||
<group key={kpi.name} position={[kpi.x, 0, Z]}>
|
|
||||||
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
|
||||||
<circleGeometry args={[0.9, 28]} />
|
|
||||||
<meshStandardMaterial color={C.steel} roughness={0.7} />
|
|
||||||
</mesh>
|
|
||||||
<group ref={(el) => { refs.current[i] = el; }}>
|
|
||||||
<mesh position={[0, kpi.h / 2, 0]}>
|
|
||||||
<boxGeometry args={[0.85, kpi.h, 0.85]} />
|
|
||||||
<meshStandardMaterial color={C.green} emissive={C.green} emissiveIntensity={0.22} roughness={0.45} metalness={0.3} />
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, kpi.h + 0.05, 0]}>
|
|
||||||
<boxGeometry args={[1.06, 0.12, 1.06]} />
|
|
||||||
<meshBasicMaterial color={C.green2} toneMapped={false} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
{/* BIG, immediately-readable counting readout integrated on the tower */}
|
|
||||||
<Html position={[0, kpi.h + 1.05, 0]} center distanceFactor={20} pointerEvents="none">
|
|
||||||
<div style={{
|
|
||||||
transform: "translateY(-50%)", textAlign: "center", whiteSpace: "nowrap",
|
|
||||||
fontFamily: "var(--font-space-grotesk), system-ui, sans-serif", userSelect: "none",
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 48, fontWeight: 900, color: "#bbf7d0", textShadow: "0 0 18px rgba(34,197,94,0.85)", lineHeight: 1 }}>
|
|
||||||
<CountUp value={kpi.value} decimals={kpi.decimals} suffix={kpi.suffix} />
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, letterSpacing: "0.16em", textTransform: "uppercase", color: "rgba(226,232,240,0.92)", marginTop: 6, fontWeight: 600 }}>{kpi.name}</div>
|
|
||||||
</div>
|
|
||||||
</Html>
|
|
||||||
</group>
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────────── 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 (
|
|
||||||
<group>
|
|
||||||
{chaotic.map((r, i) => (
|
|
||||||
<SplineRider key={`bad-${i}`} curve={r.curve} speed={0.01 + seeded(i * 9 + 1) * 0.008} offset={seeded(i * 9 + 2)}
|
|
||||||
type={TYPES[i % TYPES.length]} body={badBodies[i % badBodies.length]} accent={i % 2 === 0 ? C.red : C.orange} />
|
|
||||||
))}
|
|
||||||
{optimized.map((r, i) => (
|
|
||||||
<SplineRider key={`good-${i}`} curve={r.curve} speed={0.038 + seeded(i * 11 + 1) * 0.02} offset={seeded(i * 11 + 2)}
|
|
||||||
type={TYPES[i % TYPES.length]} body={goodBodies[i % goodBodies.length]} accent={C.green} />
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────────── Scene ───────────── */
|
|
||||||
function Scene({ progress, reduced, isMobile }: { progress: React.RefObject<number>; reduced: boolean; isMobile: boolean }) {
|
|
||||||
const { chaotic, optimized } = useMemo(() => buildPerfRoutes(), []);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<color attach="background" args={["#1b2028"]} />
|
|
||||||
<fog attach="fog" args={["#1b2028", 40, 100]} />
|
|
||||||
<ambientLight intensity={0.8} />
|
|
||||||
<directionalLight position={[10, 20, 10]} intensity={2.2} />
|
|
||||||
<hemisphereLight args={["#d7dee8", "#15171c", 0.7]} />
|
|
||||||
|
|
||||||
{/* ground + faint street grid */}
|
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
|
|
||||||
<planeGeometry args={[70, 48]} />
|
|
||||||
<meshStandardMaterial color={C.ground} roughness={0.95} metalness={0.05} />
|
|
||||||
</mesh>
|
|
||||||
<gridHelper args={[70, 56, "#3a4049", "#2a2f37"]} position={[0, 0.012, 0]} />
|
|
||||||
|
|
||||||
{/* bold per-side atmosphere wash — instant "chaos vs efficiency" read */}
|
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[LEFT_C.x, 0.02, 0]}>
|
|
||||||
<circleGeometry args={[13, 48]} />
|
|
||||||
<meshBasicMaterial color={C.red} transparent opacity={0.07} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
|
||||||
</mesh>
|
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[RIGHT_C.x, 0.02, 0]}>
|
|
||||||
<circleGeometry args={[13, 48]} />
|
|
||||||
<meshBasicMaterial color={C.green} transparent opacity={0.08} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
<Roads routes={chaotic} edge={C.red} width={0.85} />
|
|
||||||
<Roads routes={optimized} edge={C.green} width={0.85} />
|
|
||||||
{!isMobile && <RoadPulses routes={chaotic} color={C.orange} />}
|
|
||||||
{!isMobile && <RoadPulses routes={optimized} color={C.green2} />}
|
|
||||||
|
|
||||||
<GroundMarks />
|
|
||||||
<Buildings />
|
|
||||||
<Warehouse x={LEFT_C.x} z={-9.5} accent={C.red} />
|
|
||||||
<Warehouse x={RIGHT_C.x} z={-9.5} accent={C.green} />
|
|
||||||
|
|
||||||
<Fleets />
|
|
||||||
<TransformDivider />
|
|
||||||
<KpiTowers progress={progress} />
|
|
||||||
|
|
||||||
{!reduced && (
|
|
||||||
<EffectComposer multisampling={isMobile ? 0 : 2}>
|
|
||||||
{/* light bloom — only bright emissive (lights, pulses, gateway, tower caps) glow */}
|
|
||||||
<Bloom mipmapBlur intensity={isMobile ? 0.45 : 0.6} luminanceThreshold={0.55} luminanceSmoothing={0.06} radius={0.6} kernelSize={KernelSize.MEDIUM} />
|
|
||||||
<Vignette eskil={false} offset={0.3} darkness={0.5} />
|
|
||||||
</EffectComposer>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PerformanceCanvas({ progress, reduced = false, isMobile = false, active = true }: Props) {
|
|
||||||
return (
|
|
||||||
<Canvas
|
|
||||||
dpr={[1, isMobile || reduced ? 1.3 : 1.6]}
|
|
||||||
camera={{ position: [3, 16, 27], fov: 50, near: 0.1, far: 160 }}
|
|
||||||
gl={{ antialias: !isMobile, powerPreference: "high-performance", alpha: false }}
|
|
||||||
frameloop={active ? "always" : "never"}
|
|
||||||
>
|
|
||||||
<CameraRig progress={progress} />
|
|
||||||
<Scene progress={progress} reduced={reduced} isMobile={isMobile} />
|
|
||||||
</Canvas>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(PerformanceCanvas);
|
|
||||||
@@ -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<HTMLDivElement>(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 (
|
|
||||||
<section ref={containerRef} className={`dm-perf is-${pinState}`} aria-label="Results & Impact — Logistics Performance">
|
|
||||||
<div className="dm-perf-sticky">
|
|
||||||
<div className="dm-perf-card">
|
|
||||||
<div className="dm-perf-backdrop" aria-hidden />
|
|
||||||
{mountScene && (
|
|
||||||
<div className="dm-perf-canvas">
|
|
||||||
<PerformanceCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="dm-perf-vignette" aria-hidden />
|
|
||||||
|
|
||||||
<div className="dm-perf-ui">
|
|
||||||
<header className="dm-perf-head">
|
|
||||||
<motion.div className="dm-perf-eyebrow" initial={{ opacity: 0, y: 14 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.6 }}>
|
|
||||||
<span className="dm-perf-dot" /> Results & Impact
|
|
||||||
</motion.div>
|
|
||||||
<motion.h2 initial={{ opacity: 0, y: 18 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.7, delay: 0.05 }}>
|
|
||||||
What MileTruth Delivers
|
|
||||||
</motion.h2>
|
|
||||||
<motion.p initial={{ opacity: 0, y: 18 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.7, delay: 0.12 }}>
|
|
||||||
From congested traditional dispatch to a lean optimized fleet — the measurable business results across a live delivery city.
|
|
||||||
</motion.p>
|
|
||||||
|
|
||||||
<div className="dm-perf-status">
|
|
||||||
<motion.span className="dm-perf-status__item" style={{ opacity: stageA }}><span className="dm-perf-status__dot dm-perf-status__dot--red" /> Traditional dispatch</motion.span>
|
|
||||||
<motion.span className="dm-perf-status__item" style={{ opacity: stageB }}><span className="dm-perf-status__dot dm-perf-status__dot--amber" /> Transformation gateway</motion.span>
|
|
||||||
<motion.span className="dm-perf-status__item" style={{ opacity: stageC }}><span className="dm-perf-status__dot dm-perf-status__dot--green" /> Optimized network</motion.span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<motion.div className="dm-perf-label dm-perf-label--before" style={{ opacity: beforeOpacity }}>
|
|
||||||
<span className="dm-perf-label__tag">Traditional Dispatch</span>
|
|
||||||
<span className="dm-perf-label__sub">Congestion · long routes · fuel waste · delays</span>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div className="dm-perf-label dm-perf-label--after" style={{ opacity: afterOpacity }}>
|
|
||||||
<span className="dm-perf-label__tag dm-perf-label__tag--good">MileTruth Optimized</span>
|
|
||||||
<span className="dm-perf-label__sub">Clean corridors · organized fleet · faster coverage</span>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<style>{styles}</style>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -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<THREE.Group | null>;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<group ref={spinRef}>
|
|
||||||
{positions.map((p, i) => (
|
|
||||||
<mesh key={i} position={p} rotation={[0, 0, Math.PI / 2]}>
|
|
||||||
<cylinderGeometry args={[radius, radius, 0.08, 12]} />
|
|
||||||
<meshStandardMaterial color="#0b1220" metalness={0.6} roughness={0.4} />
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function VehicleBody({ type, body, accent }: { type: VType; body: string; accent: string }) {
|
|
||||||
const wheels = useRef<THREE.Group>(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) => (
|
|
||||||
<meshStandardMaterial color={color} metalness={0.45} roughness={0.45} emissive={color} emissiveIntensity={e} />
|
|
||||||
);
|
|
||||||
const glow = (color: string) => (
|
|
||||||
<meshBasicMaterial color={color} toneMapped={false} />
|
|
||||||
);
|
|
||||||
|
|
||||||
if (type === "bike") {
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
<mesh position={[0, 0.26, 0]}>
|
|
||||||
<boxGeometry args={[0.14, 0.16, 0.5]} />
|
|
||||||
{std(body, 0.2)}
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 0.42, -0.02]}>
|
|
||||||
<sphereGeometry args={[0.1, 12, 12]} />
|
|
||||||
{std("#1e293b", 0.1)}
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 0.3, 0.34]}>
|
|
||||||
<boxGeometry args={[0.04, 0.06, 0.12]} />
|
|
||||||
{glow(accent)}
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 0.24, 0.3]}>
|
|
||||||
<sphereGeometry args={[0.05, 8, 8]} />
|
|
||||||
{glow("#ffffff")}
|
|
||||||
</mesh>
|
|
||||||
<Wheels spinRef={wheels} radius={0.13} positions={[[0, 0.13, 0.28], [0, 0.13, -0.28]]} />
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "auto") {
|
|
||||||
// three-wheeled auto-rickshaw
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
<mesh position={[0, 0.34, -0.05]}>
|
|
||||||
<boxGeometry args={[0.42, 0.4, 0.6]} />
|
|
||||||
{std(body, 0.18)}
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 0.5, 0.05]}>
|
|
||||||
<boxGeometry args={[0.4, 0.18, 0.5]} />
|
|
||||||
{std("#0b1220", 0.05)}
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 0.3, 0.3]}>
|
|
||||||
<boxGeometry args={[0.36, 0.32, 0.12]} />
|
|
||||||
{glow(accent)}
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0.12, 0.22, 0.36]}><sphereGeometry args={[0.045, 8, 8]} />{glow("#ffffff")}</mesh>
|
|
||||||
<mesh position={[-0.12, 0.22, 0.36]}><sphereGeometry args={[0.045, 8, 8]} />{glow("#ffffff")}</mesh>
|
|
||||||
<Wheels spinRef={wheels} radius={0.12} positions={[[0, 0.12, 0.28], [0.2, 0.12, -0.28], [-0.2, 0.12, -0.28]]} />
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "van") {
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
<mesh position={[0, 0.4, -0.05]}>
|
|
||||||
<boxGeometry args={[0.52, 0.56, 1.0]} />
|
|
||||||
{std(body, 0.16)}
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 0.34, 0.5]}>
|
|
||||||
<boxGeometry args={[0.5, 0.44, 0.18]} />
|
|
||||||
{std("#1e293b", 0.05)}
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 0.42, -0.04]}>
|
|
||||||
<boxGeometry args={[0.53, 0.06, 0.8]} />
|
|
||||||
{glow(accent)}
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0.18, 0.24, 0.56]}><sphereGeometry args={[0.05, 8, 8]} />{glow("#ffffff")}</mesh>
|
|
||||||
<mesh position={[-0.18, 0.24, 0.56]}><sphereGeometry args={[0.05, 8, 8]} />{glow("#ffffff")}</mesh>
|
|
||||||
<Wheels spinRef={wheels} radius={0.14} positions={[[0.22, 0.14, 0.42], [-0.22, 0.14, 0.42], [0.22, 0.14, -0.42], [-0.22, 0.14, -0.42]]} />
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// truck — cab + long box trailer
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
<mesh position={[0, 0.36, 0.62]}>
|
|
||||||
<boxGeometry args={[0.56, 0.5, 0.5]} />
|
|
||||||
{std(body, 0.18)}
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 0.5, -0.2]}>
|
|
||||||
<boxGeometry args={[0.6, 0.78, 1.3]} />
|
|
||||||
{std("#cbd5e1", 0.08)}
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, 0.5, -0.2]}>
|
|
||||||
<boxGeometry args={[0.61, 0.12, 1.31]} />
|
|
||||||
{glow(accent)}
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0.2, 0.22, 0.88]}><sphereGeometry args={[0.06, 8, 8]} />{glow("#ffffff")}</mesh>
|
|
||||||
<mesh position={[-0.2, 0.22, 0.88]}><sphereGeometry args={[0.06, 8, 8]} />{glow("#ffffff")}</mesh>
|
|
||||||
<Wheels spinRef={wheels} radius={0.15} positions={[
|
|
||||||
[0.26, 0.15, 0.7], [-0.26, 0.15, 0.7],
|
|
||||||
[0.26, 0.15, -0.2], [-0.26, 0.15, -0.2],
|
|
||||||
[0.26, 0.15, -0.6], [-0.26, 0.15, -0.6],
|
|
||||||
]} />
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<THREE.Group>(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 (
|
|
||||||
<group ref={ref}>
|
|
||||||
<VehicleBodyMemo type={type} body={body} accent={accent} />
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -90,7 +90,7 @@ export default function ConnectedLogistics() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="elementor-element elementor-element-165dfa5 elementor-widget__width-initial elementor-widget elementor-widget-text-editor" data-id="165dfa5" data-element_type="widget" data-e-type="widget" data-widget_type="text-editor.default">
|
<div className="elementor-element elementor-element-165dfa5 elementor-widget__width-initial elementor-widget elementor-widget-text-editor" data-id="165dfa5" data-element_type="widget" data-e-type="widget" data-widget_type="text-editor.default">
|
||||||
<div className="elementor-widget-container">
|
<div className="elementor-widget-container">
|
||||||
<p>Detect SLA risks hours before they become problems. Act, don't react.</p>
|
<p>Detect SLA risks hours before they become problems. Act, don't react.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
|
|||||||
@@ -120,6 +120,72 @@ export default function ContactForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="elementor elementor-6585">
|
<div className="elementor elementor-6585">
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: `
|
||||||
|
/* ---- Clean contact form (scoped to this section) ---- */
|
||||||
|
.elementor-6585 .elementor-element.elementor-element-a5c503d {
|
||||||
|
--padding-top: 60px;
|
||||||
|
--padding-bottom: 60px;
|
||||||
|
--padding-left: 60px;
|
||||||
|
--padding-right: 60px;
|
||||||
|
}
|
||||||
|
.elementor-6585 .elementor-element.elementor-element-0e6fedf > .elementor-widget-container {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* drop the legacy notched / floating-label borders */
|
||||||
|
.elementor-6585 .wpforms-form .logico-form-field:before,
|
||||||
|
.elementor-6585 .wpforms-form .logico-form-field:after,
|
||||||
|
.elementor-6585 .wpforms-form .logico-label-placeholder { display: none !important; }
|
||||||
|
|
||||||
|
/* even field rhythm */
|
||||||
|
.elementor-6585 .wpforms-form .wpforms-field-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.elementor-6585 .wpforms-form .wpforms-field { padding: 0 !important; margin: 0 !important; }
|
||||||
|
|
||||||
|
/* labels stay for screen readers; placeholders carry the visible text */
|
||||||
|
.elementor-6585 .wpforms-form .wpforms-field-label {
|
||||||
|
position: absolute !important;
|
||||||
|
width: 1px; height: 1px;
|
||||||
|
padding: 0; margin: -1px;
|
||||||
|
overflow: hidden; clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap; border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* clean rounded inputs */
|
||||||
|
.elementor-6585 .wpforms-form input[type="text"],
|
||||||
|
.elementor-6585 .wpforms-form input[type="email"],
|
||||||
|
.elementor-6585 .wpforms-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #e3e3e3 !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
padding: 16px 20px !important;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #111;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: none !important;
|
||||||
|
transition: border-color .25s ease;
|
||||||
|
}
|
||||||
|
.elementor-6585 .wpforms-form textarea { min-height: 150px; resize: vertical; }
|
||||||
|
.elementor-6585 .wpforms-form input::placeholder,
|
||||||
|
.elementor-6585 .wpforms-form textarea::placeholder { color: #9a9a9a; opacity: 1; }
|
||||||
|
.elementor-6585 .wpforms-form input:focus,
|
||||||
|
.elementor-6585 .wpforms-form textarea:focus { border-color: #c01227 !important; outline: none; }
|
||||||
|
|
||||||
|
.elementor-6585 .wpforms-form .wpforms-submit-container { padding-top: 26px !important; }
|
||||||
|
|
||||||
|
@media (max-width: 1020px) {
|
||||||
|
.elementor-6585 .elementor-element.elementor-element-a5c503d {
|
||||||
|
--padding-top: 40px;
|
||||||
|
--padding-bottom: 40px;
|
||||||
|
--padding-left: 32px;
|
||||||
|
--padding-right: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` }} />
|
||||||
<div className="elementor-element elementor-element-3cd920c e-con-full e-flex cut-corner-no sticky-container-off e-con e-parent" data-id="3cd920c" data-element_type="container" data-e-type="container">
|
<div className="elementor-element elementor-element-3cd920c e-con-full e-flex cut-corner-no sticky-container-off e-con e-parent" data-id="3cd920c" data-element_type="container" data-e-type="container">
|
||||||
<div className="elementor-element elementor-element-b29b8fc e-flex e-con-boxed cut-corner-no sticky-container-off e-con e-child" data-id="b29b8fc" data-element_type="container" data-e-type="container" data-settings="{"background_background":"classic"}">
|
<div className="elementor-element elementor-element-b29b8fc e-flex e-con-boxed cut-corner-no sticky-container-off e-con e-child" data-id="b29b8fc" data-element_type="container" data-e-type="container" data-settings="{"background_background":"classic"}">
|
||||||
<div className="e-con-inner">
|
<div className="e-con-inner">
|
||||||
@@ -254,77 +320,57 @@ export default function ContactForm() {
|
|||||||
<div className="elementor-widget-container">
|
<div className="elementor-widget-container">
|
||||||
<div className="logico-wpforms-widget">
|
<div className="logico-wpforms-widget">
|
||||||
<div className="wpforms-container wpforms-render-modern" id="wpforms-369-contact">
|
<div className="wpforms-container wpforms-render-modern" id="wpforms-369-contact">
|
||||||
<form id="wpforms-form-369-contact" className="wpforms-validate wpforms-form" onSubmit={handleSubmit}>
|
<form id="wpforms-form-369-contact" className="wpforms-validate wpforms-form" onSubmit={handleSubmit} noValidate>
|
||||||
<div className="wpforms-field-container" style={{ marginTop: "30px" }}>
|
<div className="wpforms-field-container">
|
||||||
<div className="wpforms-field wpforms-field-wrapper logico-form-field" style={{ marginBottom: "20px" }}>
|
<div className="wpforms-field logico-form-field">
|
||||||
<div className="logico-label-wrapper" >
|
|
||||||
<div className="logico-label-placeholder" >
|
|
||||||
<div className="logico-label-placeholder-text">Full name</div>
|
|
||||||
</div>
|
|
||||||
<label className="wpforms-field-label" htmlFor="contact-field-name">Full name</label>
|
<label className="wpforms-field-label" htmlFor="contact-field-name">Full name</label>
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="contact-field-name"
|
id="contact-field-name"
|
||||||
className="wpforms-field-large"
|
|
||||||
name="fullName"
|
name="fullName"
|
||||||
|
placeholder="Full name"
|
||||||
value={formData.fullName}
|
value={formData.fullName}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="wpforms-field wpforms-field-wrapper logico-form-field" style={{ marginBottom: "20px" }}>
|
<div className="wpforms-field logico-form-field">
|
||||||
<div className="logico-label-wrapper">
|
|
||||||
<div className="logico-label-placeholder">
|
|
||||||
<div className="logico-label-placeholder-text">Email</div>
|
|
||||||
</div>
|
|
||||||
<label className="wpforms-field-label" htmlFor="contact-field-email">Email</label>
|
<label className="wpforms-field-label" htmlFor="contact-field-email">Email</label>
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="contact-field-email"
|
id="contact-field-email"
|
||||||
className="wpforms-field-large"
|
|
||||||
name="email"
|
name="email"
|
||||||
|
placeholder="Email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="wpforms-field wpforms-field-wrapper logico-form-field" style={{ marginBottom: "20px" }}>
|
<div className="wpforms-field logico-form-field">
|
||||||
<div className="logico-label-wrapper">
|
|
||||||
<div className="logico-label-placeholder">
|
|
||||||
<div className="logico-label-placeholder-text">Subject</div>
|
|
||||||
</div>
|
|
||||||
<label className="wpforms-field-label" htmlFor="contact-field-subject">Subject</label>
|
<label className="wpforms-field-label" htmlFor="contact-field-subject">Subject</label>
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="contact-field-subject"
|
id="contact-field-subject"
|
||||||
className="wpforms-field-large"
|
|
||||||
name="subject"
|
name="subject"
|
||||||
|
placeholder="Subject"
|
||||||
value={formData.subject}
|
value={formData.subject}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="wpforms-field wpforms-field-wrapper logico-form-field" style={{ marginBottom: "20px" }}>
|
<div className="wpforms-field logico-form-field">
|
||||||
<div className="logico-label-wrapper">
|
|
||||||
<div className="logico-label-placeholder">
|
|
||||||
<div className="logico-label-placeholder-text">Message</div>
|
|
||||||
</div>
|
|
||||||
<label className="wpforms-field-label" htmlFor="contact-field-message">Message</label>
|
<label className="wpforms-field-label" htmlFor="contact-field-message">Message</label>
|
||||||
</div>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="contact-field-message"
|
id="contact-field-message"
|
||||||
className="wpforms-field-large"
|
|
||||||
name="message"
|
name="message"
|
||||||
|
placeholder="Message"
|
||||||
value={formData.message}
|
value={formData.message}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="wpforms-submit-container" style={{ marginTop: "30px" }}>
|
<div className="wpforms-submit-container">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
id="wpforms-submit-369-contact"
|
id="wpforms-submit-369-contact"
|
||||||
|
|||||||
@@ -3,9 +3,30 @@ import React from "react";
|
|||||||
export default function ContactMap() {
|
export default function ContactMap() {
|
||||||
return (
|
return (
|
||||||
<div className="elementor-element elementor-element-7304a53 e-con-full e-flex cut-corner-no sticky-container-off e-con e-parent" data-id="7304a53" data-element_type="container" data-e-type="container">
|
<div className="elementor-element elementor-element-7304a53 e-con-full e-flex cut-corner-no sticky-container-off e-con e-parent" data-id="7304a53" data-element_type="container" data-e-type="container">
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: `
|
||||||
|
.elementor-element-7304a53 {
|
||||||
|
--padding-left: 20px;
|
||||||
|
--padding-right: 20px;
|
||||||
|
--margin-top: 40px;
|
||||||
|
--margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
.elementor-element-7304a53 .elementor-custom-embed {
|
||||||
|
border-radius: 25px 25px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #ededed;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.elementor-element-7304a53 .elementor-custom-embed iframe {
|
||||||
|
display: block;
|
||||||
|
filter: grayscale(100%);
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.elementor-element-7304a53 .elementor-custom-embed { height: 360px !important; }
|
||||||
|
}
|
||||||
|
` }} />
|
||||||
<div className="elementor-element elementor-element-5a3eed4 elementor-widget elementor-widget-google_maps" data-id="5a3eed4" data-element_type="widget" data-e-type="widget" data-widget_type="google_maps.default">
|
<div className="elementor-element elementor-element-5a3eed4 elementor-widget elementor-widget-google_maps" data-id="5a3eed4" data-element_type="widget" data-e-type="widget" data-widget_type="google_maps.default">
|
||||||
<div className="elementor-widget-container">
|
<div className="elementor-widget-container">
|
||||||
<div className="elementor-custom-embed" style={{ width: "100%", height: "450px" }}>
|
<div className="elementor-custom-embed" style={{ width: "100%", height: "500px" }}>
|
||||||
<iframe
|
<iframe
|
||||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3806.1918122409634!2d78.35579498480733!3d17.45053110831999!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x3bcb93b8c5a049b3%3A0x6f4b5999fccad985!2sJayabheri%20Enclave%2C%20Gachibowli%2C%20Hyderabad%2C%20Telangana!5e0!3m2!1sen!2sin!4v1778663239768!5m2!1sen!2sin"
|
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3806.1918122409634!2d78.35579498480733!3d17.45053110831999!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x3bcb93b8c5a049b3%3A0x6f4b5999fccad985!2sJayabheri%20Enclave%2C%20Gachibowli%2C%20Hyderabad%2C%20Telangana!5e0!3m2!1sen!2sin!4v1778663239768!5m2!1sen!2sin"
|
||||||
width="100%"
|
width="100%"
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
|
|
||||||
// Drifting particle background — client-only, mounted behind the section.
|
|
||||||
const EVParticles = dynamic(() => import("./EVParticles"), { ssr: false });
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
}
|
}
|
||||||
@@ -77,7 +73,7 @@ function CountUp({
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||||
if (reduced) {
|
if (reduced) {
|
||||||
setN(value);
|
requestAnimationFrame(() => setN(value));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const io = new IntersectionObserver(
|
const io = new IntersectionObserver(
|
||||||
@@ -444,10 +440,6 @@ export default function EVSection() {
|
|||||||
|
|
||||||
{/* ===== EV-Native Design (redesigned) ===== */}
|
{/* ===== EV-Native Design (redesigned) ===== */}
|
||||||
<section className="evnd" id="evnd" aria-label="EV-Native Design">
|
<section className="evnd" id="evnd" aria-label="EV-Native Design">
|
||||||
<div className="evnd__canvas-wrap" style={{ position: "absolute", inset: 0, zIndex: 0, pointerEvents: "none" }}>
|
|
||||||
<EVParticles />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="evnd__inner">
|
<div className="evnd__inner">
|
||||||
{/* TOP ROW */}
|
{/* TOP ROW */}
|
||||||
<div className="evnd__top">
|
<div className="evnd__top">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function HowItWorksHero() {
|
export default function HowItWorksHero() {
|
||||||
const [activeSlide, setActiveSlide] = useState(0);
|
const [activeSlide, setActiveSlide] = useState(0);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import IndustryStackMap from "./IndustryStackMap";
|
import IndustryWorldMap from "./IndustryWorldMap";
|
||||||
|
|
||||||
type Tab = "ch" | "so";
|
type Tab = "ch" | "so";
|
||||||
|
|
||||||
@@ -85,8 +85,7 @@ function Card({ sec }: { sec: Section }) {
|
|||||||
return (
|
return (
|
||||||
<section className="istk" aria-label={`${sec.title} solutions`}>
|
<section className="istk" aria-label={`${sec.title} solutions`}>
|
||||||
<div className="istk__card">
|
<div className="istk__card">
|
||||||
<span className="istk__map" aria-hidden="true" />
|
<IndustryWorldMap />
|
||||||
<IndustryStackMap />
|
|
||||||
|
|
||||||
<div className="istk__row">
|
<div className="istk__row">
|
||||||
{/* Image panel */}
|
{/* Image panel */}
|
||||||
@@ -186,25 +185,16 @@ const CSS = `
|
|||||||
padding: 44px;
|
padding: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Static dotted world map (image) — inverted to light dots on the dark card */
|
/* Procedurally-drawn dotted world map (continent dots + hub pulses + routes).
|
||||||
#ind-stack .istk__map {
|
Vector canvas, so it never distorts on the card's aspect ratio the way a
|
||||||
position: absolute;
|
stretched bitmap did. Faint gray dots read as a subtle backdrop on the dark card. */
|
||||||
inset: 0;
|
#ind-stack .ind__map {
|
||||||
z-index: 0;
|
|
||||||
background: url('/images/bg-map.png') center / 100% 100% no-repeat;
|
|
||||||
filter: invert(1) brightness(1.4);
|
|
||||||
opacity: 0.16;
|
|
||||||
pointer-events: none;
|
|
||||||
animation: istkMapBreathe 7s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animated logistics overlay (hub pulses + travelling packets) sits over the map */
|
|
||||||
#ind-stack .istk__canvas {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 1;
|
z-index: 0;
|
||||||
|
opacity: 0.7;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,10 +297,6 @@ const CSS = `
|
|||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
@keyframes istkMapBreathe {
|
|
||||||
0%, 100% { opacity: 0.12; }
|
|
||||||
50% { opacity: 0.2; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Responsive ---- */
|
/* ---- Responsive ---- */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
@@ -321,6 +307,5 @@ const CSS = `
|
|||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
#ind-stack .istk__list li { animation: none !important; opacity: 1; transform: none; }
|
#ind-stack .istk__list li { animation: none !important; opacity: 1; transform: none; }
|
||||||
#ind-stack .istk__map { animation: none !important; }
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Animated logistics overlay drawn over the static dotted world map image
|
|
||||||
* (`/images/bg-map.png`, set as the card background in IndustryStack).
|
|
||||||
*
|
|
||||||
* - 6 city hub nodes placed on real continents (lon/lat → normalised coords),
|
|
||||||
* each with a sin() expanding pulse ring + a solid red centre dot.
|
|
||||||
* - 4 delivery packets (#ef4444) travelling quadratic-Bézier arcs between
|
|
||||||
* random node pairs at 0.003–0.006 / frame; on arrival they pick a fresh
|
|
||||||
* random pair and restart — reading as live routes across the map.
|
|
||||||
*
|
|
||||||
* The canvas is full-bleed over the card and lines up with the background
|
|
||||||
* map because both fill the same rect (the map uses background-size 100% 100%).
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 6 hub cities, normalised to an equirectangular map: x=(lon+180)/360, y=(90-lat)/180.
|
|
||||||
// Kept to the right/centre so they sit in the content area (the left is the photo).
|
|
||||||
const NODES: [number, number][] = [
|
|
||||||
[0.5, 0.217], // London
|
|
||||||
[0.653, 0.361], // Dubai
|
|
||||||
[0.703, 0.394], // Mumbai
|
|
||||||
[0.836, 0.328], // Shanghai
|
|
||||||
[0.789, 0.493], // Singapore
|
|
||||||
[0.919, 0.689], // Sydney
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Packet {
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
t: number;
|
|
||||||
speed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const randNode = () => Math.floor(Math.random() * NODES.length);
|
|
||||||
|
|
||||||
function newPacket(): Packet {
|
|
||||||
let from = randNode();
|
|
||||||
let to = randNode();
|
|
||||||
while (to === from) to = randNode();
|
|
||||||
return { from, to, t: 0, speed: 0.003 + Math.random() * 0.003 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function IndustryStackMap() {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const parent = canvas?.parentElement;
|
|
||||||
if (!canvas || !parent) return;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
||||||
|
|
||||||
let w = 0;
|
|
||||||
let h = 0;
|
|
||||||
let packets: Packet[] = Array.from({ length: 4 }, newPacket);
|
|
||||||
let raf = 0;
|
|
||||||
let startTs = 0;
|
|
||||||
|
|
||||||
const resize = () => {
|
|
||||||
const rect = parent.getBoundingClientRect();
|
|
||||||
w = Math.max(1, rect.width);
|
|
||||||
h = Math.max(1, rect.height);
|
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
||||||
canvas.width = Math.round(w * dpr);
|
|
||||||
canvas.height = Math.round(h * dpr);
|
|
||||||
canvas.style.width = w + "px";
|
|
||||||
canvas.style.height = h + "px";
|
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const nodePx = (i: number) => ({ x: NODES[i][0] * w, y: NODES[i][1] * h });
|
|
||||||
|
|
||||||
const ctrl = (p0: { x: number; y: number }, p1: { x: number; y: number }) => {
|
|
||||||
const mx = (p0.x + p1.x) / 2;
|
|
||||||
const my = (p0.y + p1.y) / 2;
|
|
||||||
const lift = Math.hypot(p1.x - p0.x, p1.y - p0.y) * 0.26;
|
|
||||||
return { x: mx, y: my - lift };
|
|
||||||
};
|
|
||||||
const bezier = (
|
|
||||||
p0: { x: number; y: number },
|
|
||||||
c: { x: number; y: number },
|
|
||||||
p1: { x: number; y: number },
|
|
||||||
t: number
|
|
||||||
) => {
|
|
||||||
const u = 1 - t;
|
|
||||||
return {
|
|
||||||
x: u * u * p0.x + 2 * u * t * c.x + t * t * p1.x,
|
|
||||||
y: u * u * p0.y + 2 * u * t * c.y + t * t * p1.y,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const draw = (time: number, advance: boolean) => {
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
|
|
||||||
// Faint route arcs between every hub pair
|
|
||||||
ctx.save();
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let a = 0; a < NODES.length; a++) {
|
|
||||||
for (let b = a + 1; b < NODES.length; b++) {
|
|
||||||
const p0 = nodePx(a);
|
|
||||||
const p1 = nodePx(b);
|
|
||||||
const c = ctrl(p0, p1);
|
|
||||||
ctx.strokeStyle = "rgba(220,38,38,0.10)";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(p0.x, p0.y);
|
|
||||||
ctx.quadraticCurveTo(c.x, c.y, p1.x, p1.y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Delivery packets along Bézier arcs
|
|
||||||
for (const p of packets) {
|
|
||||||
const p0 = nodePx(p.from);
|
|
||||||
const p1 = nodePx(p.to);
|
|
||||||
const c = ctrl(p0, p1);
|
|
||||||
const pos = bezier(p0, c, p1, p.t);
|
|
||||||
// soft trail
|
|
||||||
const tt = Math.max(0, p.t - 0.06);
|
|
||||||
const prev = bezier(p0, c, p1, tt);
|
|
||||||
const grad = ctx.createLinearGradient(prev.x, prev.y, pos.x, pos.y);
|
|
||||||
grad.addColorStop(0, "rgba(239,68,68,0)");
|
|
||||||
grad.addColorStop(1, "rgba(239,68,68,0.55)");
|
|
||||||
ctx.strokeStyle = grad;
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(prev.x, prev.y);
|
|
||||||
ctx.lineTo(pos.x, pos.y);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.shadowColor = "#ef4444";
|
|
||||||
ctx.shadowBlur = 12;
|
|
||||||
ctx.fillStyle = "#ef4444";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(pos.x, pos.y, 3.5, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
|
|
||||||
if (advance) {
|
|
||||||
p.t += p.speed;
|
|
||||||
if (p.t >= 1) Object.assign(p, newPacket());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// City hub nodes — expanding pulse ring + solid centre dot
|
|
||||||
for (let i = 0; i < NODES.length; i++) {
|
|
||||||
const n = nodePx(i);
|
|
||||||
const phase = (Math.sin(time * 1.6 + i * 1.1) + 1) / 2; // 0..1
|
|
||||||
const radius = 3 + phase * 20;
|
|
||||||
const alpha = (1 - phase) * 0.5;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.strokeStyle = `rgba(220,38,38,${alpha})`;
|
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
ctx.arc(n.x, n.y, radius, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.fillStyle = "#dc2626";
|
|
||||||
ctx.shadowColor = "#dc2626";
|
|
||||||
ctx.shadowBlur = 8;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(n.x, n.y, 2.5, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loop = (ts: number) => {
|
|
||||||
if (!startTs) startTs = ts;
|
|
||||||
draw((ts - startTs) / 1000, true);
|
|
||||||
raf = requestAnimationFrame(loop);
|
|
||||||
};
|
|
||||||
|
|
||||||
resize();
|
|
||||||
if (reduced) {
|
|
||||||
draw(0, false);
|
|
||||||
} else {
|
|
||||||
raf = requestAnimationFrame(loop);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => {
|
|
||||||
resize();
|
|
||||||
if (reduced) draw(0, false);
|
|
||||||
});
|
|
||||||
ro.observe(parent);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(raf);
|
|
||||||
ro.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className="istk__canvas" aria-hidden="true" />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import Image from "next/image";
|
|
||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function MileTruthHero() {
|
|||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
requestAnimationFrame(() => setMounted(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export default function SolutionsHero() {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="content-slider-item-text logico-content-wrapper-2">
|
<div className="content-slider-item-text logico-content-wrapper-2">
|
||||||
<div className="text-content">
|
<div className="text-content">
|
||||||
<p>Discover how Doormile's connected logistics platform serves diverse industries with tailored solutions.</p>
|
<p>Discover how Doormile's connected logistics platform serves diverse industries with tailored solutions.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +199,7 @@ export default function SolutionsHero() {
|
|||||||
<div className="slide-content">
|
<div className="slide-content">
|
||||||
<div className="slide-content-inner">
|
<div className="slide-content-inner">
|
||||||
<h1 className="content-slider-item-heading logico-content-wrapper-1">
|
<h1 className="content-slider-item-heading logico-content-wrapper-1">
|
||||||
<span className="heading-content">One Platform. All Verticals.</span>
|
<span className="heading-content">One Platform. All Verticals</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div className="content-slider-item-text logico-content-wrapper-2">
|
<div className="content-slider-item-text logico-content-wrapper-2">
|
||||||
<div className="text-content">
|
<div className="text-content">
|
||||||
|
|||||||
@@ -110,11 +110,11 @@ const styles = `
|
|||||||
.dm-wf1-card {
|
.dm-wf1-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
margin: -28px 40px 0;
|
margin: 0 40px 0;
|
||||||
background: linear-gradient(180deg, #030a18 0%, #06101f 100%);
|
background: linear-gradient(180deg, #030a18 0%, #06101f 100%);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
border-top: none;
|
border-top: none;
|
||||||
border-radius: 0 0 35px 35px;
|
border-radius: 0 0 42px 42px;
|
||||||
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5);
|
||||||
padding: 48px 60px;
|
padding: 48px 60px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -201,7 +201,7 @@ const styles = `
|
|||||||
/* ── Responsive — keep insets/radius aligned to the optimisation card ── */
|
/* ── Responsive — keep insets/radius aligned to the optimisation card ── */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.dm-wf1-card {
|
.dm-wf1-card {
|
||||||
margin: -20px 20px 0;
|
margin: 0 20px 0;
|
||||||
border-radius: 0 0 42px 42px;
|
border-radius: 0 0 42px 42px;
|
||||||
padding: 44px 44px;
|
padding: 44px 44px;
|
||||||
gap: 44px;
|
gap: 44px;
|
||||||
@@ -210,7 +210,7 @@ const styles = `
|
|||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dm-wf1-card {
|
.dm-wf1-card {
|
||||||
margin: -14px 10px 0;
|
margin: 0 10px 0;
|
||||||
border-radius: 0 0 28px 28px;
|
border-radius: 0 0 28px 28px;
|
||||||
padding: 36px 28px;
|
padding: 36px 28px;
|
||||||
gap: 36px;
|
gap: 36px;
|
||||||
|
|||||||
Reference in New Issue
Block a user