update the loading issue
This commit is contained in:
BIN
public/models/optimized/auto_rickshaw.glb
Normal file
BIN
public/models/optimized/auto_rickshaw.glb
Normal file
Binary file not shown.
BIN
public/models/optimized/scooter.glb
Normal file
BIN
public/models/optimized/scooter.glb
Normal file
Binary file not shown.
BIN
public/models/optimized/truck.glb
Normal file
BIN
public/models/optimized/truck.glb
Normal file
Binary file not shown.
BIN
public/models/optimized/van.glb
Normal file
BIN
public/models/optimized/van.glb
Normal file
Binary file not shown.
@@ -10,17 +10,24 @@ import Lenis from "lenis";
|
||||
* SmoothScroll
|
||||
* ---------------------------------------------------------------------------
|
||||
* One global Lenis instance, driven by a SINGLE rAF source (GSAP's ticker) and
|
||||
* kept locked to ScrollTrigger. Deliberately gated OFF on:
|
||||
* - /miletruth — it stacks 3 pinned WebGL sections; JS scroll-smoothing there
|
||||
* fights the pins and causes the very lag we're trying to remove. Native
|
||||
* scroll is used on that route.
|
||||
* kept locked to ScrollTrigger via `lenis.on("scroll", ScrollTrigger.update)`.
|
||||
*
|
||||
* Enabled on every route. /miletruth's three scroll-driven WebGL sections use a
|
||||
* self-managed `position: fixed` pin (toggled from ScrollTrigger.onUpdate via
|
||||
* `self.progress`) — NOT GSAP's pin-spacer — so Lenis driving real document
|
||||
* scroll keeps their progress correct and just smooths the wheel input. (It was
|
||||
* previously gated off here when those sections used GSAP pins; that no longer
|
||||
* applies, and native scroll there felt noticeably janky next to every Lenis
|
||||
* route.)
|
||||
*
|
||||
* Still gated OFF on:
|
||||
* - touch devices — native momentum is smoother than emulated inertia.
|
||||
* - prefers-reduced-motion.
|
||||
*
|
||||
* Re-evaluates on every route change: the effect cleanup destroys the previous
|
||||
* instance, so entering /miletruth tears Lenis down and leaving it re-inits.
|
||||
* instance and re-inits on the next route.
|
||||
*/
|
||||
const DISABLED_ROUTES = ["/miletruth"];
|
||||
const DISABLED_ROUTES: string[] = [];
|
||||
|
||||
export default function SmoothScroll() {
|
||||
const pathname = usePathname();
|
||||
|
||||
@@ -11,8 +11,8 @@ html {
|
||||
}
|
||||
|
||||
/* Lenis global smooth scroll (src/animations/SmoothScroll.tsx). These classes are
|
||||
only present on routes/devices where Lenis is active; on /miletruth and touch
|
||||
devices Lenis is off and native scroll-behavior:smooth (above) applies. */
|
||||
only present on routes/devices where Lenis is active; on touch devices and with
|
||||
prefers-reduced-motion Lenis is off and native scroll-behavior:smooth (above) applies. */
|
||||
html.lenis,
|
||||
html.lenis body {
|
||||
height: auto;
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en-US" className={`${manrope.variable} ${spaceGrotesk.variable} ${syne.variable} ${dmSans.variable} ${inter.variable}`}>
|
||||
<html lang="en-US" data-scroll-behavior="smooth" className={`${manrope.variable} ${spaceGrotesk.variable} ${syne.variable} ${dmSans.variable} ${inter.variable}`}>
|
||||
<head>
|
||||
{/* FontAwesome icons */}
|
||||
<link
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import MileTruthHero from "../../components/sections/MileTruthHero";
|
||||
import Workflow1 from "../../components/sections/Workflow1";
|
||||
import Workflow2 from "../../components/sections/Workflow2";
|
||||
import Workflow3 from "../../components/sections/Workflow3";
|
||||
import Workflow3Lazy from "../../components/sections/Workflow3Lazy";
|
||||
|
||||
export const metadata = {
|
||||
title: "MileTruth – Doormile",
|
||||
@@ -18,7 +18,7 @@ export default function MileTruthPage() {
|
||||
<MileTruthHero />
|
||||
<Workflow1 />
|
||||
<Workflow2 />
|
||||
<Workflow3 />
|
||||
<Workflow3Lazy />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,6 +83,7 @@ export default function LoadingScreen() {
|
||||
height={38}
|
||||
priority
|
||||
className="dm-loader__logo"
|
||||
style={{ height: "auto" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,18 +2,26 @@
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { motion, useMotionValue, useMotionValueEvent, useTransform, type MotionValue } from "framer-motion";
|
||||
import { motion, useMotionValue, useTransform, type MotionValue } from "framer-motion";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { P, STRATEGIES, ENGINE_STEPS, CONSTRAINT_LIST, STRATEGY_SCORES } from "./theme";
|
||||
|
||||
const LogisticsBrainCanvas = dynamic(() => import("./LogisticsBrainCanvas"), { ssr: false });
|
||||
|
||||
/** Rounds a MotionValue to an integer for the animated stat counters. */
|
||||
/** Rounds a MotionValue to an integer for the animated stat counters.
|
||||
* Writes to the DOM imperatively so it never triggers a setState during the
|
||||
* parent's render (which `useTransform` emits synchronously on re-render). */
|
||||
function Counter({ mv }: { mv: MotionValue<number> }) {
|
||||
const [v, setV] = useState(0);
|
||||
useMotionValueEvent(mv, "change", (x) => setV(Math.round(x)));
|
||||
return <>{v}</>;
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
useEffect(() => {
|
||||
const write = (x: number) => {
|
||||
if (ref.current) ref.current.textContent = String(Math.round(x));
|
||||
};
|
||||
write(mv.get());
|
||||
return mv.on("change", write);
|
||||
}, [mv]);
|
||||
return <span ref={ref}>{Math.round(mv.get())}</span>;
|
||||
}
|
||||
|
||||
/** Active step index from scroll progress (−1 before the engine starts). */
|
||||
|
||||
@@ -154,11 +154,11 @@ export default function OptimizationSection() {
|
||||
if (!el) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
// NOTE: /miletruth runs on native scroll (no Lenis). Smooth-scrolling this
|
||||
// page fought the three stacked pinned WebGL sections and caused scroll lag;
|
||||
// global Lenis (src/animations/SmoothScroll.tsx) is intentionally disabled on
|
||||
// this route. ScrollTrigger's scrub + self-managed fixed pin work as-is on
|
||||
// native scroll.
|
||||
// NOTE: global Lenis (src/animations/SmoothScroll.tsx) is active on this
|
||||
// route. Because the pin is a self-managed `position: fixed` driven by
|
||||
// `self.progress` (not a GSAP pin-spacer) and Lenis scrolls the real
|
||||
// document hooked to ScrollTrigger.update, the scrub + fixed pin work
|
||||
// unchanged under smooth scroll.
|
||||
let lastPhase: PhaseKey = "chaos";
|
||||
let lastPin: "before" | "pinned" | "after" = "before";
|
||||
const st = ScrollTrigger.create({
|
||||
|
||||
@@ -134,7 +134,8 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
||||
if (!visible) return;
|
||||
|
||||
let u = progressArray[index];
|
||||
if (u === undefined || Number.isNaN(u)) u = progressArray[index] = def.offset ?? 0;
|
||||
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);
|
||||
@@ -149,7 +150,7 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
||||
}
|
||||
|
||||
// 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;
|
||||
const speedFactor = 0.2 + smoothstep(0.4, 2.5, minDist) * 1.05;
|
||||
|
||||
// Increment progress continuously
|
||||
u = (u + dt * def.speed * speedFactor) % 1;
|
||||
@@ -195,26 +196,46 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
||||
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 chaosFadeIn = smoothstep(0.1, 0.22, p);
|
||||
const chaosFadeOut = 1 - smoothstep(0.7, 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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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];
|
||||
@@ -223,7 +244,10 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
||||
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; }
|
||||
if (optOp < 0.02) {
|
||||
mesh.visible = false;
|
||||
continue;
|
||||
}
|
||||
let uk = u - (k + 1) * TRAIL_GAP;
|
||||
if (uk < 0) uk = 0;
|
||||
mesh.visible = true;
|
||||
@@ -233,7 +257,8 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
||||
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);
|
||||
mat.opacity =
|
||||
optOp * taper * taper * (0.65 + Math.sin(t * 9 - k * 0.7) * 0.35);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -248,7 +273,12 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
||||
chaosRefs.current[i] = el;
|
||||
}}
|
||||
>
|
||||
<TruckBillboardMemo texture={truckTex} tint="#ff5a5a" width={TRUCK_W * 0.92} aspect={TRUCK_ASPECT} />
|
||||
<TruckBillboardMemo
|
||||
texture={truckTex}
|
||||
tint="#ff5a5a"
|
||||
width={TRUCK_W * 0.92}
|
||||
aspect={TRUCK_ASPECT}
|
||||
/>
|
||||
</group>
|
||||
))}
|
||||
{/* Optimized fleet — the truck art in clean white */}
|
||||
@@ -260,7 +290,12 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
||||
optRefs.current[i] = el;
|
||||
}}
|
||||
>
|
||||
<TruckBillboardMemo texture={truckTex} tint="#d7dce4" width={TRUCK_W} aspect={TRUCK_ASPECT} />
|
||||
<TruckBillboardMemo
|
||||
texture={truckTex}
|
||||
tint="#d7dce4"
|
||||
width={TRUCK_W}
|
||||
aspect={TRUCK_ASPECT}
|
||||
/>
|
||||
</group>
|
||||
))}
|
||||
|
||||
@@ -290,5 +325,3 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
||||
}
|
||||
|
||||
export default React.memo(VehicleFleet);
|
||||
|
||||
|
||||
|
||||
48
src/components/sections/Workflow3Lazy.tsx
Normal file
48
src/components/sections/Workflow3Lazy.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// Workflow 3 is the heaviest, last-on-page section (the Strategy 3D experience +
|
||||
// its WebGL canvas chunk). It's kept OFF the initial render/compile critical path:
|
||||
// dynamically imported (ssr:false) and only mounted once the user scrolls within
|
||||
// ~1.5 viewports of it. Until then we reserve a min-height so the page scroll
|
||||
// length stays roughly stable and the mount (which happens well below the fold)
|
||||
// never shifts what the user is currently looking at.
|
||||
const Workflow3 = dynamic(() => import("./Workflow3"), { ssr: false, loading: () => null });
|
||||
|
||||
export default function Workflow3Lazy() {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
setShow(true);
|
||||
io.disconnect();
|
||||
}
|
||||
},
|
||||
// Mount well before it enters the viewport so the canvas + GLBs preload
|
||||
// and ScrollTrigger is ready by the time the user reaches it.
|
||||
{ rootMargin: "150% 0px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
if (!show) {
|
||||
// Placeholder reserves a viewport of height; the real section (much taller)
|
||||
// expands below the fold once mounted, far from the user's current position.
|
||||
return <div ref={sentinelRef} aria-hidden style={{ minHeight: "100vh" }} />;
|
||||
}
|
||||
// `display: contents` so this wrapper adds no box of its own — Workflow3 keeps
|
||||
// its exact layout/seam with the section above it.
|
||||
return (
|
||||
<div ref={sentinelRef} style={{ display: "contents" }}>
|
||||
<Workflow3 />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useMemo, useRef } from "react";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { RoundedBox, Line, Sparkles, Html, useGLTF, ContactShadows, Environment, Lightformer } from "@react-three/drei";
|
||||
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { RoundedBox, Line, Sparkles, Html, useGLTF, ContactShadows, Environment, Lightformer, Preload } from "@react-three/drei";
|
||||
import { EffectComposer, Bloom } from "@react-three/postprocessing";
|
||||
import { KernelSize } from "postprocessing";
|
||||
import * as THREE from "three";
|
||||
@@ -26,6 +26,53 @@ const BLUE = "#3B82F6"; // 03 optimization
|
||||
const ORANGE = "#F59E0B"; // 04 grading
|
||||
const RED = "#C01227"; // 05 winner
|
||||
|
||||
/* ===========================================================================
|
||||
Dev metrics HUD — enable by appending ?perf to the URL. Samples the live
|
||||
WebGL renderer (draw calls, triangles, resident geometries/textures, linked
|
||||
shader programs) plus a rolling FPS, twice a second, into a fixed overlay.
|
||||
Zero cost when not enabled (component simply isn't mounted).
|
||||
=========================================================================== */
|
||||
function PerfHud() {
|
||||
const gl = useThree((s) => s.gl);
|
||||
const el = useRef<HTMLDivElement | null>(null);
|
||||
// Rolling FPS + one-shot timings: page navigation load, and time from this
|
||||
// canvas mounting to its first rendered frame (≈ Workflow-3 activation cost).
|
||||
const acc = useRef({ t: 0, frames: 0, mount: 0, firstFrame: 0, nav: 0 });
|
||||
useEffect(() => {
|
||||
const a = acc.current;
|
||||
a.mount = performance.now();
|
||||
const navEntry = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined;
|
||||
a.nav = navEntry ? Math.round(navEntry.loadEventEnd || navEntry.domContentLoadedEventEnd) : 0;
|
||||
const d = document.createElement("div");
|
||||
d.style.cssText =
|
||||
"position:fixed;top:10px;left:10px;z-index:99999;font:11px/1.5 ui-monospace,monospace;" +
|
||||
"background:rgba(0,0,0,.82);color:#39ff14;padding:8px 11px;border-radius:7px;white-space:pre;pointer-events:none";
|
||||
document.body.appendChild(d);
|
||||
el.current = d;
|
||||
return () => { d.remove(); };
|
||||
}, []);
|
||||
useFrame((_s, dt) => {
|
||||
const a = acc.current;
|
||||
if (!a.firstFrame && a.mount) a.firstFrame = performance.now() - a.mount;
|
||||
a.frames++; a.t += dt;
|
||||
if (a.t >= 0.5 && el.current) {
|
||||
const r = gl.info.render;
|
||||
const m = gl.info.memory;
|
||||
el.current.textContent =
|
||||
`FPS ${(a.frames / a.t).toFixed(0)}\n` +
|
||||
`draws ${r.calls}${r.calls < 100 ? " ✓" : " ⚠"}\n` +
|
||||
`triangles ${r.triangles.toLocaleString()}\n` +
|
||||
`geometries ${m.geometries}\n` +
|
||||
`textures ${m.textures}\n` +
|
||||
`programs ${gl.info.programs?.length ?? 0}\n` +
|
||||
`page load ${a.nav}ms\n` +
|
||||
`WF3 1st fr ${Math.round(a.firstFrame)}ms`;
|
||||
a.frames = 0; a.t = 0;
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
Camera — fully scroll-driven (reads a ref, never React state). Dollies forward
|
||||
through the world, riding behind+above the active district and looking slightly
|
||||
@@ -62,13 +109,19 @@ function CameraRig({ progress, reduced, isMobile }: { progress: React.RefObject<
|
||||
Shared helpers
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
/** Fade a district's drei <Html> chips in/out by camera proximity (ref-driven). */
|
||||
function useLabelFade(i: number, progress: React.RefObject<number>) {
|
||||
/** Fade a district's drei <Html> chips in/out by camera proximity (ref-driven).
|
||||
* No-ops entirely when the district is asleep (its labels are unmounted then). */
|
||||
function useLabelFade(i: number, progress: React.RefObject<number>, awake: boolean) {
|
||||
const labels = useRef<HTMLElement[]>([]);
|
||||
const register = (el: HTMLElement | null) => {
|
||||
if (el && !labels.current.includes(el)) labels.current.push(el);
|
||||
};
|
||||
useEffect(() => {
|
||||
// Labels are conditionally rendered, so their refs churn; drop stale ones each time.
|
||||
if (!awake) labels.current = [];
|
||||
}, [awake]);
|
||||
useFrame(() => {
|
||||
if (!awake) return;
|
||||
const idx = (progress.current ?? 0) * (N - 1);
|
||||
const op = THREE.MathUtils.clamp(1 - (Math.abs(idx - i) - 0.4) / 0.45, 0, 1);
|
||||
for (const el of labels.current) el.style.opacity = String(op);
|
||||
@@ -162,14 +215,14 @@ function buildModel(scene: THREE.Object3D, targetSize: number) {
|
||||
return { root, wheels };
|
||||
}
|
||||
|
||||
function GLBVehicle({ kind }: { kind: string }) {
|
||||
function GLBVehicle({ kind, awake = true }: { kind: string; awake?: boolean }) {
|
||||
const tune = VEH_TUNE[kind];
|
||||
const { scene } = useGLTF(MODELS[kind]);
|
||||
const { root } = useMemo(() => buildModel(scene, tune.size), [scene, tune]);
|
||||
const g = useRef<THREE.Group>(null);
|
||||
useFrame((state) => {
|
||||
const grp = g.current;
|
||||
if (!grp) return;
|
||||
if (!grp || !awake) return;
|
||||
const t = state.clock.elapsedTime;
|
||||
switch (kind) {
|
||||
case "bike": grp.position.y = Math.abs(Math.sin(t * 3)) * 0.025; grp.rotation.z = Math.sin(t * 3) * 0.012; break;
|
||||
@@ -195,7 +248,7 @@ const TRUCK_FWD_YAW = 0;
|
||||
* is smoothed with quaternion slerp (always faces travel, never snaps). Wheels spin in
|
||||
* proportion to distance travelled; a glow ring marks the active segment under the truck.
|
||||
*/
|
||||
function RouteTruck({ route, reduced, i, progress }: { route: THREE.CatmullRomCurve3; reduced: boolean; i: number; progress: React.RefObject<number> }) {
|
||||
function RouteTruck({ route, reduced, i, progress, awake = true }: { route: THREE.CatmullRomCurve3; reduced: boolean; i: number; progress: React.RefObject<number>; awake?: boolean }) {
|
||||
const { scene } = useGLTF(MODELS.truck);
|
||||
const built = useMemo(() => buildModel(scene, 0.85), [scene]);
|
||||
const wheels = useRef<THREE.Object3D[]>([]);
|
||||
@@ -207,7 +260,7 @@ function RouteTruck({ route, reduced, i, progress }: { route: THREE.CatmullRomCu
|
||||
const prevT = useRef(0);
|
||||
useFrame((state, dt) => {
|
||||
const grp = g.current;
|
||||
if (!grp) return;
|
||||
if (!grp || !awake) return;
|
||||
const idx = (progress.current ?? 0) * (N - 1);
|
||||
const t = THREE.MathUtils.clamp(idx - (i - 0.5), 0, 1); // 0 → hub … 1 → delivery
|
||||
const pos = route.getPointAt(t);
|
||||
@@ -268,22 +321,24 @@ function OrderPacket({ curve, offset }: { curve: THREE.Curve<THREE.Vector3>; off
|
||||
}
|
||||
|
||||
/** A vehicle parked on its own glowing route node (flat on the floor, no white pad). */
|
||||
function RiderAvatar({ rider, register }: { rider: typeof RIDERS[number]; register: (el: HTMLElement | null) => void }) {
|
||||
function RiderAvatar({ rider, register, awake }: { rider: typeof RIDERS[number]; register: (el: HTMLElement | null) => void; awake: boolean }) {
|
||||
return (
|
||||
<group position={[rider.x, 0, ROW_Z]}>
|
||||
<mesh position={[0, 0.014, 0]} rotation={[-Math.PI / 2, 0, 0]}><ringGeometry args={[0.52, 0.64, 36]} /><meshBasicMaterial color={GREEN} transparent opacity={0.6} side={THREE.DoubleSide} toneMapped={false} /></mesh>
|
||||
<mesh position={[0, 0.008, 0]} rotation={[-Math.PI / 2, 0, 0]}><circleGeometry args={[0.52, 36]} /><meshBasicMaterial color={GREEN} transparent opacity={0.08} side={THREE.DoubleSide} toneMapped={false} /></mesh>
|
||||
<group position={[0, 0.02, 0]}>
|
||||
<Suspense fallback={null}>
|
||||
<GLBVehicle kind={rider.kind} />
|
||||
<GLBVehicle kind={rider.kind} awake={awake} />
|
||||
</Suspense>
|
||||
</group>
|
||||
<Html center distanceFactor={9} position={[0, 1.5, 0]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-chip" ref={register}>
|
||||
<span className="dm-st3d-chip__ico">{rider.icon}</span>
|
||||
<span className="dm-st3d-chip__txt"><b>Rider {rider.id}</b>{rider.veh}</span>
|
||||
</div>
|
||||
</Html>
|
||||
{awake && (
|
||||
<Html center distanceFactor={9} position={[0, 1.5, 0]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-chip" ref={register}>
|
||||
<span className="dm-st3d-chip__ico">{rider.icon}</span>
|
||||
<span className="dm-st3d-chip__txt"><b>Rider {rider.id}</b>{rider.veh}</span>
|
||||
</div>
|
||||
</Html>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -291,11 +346,12 @@ function RiderAvatar({ rider, register }: { rider: typeof RIDERS[number]; regist
|
||||
const ORDERS_SRC: [number, number, number] = [3.3, 0.45, -0.7];
|
||||
const HUB_CORE: [number, number, number] = [0, 0.55, -1.4];
|
||||
|
||||
const IntakeHub = React.memo(function IntakeHub({ i, progress, reduced }: { i: number; progress: React.RefObject<number>; reduced: boolean }) {
|
||||
const register = useLabelFade(i, progress);
|
||||
const IntakeHub = React.memo(function IntakeHub({ i, progress, reduced, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean }) {
|
||||
const register = useLabelFade(i, progress, awake);
|
||||
const counter = useRef<HTMLSpanElement>(null);
|
||||
const halo = useRef<THREE.Mesh>(null);
|
||||
useFrame((state, dt) => {
|
||||
if (!awake) return;
|
||||
if (counter.current) {
|
||||
if (reduced) counter.current.textContent = "59";
|
||||
else {
|
||||
@@ -308,14 +364,11 @@ const IntakeHub = React.memo(function IntakeHub({ i, progress, reduced }: { i: n
|
||||
const intake = useMemo(() => lineGeom(ORDERS_SRC, HUB_CORE), []);
|
||||
const routes = useMemo(() => RIDERS.map((r) => lineGeom(HUB_CORE, [r.x, 0.12, ROW_Z])), []);
|
||||
return (
|
||||
<group position={districtPosition(i)}>
|
||||
<group position={districtPosition(i)} visible={awake}>
|
||||
{/* orders.csv source node → intake route → hub (order packets flowing in) */}
|
||||
<GlowNode position={ORDERS_SRC} color={GREEN} size={0.12} />
|
||||
<Line points={intake.getPoints(2)} color={GREEN} lineWidth={1.8} transparent opacity={0.5} toneMapped={false} />
|
||||
{!reduced && [0, 0.5].map((o) => <OrderPacket key={o} curve={intake} offset={o} />)}
|
||||
<Html center distanceFactor={9} position={[ORDERS_SRC[0], ORDERS_SRC[1] + 0.5, ORDERS_SRC[2]]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-tag" style={{ ["--tc" as string]: GREEN }} ref={register}>📄 orders.csv</div>
|
||||
</Html>
|
||||
{awake && !reduced && [0, 0.5].map((o) => <OrderPacket key={o} curve={intake} offset={o} />)}
|
||||
|
||||
{/* AI Assignment Hub — a grounded dispatch hub (rim ring + hex dais + glowing core + halo) */}
|
||||
<group position={[0, 0, -1.4]}>
|
||||
@@ -324,25 +377,33 @@ const IntakeHub = React.memo(function IntakeHub({ i, progress, reduced }: { i: n
|
||||
<mesh position={[0, 0.55, 0]}><icosahedronGeometry args={[0.32, 1]} /><meshStandardMaterial color={GREEN} emissive={GREEN} emissiveIntensity={1.2} toneMapped={false} flatShading /></mesh>
|
||||
<mesh ref={halo} position={[0, 0.55, 0]} rotation={[Math.PI / 2.4, 0, 0]}><torusGeometry args={[0.5, 0.025, 10, 40]} /><meshStandardMaterial color={GREEN} emissive={GREEN} emissiveIntensity={1} toneMapped={false} /></mesh>
|
||||
</group>
|
||||
<Html center distanceFactor={9} position={[0, 1.8, -1.4]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-count" ref={register}><span ref={counter}>0</span> Orders</div>
|
||||
</Html>
|
||||
<Html center distanceFactor={9} position={[0, 1.32, -1.4]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-tag" style={{ ["--tc" as string]: GREEN }} ref={register}>🤖 AI Assignment Hub</div>
|
||||
</Html>
|
||||
|
||||
{/* assignment routes hub → each vehicle node */}
|
||||
{routes.map((c, j) => (
|
||||
<group key={j}>
|
||||
<Line points={c.getPoints(2)} color={GREEN} lineWidth={1.6} transparent opacity={0.45} toneMapped={false} />
|
||||
{!reduced && <Packet curve={c} color={GREEN} speed={0.55} offset={j * 0.22} />}
|
||||
{awake && !reduced && <Packet curve={c} color={GREEN} speed={0.55} offset={j * 0.22} />}
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* soft contact shadow grounding the fleet (baked once for performance) */}
|
||||
<ContactShadows position={[0, 0.02, ROW_Z]} scale={[7.5, 2.6]} resolution={512} blur={2.6} far={1.2} opacity={0.4} frames={1} color="#1e293b" />
|
||||
|
||||
{RIDERS.map((r) => <RiderAvatar key={r.id} rider={r} register={register} />)}
|
||||
{RIDERS.map((r) => <RiderAvatar key={r.id} rider={r} register={register} awake={awake} />)}
|
||||
|
||||
{awake && (
|
||||
<>
|
||||
<Html center distanceFactor={9} position={[ORDERS_SRC[0], ORDERS_SRC[1] + 0.5, ORDERS_SRC[2]]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-tag" style={{ ["--tc" as string]: GREEN }} ref={register}>📄 orders.csv</div>
|
||||
</Html>
|
||||
<Html center distanceFactor={9} position={[0, 1.8, -1.4]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-count" ref={register}><span ref={counter}>0</span> Orders</div>
|
||||
</Html>
|
||||
<Html center distanceFactor={9} position={[0, 1.32, -1.4]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-tag" style={{ ["--tc" as string]: GREEN }} ref={register}>🤖 AI Assignment Hub</div>
|
||||
</Html>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
});
|
||||
@@ -361,12 +422,12 @@ const STRATS = [
|
||||
];
|
||||
const CORE: [number, number, number] = [0, 1.45, -2.7];
|
||||
|
||||
const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduced }: { i: number; progress: React.RefObject<number>; reduced: boolean }) {
|
||||
const register = useLabelFade(i, progress);
|
||||
const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduced, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean }) {
|
||||
const register = useLabelFade(i, progress, awake);
|
||||
const ring = useRef<THREE.Mesh>(null);
|
||||
const coreMesh = useRef<THREE.Mesh>(null);
|
||||
useFrame((state, dt) => {
|
||||
if (reduced) return;
|
||||
if (reduced || !awake) return;
|
||||
if (ring.current) ring.current.rotation.z += dt * 0.6;
|
||||
if (coreMesh.current) coreMesh.current.rotation.y += dt * 0.4;
|
||||
});
|
||||
@@ -381,7 +442,7 @@ const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduc
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<group position={districtPosition(i)}>
|
||||
<group position={districtPosition(i)} visible={awake}>
|
||||
{/* dark tech dais + glowing rim (reads as a control platform, not a white slab) */}
|
||||
<mesh position={[0, 0.06, -0.6]}><cylinderGeometry args={[5.4, 5.6, 0.12, 56]} /><meshStandardMaterial color="#161b30" metalness={0.5} roughness={0.42} emissive={PURPLE} emissiveIntensity={0.07} /></mesh>
|
||||
<mesh position={[0, 0.13, -0.6]} rotation={[-Math.PI / 2, 0, 0]}><ringGeometry args={[5.2, 5.42, 64]} /><meshBasicMaterial color={PURPLE} transparent opacity={0.4} side={THREE.DoubleSide} toneMapped={false} /></mesh>
|
||||
@@ -389,22 +450,26 @@ const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduc
|
||||
<mesh position={[0, 0.65, -2.7]}><cylinderGeometry args={[0.4, 0.62, 1.2, 24]} /><meshStandardMaterial color="#2a3350" metalness={0.5} roughness={0.4} /></mesh>
|
||||
<mesh ref={coreMesh} position={CORE}><icosahedronGeometry args={[0.6, 1]} /><meshStandardMaterial color={PURPLE} emissive={PURPLE} emissiveIntensity={1.3} toneMapped={false} flatShading /></mesh>
|
||||
<mesh ref={ring} position={CORE} rotation={[Math.PI / 2.4, 0, 0]}><torusGeometry args={[0.98, 0.03, 10, 44]} /><meshStandardMaterial color={PURPLE} emissive={PURPLE} emissiveIntensity={1.1} toneMapped={false} /></mesh>
|
||||
<Html center distanceFactor={9} position={[0, 2.5, -2.7]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-tag" style={{ ["--tc" as string]: PURPLE }} ref={register}>🤖 AI Engine</div>
|
||||
</Html>
|
||||
{awake && (
|
||||
<Html center distanceFactor={9} position={[0, 2.5, -2.7]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-tag" style={{ ["--tc" as string]: PURPLE }} ref={register}>🤖 AI Engine</div>
|
||||
</Html>
|
||||
)}
|
||||
|
||||
{lanes.map((l, j) => {
|
||||
const col = l.unified ? PURPLE : "#a99bd6";
|
||||
return (
|
||||
<group key={l.name}>
|
||||
<Line points={l.curve.getPoints(2)} color={col} lineWidth={l.unified ? 2.4 : 1.6} transparent opacity={l.unified ? 0.7 : 0.5} toneMapped={false} />
|
||||
{!reduced && <Packet curve={l.curve} color={col} speed={0.5} offset={j * 0.16} size={l.unified ? 0.075 : 0.06} />}
|
||||
{awake && !reduced && <Packet curve={l.curve} color={col} speed={0.5} offset={j * 0.16} size={l.unified ? 0.075 : 0.06} />}
|
||||
{/* flat glowing route node (consistent with the dispatch network) */}
|
||||
<mesh position={[l.end[0], 0.135, l.end[2]]} rotation={[-Math.PI / 2, 0, 0]}><ringGeometry args={[0.34, 0.44, 28]} /><meshBasicMaterial color={col} transparent opacity={l.unified ? 0.6 : 0.4} side={THREE.DoubleSide} toneMapped={false} /></mesh>
|
||||
<GlowNode position={l.end} color={col} size={l.unified ? 0.15 : 0.11} />
|
||||
<Html center distanceFactor={9} position={[l.end[0], l.end[1] + 0.62, l.end[2]]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className={`dm-st3d-tag ${l.unified ? "is-u" : "is-muted"}`} style={{ ["--tc" as string]: l.unified ? PURPLE : "#94a3b8" }} ref={register}>{l.name}</div>
|
||||
</Html>
|
||||
{awake && (
|
||||
<Html center distanceFactor={9} position={[l.end[0], l.end[1] + 0.62, l.end[2]]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className={`dm-st3d-tag ${l.unified ? "is-u" : "is-muted"}`} style={{ ["--tc" as string]: l.unified ? PURPLE : "#94a3b8" }} ref={register}>{l.name}</div>
|
||||
</Html>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
@@ -467,8 +532,8 @@ function DeliveryBadge({ pos, i, progress }: { pos: [number, number, number]; i:
|
||||
);
|
||||
}
|
||||
|
||||
const CityRouteMap = React.memo(function CityRouteMap({ i, progress, reduced }: { i: number; progress: React.RefObject<number>; reduced: boolean }) {
|
||||
const register = useLabelFade(i, progress);
|
||||
const CityRouteMap = React.memo(function CityRouteMap({ i, progress, reduced, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean }) {
|
||||
const register = useLabelFade(i, progress, awake);
|
||||
// OPEN delivery path (Dispatch Hub → … → Delivery). Static "road"; only the truck moves.
|
||||
const route = useMemo(() => {
|
||||
const pts = [DEPOT3, ...DEST].map(([x, z]) => new THREE.Vector3(x, 0.12, z));
|
||||
@@ -477,11 +542,11 @@ const CityRouteMap = React.memo(function CityRouteMap({ i, progress, reduced }:
|
||||
const routePts = useMemo(() => route.getPoints(100), [route]);
|
||||
const delivery = DEST[DEST.length - 1];
|
||||
return (
|
||||
<group position={districtPosition(i)}>
|
||||
<group position={districtPosition(i)} visible={awake}>
|
||||
{/* STATIC delivery route (the road) — no flowing lines; the truck is the only mover */}
|
||||
<Line points={routePts} color={BLUE} lineWidth={2.6} transparent opacity={0.8} toneMapped={false} />
|
||||
<Suspense fallback={null}>
|
||||
<RouteTruck route={route} reduced={reduced} i={i} progress={progress} />
|
||||
<RouteTruck route={route} reduced={reduced} i={i} progress={progress} awake={awake} />
|
||||
</Suspense>
|
||||
|
||||
{/* dispatch hub (static) */}
|
||||
@@ -490,41 +555,46 @@ const CityRouteMap = React.memo(function CityRouteMap({ i, progress, reduced }:
|
||||
<mesh position={[0, 0.13, 0]}><cylinderGeometry args={[0.5, 0.58, 0.26, 6]} /><meshStandardMaterial color="#0f2036" emissive={BLUE} emissiveIntensity={0.15} metalness={0.4} roughness={0.45} /></mesh>
|
||||
<GlowNode position={[0, 0.52, 0]} color={BLUE} size={0.14} />
|
||||
</group>
|
||||
<Html center distanceFactor={9} position={[DEPOT3[0], 1.05, DEPOT3[1]]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-tag" style={{ ["--tc" as string]: BLUE }} ref={register}>🏢 Dispatch Hub</div>
|
||||
</Html>
|
||||
|
||||
{/* route nodes + delivery destination */}
|
||||
{/* route nodes + delivery destination (static geometry; labels gated below) */}
|
||||
{DEST.map(([x, z], j) => {
|
||||
const label = DEST_LABELS[j];
|
||||
const isDelivery = j === DEST.length - 1;
|
||||
return (
|
||||
<group key={j} position={[x, 0, z]}>
|
||||
<mesh position={[0, 0.02, 0]} rotation={[-Math.PI / 2, 0, 0]}><ringGeometry args={[0.16, 0.23, 20]} /><meshBasicMaterial color={BLUE} transparent opacity={0.55} side={THREE.DoubleSide} toneMapped={false} /></mesh>
|
||||
<mesh position={[0, 0.3, 0]}><cylinderGeometry args={[0.03, 0.03, 0.6, 8]} /><meshStandardMaterial color={BLUE} emissive={BLUE} emissiveIntensity={0.4} /></mesh>
|
||||
<GlowNode position={[0, 0.66, 0]} color={BLUE} size={isDelivery ? 0.16 : 0.12} />
|
||||
{label && (
|
||||
<Html center distanceFactor={9} position={[0, 1.06, 0]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-tag" style={{ ["--tc" as string]: BLUE }} ref={register}>{label}</div>
|
||||
</Html>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* delivery arrival pulse + completion badge (only when the truck arrives) */}
|
||||
<DeliveryPulse pos={[delivery[0], 0.02, delivery[1]]} i={i} progress={progress} />
|
||||
<DeliveryBadge pos={[delivery[0], 1.42, delivery[1]]} i={i} progress={progress} />
|
||||
{/* delivery arrival pulse (only mounted/animated while this district is active) */}
|
||||
{awake && <DeliveryPulse pos={[delivery[0], 0.02, delivery[1]]} i={i} progress={progress} />}
|
||||
|
||||
{/* validation overlays + optimization result */}
|
||||
{VALIDATIONS.map((v) => (
|
||||
<Html key={v.t} center distanceFactor={9} position={v.p} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-tag" style={{ ["--tc" as string]: BLUE }} ref={register}>✓ {v.t}</div>
|
||||
</Html>
|
||||
))}
|
||||
<Html center distanceFactor={9} position={[0, 3.2, -0.4]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-score" style={{ ["--tc" as string]: BLUE }} ref={register}>🗺️ Route optimized · <b>−18%</b> distance</div>
|
||||
</Html>
|
||||
{awake && (
|
||||
<>
|
||||
<Html center distanceFactor={9} position={[DEPOT3[0], 1.05, DEPOT3[1]]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-tag" style={{ ["--tc" as string]: BLUE }} ref={register}>🏢 Dispatch Hub</div>
|
||||
</Html>
|
||||
{DEST.map(([x, z], j) => {
|
||||
const label = DEST_LABELS[j];
|
||||
return label ? (
|
||||
<Html key={j} center distanceFactor={9} position={[x, 1.06, z]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-tag" style={{ ["--tc" as string]: BLUE }} ref={register}>{label}</div>
|
||||
</Html>
|
||||
) : null;
|
||||
})}
|
||||
<DeliveryBadge pos={[delivery[0], 1.42, delivery[1]]} i={i} progress={progress} />
|
||||
{VALIDATIONS.map((v) => (
|
||||
<Html key={v.t} center distanceFactor={9} position={v.p} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-tag" style={{ ["--tc" as string]: BLUE }} ref={register}>✓ {v.t}</div>
|
||||
</Html>
|
||||
))}
|
||||
<Html center distanceFactor={9} position={[0, 3.2, -0.4]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-score" style={{ ["--tc" as string]: BLUE }} ref={register}>🗺️ Route optimized · <b>−18%</b> distance</div>
|
||||
</Html>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
});
|
||||
@@ -540,8 +610,8 @@ const KPIS = [
|
||||
{ n: "Battery", v: 100, a: 0.66 },
|
||||
];
|
||||
|
||||
const CommandCenter = React.memo(function CommandCenter({ i, progress }: { i: number; progress: React.RefObject<number> }) {
|
||||
const register = useLabelFade(i, progress);
|
||||
const CommandCenter = React.memo(function CommandCenter({ i, progress, awake }: { i: number; progress: React.RefObject<number>; awake: boolean }) {
|
||||
const register = useLabelFade(i, progress, awake);
|
||||
const screens = useMemo(
|
||||
() =>
|
||||
KPIS.map((k) => {
|
||||
@@ -552,7 +622,7 @@ const CommandCenter = React.memo(function CommandCenter({ i, progress }: { i: nu
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<group position={districtPosition(i)}>
|
||||
<group position={districtPosition(i)} visible={awake}>
|
||||
<mesh position={[0, 0.45, 1.4]} rotation={[-0.12, 0, 0]}><boxGeometry args={[5.6, 0.18, 1.5]} /><meshStandardMaterial color="#dfe4f1" metalness={0.4} roughness={0.4} /></mesh>
|
||||
<mesh position={[0, 0.12, 0.9]}><boxGeometry args={[5.2, 0.24, 0.6]} /><meshStandardMaterial color="#cdd5e6" metalness={0.3} roughness={0.5} /></mesh>
|
||||
|
||||
@@ -561,19 +631,23 @@ const CommandCenter = React.memo(function CommandCenter({ i, progress }: { i: nu
|
||||
<RoundedBox args={[1.7, 1.15, 0.06]} radius={0.06} smoothness={2}>
|
||||
<meshStandardMaterial color="#ffffff" emissive={ORANGE} emissiveIntensity={0.18} metalness={0.1} roughness={0.4} transparent opacity={0.92} />
|
||||
</RoundedBox>
|
||||
<Html center distanceFactor={8} position={[0, 0, 0.05]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-kpi" style={{ ["--tc" as string]: ORANGE }} ref={register}>
|
||||
<span className="dm-st3d-kpi__n">{s.n}</span>
|
||||
<span className="dm-st3d-kpi__v">{s.v}<i>%</i></span>
|
||||
<span className="dm-st3d-kpi__bar"><i style={{ width: `${s.v}%` }} /></span>
|
||||
</div>
|
||||
</Html>
|
||||
{awake && (
|
||||
<Html center distanceFactor={8} position={[0, 0, 0.05]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-kpi" style={{ ["--tc" as string]: ORANGE }} ref={register}>
|
||||
<span className="dm-st3d-kpi__n">{s.n}</span>
|
||||
<span className="dm-st3d-kpi__v">{s.v}<i>%</i></span>
|
||||
<span className="dm-st3d-kpi__bar"><i style={{ width: `${s.v}%` }} /></span>
|
||||
</div>
|
||||
</Html>
|
||||
)}
|
||||
</group>
|
||||
))}
|
||||
|
||||
<Html center distanceFactor={9} position={[0, 3.1, -0.4]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-score" style={{ ["--tc" as string]: ORANGE }} ref={register}>Performance Grade <b>A</b> · 4.5 / 5</div>
|
||||
</Html>
|
||||
{awake && (
|
||||
<Html center distanceFactor={9} position={[0, 3.1, -0.4]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-score" style={{ ["--tc" as string]: ORANGE }} ref={register}>Performance Grade <b>A</b> · 4.5 / 5</div>
|
||||
</Html>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
});
|
||||
@@ -589,7 +663,7 @@ const PODIUM = [
|
||||
{ n: "Proximity", v: 64, x: 2.4, win: false },
|
||||
];
|
||||
|
||||
function Pillar({ p, register }: { p: typeof PODIUM[number]; register: (el: HTMLElement | null) => void }) {
|
||||
function Pillar({ p, register, awake }: { p: typeof PODIUM[number]; register: (el: HTMLElement | null) => void; awake: boolean }) {
|
||||
const h = 0.6 + (p.v / 100) * 2.2;
|
||||
const col = p.win ? RED : "#94a3b8";
|
||||
return (
|
||||
@@ -598,30 +672,32 @@ function Pillar({ p, register }: { p: typeof PODIUM[number]; register: (el: HTML
|
||||
<boxGeometry args={[1.0, h, 1.0]} />
|
||||
<meshStandardMaterial color={col} emissive={col} emissiveIntensity={p.win ? 0.7 : 0.15} metalness={0.2} roughness={0.4} transparent opacity={p.win ? 0.96 : 0.7} toneMapped={false} />
|
||||
</mesh>
|
||||
<Html center distanceFactor={9} position={[0, h + 0.45, 0]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className={`dm-st3d-tag ${p.win ? "is-win" : "is-muted"}`} style={{ ["--tc" as string]: col }} ref={register}><b>{p.v}%</b> {p.n}</div>
|
||||
</Html>
|
||||
{awake && (
|
||||
<Html center distanceFactor={9} position={[0, h + 0.45, 0]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className={`dm-st3d-tag ${p.win ? "is-win" : "is-muted"}`} style={{ ["--tc" as string]: col }} ref={register}><b>{p.v}%</b> {p.n}</div>
|
||||
</Html>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, isMobile }: { i: number; progress: React.RefObject<number>; reduced: boolean; isMobile: boolean }) {
|
||||
const register = useLabelFade(i, progress);
|
||||
const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, isMobile, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; isMobile: boolean; awake: boolean }) {
|
||||
const register = useLabelFade(i, progress, awake);
|
||||
const trophy = useRef<THREE.Group>(null);
|
||||
useFrame((state) => {
|
||||
if (trophy.current && !reduced) {
|
||||
if (trophy.current && !reduced && awake) {
|
||||
const winH = 0.6 + (88 / 100) * 2.2;
|
||||
trophy.current.position.y = winH + 0.95 + Math.sin(state.clock.elapsedTime * 1.4) * 0.08;
|
||||
trophy.current.rotation.y += 0.01;
|
||||
}
|
||||
});
|
||||
return (
|
||||
<group position={districtPosition(i)}>
|
||||
<group position={districtPosition(i)} visible={awake}>
|
||||
<mesh position={[0, 0.05, -0.6]}><cylinderGeometry args={[4.3, 4.5, 0.12, 48]} /><meshStandardMaterial color="#1d1622" metalness={0.5} roughness={0.42} emissive={RED} emissiveIntensity={0.06} /></mesh>
|
||||
<mesh position={[0, 0.12, -0.6]} rotation={[-Math.PI / 2, 0, 0]}><ringGeometry args={[4.1, 4.32, 56]} /><meshBasicMaterial color={RED} transparent opacity={0.35} side={THREE.DoubleSide} toneMapped={false} /></mesh>
|
||||
<mesh position={[-2.4, 0.14, -0.6]}><cylinderGeometry args={[0.85, 0.9, 0.14, 28]} /><meshStandardMaterial color={RED} emissive={RED} emissiveIntensity={0.9} toneMapped={false} /></mesh>
|
||||
|
||||
{PODIUM.map((p) => <Pillar key={p.n} p={p} register={register} />)}
|
||||
{PODIUM.map((p) => <Pillar key={p.n} p={p} register={register} awake={awake} />)}
|
||||
|
||||
<mesh position={[-2.4, 3.0, -0.6]}><coneGeometry args={[1.0, 2.6, 28, 1, true]} /><meshBasicMaterial color={RED} transparent opacity={0.07} side={THREE.DoubleSide} toneMapped={false} /></mesh>
|
||||
<group ref={trophy} position={[-2.4, 3.5, -0.6]}>
|
||||
@@ -630,16 +706,18 @@ const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, is
|
||||
<mesh position={[0, -0.22, 0]}><cylinderGeometry args={[0.18, 0.22, 0.1, 16]} /><meshStandardMaterial color="#FFD45A" emissive="#FFB020" emissiveIntensity={1} toneMapped={false} /></mesh>
|
||||
</group>
|
||||
|
||||
{!reduced && <Sparkles count={isMobile ? 16 : 28} scale={[5, 4, 4]} position={[-2.4, 2.6, -0.6]} size={3.2} speed={0.5} opacity={0.9} color="#ff9aa9" />}
|
||||
{awake && !reduced && <Sparkles count={isMobile ? 16 : 28} scale={[5, 4, 4]} position={[-2.4, 2.6, -0.6]} size={3.2} speed={0.5} opacity={0.9} color="#ff9aa9" />}
|
||||
|
||||
<Html center distanceFactor={9} position={[0.7, 3.0, -0.6]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-winner3d" ref={register}>
|
||||
<span className="dm-st3d-winner3d__top">🏆 Best Strategy</span>
|
||||
<span className="dm-st3d-winner3d__name">EV Aware</span>
|
||||
<span className="dm-st3d-winner3d__row"><b>88%</b> Performance Score</span>
|
||||
<span className="dm-st3d-winner3d__row"><b>52/59</b> Orders Fulfilled</span>
|
||||
</div>
|
||||
</Html>
|
||||
{awake && (
|
||||
<Html center distanceFactor={9} position={[0.7, 3.0, -0.6]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-winner3d" ref={register}>
|
||||
<span className="dm-st3d-winner3d__top">🏆 Best Strategy</span>
|
||||
<span className="dm-st3d-winner3d__name">EV Aware</span>
|
||||
<span className="dm-st3d-winner3d__row"><b>88%</b> Performance Score</span>
|
||||
<span className="dm-st3d-winner3d__row"><b>52/59</b> Orders Fulfilled</span>
|
||||
</div>
|
||||
</Html>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
});
|
||||
@@ -704,8 +782,21 @@ function Floor() {
|
||||
);
|
||||
}
|
||||
|
||||
function Scene({ progress, reduced, isMobile, stage }: { progress: React.RefObject<number>; reduced: boolean; isMobile: boolean; stage: number }) {
|
||||
const near = (i: number) => Math.abs(i - stage) <= 1; // only mount active ± 1 district
|
||||
function Scene({ progress, reduced, isMobile, stage, active, perf }: { progress: React.RefObject<number>; reduced: boolean; isMobile: boolean; stage: number; active: boolean; perf: boolean }) {
|
||||
// Districts stay MOUNTED for the whole scroll (GLB cloned once, shaders compiled
|
||||
// once → no remount hitch, no blank frames). `near` only toggles per-district
|
||||
// visibility + per-frame work + <Html> mounting, so off-screen districts render
|
||||
// nothing and run zero per-frame CPU/DOM cost (only the active ±1 stays awake).
|
||||
const near = (i: number) => Math.abs(i - stage) <= 1;
|
||||
|
||||
// Defer the expensive post-processing + ambient particles until the section is
|
||||
// actually active for the first time, then latch them on. This keeps Bloom's
|
||||
// render targets and the Sparkles buffers from being allocated during initial
|
||||
// page load / while WF3 is still below the fold — without re-allocating them
|
||||
// (and hitching) every time the user scrolls past and back.
|
||||
const [everActive, setEverActive] = useState(false);
|
||||
useEffect(() => { if (active) setEverActive(true); }, [active]);
|
||||
const heavyFx = everActive && !reduced && !isMobile;
|
||||
return (
|
||||
<>
|
||||
<color attach="background" args={[BG]} />
|
||||
@@ -727,26 +818,33 @@ function Scene({ progress, reduced, isMobile, stage }: { progress: React.RefObje
|
||||
<Floor />
|
||||
<DataRoad />
|
||||
|
||||
{near(0) && <IntakeHub i={0} progress={progress} reduced={reduced} />}
|
||||
{near(1) && <StrategyNetwork i={1} progress={progress} reduced={reduced} />}
|
||||
{near(2) && <CityRouteMap i={2} progress={progress} reduced={reduced} />}
|
||||
{near(3) && <CommandCenter i={3} progress={progress} />}
|
||||
{near(4) && <WinnerPodium i={4} progress={progress} reduced={reduced} isMobile={isMobile} />}
|
||||
<IntakeHub i={0} progress={progress} reduced={reduced} awake={near(0)} />
|
||||
<StrategyNetwork i={1} progress={progress} reduced={reduced} awake={near(1)} />
|
||||
<CityRouteMap i={2} progress={progress} reduced={reduced} awake={near(2)} />
|
||||
<CommandCenter i={3} progress={progress} awake={near(3)} />
|
||||
<WinnerPodium i={4} progress={progress} reduced={reduced} isMobile={isMobile} awake={near(4)} />
|
||||
|
||||
{!reduced && !isMobile && (
|
||||
{heavyFx && (
|
||||
<Sparkles count={30} scale={[18, 7, N * 13]} position={[0, 3, (-(N - 1) * 13) / 2]} size={2} speed={0.22} opacity={0.35} color="#9aa6c4" />
|
||||
)}
|
||||
|
||||
{!reduced && !isMobile && (
|
||||
{heavyFx && (
|
||||
<EffectComposer multisampling={0}>
|
||||
<Bloom mipmapBlur intensity={0.7} luminanceThreshold={0.74} luminanceSmoothing={0.06} radius={0.68} kernelSize={KernelSize.SMALL} />
|
||||
</EffectComposer>
|
||||
)}
|
||||
|
||||
{/* Compile every material/texture upfront (districts are all mounted) so the
|
||||
first time a district becomes visible there's no shader-compile stall. */}
|
||||
<Preload all />
|
||||
{perf && <PerfHud />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyCanvas({ progress, reduced = false, isMobile = false, active = true, stage = 0 }: Props) {
|
||||
// Opt-in dev metrics overlay: append ?perf to the URL.
|
||||
const perf = typeof window !== "undefined" && new URLSearchParams(window.location.search).has("perf");
|
||||
return (
|
||||
<Canvas
|
||||
dpr={[1, isMobile ? 1 : 1.25]}
|
||||
@@ -754,7 +852,7 @@ export default function StrategyCanvas({ progress, reduced = false, isMobile = f
|
||||
gl={{ antialias: false, powerPreference: "high-performance", alpha: false }}
|
||||
frameloop={active ? "always" : "never"}
|
||||
>
|
||||
<Scene progress={progress} reduced={reduced} isMobile={isMobile} stage={stage} />
|
||||
<Scene progress={progress} reduced={reduced} isMobile={isMobile} stage={stage} active={active} perf={perf} />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user