update miletruth page
This commit is contained in:
158
src/components/optimization/AICore.tsx
Normal file
158
src/components/optimization/AICore.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { COLORS } from "./constants";
|
||||
import { seeded, smoothstep } from "./math";
|
||||
|
||||
type Props = {
|
||||
progress: React.RefObject<number>;
|
||||
reduced?: boolean;
|
||||
};
|
||||
|
||||
const CORE_Y = 3.4;
|
||||
|
||||
type CalcBeamProps = {
|
||||
offset: THREE.Vector3;
|
||||
index: number;
|
||||
progress: React.RefObject<number>;
|
||||
color: string;
|
||||
};
|
||||
|
||||
function CalculationBeam({ offset, index, progress, color }: CalcBeamProps) {
|
||||
const lineRef = useRef<THREE.LineSegments>(null);
|
||||
const lineMatRef = useRef<THREE.LineBasicMaterial>(null);
|
||||
const packetRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||
|
||||
const packetCount = 3;
|
||||
const offsets = useMemo(() => Array.from({ length: packetCount }, (_, k) => k / packetCount), []);
|
||||
|
||||
useFrame((state) => {
|
||||
const p = progress.current ?? 0;
|
||||
const t = state.clock.elapsedTime;
|
||||
|
||||
// Beams are active during AI Scan and Route Generation phases (progress 0.22 -> 0.76)
|
||||
const activeFactor = smoothstep(0.20, 0.40, p) * (1 - smoothstep(0.72, 0.85, p));
|
||||
const isVisible = activeFactor > 0.01;
|
||||
|
||||
if (lineRef.current) lineRef.current.visible = isVisible;
|
||||
if (lineMatRef.current && isVisible) {
|
||||
lineMatRef.current.opacity = activeFactor * (0.15 + Math.sin(t * 12 + index) * 0.08);
|
||||
}
|
||||
|
||||
packetRefs.current.forEach((mesh, k) => {
|
||||
if (!mesh) return;
|
||||
mesh.visible = isVisible;
|
||||
if (!isVisible) return;
|
||||
|
||||
const speed = 0.8 + seeded(index * 5 + k) * 0.4;
|
||||
const u = (offsets[k] + t * (speed / 3)) % 1;
|
||||
|
||||
// Linearly interpolate from center [0,0,0] to hub offset
|
||||
mesh.position.copy(offset).multiplyScalar(u);
|
||||
|
||||
const mat = mesh.material as THREE.MeshBasicMaterial;
|
||||
mat.opacity = activeFactor * Math.sin(u * Math.PI) * 0.8;
|
||||
|
||||
const s = 0.04 + Math.sin(t * 8 + k) * 0.015;
|
||||
mesh.scale.setScalar(s / 0.06);
|
||||
});
|
||||
});
|
||||
|
||||
// Pre-generated geometry for the static connecting line
|
||||
const lineGeo = useMemo(() => {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const pts = new Float32Array([0, 0, 0, offset.x, offset.y, offset.z]);
|
||||
geo.setAttribute("position", new THREE.BufferAttribute(pts, 3));
|
||||
return geo;
|
||||
}, [offset]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Connector line segment */}
|
||||
<lineSegments ref={lineRef} geometry={lineGeo}>
|
||||
<lineBasicMaterial
|
||||
ref={lineMatRef}
|
||||
color={color}
|
||||
transparent
|
||||
opacity={0}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</lineSegments>
|
||||
|
||||
{/* Streams of flowing energy data packets */}
|
||||
{Array.from({ length: packetCount }).map((_, k) => (
|
||||
<mesh
|
||||
key={k}
|
||||
ref={(el) => {
|
||||
packetRefs.current[k] = el;
|
||||
}}
|
||||
visible={false}
|
||||
>
|
||||
<sphereGeometry args={[0.06, 8, 8]} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
transparent
|
||||
opacity={0}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
const CalculationBeamMemo = React.memo(CalculationBeam);
|
||||
|
||||
/**
|
||||
* The Doormile AI core: a clean floating holographic orb with energy rings,
|
||||
* a particle shell and neural links. It powers up during the AI-scan phase,
|
||||
* fires an expanding radar scan, then a route-optimization burst.
|
||||
*/
|
||||
function AICore({ progress, reduced = false }: Props) {
|
||||
const root = useRef<THREE.Group>(null);
|
||||
|
||||
const hubOffsets = useMemo(() => {
|
||||
return Array.from({ length: 5 }, (_, c) => {
|
||||
const baseAngle = (c / 5) * Math.PI * 2 + 0.3;
|
||||
const radius = 7 + seeded(c * 7 + 1) * 3;
|
||||
const cx = Math.cos(baseAngle) * radius;
|
||||
const cz = Math.sin(baseAngle) * radius;
|
||||
return new THREE.Vector3(cx, 0.1 - CORE_Y, cz);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useFrame((state) => {
|
||||
const t = state.clock.elapsedTime;
|
||||
|
||||
if (root.current) {
|
||||
root.current.position.y = CORE_Y + Math.sin(t * 0.8) * 0.12;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={root} position={[0, CORE_Y, 0]}>
|
||||
{/* Sleek, subtle dispatch beacon directly above warehouse tower */}
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.06, 12, 12]} />
|
||||
<meshBasicMaterial color={COLORS.green} blending={THREE.AdditiveBlending} />
|
||||
</mesh>
|
||||
|
||||
{/* Route Calculation/Analysis Beams */}
|
||||
{hubOffsets.map((offset, i) => (
|
||||
<CalculationBeamMemo
|
||||
key={`calc-beam-${i}`}
|
||||
offset={offset}
|
||||
index={i}
|
||||
progress={progress}
|
||||
color={i % 2 === 0 ? COLORS.cyan : COLORS.green}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(AICore);
|
||||
191
src/components/optimization/HologramCity.tsx
Normal file
191
src/components/optimization/HologramCity.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { COLORS } from "./constants";
|
||||
import { damp, lerp, seeded, smoothstep } from "./math";
|
||||
|
||||
type Props = {
|
||||
progress: React.RefObject<number>;
|
||||
count?: number;
|
||||
reduced?: boolean;
|
||||
};
|
||||
|
||||
const CYAN = new THREE.Color(COLORS.cyan);
|
||||
const RED = new THREE.Color(COLORS.red);
|
||||
const GREEN = new THREE.Color(COLORS.green);
|
||||
|
||||
// Generate deterministic cluster centers matching routes.ts
|
||||
const clusterCount = 5;
|
||||
const clusterCenters = Array.from({ length: clusterCount }, (_, c) => {
|
||||
const baseAngle = (c / clusterCount) * Math.PI * 2 + 0.3;
|
||||
const radius = 7 + seeded(c * 7 + 1) * 3;
|
||||
const cx = Math.cos(baseAngle) * radius;
|
||||
const cz = Math.sin(baseAngle) * radius;
|
||||
return new THREE.Vector3(cx, 0.1, cz);
|
||||
});
|
||||
|
||||
function HologramCity({ progress, reduced = false }: Props) {
|
||||
const mainWarehouseRef = useRef<THREE.Group>(null);
|
||||
const radarRefs = useRef<(THREE.Group | null)[]>([]);
|
||||
const corridorMats = useRef<(THREE.LineBasicMaterial | null)[]>([]);
|
||||
const zoneMats = useRef<(THREE.MeshBasicMaterial | null)[]>([]);
|
||||
const eased = useRef(0);
|
||||
|
||||
// Pre-generate corridor geometries (Warehouse [0, 0, 0] to Cluster Centers)
|
||||
const corridorGeometries = useMemo(() => {
|
||||
return clusterCenters.map((center) => {
|
||||
const pts = [
|
||||
new THREE.Vector3(0, 0.15, 0),
|
||||
new THREE.Vector3(center.x * 0.5, 0.6, center.z * 0.5), // arch up slightly in the middle
|
||||
new THREE.Vector3(center.x, 0.15, center.z),
|
||||
];
|
||||
return new THREE.CatmullRomCurve3(pts).getPoints(24);
|
||||
});
|
||||
}, []);
|
||||
|
||||
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;
|
||||
|
||||
// Animate Main Warehouse central beacon rotation
|
||||
if (mainWarehouseRef.current) {
|
||||
mainWarehouseRef.current.position.y = Math.sin(t * 1.2) * 0.04;
|
||||
}
|
||||
|
||||
// Rotate Radar dishes on top of the Regional Hubs
|
||||
radarRefs.current.forEach((radar, idx) => {
|
||||
if (radar) {
|
||||
radar.rotation.y = t * (0.8 + idx * 0.2);
|
||||
}
|
||||
});
|
||||
|
||||
// Animate corridor line opacity and color:
|
||||
// Under unoptimized states (chaos/scan) they are faint or red.
|
||||
// As optimization settles, they light up with active cyan/green.
|
||||
corridorMats.current.forEach((mat, idx) => {
|
||||
if (mat) {
|
||||
const activeColor = idx % 2 === 0 ? CYAN : GREEN;
|
||||
const colorFactor = smoothstep(0.45, 0.8, e);
|
||||
mat.color.copy(RED).lerp(activeColor, colorFactor);
|
||||
mat.opacity = lerp(0.12, 0.7, colorFactor) * (0.8 + Math.sin(t * 4 - idx * 0.5) * 0.2);
|
||||
}
|
||||
});
|
||||
|
||||
// Animate ground delivery zone circles
|
||||
zoneMats.current.forEach((mat, idx) => {
|
||||
if (mat) {
|
||||
const activeColor = idx % 2 === 0 ? CYAN : GREEN;
|
||||
const colorFactor = smoothstep(0.5, 0.82, e);
|
||||
mat.color.copy(RED).lerp(activeColor, colorFactor);
|
||||
mat.opacity = lerp(0.06, 0.22, colorFactor) * (0.85 + Math.sin(t * 3 - idx) * 0.15);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Sleek Dark Cyber Ground Grid */}
|
||||
<gridHelper
|
||||
args={[60, 48, COLORS.cyan, COLORS.cyan]}
|
||||
position={[0, -0.01, 0]}
|
||||
>
|
||||
<lineBasicMaterial
|
||||
attach="material"
|
||||
color={COLORS.cyan}
|
||||
transparent
|
||||
opacity={0.05}
|
||||
/>
|
||||
</gridHelper>
|
||||
|
||||
{/* Primary Transport Corridors (Warehouse to Cluster Hubs) */}
|
||||
{corridorGeometries.map((points, i) => (
|
||||
<line key={`corr${i}`}>
|
||||
<bufferGeometry>
|
||||
<float32BufferAttribute
|
||||
attach="attributes-position"
|
||||
args={[new Float32Array(points.flatMap(p => [p.x, p.y, p.z])), 3]}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<lineBasicMaterial
|
||||
ref={(el) => {
|
||||
corridorMats.current[i] = el;
|
||||
}}
|
||||
transparent
|
||||
opacity={0.15}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</line>
|
||||
))}
|
||||
|
||||
{/* CENTRAL LOGISTICS ASSET: Main Warehouse Hub */}
|
||||
<group ref={mainWarehouseRef} position={[0, 0, 0]}>
|
||||
{/* Core foundation structure */}
|
||||
<mesh position={[0, 0.35, 0]}>
|
||||
<boxGeometry args={[2.2, 0.7, 1.8]} />
|
||||
<meshBasicMaterial color={COLORS.ink} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.35, 0]}>
|
||||
<boxGeometry args={[2.2, 0.7, 1.8]} />
|
||||
<meshBasicMaterial color={COLORS.cyan} wireframe transparent opacity={0.65} />
|
||||
</mesh>
|
||||
|
||||
{/* Loading Docks */}
|
||||
{[-0.6, 0, 0.6].map((offset, i) => (
|
||||
<mesh key={i} position={[offset, 0.18, 0.91]}>
|
||||
<boxGeometry args={[0.3, 0.35, 0.06]} />
|
||||
<meshBasicMaterial color={COLORS.cyan} transparent opacity={0.8} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Upper Level / Control Deck */}
|
||||
<mesh position={[0, 0.85, 0]}>
|
||||
<boxGeometry args={[1.2, 0.3, 1.0]} />
|
||||
<meshBasicMaterial color={COLORS.cyan} transparent opacity={0.15} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.85, 0]}>
|
||||
<boxGeometry args={[1.2, 0.3, 1.0]} />
|
||||
<meshBasicMaterial color={COLORS.cyan} wireframe transparent opacity={0.9} />
|
||||
</mesh>
|
||||
|
||||
{/* Rooftop Solar Panels */}
|
||||
{[-0.4, 0.4].map((offset, i) => (
|
||||
<mesh key={i} position={[offset, 0.71, -0.4]} rotation={[0.15, 0, 0]}>
|
||||
<boxGeometry args={[0.5, 0.03, 0.6]} />
|
||||
<meshBasicMaterial color="#1e293b" />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Central Communications Array / Tower */}
|
||||
<group position={[0, 1.0, 0]}>
|
||||
{/* Main rod */}
|
||||
<mesh position={[0, 0.4, 0]}>
|
||||
<cylinderGeometry args={[0.03, 0.03, 0.8, 8]} />
|
||||
<meshBasicMaterial color={COLORS.cyan} />
|
||||
</mesh>
|
||||
{/* Glowing Beacon */}
|
||||
<mesh position={[0, 0.8, 0]}>
|
||||
<sphereGeometry args={[0.12, 16, 16]} />
|
||||
<meshBasicMaterial color="#ffffff" />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* Pulsing ground base glow */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.02, 0]}>
|
||||
<ringGeometry args={[1.8, 2.2, 32]} />
|
||||
<meshBasicMaterial
|
||||
color={COLORS.cyan}
|
||||
transparent
|
||||
opacity={0.3}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(HologramCity);
|
||||
152
src/components/optimization/MetricsPanel.tsx
Normal file
152
src/components/optimization/MetricsPanel.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { motion, useTransform, type MotionValue } from "framer-motion";
|
||||
import { COLORS, KPIS, Kpi, rgba } from "./constants";
|
||||
|
||||
type Props = {
|
||||
/** shared scroll progress (0 → 1) */
|
||||
scroll: MotionValue<number>;
|
||||
};
|
||||
|
||||
const COUNT_START = 0.7;
|
||||
const COUNT_END = 0.97;
|
||||
|
||||
function Metric({ kpi, scroll, index }: { kpi: Kpi; scroll: MotionValue<number>; index: number }) {
|
||||
const [jitter, setJitter] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
// Tiny micro-fluctuation between -0.4% and +0.4%
|
||||
const val = (Math.random() - 0.5) * 0.008;
|
||||
setJitter(val);
|
||||
}, 800 + Math.random() * 600); // slightly staggered updates
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Single reactive string — updates without re-rendering React.
|
||||
const value = useTransform(scroll, (v) => {
|
||||
const t = Math.min(1, Math.max(0, (v - COUNT_START) / (COUNT_END - COUNT_START)));
|
||||
let n = kpi.before + (kpi.after - kpi.before) * t;
|
||||
|
||||
// Apply high-frequency live operational fluctuations on settled state
|
||||
if (t > 0.95) {
|
||||
n = n * (1 + jitter);
|
||||
}
|
||||
|
||||
const afterSide = t > 0.5;
|
||||
const prefix = afterSide ? kpi.prefixAfter ?? "" : kpi.prefixBefore ?? "";
|
||||
|
||||
if (kpi.key === "delayed" && kpi.after === 0 && t > 0.95) {
|
||||
return `0`;
|
||||
}
|
||||
|
||||
return `${prefix}${Math.round(n)}${kpi.suffix ?? ""}`;
|
||||
});
|
||||
|
||||
const label = useTransform(scroll, (v) =>
|
||||
v > (COUNT_START + COUNT_END) / 2 ? kpi.labelAfter : kpi.labelBefore,
|
||||
);
|
||||
|
||||
const fromColor = kpi.key === "orders" ? COLORS.cyan : COLORS.red;
|
||||
const toColor = COLORS.green;
|
||||
const accent = useTransform(
|
||||
scroll,
|
||||
[COUNT_START, COUNT_END],
|
||||
[rgba(fromColor, 1), rgba(toColor, 1)],
|
||||
);
|
||||
const glow = useTransform(
|
||||
scroll,
|
||||
[COUNT_START, COUNT_END],
|
||||
[rgba(fromColor, 0.28), rgba(toColor, 0.32)],
|
||||
);
|
||||
const boxShadow = useTransform(glow, (g) => `0 10px 40px -12px ${g}, inset 0 1px 0 rgba(255,255,255,0.06)`);
|
||||
const borderColor = useTransform(
|
||||
scroll,
|
||||
[COUNT_START, COUNT_END],
|
||||
[rgba(fromColor, 0.4), rgba(toColor, 0.45)],
|
||||
);
|
||||
|
||||
// Improvement arrow appears once optimized.
|
||||
const arrowOpacity = useTransform(scroll, [COUNT_END - 0.12, COUNT_END], [0, 1]);
|
||||
// Bar fills as the counter morphs from before → after.
|
||||
const fillScale = useTransform(scroll, [COUNT_START, COUNT_END], [0.12, 1]);
|
||||
|
||||
const trendColor = kpi.goodWhenLower ? COLORS.green : (kpi.key === "orders" ? COLORS.cyan : COLORS.green);
|
||||
|
||||
const sparklinePath = React.useMemo(() => {
|
||||
switch (kpi.key) {
|
||||
case "distance":
|
||||
case "vehicles":
|
||||
return "M 0,3 C 16,3 32,17 64,17"; // Downward trend
|
||||
case "orders":
|
||||
return "M 0,17 C 16,6 32,19 64,8"; // Stable high wavy trend
|
||||
case "delayed":
|
||||
case "cost":
|
||||
return "M 0,1 C 16,1 28,19 64,19"; // Sharp drop
|
||||
default:
|
||||
return "M 0,10 L 64,10";
|
||||
}
|
||||
}, [kpi.key]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="dm-opt-metric"
|
||||
style={{ boxShadow, borderColor, animationDelay: `${0.06 * index}s` }}
|
||||
>
|
||||
<div className="dm-opt-metric__top">
|
||||
<motion.span className="dm-opt-metric__label">{label}</motion.span>
|
||||
<motion.span className="dm-opt-metric__arrow" style={{ opacity: arrowOpacity, color: COLORS.green }}>
|
||||
{kpi.goodWhenLower ? "▼" : "▲"}
|
||||
</motion.span>
|
||||
</div>
|
||||
<motion.div className="dm-opt-metric__value" style={{ color: accent }}>
|
||||
{value}
|
||||
</motion.div>
|
||||
|
||||
{/* Sparkline trend graphic */}
|
||||
<div className="dm-opt-metric__sparkline" style={{ position: "absolute", bottom: 8, right: 12, opacity: 0.32, width: 64, height: 20, pointerEvents: "none" }}>
|
||||
<svg width="100%" height="100%" viewBox="0 0 64 20" fill="none">
|
||||
<defs>
|
||||
<linearGradient id={`grad-${kpi.key}`} x1="0" y1="0" x2="0" y2="20" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor={trendColor} stopOpacity="0.22" />
|
||||
<stop offset="100%" stopColor={trendColor} stopOpacity="0.0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d={sparklinePath}
|
||||
stroke={trendColor}
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d={`${sparklinePath} L 64,20 L 0,20 Z`}
|
||||
fill={`url(#grad-${kpi.key})`}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="dm-opt-metric__bar">
|
||||
<motion.div
|
||||
className="dm-opt-metric__fill"
|
||||
style={{ background: accent, scaleX: fillScale }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const MetricCard = React.memo(Metric);
|
||||
|
||||
function MetricsPanel({ scroll }: Props) {
|
||||
return (
|
||||
<div className="dm-opt-metrics" role="group" aria-label="Optimization results">
|
||||
{KPIS.map((kpi, i) => (
|
||||
<MetricCard key={kpi.key} kpi={kpi} scroll={scroll} index={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(MetricsPanel);
|
||||
85
src/components/optimization/OptimizationCanvas.tsx
Normal file
85
src/components/optimization/OptimizationCanvas.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { EffectComposer, Bloom, Vignette } from "@react-three/postprocessing";
|
||||
import { KernelSize } from "postprocessing";
|
||||
import * as THREE from "three";
|
||||
import { COLORS } from "./constants";
|
||||
import { damp, lerp, seeded } from "./math";
|
||||
import HologramCity from "./HologramCity";
|
||||
import RouteSystem from "./RouteSystem";
|
||||
import VehicleFleet from "./VehicleFleet";
|
||||
import AICore from "./AICore";
|
||||
|
||||
type Props = {
|
||||
progress: React.RefObject<number>;
|
||||
reduced?: boolean;
|
||||
isMobile?: boolean;
|
||||
/** Pause the render loop when the section is scrolled off-screen. */
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
/** Slow cinematic camera move from a high chaotic view to a settled framing. */
|
||||
function CameraRig({ progress }: { progress: React.RefObject<number> }) {
|
||||
const eased = useRef(0);
|
||||
useFrame((state, dt) => {
|
||||
const p = progress.current ?? 0;
|
||||
eased.current = damp(eased.current, p, 1.5, dt);
|
||||
const e = eased.current;
|
||||
const t = state.clock.elapsedTime;
|
||||
|
||||
const radius = lerp(17, 13, e);
|
||||
const angle = lerp(-0.5, 0.45, e) + t * 0.02;
|
||||
const height = lerp(9, 6.5, e) + Math.sin(t * 0.4) * 0.3;
|
||||
|
||||
const cam = state.camera;
|
||||
cam.position.x = Math.sin(angle) * radius;
|
||||
cam.position.z = Math.cos(angle) * radius;
|
||||
cam.position.y = height;
|
||||
cam.lookAt(0, 2.4, 0);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function OptimizationCanvas({ progress, reduced = false, isMobile = false, active = true }: Props) {
|
||||
const cityCount = isMobile ? 48 : 90;
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
flat
|
||||
dpr={[1, isMobile || reduced ? 1.3 : 1.6]}
|
||||
camera={{ position: [0, 9, 19], fov: 50, near: 0.1, far: 120 }}
|
||||
gl={{ antialias: !isMobile, powerPreference: "high-performance", alpha: false }}
|
||||
frameloop={active ? "always" : "never"}
|
||||
>
|
||||
<color attach="background" args={[COLORS.bg]} />
|
||||
<fog attach="fog" args={[COLORS.bg, 18, 52]} />
|
||||
<ambientLight intensity={0.6} />
|
||||
|
||||
<CameraRig progress={progress} />
|
||||
<HologramCity progress={progress} count={cityCount} reduced={reduced} />
|
||||
<RouteSystem progress={progress} reduced={reduced} isMobile={isMobile} />
|
||||
<VehicleFleet progress={progress} reduced={reduced} />
|
||||
<AICore progress={progress} reduced={reduced} />
|
||||
|
||||
{/* Bloom is what turns the additive-blended wireframes/beams into a real
|
||||
glowing hologram. Skipped under reduced-motion; lighter on mobile. */}
|
||||
{!reduced && (
|
||||
<EffectComposer multisampling={isMobile ? 0 : 2}>
|
||||
<Bloom
|
||||
mipmapBlur
|
||||
intensity={isMobile ? 0.7 : 1.0}
|
||||
luminanceThreshold={0.15}
|
||||
luminanceSmoothing={0.04}
|
||||
radius={isMobile ? 0.6 : 0.75}
|
||||
kernelSize={KernelSize.MEDIUM}
|
||||
/>
|
||||
<Vignette eskil={false} offset={0.25} darkness={0.5} />
|
||||
</EffectComposer>
|
||||
)}
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(OptimizationCanvas);
|
||||
881
src/components/optimization/OptimizationSection.tsx
Normal file
881
src/components/optimization/OptimizationSection.tsx
Normal file
@@ -0,0 +1,881 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { motion, AnimatePresence, useMotionValue, useTransform } from "framer-motion";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import Lenis from "lenis";
|
||||
import {
|
||||
COLORS,
|
||||
PHASE_LABELS,
|
||||
PhaseKey,
|
||||
phaseFromProgress,
|
||||
rgba,
|
||||
} from "./constants";
|
||||
import MetricsPanel from "./MetricsPanel";
|
||||
|
||||
// 3D scene is client-only and code-split so it never blocks first paint.
|
||||
const OptimizationCanvas = dynamic(() => import("./OptimizationCanvas"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const PHASE_ORDER: PhaseKey[] = [
|
||||
"chaos",
|
||||
"scan",
|
||||
"dissolve",
|
||||
"optimize",
|
||||
"reorganize",
|
||||
"metrics",
|
||||
];
|
||||
|
||||
const WORKFLOW_STEPS = [
|
||||
{ label: "Analyze", icon: "🔍", activateAt: 0 },
|
||||
{ label: "Optimize", icon: "⚡", activateAt: 2 },
|
||||
{ label: "Assign", icon: "🚛", activateAt: 3 },
|
||||
{ label: "Execute", icon: "📡", activateAt: 4 },
|
||||
{ label: "Monitor", icon: "📊", activateAt: 5 },
|
||||
];
|
||||
|
||||
export default function OptimizationSection() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const progressRef = useRef(0);
|
||||
const scroll = useMotionValue(0);
|
||||
|
||||
const [phase, setPhase] = useState<PhaseKey>("chaos");
|
||||
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);
|
||||
|
||||
const [orders, setOrders] = useState(59);
|
||||
const [accuracy, setAccuracy] = useState(98.7);
|
||||
const [activeVehicles, setActiveVehicles] = useState(5);
|
||||
const [carbon, setCarbon] = useState(-12.0);
|
||||
const [routeHealth, setRouteHealth] = useState(99.4);
|
||||
|
||||
// Interval timers for high-fidelity live dashboard fluctuations
|
||||
useEffect(() => {
|
||||
const ordersInterval = setInterval(() => {
|
||||
setOrders((prev) => prev + (Math.random() > 0.4 ? 1 : 0));
|
||||
}, 4500);
|
||||
|
||||
const accuracyInterval = setInterval(() => {
|
||||
setAccuracy((prev) => {
|
||||
const delta = (Math.random() - 0.5) * 0.15;
|
||||
const next = prev + delta;
|
||||
return parseFloat(Math.min(99.1, Math.max(98.4, next)).toFixed(2));
|
||||
});
|
||||
}, 2800);
|
||||
|
||||
const vehicleInterval = setInterval(() => {
|
||||
setActiveVehicles((prev) => (prev === 5 ? (Math.random() > 0.5 ? 4 : 5) : (Math.random() > 0.3 ? 5 : 4)));
|
||||
}, 3500);
|
||||
|
||||
const carbonInterval = setInterval(() => {
|
||||
setCarbon((prev) => {
|
||||
const delta = (Math.random() - 0.5) * 0.2;
|
||||
const next = prev + delta;
|
||||
return parseFloat(Math.min(-11.5, Math.max(-12.8, next)).toFixed(1));
|
||||
});
|
||||
}, 3200);
|
||||
|
||||
const healthInterval = setInterval(() => {
|
||||
setRouteHealth((prev) => {
|
||||
const delta = (Math.random() - 0.5) * 0.12;
|
||||
const next = prev + delta;
|
||||
return parseFloat(Math.min(99.9, Math.max(98.8, next)).toFixed(2));
|
||||
});
|
||||
}, 2500);
|
||||
|
||||
return () => {
|
||||
clearInterval(ordersInterval);
|
||||
clearInterval(accuracyInterval);
|
||||
clearInterval(vehicleInterval);
|
||||
clearInterval(carbonInterval);
|
||||
clearInterval(healthInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Environment detection (client only).
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Mount the WebGL scene when the section approaches the viewport, and pause
|
||||
// its render loop entirely once it scrolls off-screen (battery / 60fps).
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const mountIo = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
setMountScene(true);
|
||||
// Activate immediately on mount so the render loop starts even on a
|
||||
// direct scroll-jump (the activeIo below only fires on later changes).
|
||||
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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Drive the shared scroll progress with GSAP ScrollTrigger.
|
||||
// We pin via our own `position: fixed` (toggled by `pinState`) rather than
|
||||
// GSAP's pin or CSS `position: sticky`. CSS sticky is broken by an ancestor
|
||||
// (.body-container) using `overflow: hidden`, and GSAP's pin offsets the
|
||||
// element by the fixed site-header height. A self-managed fixed element
|
||||
// pins to the viewport top deterministically and ignores ancestor overflow.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
// Smooth scroll (Lenis), driven by a SINGLE rAF source — GSAP's ticker.
|
||||
// Previously lenis.raf() was called from both a manual requestAnimationFrame
|
||||
// loop AND gsap.ticker, double-stepping the integrator every frame, which is
|
||||
// what made scrolling stutter. One source keeps Lenis + ScrollTrigger locked.
|
||||
const lenis = new Lenis({
|
||||
duration: 1.05,
|
||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
orientation: "vertical",
|
||||
gestureOrientation: "vertical",
|
||||
smoothWheel: true,
|
||||
});
|
||||
|
||||
lenis.on("scroll", ScrollTrigger.update);
|
||||
const tickerCb = (time: number) => lenis.raf(time * 1000); // ticker is in seconds, Lenis wants ms
|
||||
gsap.ticker.add(tickerCb);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
|
||||
let lastPhase: PhaseKey = "chaos";
|
||||
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 next = phaseFromProgress(p);
|
||||
if (next !== lastPhase) {
|
||||
lastPhase = next;
|
||||
setPhase(next);
|
||||
}
|
||||
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();
|
||||
gsap.ticker.remove(tickerCb);
|
||||
lenis.destroy();
|
||||
};
|
||||
}, [scroll]);
|
||||
|
||||
// Overlay reactions to scroll (no React re-render — direct DOM updates).
|
||||
const leftOpacity = useTransform(scroll, [0.3, 0.55], [1, 0.32]);
|
||||
const leftBlur = useTransform(scroll, [0.3, 0.55], [0, 3]);
|
||||
const leftFilter = useTransform(leftBlur, (b) => `blur(${b}px)`);
|
||||
const rightOpacity = useTransform(scroll, [0.42, 0.66], [0.36, 1]);
|
||||
const scanWidth = useTransform(scroll, [0, 1], ["0%", "100%"]);
|
||||
const scanLineY = useTransform(scroll, [0.2, 0.42], ["8%", "92%"]);
|
||||
const scanLineOpacity = useTransform(scroll, [0.18, 0.22, 0.42, 0.46], [0, 1, 1, 0]);
|
||||
const dividerOpacity = useTransform(scroll, [0.45, 0.6], [0.15, 0.75]);
|
||||
|
||||
const phaseIndex = PHASE_ORDER.indexOf(phase);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={containerRef}
|
||||
className={`dm-opt is-${pinState}`}
|
||||
aria-label="AI Logistics Optimization"
|
||||
>
|
||||
<div className="dm-opt-sticky">
|
||||
<div className="dm-opt-card">
|
||||
{/* Static backdrop (also the canvas loading state) */}
|
||||
<div className="dm-opt-backdrop" aria-hidden />
|
||||
|
||||
{/* 3D scene */}
|
||||
{mountScene && (
|
||||
<div className="dm-opt-canvas">
|
||||
<OptimizationCanvas
|
||||
progress={progressRef}
|
||||
reduced={reduced}
|
||||
isMobile={isMobile}
|
||||
active={sceneActive}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Depth vignette */}
|
||||
<div className="dm-opt-vignette" aria-hidden />
|
||||
|
||||
{/* Floating AI Status Badge replacing giant abstract sphere centerpiece */}
|
||||
<AnimatePresence mode="wait">
|
||||
{phase !== "chaos" && (
|
||||
<motion.div
|
||||
key={phase}
|
||||
className="dm-opt-floating-badge"
|
||||
initial={{ scale: 0.85, opacity: 0 }}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
scale: 0.85,
|
||||
opacity: 0,
|
||||
}}
|
||||
transition={{ duration: 0.28, ease: "easeInOut" }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "40%",
|
||||
left: "50%",
|
||||
x: "-50%",
|
||||
y: "-50%",
|
||||
zIndex: 10,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div className="dm-opt-floating-badge__inner">
|
||||
<span className={`dm-opt-floating-badge__dot is-${phase}`} />
|
||||
<span className="dm-opt-floating-badge__text">
|
||||
{PHASE_LABELS[phase]}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Center scan divider */}
|
||||
<motion.div
|
||||
className="dm-opt-divider"
|
||||
style={{ opacity: dividerOpacity }}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* AI scan sweep line */}
|
||||
<motion.div
|
||||
className="dm-opt-scanline"
|
||||
style={{ top: scanLineY, opacity: scanLineOpacity }}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* UI overlay */}
|
||||
<div className="dm-opt-ui">
|
||||
{/* Header */}
|
||||
<header className="dm-opt-head">
|
||||
<motion.div
|
||||
className="dm-opt-eyebrow"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<span className="dm-opt-dot" /> Doormile AI Control Tower
|
||||
</motion.div>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 22 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.7, delay: 0.05 }}
|
||||
>
|
||||
AI Logistics Optimization Engine
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 22 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.7, delay: 0.12 }}
|
||||
>
|
||||
Watch Doormile's AI engine transform chaotic logistics into precision-optimized delivery networks — reducing distance, fleet size, delays, and cost in real time.
|
||||
</motion.p>
|
||||
|
||||
{/* Workflow step indicators */}
|
||||
<motion.div
|
||||
className="dm-opt-steps"
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.7, delay: 0.18 }}
|
||||
>
|
||||
{WORKFLOW_STEPS.map((step, i) => {
|
||||
const isActive = phaseIndex >= step.activateAt;
|
||||
const isCurrent = phaseIndex === step.activateAt;
|
||||
return (
|
||||
<React.Fragment key={step.label}>
|
||||
{i > 0 && <span className={`dm-opt-steps__line ${isActive ? 'is-active' : ''}`} />}
|
||||
<span className={`dm-opt-steps__pill ${isActive ? 'is-active' : ''} ${isCurrent ? 'is-current' : ''}`}>
|
||||
<span className="dm-opt-steps__icon">{step.icon}</span>
|
||||
<span className="dm-opt-steps__text">{step.label}</span>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
{/* Scan progress bar */}
|
||||
<div className="dm-opt-progress">
|
||||
<div className="dm-opt-progress__track">
|
||||
<motion.div className="dm-opt-progress__fill" style={{ width: scanWidth }} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Side comparison panels */}
|
||||
<div className="dm-opt-compare">
|
||||
<motion.aside
|
||||
className="dm-opt-panel dm-opt-panel--bad"
|
||||
style={{ opacity: leftOpacity, filter: leftFilter }}
|
||||
>
|
||||
<div className="dm-opt-panel__badge">
|
||||
<span className="dm-opt-pulse dm-opt-pulse--red" /> System: Congested
|
||||
</div>
|
||||
<h3>Without Optimization</h3>
|
||||
<ul>
|
||||
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> Chaotic overlapping routes</li>
|
||||
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> Duplicate & idle trips</li>
|
||||
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> 8 vehicles required</li>
|
||||
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> 23 delivery delays</li>
|
||||
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> +18% cost overrun</li>
|
||||
</ul>
|
||||
</motion.aside>
|
||||
|
||||
<motion.aside
|
||||
className="dm-opt-panel dm-opt-panel--good"
|
||||
style={{ opacity: rightOpacity }}
|
||||
>
|
||||
<div className="dm-opt-panel__badge dm-opt-panel__badge--good">
|
||||
<span className="dm-opt-pulse dm-opt-pulse--green" /> System: Optimized
|
||||
</div>
|
||||
<h3>With Doormile AI</h3>
|
||||
<ul>
|
||||
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Optimized route clusters</li>
|
||||
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Intelligent vehicle assignment</li>
|
||||
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Multi-trip & EV planning</li>
|
||||
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Zero delivery delays</li>
|
||||
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> 18% cost saved</li>
|
||||
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Carbon footprint reduced</li>
|
||||
</ul>
|
||||
</motion.aside>
|
||||
</div>
|
||||
|
||||
{/* KPI metrics */}
|
||||
<div className="dm-opt-foot">
|
||||
<MetricsPanel scroll={scroll} />
|
||||
{/* Bottom insight bar */}
|
||||
<motion.div
|
||||
className="dm-opt-insight"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
>
|
||||
<span className="dm-opt-insight__dot" />
|
||||
<span className="dm-opt-insight__text">Live Analytics: <strong>{orders} Orders</strong></span>
|
||||
<span className="dm-opt-insight__sep" />
|
||||
<span className="dm-opt-insight__text">AI Accuracy: <strong>{accuracy}%</strong></span>
|
||||
<span className="dm-opt-insight__sep" />
|
||||
<span className="dm-opt-insight__text">Fleet: <strong>{activeVehicles}/5 EV Active</strong></span>
|
||||
<span className="dm-opt-insight__sep" />
|
||||
<span className="dm-opt-insight__text">Route Health: <strong>{routeHealth}%</strong></span>
|
||||
<span className="dm-opt-insight__sep" />
|
||||
<span className="dm-opt-insight__text">Carbon: <strong>{carbon}%</strong></span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{styles}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = `
|
||||
/* ===== OUTER SECTION: Transparent so the card floats ===== */
|
||||
.dm-opt {
|
||||
position: relative;
|
||||
height: 230vh;
|
||||
background: transparent;
|
||||
margin-bottom: 120px;
|
||||
}
|
||||
.dm-opt-sticky {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
.dm-opt.is-pinned .dm-opt-sticky { position: fixed; top: 0; left: 0; }
|
||||
.dm-opt.is-after .dm-opt-sticky { position: absolute; top: auto; bottom: 0; }
|
||||
|
||||
/* ===== FLOATING CARD — the only colored surface ===== */
|
||||
.dm-opt-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(165deg, #06101f 0%, #020617 35%, #040d1c 70%, #030a18 100%) !important;
|
||||
border: 1.5px solid ${rgba("#ffffff", 0.08)} !important;
|
||||
box-shadow:
|
||||
0 0 0 1px ${rgba(COLORS.cyan, 0.04)},
|
||||
0 4px 30px -4px rgba(0, 0, 0, 0.7),
|
||||
0 20px 80px -20px rgba(0, 0, 0, 0.6),
|
||||
0 0 120px -30px ${rgba(COLORS.cyan, 0.08)},
|
||||
inset 0 1px 0 ${rgba("#ffffff", 0.06)},
|
||||
inset 0 -1px 0 ${rgba("#ffffff", 0.02)} !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
/* Animated subtle grid pattern */
|
||||
.dm-opt-card::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none;
|
||||
opacity: 0.035;
|
||||
background-image:
|
||||
linear-gradient(${rgba(COLORS.cyan, 0.5)} 1px, transparent 1px),
|
||||
linear-gradient(90deg, ${rgba(COLORS.cyan, 0.5)} 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
animation: dmOptGridDrift 25s linear infinite;
|
||||
}
|
||||
@keyframes dmOptGridDrift {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 60px 60px; }
|
||||
}
|
||||
/* Radial center glow behind 3D scene */
|
||||
.dm-opt-card::after {
|
||||
content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(ellipse 50% 45% at 50% 48%, ${rgba(COLORS.cyan, 0.08)} 0%, transparent 70%),
|
||||
radial-gradient(ellipse 60% 40% at 50% 90%, ${rgba(COLORS.green, 0.05)} 0%, transparent 60%),
|
||||
radial-gradient(ellipse 80% 50% at 50% 10%, ${rgba(COLORS.cyan, 0.04)} 0%, transparent 50%);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dm-opt-card {
|
||||
top: 96px !important;
|
||||
left: 20px !important;
|
||||
right: 20px !important;
|
||||
bottom: 16px !important;
|
||||
border-radius: 42px !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-opt-card {
|
||||
top: 86px !important;
|
||||
left: 10px !important;
|
||||
right: 10px !important;
|
||||
bottom: 10px !important;
|
||||
border-radius: 28px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== INNER LAYERS ===== */
|
||||
.dm-opt-backdrop {
|
||||
position: absolute;
|
||||
inset: 0; z-index: 0;
|
||||
background:
|
||||
radial-gradient(100% 70% at 50% 6%, ${rgba(COLORS.cyan, 0.06)} 0%, transparent 55%),
|
||||
radial-gradient(80% 60% at 50% 100%, ${rgba(COLORS.green, 0.05)} 0%, transparent 55%);
|
||||
}
|
||||
.dm-opt-canvas { position: absolute; inset: 0; z-index: 1; }
|
||||
.dm-opt-canvas canvas { display: block; }
|
||||
.dm-opt-vignette {
|
||||
position: absolute; inset: 0; z-index: 2; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(110% 90% at 50% 50%, transparent 48%, ${rgba("#020617", 0.88)} 100%),
|
||||
linear-gradient(180deg, ${rgba("#020617", 0.6)} 0%, transparent 20%, transparent 65%, ${rgba("#020617", 0.92)} 100%);
|
||||
}
|
||||
.dm-opt-divider {
|
||||
position: absolute; left: 50%; top: 14%; bottom: 28%;
|
||||
width: 1px; z-index: 3; pointer-events: none; transform: translateX(-0.5px);
|
||||
background: linear-gradient(180deg, transparent, ${rgba(COLORS.cyan, 0.6)}, transparent);
|
||||
box-shadow: 0 0 16px ${rgba(COLORS.cyan, 0.4)};
|
||||
}
|
||||
.dm-opt-scanline {
|
||||
position: absolute; left: 6%; right: 6%; height: 2px; z-index: 3; pointer-events: none;
|
||||
background: linear-gradient(90deg, transparent, ${COLORS.cyan}, transparent);
|
||||
box-shadow: 0 0 22px ${rgba(COLORS.cyan, 0.8)};
|
||||
}
|
||||
|
||||
/* ===== FLOATING AI STATUS BADGE ===== */
|
||||
.dm-opt-floating-badge {
|
||||
pointer-events: none;
|
||||
}
|
||||
.dm-opt-floating-badge__inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
background: ${rgba(COLORS.ink, 0.85)};
|
||||
border: 1.5px solid ${rgba(COLORS.cyan, 0.3)};
|
||||
box-shadow:
|
||||
0 10px 30px -5px rgba(0, 0, 0, 0.65),
|
||||
0 0 24px -2px ${rgba(COLORS.cyan, 0.18)},
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
transition: border-color 0.4s ease, box-shadow 0.4s ease;
|
||||
}
|
||||
.dm-opt-floating-badge__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: ${COLORS.cyan};
|
||||
box-shadow: 0 0 10px ${COLORS.cyan};
|
||||
animation: dmOptPulse 1.4s ease-in-out infinite;
|
||||
transition: background 0.4s ease, box-shadow 0.4s ease;
|
||||
}
|
||||
.dm-opt-floating-badge__dot.is-scan {
|
||||
background: ${COLORS.cyan};
|
||||
box-shadow: 0 0 10px ${COLORS.cyan};
|
||||
}
|
||||
.dm-opt-floating-badge__dot.is-dissolve,
|
||||
.dm-opt-floating-badge__dot.is-optimize {
|
||||
background: ${COLORS.amber};
|
||||
box-shadow: 0 0 10px ${COLORS.amber};
|
||||
}
|
||||
.dm-opt-floating-badge__dot.is-reorganize {
|
||||
background: #C084FC;
|
||||
box-shadow: 0 0 10px #C084FC;
|
||||
}
|
||||
.dm-opt-floating-badge__dot.is-metrics {
|
||||
background: ${COLORS.green};
|
||||
box-shadow: 0 0 10px ${COLORS.green};
|
||||
}
|
||||
.dm-opt-floating-badge__text {
|
||||
font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: #F8FAFC;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* ===== UI OVERLAY ===== */
|
||||
.dm-opt-ui { position: absolute; inset: 0; z-index: 4; pointer-events: none; }
|
||||
.dm-opt-ui h2, .dm-opt-ui h3, .dm-opt-metric__value, .dm-opt-eyebrow, .dm-opt-phase {
|
||||
font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* ===== HEADER — compact, no dead space ===== */
|
||||
.dm-opt-head {
|
||||
position: absolute; top: clamp(18px, 3vh, 36px); left: 50%;
|
||||
transform: translateX(-50%); width: min(640px, 90vw); text-align: center;
|
||||
}
|
||||
.dm-opt-eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase;
|
||||
color: ${COLORS.cyan}; padding: 5px 14px; border-radius: 999px;
|
||||
background: ${rgba(COLORS.cyan, 0.06)}; border: 1px solid ${rgba(COLORS.cyan, 0.25)};
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.dm-opt-dot { width: 6px; height: 6px; border-radius: 50%; background: ${COLORS.cyan}; box-shadow: 0 0 10px ${COLORS.cyan}; }
|
||||
.dm-opt .dm-opt-head h2 {
|
||||
margin: 8px 0 4px !important; padding: 0 !important; color: #F8FAFC !important;
|
||||
font-weight: 700 !important; text-transform: none !important;
|
||||
font-size: clamp(22px, 2.4vw, 36px) !important; line-height: 1.1 !important;
|
||||
letter-spacing: -0.015em !important;
|
||||
}
|
||||
.dm-opt .dm-opt-head p {
|
||||
margin: 0 auto !important; padding: 0 !important; color: ${COLORS.textDim} !important;
|
||||
max-width: 440px; font-size: clamp(11px, 1vw, 13px) !important; line-height: 1.45 !important;
|
||||
}
|
||||
|
||||
/* ===== WORKFLOW STEPS ===== */
|
||||
.dm-opt-steps {
|
||||
display: flex; align-items: center; justify-content: center; gap: 0;
|
||||
margin-top: 12px; flex-wrap: wrap;
|
||||
}
|
||||
.dm-opt-steps__pill {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 4px 10px; border-radius: 999px; font-weight: 600;
|
||||
letter-spacing: 0.04em; text-transform: uppercase;
|
||||
color: ${COLORS.slate};
|
||||
background: ${rgba(COLORS.ink, 0.5)};
|
||||
border: 1px solid ${rgba(COLORS.slate, 0.2)};
|
||||
backdrop-filter: blur(6px);
|
||||
transition: all 0.45s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.dm-opt-steps__pill.is-active {
|
||||
color: #E2E8F0;
|
||||
background: ${rgba(COLORS.cyan, 0.10)};
|
||||
border-color: ${rgba(COLORS.cyan, 0.35)};
|
||||
box-shadow: 0 0 18px -6px ${rgba(COLORS.cyan, 0.5)};
|
||||
}
|
||||
.dm-opt-steps__pill.is-current {
|
||||
color: ${COLORS.cyan};
|
||||
border-color: ${rgba(COLORS.cyan, 0.6)};
|
||||
box-shadow: 0 0 24px -4px ${rgba(COLORS.cyan, 0.6)};
|
||||
}
|
||||
.dm-opt-steps__icon { font-size: 11px; }
|
||||
.dm-opt-steps__text { font-size: 9.5px; }
|
||||
.dm-opt-steps__line {
|
||||
display: block; width: 16px; height: 1px; margin: 0 2px;
|
||||
background: ${rgba(COLORS.slate, 0.3)};
|
||||
transition: background 0.45s ease;
|
||||
}
|
||||
.dm-opt-steps__line.is-active {
|
||||
background: linear-gradient(90deg, ${COLORS.cyan}, ${COLORS.green});
|
||||
box-shadow: 0 0 6px ${rgba(COLORS.cyan, 0.5)};
|
||||
}
|
||||
|
||||
/* ===== PROGRESS BAR ===== */
|
||||
.dm-opt-progress { margin-top: 10px; }
|
||||
.dm-opt-progress__track {
|
||||
height: 2px; border-radius: 999px; overflow: hidden;
|
||||
background: ${rgba(COLORS.cyan, 0.10)}; max-width: 420px; margin: 0 auto;
|
||||
}
|
||||
.dm-opt-progress__fill {
|
||||
height: 100%; border-radius: 999px;
|
||||
background: linear-gradient(90deg, ${COLORS.cyan}, ${COLORS.green});
|
||||
box-shadow: 0 0 12px ${rgba(COLORS.cyan, 0.6)};
|
||||
}
|
||||
.dm-opt-status {
|
||||
display: inline-flex; align-items: center; gap: 8px; margin-top: 8px;
|
||||
padding: 4px 11px; border-radius: 999px;
|
||||
background: ${rgba(COLORS.ink, 0.55)}; border: 1px solid ${rgba(COLORS.cyan, 0.18)};
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.dm-opt-status__dot {
|
||||
width: 5px; height: 5px; border-radius: 50%; background: ${COLORS.cyan};
|
||||
box-shadow: 0 0 8px ${COLORS.cyan}; animation: dmOptPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.dm-opt-status__label {
|
||||
font-size: 9.5px; letter-spacing: 0.14em; text-transform: uppercase; color: #E2E8F0; font-weight: 600;
|
||||
}
|
||||
.dm-opt-status__step { font-size: 9.5px; color: ${COLORS.cyan}; font-weight: 600; }
|
||||
|
||||
/* ===== COMPARE PANELS — tighter, stronger ===== */
|
||||
.dm-opt-compare {
|
||||
position: absolute; top: 50%; left: 0; right: 0; transform: translateY(-50%);
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
gap: 12px; padding: 0 clamp(12px, 2.5vw, 36px);
|
||||
}
|
||||
.dm-opt-panel {
|
||||
pointer-events: auto; width: clamp(230px, 26vw, 340px);
|
||||
padding: 18px 20px; border-radius: 20px;
|
||||
background: ${rgba(COLORS.ink, 0.72)};
|
||||
border: 1px solid ${rgba(COLORS.slate, 0.22)};
|
||||
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
.dm-opt-panel--bad {
|
||||
border-color: ${rgba(COLORS.red, 0.45)};
|
||||
box-shadow:
|
||||
0 0 30px -8px ${rgba(COLORS.red, 0.35)},
|
||||
0 20px 50px -20px ${rgba(COLORS.red, 0.25)},
|
||||
inset 0 1px 0 ${rgba(COLORS.red, 0.08)};
|
||||
}
|
||||
.dm-opt-panel--good {
|
||||
border-color: ${rgba(COLORS.green, 0.45)};
|
||||
box-shadow:
|
||||
0 0 30px -8px ${rgba(COLORS.green, 0.35)},
|
||||
0 20px 50px -20px ${rgba(COLORS.green, 0.25)},
|
||||
inset 0 1px 0 ${rgba(COLORS.green, 0.08)};
|
||||
}
|
||||
.dm-opt .dm-opt-panel h3 {
|
||||
margin: 10px 0 12px !important; padding: 0 !important; color: #F1F5F9 !important;
|
||||
font-size: clamp(15px, 1.4vw, 19px) !important; font-weight: 600 !important;
|
||||
line-height: 1.15 !important; text-transform: none !important; letter-spacing: -0.01em !important;
|
||||
}
|
||||
.dm-opt .dm-opt-panel ul { list-style: none !important; margin: 0 !important; padding: 0 !important; display: grid; gap: 7px; }
|
||||
.dm-opt .dm-opt-panel li {
|
||||
position: relative; padding-left: 0 !important; margin: 0 !important;
|
||||
color: ${COLORS.textDim} !important; font-size: 12.5px !important; line-height: 1.35 !important;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.dm-opt .dm-opt-panel li::marker { content: "" !important; }
|
||||
/* Remove old dot pseudo — now using ✖/✔ markers */
|
||||
.dm-opt .dm-opt-panel li::before { content: none !important; display: none !important; }
|
||||
|
||||
/* ✖/✔ markers */
|
||||
.dm-opt-marker {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; border-radius: 6px; flex-shrink: 0;
|
||||
font-size: 10px; font-weight: 700; line-height: 1;
|
||||
}
|
||||
.dm-opt-marker--x {
|
||||
background: ${rgba(COLORS.red, 0.15)};
|
||||
color: ${COLORS.red};
|
||||
border: 1px solid ${rgba(COLORS.red, 0.35)};
|
||||
box-shadow: 0 0 8px ${rgba(COLORS.red, 0.3)};
|
||||
}
|
||||
.dm-opt-marker--ok {
|
||||
background: ${rgba(COLORS.green, 0.15)};
|
||||
color: ${COLORS.green};
|
||||
border: 1px solid ${rgba(COLORS.green, 0.35)};
|
||||
box-shadow: 0 0 8px ${rgba(COLORS.green, 0.3)};
|
||||
}
|
||||
|
||||
.dm-opt-panel__badge {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; font-weight: 700;
|
||||
color: ${COLORS.red}; padding: 5px 10px; border-radius: 999px;
|
||||
background: ${rgba(COLORS.red, 0.1)}; border: 1px solid ${rgba(COLORS.red, 0.35)};
|
||||
}
|
||||
.dm-opt-panel__badge--good { color: ${COLORS.green}; background: ${rgba(COLORS.green, 0.1)}; border-color: ${rgba(COLORS.green, 0.35)}; }
|
||||
.dm-opt-pulse { width: 6px; height: 6px; border-radius: 50%; animation: dmOptPulse 1.4s ease-in-out infinite; }
|
||||
.dm-opt-pulse--red { background: ${COLORS.red}; box-shadow: 0 0 10px ${COLORS.red}; }
|
||||
.dm-opt-pulse--green { background: ${COLORS.green}; box-shadow: 0 0 10px ${COLORS.green}; }
|
||||
@keyframes dmOptPulse { 0%,100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.5); opacity: 0.5; } }
|
||||
|
||||
/* ===== METRICS ===== */
|
||||
.dm-opt-foot {
|
||||
position: absolute; left: 0; right: 0; bottom: clamp(12px, 2.5vh, 28px);
|
||||
padding: 0 clamp(12px, 3vw, 36px);
|
||||
}
|
||||
.dm-opt-metrics {
|
||||
pointer-events: auto; display: grid; grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px; max-width: 1100px; margin: 0 auto;
|
||||
}
|
||||
.dm-opt-metric {
|
||||
position: relative; padding: 14px 14px 12px; border-radius: 16px;
|
||||
background: ${rgba(COLORS.ink, 0.72)};
|
||||
border: 1px solid ${rgba(COLORS.slate, 0.25)};
|
||||
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
||||
overflow: hidden;
|
||||
opacity: 0; transform: translateY(22px);
|
||||
animation: dmOptCardIn 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
box-shadow: 0 8px 32px -8px rgba(0, 0, 0, 0.5), inset 0 1px 0 ${rgba("#ffffff", 0.04)};
|
||||
transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.4s ease, box-shadow 0.4s ease, background 0.4s ease !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dm-opt-metric:hover {
|
||||
transform: translateY(-5px) scale(1.028) !important;
|
||||
background: ${rgba(COLORS.ink, 0.9)} !important;
|
||||
border-color: ${rgba(COLORS.cyan, 0.52)} !important;
|
||||
box-shadow:
|
||||
0 18px 48px -8px ${rgba(COLORS.cyan, 0.25)},
|
||||
0 4px 12px -2px ${rgba(COLORS.cyan, 0.12)},
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
.dm-opt-metric:hover .dm-opt-metric__sparkline {
|
||||
opacity: 0.72 !important;
|
||||
}
|
||||
.dm-opt-metric__sparkline {
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
@keyframes dmOptCardIn { to { opacity: 1; transform: translateY(0); } }
|
||||
.dm-opt-metric__top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.dm-opt-metric__label {
|
||||
font-size: 10px; letter-spacing: 0.04em; color: ${COLORS.textDim}; text-transform: uppercase;
|
||||
}
|
||||
.dm-opt-metric__arrow { font-size: 11px; }
|
||||
.dm-opt-metric__value { margin-top: 6px; font-size: clamp(20px, 2.8vw, 32px); font-weight: 700; line-height: 1; }
|
||||
.dm-opt-metric__bar { margin-top: 10px; height: 3px; border-radius: 999px; background: ${rgba(COLORS.slate, 0.2)}; overflow: hidden; }
|
||||
.dm-opt-metric__fill { height: 100%; border-radius: 999px; transform-origin: left center; }
|
||||
|
||||
/* ===== BOTTOM INSIGHT BAR ===== */
|
||||
.dm-opt-insight {
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
max-width: 760px; margin: 10px auto 0;
|
||||
padding: 7px 18px; border-radius: 999px;
|
||||
background: ${rgba(COLORS.ink, 0.6)};
|
||||
border: 1px solid ${rgba(COLORS.cyan, 0.12)};
|
||||
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.dm-opt-insight__dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
|
||||
background: ${COLORS.green};
|
||||
box-shadow: 0 0 12px ${COLORS.green};
|
||||
animation: dmOptPulse 2s ease-in-out infinite;
|
||||
}
|
||||
.dm-opt-insight__text {
|
||||
font-size: 10.5px; color: ${COLORS.textDim}; line-height: 1.3;
|
||||
letter-spacing: 0.03em; font-weight: 500;
|
||||
}
|
||||
.dm-opt-insight__text strong {
|
||||
color: #E2E8F0; font-weight: 700;
|
||||
}
|
||||
.dm-opt-insight__sep {
|
||||
width: 1px; height: 12px; flex-shrink: 0;
|
||||
background: ${rgba(COLORS.slate, 0.35)};
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-width: 1024px) {
|
||||
.dm-opt-panel { width: clamp(190px, 30vw, 280px); padding: 14px 16px; }
|
||||
.dm-opt-panel li { font-size: 11.5px; }
|
||||
.dm-opt-marker { width: 16px; height: 16px; font-size: 9px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-opt { height: 200vh; }
|
||||
.dm-opt-card {
|
||||
top: 8px !important; left: 8px !important; right: 8px !important; bottom: 8px !important;
|
||||
border-radius: 24px !important;
|
||||
}
|
||||
.dm-opt-compare {
|
||||
top: auto; bottom: 148px; transform: none;
|
||||
flex-direction: row; align-items: stretch; gap: 6px; padding: 0 10px;
|
||||
}
|
||||
.dm-opt-panel { width: 50%; padding: 10px 11px; border-radius: 14px; }
|
||||
.dm-opt-panel ul { gap: 4px; }
|
||||
.dm-opt-panel li { font-size: 10px; }
|
||||
.dm-opt-panel li::before { display: none !important; }
|
||||
.dm-opt-marker { width: 14px; height: 14px; font-size: 8px; border-radius: 4px; }
|
||||
.dm-opt-panel h3 { margin: 6px 0 5px; font-size: 14px; }
|
||||
.dm-opt-panel__badge { font-size: 8px; padding: 3px 7px; }
|
||||
.dm-opt-metrics { grid-template-columns: repeat(5, 1fr); gap: 4px; }
|
||||
.dm-opt-metric { padding: 7px 5px; border-radius: 10px; }
|
||||
.dm-opt-metric__label { font-size: 7.5px; letter-spacing: 0; }
|
||||
.dm-opt-metric__value { font-size: 14px; }
|
||||
.dm-opt-metric__bar { margin-top: 6px; }
|
||||
.dm-opt-head h2 { font-size: 22px; margin: 6px 0 4px; }
|
||||
.dm-opt-head p { font-size: 10.5px; }
|
||||
.dm-opt-phases { display: none; }
|
||||
.dm-opt-steps { gap: 0; }
|
||||
.dm-opt-steps__pill { padding: 3px 6px; }
|
||||
.dm-opt-steps__icon { font-size: 9px; }
|
||||
.dm-opt-steps__text { font-size: 7.5px; }
|
||||
.dm-opt-steps__line { width: 6px; }
|
||||
.dm-opt-insight { padding: 5px 10px; gap: 5px; }
|
||||
.dm-opt-insight__text { font-size: 8.5px; }
|
||||
.dm-opt-insight__sep { height: 10px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dm-opt-pulse { animation: none; }
|
||||
.dm-opt-metric { animation: none; opacity: 1; transform: none; }
|
||||
.dm-opt-insight__dot { animation: none; }
|
||||
.dm-opt-card::before { animation: none; }
|
||||
}
|
||||
`;
|
||||
514
src/components/optimization/RouteSystem.tsx
Normal file
514
src/components/optimization/RouteSystem.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { COLORS } from "./constants";
|
||||
import { buildRoutes } from "./routes";
|
||||
import { smoothstep, seeded } from "./math";
|
||||
|
||||
type Props = {
|
||||
progress: React.RefObject<number>;
|
||||
reduced?: boolean;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
const RADIAL = 6;
|
||||
const TUBULAR = 120;
|
||||
|
||||
type TubeHandle = { geo: THREE.TubeGeometry; mat: THREE.MeshBasicMaterial };
|
||||
|
||||
type PulsingRingProps = {
|
||||
position: THREE.Vector3;
|
||||
color: string;
|
||||
maxScale?: number;
|
||||
pulseSpeed?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean floating radar ring that pulses outwards and fades.
|
||||
*/
|
||||
function PulsingRing({ position, color, maxScale = 2.2, pulseSpeed = 2.4 }: PulsingRingProps) {
|
||||
const ref = useRef<THREE.Mesh>(null);
|
||||
const matRef = useRef<THREE.MeshBasicMaterial>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!ref.current || !matRef.current) return;
|
||||
const t = state.clock.elapsedTime;
|
||||
const cycle = (t * (pulseSpeed / 4)) % 1; // 0 -> 1 loop
|
||||
const s = 0.4 + cycle * (maxScale - 0.4);
|
||||
ref.current.scale.setScalar(s);
|
||||
matRef.current.opacity = Math.sin((1 - cycle) * Math.PI) * 0.72;
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={ref} position={[position.x, 0.03, position.z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.22, 0.28, 24]} />
|
||||
<meshBasicMaterial
|
||||
ref={matRef}
|
||||
color={color}
|
||||
transparent
|
||||
opacity={0}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
const PulsingRingMemo = React.memo(PulsingRing);
|
||||
|
||||
/* ── Chaotic Route Flow Particles & Chevrons ── */
|
||||
const CHAOS_TMP_POS = new THREE.Vector3();
|
||||
const CHAOS_TMP_TAN = new THREE.Vector3();
|
||||
|
||||
function ChaoticFlow({ progress, chaoticCurves }: {
|
||||
progress: React.RefObject<number>;
|
||||
chaoticCurves: THREE.CatmullRomCurve3[];
|
||||
}) {
|
||||
const particlesPerCurve = 4;
|
||||
const count = chaoticCurves.length * particlesPerCurve;
|
||||
const particleRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||
const chevronRefs = useRef<(THREE.Group | null)[]>([]);
|
||||
|
||||
const particleOffsets = useMemo(
|
||||
() => Array.from({ length: count }, (_, i) => seeded(i * 13 + 3)),
|
||||
[count]
|
||||
);
|
||||
const chevronOffsets = useMemo(
|
||||
() => Array.from({ length: chaoticCurves.length * 2 }, (_, i) => (i % 2) / 2),
|
||||
[chaoticCurves.length]
|
||||
);
|
||||
|
||||
useFrame((state) => {
|
||||
const p = progress.current ?? 0;
|
||||
const vis = 1 - smoothstep(0.48, 0.60, p);
|
||||
|
||||
const isVisible = vis > 0.01;
|
||||
particleRefs.current.forEach(m => { if (m) m.visible = isVisible; });
|
||||
chevronRefs.current.forEach(g => { if (g) g.visible = isVisible; });
|
||||
if (!isVisible) return;
|
||||
|
||||
const t = state.clock.elapsedTime;
|
||||
|
||||
// Animate tiny red flow particles
|
||||
for (let i = 0; i < count; i++) {
|
||||
const mesh = particleRefs.current[i];
|
||||
if (!mesh) continue;
|
||||
const curveIdx = Math.floor(i / particlesPerCurve);
|
||||
const curve = chaoticCurves[curveIdx];
|
||||
|
||||
const speed = 0.05 + seeded(i * 9) * 0.03;
|
||||
const u = (particleOffsets[i] + t * speed) % 1;
|
||||
|
||||
curve.getPointAt(u, CHAOS_TMP_POS);
|
||||
mesh.position.copy(CHAOS_TMP_POS);
|
||||
|
||||
const mat = mesh.material as THREE.MeshBasicMaterial;
|
||||
mat.opacity = vis * 0.6 * Math.sin(u * Math.PI);
|
||||
}
|
||||
|
||||
// Animate red chevrons (erratic flow)
|
||||
for (let i = 0; i < chaoticCurves.length * 2; i++) {
|
||||
const group = chevronRefs.current[i];
|
||||
if (!group) continue;
|
||||
const curveIdx = Math.floor(i / 2);
|
||||
const curve = chaoticCurves[curveIdx];
|
||||
|
||||
const u = (chevronOffsets[i] + t * 0.04) % 1;
|
||||
curve.getPointAt(u, CHAOS_TMP_POS);
|
||||
curve.getTangentAt(u, CHAOS_TMP_TAN);
|
||||
|
||||
group.position.copy(CHAOS_TMP_POS);
|
||||
group.rotation.y = Math.atan2(CHAOS_TMP_TAN.x, CHAOS_TMP_TAN.z);
|
||||
|
||||
group.traverse((o) => {
|
||||
const m = (o as THREE.Mesh).material as THREE.MeshBasicMaterial | undefined;
|
||||
if (m && "opacity" in m) {
|
||||
m.opacity = vis * 0.5 * Math.sin(u * Math.PI);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Chaotic Red Flow Particles */}
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<mesh
|
||||
key={`cp-${i}`}
|
||||
ref={(el) => { particleRefs.current[i] = el; }}
|
||||
visible={false}
|
||||
>
|
||||
<sphereGeometry args={[0.06, 6, 6]} />
|
||||
<meshBasicMaterial
|
||||
color={COLORS.red}
|
||||
transparent
|
||||
opacity={0}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Chaotic Red Chevrons */}
|
||||
{Array.from({ length: chaoticCurves.length * 2 }).map((_, i) => (
|
||||
<group
|
||||
key={`cc-${i}`}
|
||||
ref={(el) => { chevronRefs.current[i] = el; }}
|
||||
visible={false}
|
||||
>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<coneGeometry args={[0.06, 0.16, 4]} />
|
||||
<meshBasicMaterial
|
||||
color={COLORS.red}
|
||||
transparent
|
||||
opacity={0}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
const ChaoticFlowMemo = React.memo(ChaoticFlow);
|
||||
|
||||
/* ── Optimized Route Flow Particles, Chevrons & Delivery Pulses ── */
|
||||
const OPT_TMP_POS = new THREE.Vector3();
|
||||
const OPT_TMP_TAN = new THREE.Vector3();
|
||||
|
||||
function OptimizedFlow({ progress, optimizedCurves }: {
|
||||
progress: React.RefObject<number>;
|
||||
optimizedCurves: THREE.CatmullRomCurve3[];
|
||||
}) {
|
||||
const particlesPerCurve = 6;
|
||||
const count = optimizedCurves.length * particlesPerCurve;
|
||||
const particleRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||
const chevronRefs = useRef<(THREE.Group | null)[]>([]);
|
||||
const pulseRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||
|
||||
const particleOffsets = useMemo(
|
||||
() => Array.from({ length: count }, (_, i) => seeded(i * 17 + 4)),
|
||||
[count]
|
||||
);
|
||||
const chevronOffsets = useMemo(
|
||||
() => Array.from({ length: optimizedCurves.length * 3 }, (_, i) => (i % 3) / 3),
|
||||
[optimizedCurves.length]
|
||||
);
|
||||
|
||||
useFrame((state) => {
|
||||
const p = progress.current ?? 0;
|
||||
const vis = smoothstep(0.52, 0.70, p);
|
||||
|
||||
const isVisible = vis > 0.01;
|
||||
particleRefs.current.forEach(m => { if (m) m.visible = isVisible; });
|
||||
chevronRefs.current.forEach(g => { if (g) g.visible = isVisible; });
|
||||
pulseRefs.current.forEach(m => { if (m) m.visible = isVisible; });
|
||||
if (!isVisible) return;
|
||||
|
||||
const t = state.clock.elapsedTime;
|
||||
|
||||
// 1. Animate smooth green/cyan flow particles
|
||||
for (let i = 0; i < count; i++) {
|
||||
const mesh = particleRefs.current[i];
|
||||
if (!mesh) continue;
|
||||
const curveIdx = Math.floor(i / particlesPerCurve);
|
||||
const curve = optimizedCurves[curveIdx];
|
||||
|
||||
const speed = 0.08 + seeded(i * 11) * 0.04;
|
||||
const u = (particleOffsets[i] + t * speed) % 1;
|
||||
|
||||
curve.getPointAt(u, OPT_TMP_POS);
|
||||
mesh.position.copy(OPT_TMP_POS);
|
||||
|
||||
const mat = mesh.material as THREE.MeshBasicMaterial;
|
||||
mat.opacity = vis * 0.75 * Math.sin(u * Math.PI);
|
||||
const s = 0.04 + Math.sin(t * 6 + i) * 0.012;
|
||||
mesh.scale.setScalar(s / 0.05);
|
||||
}
|
||||
|
||||
// 2. Animate direction chevrons (fast, steady green chevrons)
|
||||
for (let i = 0; i < optimizedCurves.length * 3; i++) {
|
||||
const group = chevronRefs.current[i];
|
||||
if (!group) continue;
|
||||
const curveIdx = Math.floor(i / 3);
|
||||
const curve = optimizedCurves[curveIdx];
|
||||
|
||||
const u = (chevronOffsets[i] + t * 0.16) % 1;
|
||||
curve.getPointAt(u, OPT_TMP_POS);
|
||||
curve.getTangentAt(u, OPT_TMP_TAN);
|
||||
|
||||
group.position.copy(OPT_TMP_POS);
|
||||
group.rotation.y = Math.atan2(OPT_TMP_TAN.x, OPT_TMP_TAN.z);
|
||||
group.rotation.x = Math.atan2(OPT_TMP_TAN.y, Math.sqrt(OPT_TMP_TAN.x * OPT_TMP_TAN.x + OPT_TMP_TAN.z * OPT_TMP_TAN.z)) - Math.PI / 2;
|
||||
|
||||
group.traverse((o) => {
|
||||
const m = (o as THREE.Mesh).material as THREE.MeshBasicMaterial | undefined;
|
||||
if (m && "opacity" in m) {
|
||||
m.opacity = vis * 0.85 * Math.sin(u * Math.PI);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Animate expanding delivery pulses (shooting data pulses)
|
||||
for (let i = 0; i < optimizedCurves.length; i++) {
|
||||
const mesh = pulseRefs.current[i];
|
||||
if (!mesh) continue;
|
||||
const curve = optimizedCurves[i];
|
||||
|
||||
const u = (t * 0.55 + i * 0.2) % 1;
|
||||
curve.getPointAt(u, OPT_TMP_POS);
|
||||
mesh.position.copy(OPT_TMP_POS);
|
||||
|
||||
const mat = mesh.material as THREE.MeshBasicMaterial;
|
||||
mat.opacity = vis * 0.9 * Math.sin(u * Math.PI);
|
||||
const s = 0.08 + Math.sin(t * 12 + i) * 0.02;
|
||||
mesh.scale.setScalar(s / 0.07);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Tiny Cyan/Green Flow Particles */}
|
||||
{Array.from({ length: count }).map((_, i) => {
|
||||
const color = (Math.floor(i / particlesPerCurve) % 2 === 0) ? COLORS.cyan : COLORS.green;
|
||||
return (
|
||||
<mesh
|
||||
key={`op-${i}`}
|
||||
ref={(el) => { particleRefs.current[i] = el; }}
|
||||
visible={false}
|
||||
>
|
||||
<sphereGeometry args={[0.05, 8, 8]} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
transparent
|
||||
opacity={0}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Glowing Chevrons */}
|
||||
{Array.from({ length: optimizedCurves.length * 3 }).map((_, i) => {
|
||||
const color = (Math.floor(i / 3) % 2 === 0) ? COLORS.cyan : COLORS.green;
|
||||
return (
|
||||
<group
|
||||
key={`oc-${i}`}
|
||||
ref={(el) => { chevronRefs.current[i] = el; }}
|
||||
visible={false}
|
||||
>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<coneGeometry args={[0.07, 0.20, 4]} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
transparent
|
||||
opacity={0}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Large Delivery Pulses */}
|
||||
{optimizedCurves.map((_, i) => {
|
||||
const color = (i % 2 === 0) ? COLORS.cyan : COLORS.green;
|
||||
return (
|
||||
<mesh
|
||||
key={`pulse-${i}`}
|
||||
ref={(el) => { pulseRefs.current[i] = el; }}
|
||||
visible={false}
|
||||
>
|
||||
<sphereGeometry args={[0.07, 12, 12]} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
transparent
|
||||
opacity={0}
|
||||
depthWrite={false}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
const OptimizedFlowMemo = React.memo(OptimizedFlow);
|
||||
|
||||
/**
|
||||
* Glowing route network. Chaotic red routes are pre-drawn then "erased" via
|
||||
* geometry draw-range during the dissolve phase; optimized cyan/green routes
|
||||
* draw themselves in during the optimize phase. Delivery nodes flip red→green.
|
||||
*/
|
||||
function RouteSystem({ progress, reduced = false, isMobile = false }: Props) {
|
||||
const { chaotic, optimized, chaosNodes, optimizedNodes } = useMemo(
|
||||
() => buildRoutes(),
|
||||
[],
|
||||
);
|
||||
|
||||
const chaosRefs = useRef<TubeHandle[]>([]);
|
||||
const optRefs = useRef<TubeHandle[]>([]);
|
||||
const chaosNodeGroup = useRef<THREE.Group>(null);
|
||||
const optNodeGroup = useRef<THREE.Group>(null);
|
||||
|
||||
const registerChaos = (i: number) => (geo: THREE.TubeGeometry | null) => {
|
||||
if (geo) chaosRefs.current[i] = { ...(chaosRefs.current[i] ?? {}), geo } as TubeHandle;
|
||||
};
|
||||
const registerChaosMat = (i: number) => (mat: THREE.MeshBasicMaterial | null) => {
|
||||
if (mat) chaosRefs.current[i] = { ...(chaosRefs.current[i] ?? {}), mat } as TubeHandle;
|
||||
};
|
||||
const registerOpt = (i: number) => (geo: THREE.TubeGeometry | null) => {
|
||||
if (geo) optRefs.current[i] = { ...(optRefs.current[i] ?? {}), geo } as TubeHandle;
|
||||
};
|
||||
const registerOptMat = (i: number) => (mat: THREE.MeshBasicMaterial | null) => {
|
||||
if (mat) optRefs.current[i] = { ...(optRefs.current[i] ?? {}), mat } as TubeHandle;
|
||||
};
|
||||
|
||||
const setDraw = (geo: THREE.TubeGeometry, frac: number) => {
|
||||
if (!geo.index) return;
|
||||
const total = geo.index.count;
|
||||
const step = RADIAL * 6;
|
||||
const count = Math.max(0, Math.floor((total * frac) / step) * step);
|
||||
geo.setDrawRange(0, count);
|
||||
};
|
||||
|
||||
useFrame((state) => {
|
||||
const p = progress.current ?? 0;
|
||||
const t = state.clock.elapsedTime;
|
||||
|
||||
// Chaotic routes: full → erased during dissolve, with warning flicker.
|
||||
const chaosDraw = 1 - smoothstep(0.4, 0.56, p);
|
||||
const chaosBase = (1 - smoothstep(0.38, 0.55, p)) * 0.85;
|
||||
const flicker = 0.7 + Math.sin(t * 7) * 0.18;
|
||||
for (const h of chaosRefs.current) {
|
||||
if (!h?.geo) continue;
|
||||
setDraw(h.geo, chaosDraw);
|
||||
if (h.mat) h.mat.opacity = chaosBase * flicker;
|
||||
}
|
||||
|
||||
// Optimized routes: draw-in during optimize phase + flowing glow.
|
||||
const optDraw = smoothstep(0.55, 0.74, p);
|
||||
const optBase = smoothstep(0.55, 0.66, p);
|
||||
for (let i = 0; i < optRefs.current.length; i++) {
|
||||
const h = optRefs.current[i];
|
||||
if (!h?.geo) continue;
|
||||
setDraw(h.geo, optDraw);
|
||||
if (h.mat) {
|
||||
// Keep the tube a crisp, saturated energy line rather than a
|
||||
// bloom-blown white slab — the flow energy comes from the pulses + trails.
|
||||
const flow = 0.52 + Math.sin(t * 3 - i * 0.7) * 0.12;
|
||||
h.mat.opacity = optBase * flow;
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery nodes
|
||||
if (chaosNodeGroup.current) {
|
||||
const s = 0.0001 + (1 - smoothstep(0.4, 0.55, p));
|
||||
chaosNodeGroup.current.scale.setScalar(s);
|
||||
}
|
||||
if (optNodeGroup.current) {
|
||||
const appear = smoothstep(0.6, 0.82, p);
|
||||
const pop = appear * (1 + Math.sin(t * 4) * 0.06 * appear);
|
||||
optNodeGroup.current.scale.setScalar(0.0001 + pop);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Chaotic routes (red) */}
|
||||
{chaotic.map((r, i) => (
|
||||
<mesh key={`c${i}`} frustumCulled={false}>
|
||||
<tubeGeometry
|
||||
ref={registerChaos(i)}
|
||||
args={[r.curve, TUBULAR, 0.08, RADIAL, false]}
|
||||
/>
|
||||
<meshBasicMaterial
|
||||
ref={registerChaosMat(i)}
|
||||
color={COLORS.red}
|
||||
transparent
|
||||
opacity={0.85}
|
||||
depthWrite={false}
|
||||
blending={reduced ? THREE.NormalBlending : THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Optimized routes (cyan → green) */}
|
||||
{optimized.map((r, i) => (
|
||||
<mesh key={`o${i}`} frustumCulled={false}>
|
||||
<tubeGeometry
|
||||
ref={registerOpt(i)}
|
||||
args={[r.curve, TUBULAR, 0.10, RADIAL, false]}
|
||||
/>
|
||||
<meshBasicMaterial
|
||||
ref={registerOptMat(i)}
|
||||
color={i % 2 === 0 ? COLORS.cyan : COLORS.green}
|
||||
transparent
|
||||
opacity={0}
|
||||
depthWrite={false}
|
||||
blending={reduced ? THREE.NormalBlending : THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Chaos delivery nodes (red, delayed) with pulsing warning alerts */}
|
||||
<group ref={chaosNodeGroup}>
|
||||
{chaosNodes.map((n, i) => (
|
||||
<group key={`cn-group-${i}`}>
|
||||
<mesh position={n}>
|
||||
<sphereGeometry args={[0.18, 12, 12]} />
|
||||
<meshBasicMaterial
|
||||
color={COLORS.red}
|
||||
transparent
|
||||
opacity={0.9}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
<PulsingRingMemo position={n} color={COLORS.red} />
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
|
||||
{/* Optimized delivery nodes (green, fulfilled) with pulsing success alerts */}
|
||||
<group ref={optNodeGroup} scale={0.0001}>
|
||||
{optimizedNodes.map((n, i) => (
|
||||
<group key={`on-group-${i}`}>
|
||||
<mesh position={n}>
|
||||
<sphereGeometry args={[0.15, 14, 14]} />
|
||||
<meshBasicMaterial
|
||||
color={COLORS.green}
|
||||
transparent
|
||||
opacity={0.95}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
<PulsingRingMemo position={n} color={COLORS.green} maxScale={1.8} pulseSpeed={3.0} />
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
|
||||
{/* Flowing delivery particles along routes */}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<ChaoticFlowMemo
|
||||
progress={progress}
|
||||
chaoticCurves={chaotic.map(r => r.curve)}
|
||||
/>
|
||||
<OptimizedFlowMemo
|
||||
progress={progress}
|
||||
optimizedCurves={optimized.map(r => r.curve)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(RouteSystem);
|
||||
294
src/components/optimization/VehicleFleet.tsx
Normal file
294
src/components/optimization/VehicleFleet.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { COLORS } from "./constants";
|
||||
import { buildRoutes, RouteDef } from "./routes";
|
||||
import { seeded, smoothstep } from "./math";
|
||||
|
||||
type Props = {
|
||||
progress: React.RefObject<number>;
|
||||
reduced?: boolean;
|
||||
};
|
||||
|
||||
type VType = "truck" | "van" | "bike";
|
||||
|
||||
type VehicleDef = {
|
||||
curve: THREE.CatmullRomCurve3;
|
||||
type: VType;
|
||||
speed: number;
|
||||
offset: number;
|
||||
color: string;
|
||||
nodes: THREE.Vector3[];
|
||||
};
|
||||
|
||||
const TMP_POS = new THREE.Vector3();
|
||||
const TMP_TAN = new THREE.Vector3();
|
||||
const TRAIL_TMP = new THREE.Vector3();
|
||||
|
||||
// Glowing energy comet-tail trailing each optimized vehicle (see ref image:
|
||||
// a delivery van with flowing green light streaks). Sampled along the vehicle's
|
||||
// own route curve behind its current position, so it always hugs the track and
|
||||
// never smears on a scroll-jump.
|
||||
const TRAIL_N = 16;
|
||||
const TRAIL_GAP = 0.011;
|
||||
|
||||
/** Flat billboarded truck cutout from the user's artwork (modelled facing +Z;
|
||||
* the fleet loop rotates each one to face the camera). */
|
||||
function TruckBillboard({
|
||||
texture,
|
||||
tint,
|
||||
width,
|
||||
aspect,
|
||||
}: {
|
||||
texture: THREE.Texture;
|
||||
tint: string;
|
||||
width: number;
|
||||
aspect: number;
|
||||
}) {
|
||||
const h = width / aspect;
|
||||
return (
|
||||
<mesh position={[0, h / 2.4, 0]}>
|
||||
<planeGeometry args={[width, h]} />
|
||||
<meshBasicMaterial
|
||||
map={texture}
|
||||
color={tint}
|
||||
transparent
|
||||
depthWrite={false}
|
||||
side={THREE.DoubleSide}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
const TruckBillboardMemo = React.memo(TruckBillboard);
|
||||
|
||||
const HUB = new THREE.Vector3(0, 0.5, 0);
|
||||
|
||||
/**
|
||||
* Moving fleet. 12 erratic vehicles ride the chaotic routes; on the reorganize
|
||||
* phase they fade out and 8 optimally-assigned vehicles take over the clean
|
||||
* routes. Position + heading are driven along the shared curves each frame.
|
||||
*/
|
||||
function VehicleFleet({ progress, reduced = false }: Props) {
|
||||
const { chaotic, optimized } = useMemo(() => buildRoutes(), []);
|
||||
|
||||
const types: VType[] = ["truck", "van", "bike"];
|
||||
|
||||
const chaosFleet = useMemo<VehicleDef[]>(() => {
|
||||
return Array.from({ length: 7 }, (_, i) => ({
|
||||
curve: (chaotic[i % chaotic.length] as RouteDef).curve,
|
||||
type: types[i % 3],
|
||||
speed: 0.018 + seeded(i * 5 + 1) * 0.03,
|
||||
offset: seeded(i * 5 + 2),
|
||||
color: COLORS.red,
|
||||
nodes: (chaotic[i % chaotic.length] as RouteDef).nodes || [],
|
||||
}));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chaotic]);
|
||||
|
||||
const optFleet = useMemo<VehicleDef[]>(() => {
|
||||
return Array.from({ length: 5 }, (_, i) => ({
|
||||
curve: (optimized[i % optimized.length] as RouteDef).curve,
|
||||
type: types[i % 3],
|
||||
speed: 0.05 + seeded(i * 7 + 1) * 0.025,
|
||||
offset: seeded(i * 7 + 2),
|
||||
color: i % 2 === 0 ? COLORS.cyan : COLORS.green,
|
||||
nodes: (optimized[i % optimized.length] as RouteDef).nodes || [],
|
||||
}));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [optimized]);
|
||||
|
||||
const chaosRefs = useRef<(THREE.Group | null)[]>([]);
|
||||
const optRefs = useRef<(THREE.Group | null)[]>([]);
|
||||
const optTrailRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||
|
||||
// The optimized fleet uses the user's isometric truck artwork as a flat
|
||||
// billboarded cutout (loaded imperatively so we don't need a Suspense fence).
|
||||
const truckTex = useMemo(() => {
|
||||
const t = new THREE.TextureLoader().load("/images/truck.png");
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
t.anisotropy = 8;
|
||||
return t;
|
||||
}, []);
|
||||
const TRUCK_ASPECT = 1080 / 948; // rasterized PNG dimensions
|
||||
const TRUCK_W = 1.75;
|
||||
|
||||
// Persistent progress values
|
||||
const chaosProgress = 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 = (
|
||||
group: THREE.Group | null,
|
||||
def: VehicleDef,
|
||||
progressArray: number[],
|
||||
index: number,
|
||||
dt: number,
|
||||
opacity: number,
|
||||
) => {
|
||||
if (!group || !def?.curve) return;
|
||||
const visible = opacity > 0.02;
|
||||
group.visible = visible;
|
||||
if (!visible) return;
|
||||
|
||||
let u = progressArray[index];
|
||||
if (u === undefined || Number.isNaN(u)) u = progressArray[index] = def.offset ?? 0;
|
||||
|
||||
// Get position to compute distance to nodes
|
||||
def.curve.getPointAt(u, TMP_POS);
|
||||
|
||||
// Compute distance to nearest node
|
||||
let minDist = TMP_POS.distanceTo(HUB);
|
||||
if (def.nodes && def.nodes.length > 0) {
|
||||
def.nodes.forEach((node) => {
|
||||
const dist = TMP_POS.distanceTo(node);
|
||||
if (dist < minDist) minDist = dist;
|
||||
});
|
||||
}
|
||||
|
||||
// Slow at nodes (speed reduction to 20%), accelerate on long corridors (up to 125%)
|
||||
const speedFactor = 0.20 + smoothstep(0.4, 2.5, minDist) * 1.05;
|
||||
|
||||
// Increment progress continuously
|
||||
u = (u + dt * def.speed * speedFactor) % 1;
|
||||
progressArray[index] = u;
|
||||
|
||||
// Get final position and orientation
|
||||
def.curve.getPointAt(u, TMP_POS);
|
||||
def.curve.getTangentAt(u, TMP_TAN);
|
||||
|
||||
group.position.copy(TMP_POS);
|
||||
group.rotation.y = Math.atan2(TMP_TAN.x, TMP_TAN.z);
|
||||
|
||||
group.traverse((o) => {
|
||||
const m = (o as THREE.Mesh).material as THREE.Material | undefined;
|
||||
if (m && "opacity" in m) {
|
||||
(m as THREE.MeshBasicMaterial).opacity = opacity * baseOpacity(o);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// preserve per-material relative opacity (fill vs wire vs light)
|
||||
const baseOpacity = (o: THREE.Object3D) => {
|
||||
const m = (o as THREE.Mesh).material as THREE.MeshBasicMaterial | undefined;
|
||||
if (!m) return 1;
|
||||
if (m.map) return 1; // textured truck cutout fades at full strength
|
||||
if ((m as THREE.MeshBasicMaterial).wireframe) return 1;
|
||||
if (m.color && m.color.getHex() === 0xffffff) return 1;
|
||||
if (m.color && m.color.getHex() === 0xef4444) return 1; // headlight/taillight colors
|
||||
return 0.2;
|
||||
};
|
||||
|
||||
useFrame((state, dt) => {
|
||||
const p = progress.current ?? 0;
|
||||
const t = state.clock.elapsedTime;
|
||||
const safeDt = Math.min(0.06, dt);
|
||||
|
||||
const chaosFadeIn = smoothstep(0.10, 0.22, p);
|
||||
const chaosFadeOut = 1 - smoothstep(0.70, 0.82, p);
|
||||
const chaosOp = chaosFadeIn * chaosFadeOut * (0.85 + Math.sin(t * 6) * 0.1);
|
||||
const optOp = smoothstep(0.68, 0.82, p);
|
||||
|
||||
const cam = state.camera;
|
||||
for (let i = 0; i < chaosFleet.length; i++) {
|
||||
place(chaosRefs.current[i], chaosFleet[i], chaosProgress.current, i, safeDt, chaosOp);
|
||||
const g = chaosRefs.current[i];
|
||||
if (g && g.visible) {
|
||||
g.rotation.y = Math.atan2(cam.position.x - g.position.x, cam.position.z - g.position.z);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < optFleet.length; i++) {
|
||||
place(optRefs.current[i], optFleet[i], optProgress.current, i, safeDt, optOp);
|
||||
// The truck is 2D cutout art, so billboard it around Y to always face the
|
||||
// camera (overrides the tangent heading set inside place()).
|
||||
const g = optRefs.current[i];
|
||||
if (g && g.visible) {
|
||||
g.rotation.y = Math.atan2(cam.position.x - g.position.x, cam.position.z - g.position.z);
|
||||
}
|
||||
// Energy comet-tail behind each optimized vehicle.
|
||||
const u = optProgress.current[i];
|
||||
const curve = optFleet[i]?.curve;
|
||||
if (!curve || u === undefined || Number.isNaN(u)) continue;
|
||||
for (let k = 0; k < TRAIL_N; k++) {
|
||||
const mesh = optTrailRefs.current[i * TRAIL_N + k];
|
||||
if (!mesh) continue;
|
||||
if (optOp < 0.02) { mesh.visible = false; continue; }
|
||||
let uk = u - (k + 1) * TRAIL_GAP;
|
||||
if (uk < 0) uk = 0;
|
||||
mesh.visible = true;
|
||||
curve.getPointAt(uk, TRAIL_TMP);
|
||||
mesh.position.copy(TRAIL_TMP);
|
||||
const taper = 1 - k / TRAIL_N;
|
||||
const size = 0.05 + taper * 0.13;
|
||||
mesh.scale.setScalar(size / 0.1);
|
||||
const mat = mesh.material as THREE.MeshBasicMaterial;
|
||||
mat.opacity = optOp * taper * taper * (0.65 + Math.sin(t * 9 - k * 0.7) * 0.35);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Chaotic fleet — the user's truck art tinted red (congested/before) */}
|
||||
{chaosFleet.map((_v, i) => (
|
||||
<group
|
||||
key={`cv${i}`}
|
||||
ref={(el) => {
|
||||
chaosRefs.current[i] = el;
|
||||
}}
|
||||
>
|
||||
<TruckBillboardMemo texture={truckTex} tint="#ff5a5a" width={TRUCK_W * 0.92} aspect={TRUCK_ASPECT} />
|
||||
</group>
|
||||
))}
|
||||
{/* Optimized fleet — the truck art in clean white */}
|
||||
{optFleet.map((_v, i) => (
|
||||
<group
|
||||
key={`ov${i}`}
|
||||
visible={false}
|
||||
ref={(el) => {
|
||||
optRefs.current[i] = el;
|
||||
}}
|
||||
>
|
||||
<TruckBillboardMemo texture={truckTex} tint="#d7dce4" width={TRUCK_W} aspect={TRUCK_ASPECT} />
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Glowing energy comet-tails for the optimized fleet */}
|
||||
{optFleet.map((v, i) =>
|
||||
Array.from({ length: TRAIL_N }, (_, k) => (
|
||||
<mesh
|
||||
key={`ot-${i}-${k}`}
|
||||
visible={false}
|
||||
ref={(el) => {
|
||||
optTrailRefs.current[i * TRAIL_N + k] = el;
|
||||
}}
|
||||
>
|
||||
<sphereGeometry args={[0.1, 8, 8]} />
|
||||
<meshBasicMaterial
|
||||
color={v.color}
|
||||
transparent
|
||||
opacity={0}
|
||||
depthWrite={false}
|
||||
blending={reduced ? THREE.NormalBlending : THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
)),
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(VehicleFleet);
|
||||
|
||||
|
||||
118
src/components/optimization/constants.ts
Normal file
118
src/components/optimization/constants.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Shared design tokens + KPI data for the AI Logistics Optimization section.
|
||||
* Centralizing here keeps the 3D scene, overlay and metrics panel in sync.
|
||||
*/
|
||||
|
||||
export const COLORS = {
|
||||
bg: "#020617",
|
||||
cyan: "#00E5FF",
|
||||
green: "#22C55E",
|
||||
red: "#EF4444",
|
||||
amber: "#F59E0B",
|
||||
ink: "#0B1220",
|
||||
slate: "#64748B",
|
||||
textDim: "rgba(226,232,240,0.66)",
|
||||
} as const;
|
||||
|
||||
/** rgba helper used by inline styles + scoped CSS. */
|
||||
export function rgba(hex: string, alpha: number): string {
|
||||
const h = hex.replace("#", "");
|
||||
const r = parseInt(h.substring(0, 2), 16);
|
||||
const g = parseInt(h.substring(2, 4), 16);
|
||||
const b = parseInt(h.substring(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
export type Kpi = {
|
||||
key: string;
|
||||
/** numeric value the counter animates between */
|
||||
before: number;
|
||||
after: number;
|
||||
prefixBefore?: string;
|
||||
prefixAfter?: string;
|
||||
suffix?: string;
|
||||
labelBefore: string;
|
||||
labelAfter: string;
|
||||
/** lower number is the better outcome (drives the up/down arrow) */
|
||||
goodWhenLower: boolean;
|
||||
};
|
||||
|
||||
export const KPIS: Kpi[] = [
|
||||
{
|
||||
key: "distance",
|
||||
before: 312,
|
||||
after: 182,
|
||||
suffix: " km",
|
||||
labelBefore: "Route Distance",
|
||||
labelAfter: "Route Distance",
|
||||
goodWhenLower: true,
|
||||
},
|
||||
{
|
||||
key: "vehicles",
|
||||
before: 8,
|
||||
after: 5,
|
||||
labelBefore: "Vehicles",
|
||||
labelAfter: "Vehicles",
|
||||
goodWhenLower: true,
|
||||
},
|
||||
{
|
||||
key: "orders",
|
||||
before: 59,
|
||||
after: 59,
|
||||
labelBefore: "Orders",
|
||||
labelAfter: "Orders Fulfilled",
|
||||
goodWhenLower: false,
|
||||
},
|
||||
{
|
||||
key: "delayed",
|
||||
before: 23,
|
||||
after: 0,
|
||||
labelBefore: "Delayed",
|
||||
labelAfter: "Delayed",
|
||||
goodWhenLower: true,
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
before: 18,
|
||||
after: 18,
|
||||
prefixBefore: "+",
|
||||
prefixAfter: "−",
|
||||
suffix: "%",
|
||||
labelBefore: "Cost Overrun",
|
||||
labelAfter: "Cost Saved",
|
||||
goodWhenLower: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Scroll-phase thresholds (normalized 0 → 1). The whole narrative is driven by
|
||||
* a single scroll progress value shared between GSAP, R3F and Framer Motion.
|
||||
*/
|
||||
export const PHASES = {
|
||||
chaos: 0.0, // Stage 1 & 2: Network appears & vehicles active
|
||||
scan: 0.28, // Stage 3: AI scans routes
|
||||
dissolve: 0.42, // Stage 4 & 5: Optimize begins & bad routes dissolve
|
||||
optimize: 0.56, // Stage 6: Optimized routes appear
|
||||
reorganize: 0.70, // Reassigning vehicles
|
||||
metrics: 0.84, // Stage 7: KPIs update & complete
|
||||
} as const;
|
||||
|
||||
export type PhaseKey = keyof typeof PHASES;
|
||||
|
||||
export function phaseFromProgress(p: number): PhaseKey {
|
||||
if (p >= PHASES.metrics) return "metrics";
|
||||
if (p >= PHASES.reorganize) return "reorganize";
|
||||
if (p >= PHASES.optimize) return "optimize";
|
||||
if (p >= PHASES.dissolve) return "dissolve";
|
||||
if (p >= PHASES.scan) return "scan";
|
||||
return "chaos";
|
||||
}
|
||||
|
||||
export const PHASE_LABELS: Record<PhaseKey, string> = {
|
||||
chaos: "Monitoring network",
|
||||
scan: "AI SCANNING NETWORK",
|
||||
dissolve: "AI OPTIMIZING ROUTES",
|
||||
optimize: "AI OPTIMIZING ROUTES",
|
||||
reorganize: "AI REASSIGNING VEHICLES",
|
||||
metrics: "AI COMPLETE",
|
||||
};
|
||||
36
src/components/optimization/math.ts
Normal file
36
src/components/optimization/math.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/** Small framerate-independent math helpers shared by the 3D scene. */
|
||||
|
||||
export const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
|
||||
export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
|
||||
/** Linear remap of `v` from [inMin,inMax] to [outMin,outMax], clamped. */
|
||||
export function remap(
|
||||
v: number,
|
||||
inMin: number,
|
||||
inMax: number,
|
||||
outMin = 0,
|
||||
outMax = 1,
|
||||
): number {
|
||||
const t = clamp01((v - inMin) / (inMax - inMin || 1));
|
||||
return outMin + (outMax - outMin) * 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 — frame-rate independent.
|
||||
* lambda ~ responsiveness (higher = snappier).
|
||||
*/
|
||||
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) — avoids Math.random for stable SSR/scene. */
|
||||
export function seeded(i: number): number {
|
||||
const x = Math.sin(i * 127.1 + 311.7) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
91
src/components/optimization/routes.ts
Normal file
91
src/components/optimization/routes.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as THREE from "three";
|
||||
import { seeded } from "./math";
|
||||
|
||||
/**
|
||||
* Deterministic route + delivery-node generation shared by RouteSystem and
|
||||
* VehicleFleet so glowing paths and moving vehicles stay perfectly aligned.
|
||||
*/
|
||||
|
||||
export const HUB = new THREE.Vector3(0, 0.5, 0);
|
||||
export const ROUTE_Y = 0.45;
|
||||
|
||||
export type RouteDef = {
|
||||
curve: THREE.CatmullRomCurve3;
|
||||
nodes: THREE.Vector3[];
|
||||
};
|
||||
|
||||
export type SceneRoutes = {
|
||||
chaotic: RouteDef[];
|
||||
optimized: RouteDef[];
|
||||
chaosNodes: THREE.Vector3[];
|
||||
optimizedNodes: THREE.Vector3[];
|
||||
};
|
||||
|
||||
function arc(points: THREE.Vector3[]): THREE.CatmullRomCurve3 {
|
||||
return new THREE.CatmullRomCurve3(points, false, "catmullrom", 0.5);
|
||||
}
|
||||
|
||||
let cached: SceneRoutes | null = null;
|
||||
|
||||
export function buildRoutes(): SceneRoutes {
|
||||
if (cached) return cached;
|
||||
|
||||
// --- Optimized clusters: 5 tidy zones around the hub --------------------
|
||||
const clusterCount = 5;
|
||||
const optimized: RouteDef[] = [];
|
||||
const optimizedNodes: THREE.Vector3[] = [];
|
||||
|
||||
for (let c = 0; c < clusterCount; c++) {
|
||||
const baseAngle = (c / clusterCount) * Math.PI * 2 + 0.3;
|
||||
const radius = 7 + seeded(c * 7 + 1) * 3;
|
||||
const cx = Math.cos(baseAngle) * radius;
|
||||
const cz = Math.sin(baseAngle) * radius;
|
||||
|
||||
// 2-3 stops per cluster (multi-trip planning)
|
||||
const stops = 2 + Math.floor(seeded(c * 7 + 2) * 2);
|
||||
const pts: THREE.Vector3[] = [HUB.clone()];
|
||||
// gentle lift-off control point
|
||||
pts.push(new THREE.Vector3(cx * 0.35, ROUTE_Y + 1.4, cz * 0.35));
|
||||
|
||||
const nodes: THREE.Vector3[] = [];
|
||||
for (let s = 0; s < stops; s++) {
|
||||
const jx = (seeded(c * 31 + s * 5 + 3) - 0.5) * 3.2;
|
||||
const jz = (seeded(c * 31 + s * 5 + 4) - 0.5) * 3.2;
|
||||
const node = new THREE.Vector3(cx + jx, ROUTE_Y, cz + jz);
|
||||
nodes.push(node);
|
||||
optimizedNodes.push(node);
|
||||
pts.push(node.clone());
|
||||
}
|
||||
optimized.push({ curve: arc(pts), nodes });
|
||||
}
|
||||
|
||||
// --- Chaotic routes: tangled, overlapping, wandering --------------------
|
||||
const chaotic: RouteDef[] = [];
|
||||
const chaosNodes: THREE.Vector3[] = [];
|
||||
const chaosCount = 6;
|
||||
|
||||
for (let r = 0; r < chaosCount; r++) {
|
||||
const segs = 4 + Math.floor(seeded(r * 13 + 1) * 3);
|
||||
const pts: THREE.Vector3[] = [HUB.clone()];
|
||||
let angle = seeded(r * 13 + 2) * Math.PI * 2;
|
||||
const nodes: THREE.Vector3[] = [];
|
||||
for (let s = 0; s < segs; s++) {
|
||||
angle += (seeded(r * 13 + s * 3 + 3) - 0.5) * 2.6; // erratic turns
|
||||
const reach = 2.6 + seeded(r * 13 + s * 3 + 4) * 6.4; // tighter, more central
|
||||
const px = Math.cos(angle) * reach + (seeded(r * 17 + s) - 0.5) * 2.6;
|
||||
const pz = Math.sin(angle) * reach + (seeded(r * 19 + s) - 0.5) * 2.6;
|
||||
const py = ROUTE_Y + seeded(r * 23 + s) * 1.1;
|
||||
const p = new THREE.Vector3(px, py, pz);
|
||||
pts.push(p);
|
||||
if (s === segs - 1) {
|
||||
const node = new THREE.Vector3(px, ROUTE_Y, pz);
|
||||
chaosNodes.push(node);
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
chaotic.push({ curve: arc(pts), nodes });
|
||||
}
|
||||
|
||||
cached = { chaotic, optimized, chaosNodes, optimizedNodes };
|
||||
return cached;
|
||||
}
|
||||
Reference in New Issue
Block a user