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 (
-
+
-
-