diff --git a/public/models/optimized/auto_rickshaw.glb b/public/models/optimized/auto_rickshaw.glb new file mode 100644 index 0000000..ec6bf6a Binary files /dev/null and b/public/models/optimized/auto_rickshaw.glb differ diff --git a/public/models/optimized/scooter.glb b/public/models/optimized/scooter.glb new file mode 100644 index 0000000..19f3fb4 Binary files /dev/null and b/public/models/optimized/scooter.glb differ diff --git a/public/models/optimized/truck.glb b/public/models/optimized/truck.glb new file mode 100644 index 0000000..5890358 Binary files /dev/null and b/public/models/optimized/truck.glb differ diff --git a/public/models/optimized/van.glb b/public/models/optimized/van.glb new file mode 100644 index 0000000..42b939c Binary files /dev/null and b/public/models/optimized/van.glb differ diff --git a/src/animations/SmoothScroll.tsx b/src/animations/SmoothScroll.tsx index 92114c0..cd9da50 100644 --- a/src/animations/SmoothScroll.tsx +++ b/src/animations/SmoothScroll.tsx @@ -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(); diff --git a/src/app/globals.css b/src/app/globals.css index d601be2..b8f6b34 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4d3686d..97a7ea3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -63,7 +63,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + {/* FontAwesome icons */} - + diff --git a/src/components/layout/LoadingScreen.tsx b/src/components/layout/LoadingScreen.tsx index 8e0ff19..af411a4 100644 --- a/src/components/layout/LoadingScreen.tsx +++ b/src/components/layout/LoadingScreen.tsx @@ -83,6 +83,7 @@ export default function LoadingScreen() { height={38} priority className="dm-loader__logo" + style={{ height: "auto" }} /> diff --git a/src/components/logisticsbrain/LogisticsBrainSection.tsx b/src/components/logisticsbrain/LogisticsBrainSection.tsx index 2a86156..4f531cb 100644 --- a/src/components/logisticsbrain/LogisticsBrainSection.tsx +++ b/src/components/logisticsbrain/LogisticsBrainSection.tsx @@ -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 }) { - const [v, setV] = useState(0); - useMotionValueEvent(mv, "change", (x) => setV(Math.round(x))); - return <>{v}; + const ref = useRef(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 {Math.round(mv.get())}; } /** Active step index from scroll progress (−1 before the engine starts). */ diff --git a/src/components/optimization/OptimizationSection.tsx b/src/components/optimization/OptimizationSection.tsx index 166d63e..caaaa4e 100644 --- a/src/components/optimization/OptimizationSection.tsx +++ b/src/components/optimization/OptimizationSection.tsx @@ -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({ diff --git a/src/components/optimization/VehicleFleet.tsx b/src/components/optimization/VehicleFleet.tsx index 18833d7..8197f7c 100644 --- a/src/components/optimization/VehicleFleet.tsx +++ b/src/components/optimization/VehicleFleet.tsx @@ -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; }} > - + ))} {/* Optimized fleet — the truck art in clean white */} @@ -260,7 +290,12 @@ function VehicleFleet({ progress, reduced = false }: Props) { optRefs.current[i] = el; }} > - + ))} @@ -290,5 +325,3 @@ function VehicleFleet({ progress, reduced = false }: Props) { } export default React.memo(VehicleFleet); - - diff --git a/src/components/sections/Workflow3Lazy.tsx b/src/components/sections/Workflow3Lazy.tsx new file mode 100644 index 0000000..80fa939 --- /dev/null +++ b/src/components/sections/Workflow3Lazy.tsx @@ -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(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
; + } + // `display: contents` so this wrapper adds no box of its own — Workflow3 keeps + // its exact layout/seam with the section above it. + return ( +
+ +
+ ); +} diff --git a/src/components/strategy/StrategyCanvas.tsx b/src/components/strategy/StrategyCanvas.tsx index 277a41e..424fe32 100644 --- a/src/components/strategy/StrategyCanvas.tsx +++ b/src/components/strategy/StrategyCanvas.tsx @@ -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(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 chips in/out by camera proximity (ref-driven). */ -function useLabelFade(i: number, progress: React.RefObject) { +/** Fade a district's drei 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, awake: boolean) { const labels = useRef([]); 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(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 }) { +function RouteTruck({ route, reduced, i, progress, awake = true }: { route: THREE.CatmullRomCurve3; reduced: boolean; i: number; progress: React.RefObject; awake?: boolean }) { const { scene } = useGLTF(MODELS.truck); const built = useMemo(() => buildModel(scene, 0.85), [scene]); const wheels = useRef([]); @@ -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; 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 ( - + - -
- {rider.icon} - Rider {rider.id}{rider.veh} -
- + {awake && ( + +
+ {rider.icon} + Rider {rider.id}{rider.veh} +
+ + )}
); } @@ -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; reduced: boolean }) { - const register = useLabelFade(i, progress); +const IntakeHub = React.memo(function IntakeHub({ i, progress, reduced, awake }: { i: number; progress: React.RefObject; reduced: boolean; awake: boolean }) { + const register = useLabelFade(i, progress, awake); const counter = useRef(null); const halo = useRef(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 ( - + {/* orders.csv source node → intake route → hub (order packets flowing in) */} - {!reduced && [0, 0.5].map((o) => )} - -
📄 orders.csv
- + {awake && !reduced && [0, 0.5].map((o) => )} {/* AI Assignment Hub — a grounded dispatch hub (rim ring + hex dais + glowing core + halo) */} @@ -324,25 +377,33 @@ const IntakeHub = React.memo(function IntakeHub({ i, progress, reduced }: { i: n - -
0 Orders
- - -
🤖 AI Assignment Hub
- {/* assignment routes hub → each vehicle node */} {routes.map((c, j) => ( - {!reduced && } + {awake && !reduced && } ))} {/* soft contact shadow grounding the fleet (baked once for performance) */} - {RIDERS.map((r) => )} + {RIDERS.map((r) => )} + + {awake && ( + <> + +
📄 orders.csv
+ + +
0 Orders
+ + +
🤖 AI Assignment Hub
+ + + )}
); }); @@ -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; reduced: boolean }) { - const register = useLabelFade(i, progress); +const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduced, awake }: { i: number; progress: React.RefObject; reduced: boolean; awake: boolean }) { + const register = useLabelFade(i, progress, awake); const ring = useRef(null); const coreMesh = useRef(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 ( - + {/* dark tech dais + glowing rim (reads as a control platform, not a white slab) */} @@ -389,22 +450,26 @@ const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduc - -
🤖 AI Engine
- + {awake && ( + +
🤖 AI Engine
+ + )} {lanes.map((l, j) => { const col = l.unified ? PURPLE : "#a99bd6"; return ( - {!reduced && } + {awake && !reduced && } {/* flat glowing route node (consistent with the dispatch network) */} - -
{l.name}
- + {awake && ( + +
{l.name}
+ + )}
); })} @@ -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; reduced: boolean }) { - const register = useLabelFade(i, progress); +const CityRouteMap = React.memo(function CityRouteMap({ i, progress, reduced, awake }: { i: number; progress: React.RefObject; 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 ( - + {/* STATIC delivery route (the road) — no flowing lines; the truck is the only mover */} - + {/* dispatch hub (static) */} @@ -490,41 +555,46 @@ const CityRouteMap = React.memo(function CityRouteMap({ i, progress, reduced }: - -
🏢 Dispatch Hub
- - {/* 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 ( - {label && ( - -
{label}
- - )}
); })} - {/* delivery arrival pulse + completion badge (only when the truck arrives) */} - - + {/* delivery arrival pulse (only mounted/animated while this district is active) */} + {awake && } - {/* validation overlays + optimization result */} - {VALIDATIONS.map((v) => ( - -
✓ {v.t}
- - ))} - -
🗺️ Route optimized · −18% distance
- + {awake && ( + <> + +
🏢 Dispatch Hub
+ + {DEST.map(([x, z], j) => { + const label = DEST_LABELS[j]; + return label ? ( + +
{label}
+ + ) : null; + })} + + {VALIDATIONS.map((v) => ( + +
✓ {v.t}
+ + ))} + +
🗺️ Route optimized · −18% distance
+ + + )}
); }); @@ -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 }) { - const register = useLabelFade(i, progress); +const CommandCenter = React.memo(function CommandCenter({ i, progress, awake }: { i: number; progress: React.RefObject; 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 ( - + @@ -561,19 +631,23 @@ const CommandCenter = React.memo(function CommandCenter({ i, progress }: { i: nu - -
- {s.n} - {s.v}% - -
- + {awake && ( + +
+ {s.n} + {s.v}% + +
+ + )}
))} - -
Performance Grade A · 4.5 / 5
- + {awake && ( + +
Performance Grade A · 4.5 / 5
+ + )}
); }); @@ -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 - -
{p.v}% {p.n}
- + {awake && ( + +
{p.v}% {p.n}
+ + )}
); } -const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, isMobile }: { i: number; progress: React.RefObject; 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; reduced: boolean; isMobile: boolean; awake: boolean }) { + const register = useLabelFade(i, progress, awake); const trophy = useRef(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 ( - + - {PODIUM.map((p) => )} + {PODIUM.map((p) => )} @@ -630,16 +706,18 @@ const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, is - {!reduced && } + {awake && !reduced && } - -
- 🏆 Best Strategy - EV Aware - 88% Performance Score - 52/59 Orders Fulfilled -
- + {awake && ( + +
+ 🏆 Best Strategy + EV Aware + 88% Performance Score + 52/59 Orders Fulfilled +
+ + )}
); }); @@ -704,8 +782,21 @@ function Floor() { ); } -function Scene({ progress, reduced, isMobile, stage }: { progress: React.RefObject; 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; 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 + 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 ( <> @@ -727,26 +818,33 @@ function Scene({ progress, reduced, isMobile, stage }: { progress: React.RefObje - {near(0) && } - {near(1) && } - {near(2) && } - {near(3) && } - {near(4) && } + + + + + - {!reduced && !isMobile && ( + {heavyFx && ( )} - {!reduced && !isMobile && ( + {heavyFx && ( )} + + {/* Compile every material/texture upfront (districts are all mounted) so the + first time a district becomes visible there's no shader-compile stall. */} + + {perf && } ); } 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 ( - + ); }