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
|
* SmoothScroll
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
* One global Lenis instance, driven by a SINGLE rAF source (GSAP's ticker) and
|
* One global Lenis instance, driven by a SINGLE rAF source (GSAP's ticker) and
|
||||||
* kept locked to ScrollTrigger. Deliberately gated OFF on:
|
* kept locked to ScrollTrigger via `lenis.on("scroll", ScrollTrigger.update)`.
|
||||||
* - /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
|
* Enabled on every route. /miletruth's three scroll-driven WebGL sections use a
|
||||||
* scroll is used on that route.
|
* 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.
|
* - touch devices — native momentum is smoother than emulated inertia.
|
||||||
* - prefers-reduced-motion.
|
* - prefers-reduced-motion.
|
||||||
*
|
*
|
||||||
* Re-evaluates on every route change: the effect cleanup destroys the previous
|
* 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() {
|
export default function SmoothScroll() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Lenis global smooth scroll (src/animations/SmoothScroll.tsx). These classes are
|
/* Lenis global smooth scroll (src/animations/SmoothScroll.tsx). These classes are
|
||||||
only present on routes/devices where Lenis is active; on /miletruth and touch
|
only present on routes/devices where Lenis is active; on touch devices and with
|
||||||
devices Lenis is off and native scroll-behavior:smooth (above) applies. */
|
prefers-reduced-motion Lenis is off and native scroll-behavior:smooth (above) applies. */
|
||||||
html.lenis,
|
html.lenis,
|
||||||
html.lenis body {
|
html.lenis body {
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
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>
|
<head>
|
||||||
{/* FontAwesome icons */}
|
{/* FontAwesome icons */}
|
||||||
<link
|
<link
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import MileTruthHero from "../../components/sections/MileTruthHero";
|
import MileTruthHero from "../../components/sections/MileTruthHero";
|
||||||
import Workflow1 from "../../components/sections/Workflow1";
|
import Workflow1 from "../../components/sections/Workflow1";
|
||||||
import Workflow2 from "../../components/sections/Workflow2";
|
import Workflow2 from "../../components/sections/Workflow2";
|
||||||
import Workflow3 from "../../components/sections/Workflow3";
|
import Workflow3Lazy from "../../components/sections/Workflow3Lazy";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "MileTruth – Doormile",
|
title: "MileTruth – Doormile",
|
||||||
@@ -18,7 +18,7 @@ export default function MileTruthPage() {
|
|||||||
<MileTruthHero />
|
<MileTruthHero />
|
||||||
<Workflow1 />
|
<Workflow1 />
|
||||||
<Workflow2 />
|
<Workflow2 />
|
||||||
<Workflow3 />
|
<Workflow3Lazy />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export default function LoadingScreen() {
|
|||||||
height={38}
|
height={38}
|
||||||
priority
|
priority
|
||||||
className="dm-loader__logo"
|
className="dm-loader__logo"
|
||||||
|
style={{ height: "auto" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,26 @@
|
|||||||
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
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 gsap from "gsap";
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
import { P, STRATEGIES, ENGINE_STEPS, CONSTRAINT_LIST, STRATEGY_SCORES } from "./theme";
|
import { P, STRATEGIES, ENGINE_STEPS, CONSTRAINT_LIST, STRATEGY_SCORES } from "./theme";
|
||||||
|
|
||||||
const LogisticsBrainCanvas = dynamic(() => import("./LogisticsBrainCanvas"), { ssr: false });
|
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> }) {
|
function Counter({ mv }: { mv: MotionValue<number> }) {
|
||||||
const [v, setV] = useState(0);
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
useMotionValueEvent(mv, "change", (x) => setV(Math.round(x)));
|
useEffect(() => {
|
||||||
return <>{v}</>;
|
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). */
|
/** Active step index from scroll progress (−1 before the engine starts). */
|
||||||
|
|||||||
@@ -154,11 +154,11 @@ export default function OptimizationSection() {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
// NOTE: /miletruth runs on native scroll (no Lenis). Smooth-scrolling this
|
// NOTE: global Lenis (src/animations/SmoothScroll.tsx) is active on this
|
||||||
// page fought the three stacked pinned WebGL sections and caused scroll lag;
|
// route. Because the pin is a self-managed `position: fixed` driven by
|
||||||
// global Lenis (src/animations/SmoothScroll.tsx) is intentionally disabled on
|
// `self.progress` (not a GSAP pin-spacer) and Lenis scrolls the real
|
||||||
// this route. ScrollTrigger's scrub + self-managed fixed pin work as-is on
|
// document hooked to ScrollTrigger.update, the scrub + fixed pin work
|
||||||
// native scroll.
|
// unchanged under smooth scroll.
|
||||||
let lastPhase: PhaseKey = "chaos";
|
let lastPhase: PhaseKey = "chaos";
|
||||||
let lastPin: "before" | "pinned" | "after" = "before";
|
let lastPin: "before" | "pinned" | "after" = "before";
|
||||||
const st = ScrollTrigger.create({
|
const st = ScrollTrigger.create({
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
|||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
|
|
||||||
let u = progressArray[index];
|
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
|
// Get position to compute distance to nodes
|
||||||
def.curve.getPointAt(u, TMP_POS);
|
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%)
|
// 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
|
// Increment progress continuously
|
||||||
u = (u + dt * def.speed * speedFactor) % 1;
|
u = (u + dt * def.speed * speedFactor) % 1;
|
||||||
@@ -195,26 +196,46 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
|||||||
const t = state.clock.elapsedTime;
|
const t = state.clock.elapsedTime;
|
||||||
const safeDt = Math.min(0.06, dt);
|
const safeDt = Math.min(0.06, dt);
|
||||||
|
|
||||||
const chaosFadeIn = smoothstep(0.10, 0.22, p);
|
const chaosFadeIn = smoothstep(0.1, 0.22, p);
|
||||||
const chaosFadeOut = 1 - smoothstep(0.70, 0.82, p);
|
const chaosFadeOut = 1 - smoothstep(0.7, 0.82, p);
|
||||||
const chaosOp = chaosFadeIn * chaosFadeOut * (0.85 + Math.sin(t * 6) * 0.1);
|
const chaosOp = chaosFadeIn * chaosFadeOut * (0.85 + Math.sin(t * 6) * 0.1);
|
||||||
const optOp = smoothstep(0.68, 0.82, p);
|
const optOp = smoothstep(0.68, 0.82, p);
|
||||||
|
|
||||||
const cam = state.camera;
|
const cam = state.camera;
|
||||||
for (let i = 0; i < chaosFleet.length; i++) {
|
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];
|
const g = chaosRefs.current[i];
|
||||||
if (g && g.visible) {
|
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++) {
|
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
|
// The truck is 2D cutout art, so billboard it around Y to always face the
|
||||||
// camera (overrides the tangent heading set inside place()).
|
// camera (overrides the tangent heading set inside place()).
|
||||||
const g = optRefs.current[i];
|
const g = optRefs.current[i];
|
||||||
if (g && g.visible) {
|
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.
|
// Energy comet-tail behind each optimized vehicle.
|
||||||
const u = optProgress.current[i];
|
const u = optProgress.current[i];
|
||||||
@@ -223,7 +244,10 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
|||||||
for (let k = 0; k < TRAIL_N; k++) {
|
for (let k = 0; k < TRAIL_N; k++) {
|
||||||
const mesh = optTrailRefs.current[i * TRAIL_N + k];
|
const mesh = optTrailRefs.current[i * TRAIL_N + k];
|
||||||
if (!mesh) continue;
|
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;
|
let uk = u - (k + 1) * TRAIL_GAP;
|
||||||
if (uk < 0) uk = 0;
|
if (uk < 0) uk = 0;
|
||||||
mesh.visible = true;
|
mesh.visible = true;
|
||||||
@@ -233,7 +257,8 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
|||||||
const size = 0.05 + taper * 0.13;
|
const size = 0.05 + taper * 0.13;
|
||||||
mesh.scale.setScalar(size / 0.1);
|
mesh.scale.setScalar(size / 0.1);
|
||||||
const mat = mesh.material as THREE.MeshBasicMaterial;
|
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;
|
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>
|
</group>
|
||||||
))}
|
))}
|
||||||
{/* Optimized fleet — the truck art in clean white */}
|
{/* Optimized fleet — the truck art in clean white */}
|
||||||
@@ -260,7 +290,12 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
|||||||
optRefs.current[i] = el;
|
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>
|
</group>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -290,5 +325,3 @@ function VehicleFleet({ progress, reduced = false }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(VehicleFleet);
|
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";
|
"use client";
|
||||||
|
|
||||||
import React, { Suspense, useEffect, useMemo, useRef } from "react";
|
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Canvas, useFrame } from "@react-three/fiber";
|
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||||
import { RoundedBox, Line, Sparkles, Html, useGLTF, ContactShadows, Environment, Lightformer } from "@react-three/drei";
|
import { RoundedBox, Line, Sparkles, Html, useGLTF, ContactShadows, Environment, Lightformer, Preload } from "@react-three/drei";
|
||||||
import { EffectComposer, Bloom } from "@react-three/postprocessing";
|
import { EffectComposer, Bloom } from "@react-three/postprocessing";
|
||||||
import { KernelSize } from "postprocessing";
|
import { KernelSize } from "postprocessing";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
@@ -26,6 +26,53 @@ const BLUE = "#3B82F6"; // 03 optimization
|
|||||||
const ORANGE = "#F59E0B"; // 04 grading
|
const ORANGE = "#F59E0B"; // 04 grading
|
||||||
const RED = "#C01227"; // 05 winner
|
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
|
Camera — fully scroll-driven (reads a ref, never React state). Dollies forward
|
||||||
through the world, riding behind+above the active district and looking slightly
|
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
|
Shared helpers
|
||||||
--------------------------------------------------------------------------- */
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
/** Fade a district's drei <Html> chips in/out by camera proximity (ref-driven). */
|
/** Fade a district's drei <Html> chips in/out by camera proximity (ref-driven).
|
||||||
function useLabelFade(i: number, progress: React.RefObject<number>) {
|
* 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 labels = useRef<HTMLElement[]>([]);
|
||||||
const register = (el: HTMLElement | null) => {
|
const register = (el: HTMLElement | null) => {
|
||||||
if (el && !labels.current.includes(el)) labels.current.push(el);
|
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(() => {
|
useFrame(() => {
|
||||||
|
if (!awake) return;
|
||||||
const idx = (progress.current ?? 0) * (N - 1);
|
const idx = (progress.current ?? 0) * (N - 1);
|
||||||
const op = THREE.MathUtils.clamp(1 - (Math.abs(idx - i) - 0.4) / 0.45, 0, 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);
|
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 };
|
return { root, wheels };
|
||||||
}
|
}
|
||||||
|
|
||||||
function GLBVehicle({ kind }: { kind: string }) {
|
function GLBVehicle({ kind, awake = true }: { kind: string; awake?: boolean }) {
|
||||||
const tune = VEH_TUNE[kind];
|
const tune = VEH_TUNE[kind];
|
||||||
const { scene } = useGLTF(MODELS[kind]);
|
const { scene } = useGLTF(MODELS[kind]);
|
||||||
const { root } = useMemo(() => buildModel(scene, tune.size), [scene, tune]);
|
const { root } = useMemo(() => buildModel(scene, tune.size), [scene, tune]);
|
||||||
const g = useRef<THREE.Group>(null);
|
const g = useRef<THREE.Group>(null);
|
||||||
useFrame((state) => {
|
useFrame((state) => {
|
||||||
const grp = g.current;
|
const grp = g.current;
|
||||||
if (!grp) return;
|
if (!grp || !awake) return;
|
||||||
const t = state.clock.elapsedTime;
|
const t = state.clock.elapsedTime;
|
||||||
switch (kind) {
|
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;
|
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
|
* 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.
|
* 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 { scene } = useGLTF(MODELS.truck);
|
||||||
const built = useMemo(() => buildModel(scene, 0.85), [scene]);
|
const built = useMemo(() => buildModel(scene, 0.85), [scene]);
|
||||||
const wheels = useRef<THREE.Object3D[]>([]);
|
const wheels = useRef<THREE.Object3D[]>([]);
|
||||||
@@ -207,7 +260,7 @@ function RouteTruck({ route, reduced, i, progress }: { route: THREE.CatmullRomCu
|
|||||||
const prevT = useRef(0);
|
const prevT = useRef(0);
|
||||||
useFrame((state, dt) => {
|
useFrame((state, dt) => {
|
||||||
const grp = g.current;
|
const grp = g.current;
|
||||||
if (!grp) return;
|
if (!grp || !awake) return;
|
||||||
const idx = (progress.current ?? 0) * (N - 1);
|
const idx = (progress.current ?? 0) * (N - 1);
|
||||||
const t = THREE.MathUtils.clamp(idx - (i - 0.5), 0, 1); // 0 → hub … 1 → delivery
|
const t = THREE.MathUtils.clamp(idx - (i - 0.5), 0, 1); // 0 → hub … 1 → delivery
|
||||||
const pos = route.getPointAt(t);
|
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). */
|
/** 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 (
|
return (
|
||||||
<group position={[rider.x, 0, ROW_Z]}>
|
<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.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>
|
<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]}>
|
<group position={[0, 0.02, 0]}>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<GLBVehicle kind={rider.kind} />
|
<GLBVehicle kind={rider.kind} awake={awake} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</group>
|
</group>
|
||||||
<Html center distanceFactor={9} position={[0, 1.5, 0]} zIndexRange={[20, 0]} pointerEvents="none">
|
{awake && (
|
||||||
<div className="dm-st3d-chip" ref={register}>
|
<Html center distanceFactor={9} position={[0, 1.5, 0]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||||
<span className="dm-st3d-chip__ico">{rider.icon}</span>
|
<div className="dm-st3d-chip" ref={register}>
|
||||||
<span className="dm-st3d-chip__txt"><b>Rider {rider.id}</b>{rider.veh}</span>
|
<span className="dm-st3d-chip__ico">{rider.icon}</span>
|
||||||
</div>
|
<span className="dm-st3d-chip__txt"><b>Rider {rider.id}</b>{rider.veh}</span>
|
||||||
</Html>
|
</div>
|
||||||
|
</Html>
|
||||||
|
)}
|
||||||
</group>
|
</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 ORDERS_SRC: [number, number, number] = [3.3, 0.45, -0.7];
|
||||||
const HUB_CORE: [number, number, number] = [0, 0.55, -1.4];
|
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 IntakeHub = React.memo(function IntakeHub({ i, progress, reduced, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean }) {
|
||||||
const register = useLabelFade(i, progress);
|
const register = useLabelFade(i, progress, awake);
|
||||||
const counter = useRef<HTMLSpanElement>(null);
|
const counter = useRef<HTMLSpanElement>(null);
|
||||||
const halo = useRef<THREE.Mesh>(null);
|
const halo = useRef<THREE.Mesh>(null);
|
||||||
useFrame((state, dt) => {
|
useFrame((state, dt) => {
|
||||||
|
if (!awake) return;
|
||||||
if (counter.current) {
|
if (counter.current) {
|
||||||
if (reduced) counter.current.textContent = "59";
|
if (reduced) counter.current.textContent = "59";
|
||||||
else {
|
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 intake = useMemo(() => lineGeom(ORDERS_SRC, HUB_CORE), []);
|
||||||
const routes = useMemo(() => RIDERS.map((r) => lineGeom(HUB_CORE, [r.x, 0.12, ROW_Z])), []);
|
const routes = useMemo(() => RIDERS.map((r) => lineGeom(HUB_CORE, [r.x, 0.12, ROW_Z])), []);
|
||||||
return (
|
return (
|
||||||
<group position={districtPosition(i)}>
|
<group position={districtPosition(i)} visible={awake}>
|
||||||
{/* orders.csv source node → intake route → hub (order packets flowing in) */}
|
{/* orders.csv source node → intake route → hub (order packets flowing in) */}
|
||||||
<GlowNode position={ORDERS_SRC} color={GREEN} size={0.12} />
|
<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} />
|
<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} />)}
|
{awake && !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>
|
|
||||||
|
|
||||||
{/* AI Assignment Hub — a grounded dispatch hub (rim ring + hex dais + glowing core + halo) */}
|
{/* AI Assignment Hub — a grounded dispatch hub (rim ring + hex dais + glowing core + halo) */}
|
||||||
<group position={[0, 0, -1.4]}>
|
<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 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>
|
<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>
|
</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 */}
|
{/* assignment routes hub → each vehicle node */}
|
||||||
{routes.map((c, j) => (
|
{routes.map((c, j) => (
|
||||||
<group key={j}>
|
<group key={j}>
|
||||||
<Line points={c.getPoints(2)} color={GREEN} lineWidth={1.6} transparent opacity={0.45} toneMapped={false} />
|
<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>
|
</group>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* soft contact shadow grounding the fleet (baked once for performance) */}
|
{/* 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" />
|
<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>
|
</group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -361,12 +422,12 @@ const STRATS = [
|
|||||||
];
|
];
|
||||||
const CORE: [number, number, number] = [0, 1.45, -2.7];
|
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 StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduced, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean }) {
|
||||||
const register = useLabelFade(i, progress);
|
const register = useLabelFade(i, progress, awake);
|
||||||
const ring = useRef<THREE.Mesh>(null);
|
const ring = useRef<THREE.Mesh>(null);
|
||||||
const coreMesh = useRef<THREE.Mesh>(null);
|
const coreMesh = useRef<THREE.Mesh>(null);
|
||||||
useFrame((state, dt) => {
|
useFrame((state, dt) => {
|
||||||
if (reduced) return;
|
if (reduced || !awake) return;
|
||||||
if (ring.current) ring.current.rotation.z += dt * 0.6;
|
if (ring.current) ring.current.rotation.z += dt * 0.6;
|
||||||
if (coreMesh.current) coreMesh.current.rotation.y += dt * 0.4;
|
if (coreMesh.current) coreMesh.current.rotation.y += dt * 0.4;
|
||||||
});
|
});
|
||||||
@@ -381,7 +442,7 @@ const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduc
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
return (
|
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) */}
|
{/* 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.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>
|
<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 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={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>
|
<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">
|
{awake && (
|
||||||
<div className="dm-st3d-tag" style={{ ["--tc" as string]: PURPLE }} ref={register}>🤖 AI Engine</div>
|
<Html center distanceFactor={9} position={[0, 2.5, -2.7]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||||
</Html>
|
<div className="dm-st3d-tag" style={{ ["--tc" as string]: PURPLE }} ref={register}>🤖 AI Engine</div>
|
||||||
|
</Html>
|
||||||
|
)}
|
||||||
|
|
||||||
{lanes.map((l, j) => {
|
{lanes.map((l, j) => {
|
||||||
const col = l.unified ? PURPLE : "#a99bd6";
|
const col = l.unified ? PURPLE : "#a99bd6";
|
||||||
return (
|
return (
|
||||||
<group key={l.name}>
|
<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} />
|
<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) */}
|
{/* 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>
|
<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} />
|
<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">
|
{awake && (
|
||||||
<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 center distanceFactor={9} position={[l.end[0], l.end[1] + 0.62, l.end[2]]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||||
</Html>
|
<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>
|
</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 CityRouteMap = React.memo(function CityRouteMap({ i, progress, reduced, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean }) {
|
||||||
const register = useLabelFade(i, progress);
|
const register = useLabelFade(i, progress, awake);
|
||||||
// OPEN delivery path (Dispatch Hub → … → Delivery). Static "road"; only the truck moves.
|
// OPEN delivery path (Dispatch Hub → … → Delivery). Static "road"; only the truck moves.
|
||||||
const route = useMemo(() => {
|
const route = useMemo(() => {
|
||||||
const pts = [DEPOT3, ...DEST].map(([x, z]) => new THREE.Vector3(x, 0.12, z));
|
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 routePts = useMemo(() => route.getPoints(100), [route]);
|
||||||
const delivery = DEST[DEST.length - 1];
|
const delivery = DEST[DEST.length - 1];
|
||||||
return (
|
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 */}
|
{/* 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} />
|
<Line points={routePts} color={BLUE} lineWidth={2.6} transparent opacity={0.8} toneMapped={false} />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<RouteTruck route={route} reduced={reduced} i={i} progress={progress} />
|
<RouteTruck route={route} reduced={reduced} i={i} progress={progress} awake={awake} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
{/* dispatch hub (static) */}
|
{/* 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>
|
<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} />
|
<GlowNode position={[0, 0.52, 0]} color={BLUE} size={0.14} />
|
||||||
</group>
|
</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) => {
|
{DEST.map(([x, z], j) => {
|
||||||
const label = DEST_LABELS[j];
|
|
||||||
const isDelivery = j === DEST.length - 1;
|
const isDelivery = j === DEST.length - 1;
|
||||||
return (
|
return (
|
||||||
<group key={j} position={[x, 0, z]}>
|
<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.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>
|
<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} />
|
<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>
|
</group>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* delivery arrival pulse + completion badge (only when the truck arrives) */}
|
{/* delivery arrival pulse (only mounted/animated while this district is active) */}
|
||||||
<DeliveryPulse pos={[delivery[0], 0.02, delivery[1]]} i={i} progress={progress} />
|
{awake && <DeliveryPulse pos={[delivery[0], 0.02, delivery[1]]} i={i} progress={progress} />}
|
||||||
<DeliveryBadge pos={[delivery[0], 1.42, delivery[1]]} i={i} progress={progress} />
|
|
||||||
|
|
||||||
{/* validation overlays + optimization result */}
|
{awake && (
|
||||||
{VALIDATIONS.map((v) => (
|
<>
|
||||||
<Html key={v.t} center distanceFactor={9} position={v.p} zIndexRange={[20, 0]} pointerEvents="none">
|
<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}>✓ {v.t}</div>
|
<div className="dm-st3d-tag" style={{ ["--tc" as string]: BLUE }} ref={register}>🏢 Dispatch Hub</div>
|
||||||
</Html>
|
</Html>
|
||||||
))}
|
{DEST.map(([x, z], j) => {
|
||||||
<Html center distanceFactor={9} position={[0, 3.2, -0.4]} zIndexRange={[20, 0]} pointerEvents="none">
|
const label = DEST_LABELS[j];
|
||||||
<div className="dm-st3d-score" style={{ ["--tc" as string]: BLUE }} ref={register}>🗺️ Route optimized · <b>−18%</b> distance</div>
|
return label ? (
|
||||||
</Html>
|
<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>
|
</group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -540,8 +610,8 @@ const KPIS = [
|
|||||||
{ n: "Battery", v: 100, a: 0.66 },
|
{ n: "Battery", v: 100, a: 0.66 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CommandCenter = React.memo(function CommandCenter({ i, progress }: { i: number; progress: React.RefObject<number> }) {
|
const CommandCenter = React.memo(function CommandCenter({ i, progress, awake }: { i: number; progress: React.RefObject<number>; awake: boolean }) {
|
||||||
const register = useLabelFade(i, progress);
|
const register = useLabelFade(i, progress, awake);
|
||||||
const screens = useMemo(
|
const screens = useMemo(
|
||||||
() =>
|
() =>
|
||||||
KPIS.map((k) => {
|
KPIS.map((k) => {
|
||||||
@@ -552,7 +622,7 @@ const CommandCenter = React.memo(function CommandCenter({ i, progress }: { i: nu
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
return (
|
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.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>
|
<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}>
|
<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} />
|
<meshStandardMaterial color="#ffffff" emissive={ORANGE} emissiveIntensity={0.18} metalness={0.1} roughness={0.4} transparent opacity={0.92} />
|
||||||
</RoundedBox>
|
</RoundedBox>
|
||||||
<Html center distanceFactor={8} position={[0, 0, 0.05]} zIndexRange={[20, 0]} pointerEvents="none">
|
{awake && (
|
||||||
<div className="dm-st3d-kpi" style={{ ["--tc" as string]: ORANGE }} ref={register}>
|
<Html center distanceFactor={8} position={[0, 0, 0.05]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||||
<span className="dm-st3d-kpi__n">{s.n}</span>
|
<div className="dm-st3d-kpi" style={{ ["--tc" as string]: ORANGE }} ref={register}>
|
||||||
<span className="dm-st3d-kpi__v">{s.v}<i>%</i></span>
|
<span className="dm-st3d-kpi__n">{s.n}</span>
|
||||||
<span className="dm-st3d-kpi__bar"><i style={{ width: `${s.v}%` }} /></span>
|
<span className="dm-st3d-kpi__v">{s.v}<i>%</i></span>
|
||||||
</div>
|
<span className="dm-st3d-kpi__bar"><i style={{ width: `${s.v}%` }} /></span>
|
||||||
</Html>
|
</div>
|
||||||
|
</Html>
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Html center distanceFactor={9} position={[0, 3.1, -0.4]} zIndexRange={[20, 0]} pointerEvents="none">
|
{awake && (
|
||||||
<div className="dm-st3d-score" style={{ ["--tc" as string]: ORANGE }} ref={register}>Performance Grade <b>A</b> · 4.5 / 5</div>
|
<Html center distanceFactor={9} position={[0, 3.1, -0.4]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||||
</Html>
|
<div className="dm-st3d-score" style={{ ["--tc" as string]: ORANGE }} ref={register}>Performance Grade <b>A</b> · 4.5 / 5</div>
|
||||||
|
</Html>
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -589,7 +663,7 @@ const PODIUM = [
|
|||||||
{ n: "Proximity", v: 64, x: 2.4, win: false },
|
{ 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 h = 0.6 + (p.v / 100) * 2.2;
|
||||||
const col = p.win ? RED : "#94a3b8";
|
const col = p.win ? RED : "#94a3b8";
|
||||||
return (
|
return (
|
||||||
@@ -598,30 +672,32 @@ function Pillar({ p, register }: { p: typeof PODIUM[number]; register: (el: HTML
|
|||||||
<boxGeometry args={[1.0, h, 1.0]} />
|
<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} />
|
<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>
|
</mesh>
|
||||||
<Html center distanceFactor={9} position={[0, h + 0.45, 0]} zIndexRange={[20, 0]} pointerEvents="none">
|
{awake && (
|
||||||
<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 center distanceFactor={9} position={[0, h + 0.45, 0]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||||
</Html>
|
<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>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, isMobile }: { i: number; progress: React.RefObject<number>; reduced: boolean; isMobile: boolean }) {
|
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);
|
const register = useLabelFade(i, progress, awake);
|
||||||
const trophy = useRef<THREE.Group>(null);
|
const trophy = useRef<THREE.Group>(null);
|
||||||
useFrame((state) => {
|
useFrame((state) => {
|
||||||
if (trophy.current && !reduced) {
|
if (trophy.current && !reduced && awake) {
|
||||||
const winH = 0.6 + (88 / 100) * 2.2;
|
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.position.y = winH + 0.95 + Math.sin(state.clock.elapsedTime * 1.4) * 0.08;
|
||||||
trophy.current.rotation.y += 0.01;
|
trophy.current.rotation.y += 0.01;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return (
|
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.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={[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>
|
<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>
|
<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]}>
|
<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>
|
<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>
|
</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">
|
{awake && (
|
||||||
<div className="dm-st3d-winner3d" ref={register}>
|
<Html center distanceFactor={9} position={[0.7, 3.0, -0.6]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||||
<span className="dm-st3d-winner3d__top">🏆 Best Strategy</span>
|
<div className="dm-st3d-winner3d" ref={register}>
|
||||||
<span className="dm-st3d-winner3d__name">EV Aware</span>
|
<span className="dm-st3d-winner3d__top">🏆 Best Strategy</span>
|
||||||
<span className="dm-st3d-winner3d__row"><b>88%</b> Performance Score</span>
|
<span className="dm-st3d-winner3d__name">EV Aware</span>
|
||||||
<span className="dm-st3d-winner3d__row"><b>52/59</b> Orders Fulfilled</span>
|
<span className="dm-st3d-winner3d__row"><b>88%</b> Performance Score</span>
|
||||||
</div>
|
<span className="dm-st3d-winner3d__row"><b>52/59</b> Orders Fulfilled</span>
|
||||||
</Html>
|
</div>
|
||||||
|
</Html>
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -704,8 +782,21 @@ function Floor() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Scene({ progress, reduced, isMobile, stage }: { progress: React.RefObject<number>; reduced: boolean; isMobile: boolean; stage: number }) {
|
function Scene({ progress, reduced, isMobile, stage, active, perf }: { progress: React.RefObject<number>; reduced: boolean; isMobile: boolean; stage: number; active: boolean; perf: boolean }) {
|
||||||
const near = (i: number) => Math.abs(i - stage) <= 1; // only mount active ± 1 district
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<color attach="background" args={[BG]} />
|
<color attach="background" args={[BG]} />
|
||||||
@@ -727,26 +818,33 @@ function Scene({ progress, reduced, isMobile, stage }: { progress: React.RefObje
|
|||||||
<Floor />
|
<Floor />
|
||||||
<DataRoad />
|
<DataRoad />
|
||||||
|
|
||||||
{near(0) && <IntakeHub i={0} progress={progress} reduced={reduced} />}
|
<IntakeHub i={0} progress={progress} reduced={reduced} awake={near(0)} />
|
||||||
{near(1) && <StrategyNetwork i={1} progress={progress} reduced={reduced} />}
|
<StrategyNetwork i={1} progress={progress} reduced={reduced} awake={near(1)} />
|
||||||
{near(2) && <CityRouteMap i={2} progress={progress} reduced={reduced} />}
|
<CityRouteMap i={2} progress={progress} reduced={reduced} awake={near(2)} />
|
||||||
{near(3) && <CommandCenter i={3} progress={progress} />}
|
<CommandCenter i={3} progress={progress} awake={near(3)} />
|
||||||
{near(4) && <WinnerPodium i={4} progress={progress} reduced={reduced} isMobile={isMobile} />}
|
<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" />
|
<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}>
|
<EffectComposer multisampling={0}>
|
||||||
<Bloom mipmapBlur intensity={0.7} luminanceThreshold={0.74} luminanceSmoothing={0.06} radius={0.68} kernelSize={KernelSize.SMALL} />
|
<Bloom mipmapBlur intensity={0.7} luminanceThreshold={0.74} luminanceSmoothing={0.06} radius={0.68} kernelSize={KernelSize.SMALL} />
|
||||||
</EffectComposer>
|
</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) {
|
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 (
|
return (
|
||||||
<Canvas
|
<Canvas
|
||||||
dpr={[1, isMobile ? 1 : 1.25]}
|
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 }}
|
gl={{ antialias: false, powerPreference: "high-performance", alpha: false }}
|
||||||
frameloop={active ? "always" : "never"}
|
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>
|
</Canvas>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user