fix scroll smooth
This commit is contained in:
2
public/css/custom-frontend.min.css
vendored
2
public/css/custom-frontend.min.css
vendored
@@ -3214,7 +3214,7 @@ body.rtl .e-con {
|
||||
|
||||
.btn-we-primary {
|
||||
background: var(--we-white);
|
||||
color: var(-we-primary);
|
||||
color: var(--we-primary);
|
||||
padding: 18px 48px;
|
||||
border-radius: 100px;
|
||||
font-weight: 700;
|
||||
|
||||
2
public/css/vendor/vendor-theme-core.css
vendored
2
public/css/vendor/vendor-theme-core.css
vendored
@@ -14761,7 +14761,7 @@ a.e-con:hover p {
|
||||
}
|
||||
|
||||
html.elementor-html {
|
||||
background: url(../../../../../../../../../themes/logico/img/bg-transparency.png) center center repeat
|
||||
background: url(/themes/logico/img/bg-transparency.png) center center repeat
|
||||
}
|
||||
|
||||
:where(body).single-elementor-hf {
|
||||
|
||||
BIN
public/images/preloader.png
Normal file
BIN
public/images/preloader.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
@@ -66,13 +66,34 @@ export default function SmoothScroll() {
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const lenis = new Lenis({
|
||||
duration: 1.05,
|
||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
orientation: "vertical",
|
||||
gestureOrientation: "vertical",
|
||||
smoothWheel: true,
|
||||
});
|
||||
// /miletruth is one long stack of tall, pinned 3D sections, each with its own
|
||||
// ScrollTrigger `scrub`. The default duration-based momentum (1.05s) compounds
|
||||
// with that scrub, so the wheel feels heavy, slow and disconnected. On this
|
||||
// route we switch to a snappy `lerp`-based follow + a higher wheel multiplier:
|
||||
// the page travels fast and stays tightly locked to the wheel, while each
|
||||
// section's scrub supplies the visual smoothing. Other routes keep the softer
|
||||
// duration-based feel that suits their normal content.
|
||||
const isMileTruth =
|
||||
pathname === "/miletruth" || pathname.startsWith("/miletruth/");
|
||||
|
||||
const lenis = new Lenis(
|
||||
isMileTruth
|
||||
? {
|
||||
lerp: 0.13, // snappy follow (higher = less smoothing lag)
|
||||
wheelMultiplier: 1.3, // travel further per wheel tick → fast
|
||||
touchMultiplier: 1.6,
|
||||
orientation: "vertical",
|
||||
gestureOrientation: "vertical",
|
||||
smoothWheel: true,
|
||||
}
|
||||
: {
|
||||
duration: 1.05,
|
||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
orientation: "vertical",
|
||||
gestureOrientation: "vertical",
|
||||
smoothWheel: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (!window.location.hash) {
|
||||
lenis.scrollTo(0, { immediate: true });
|
||||
|
||||
@@ -796,32 +796,20 @@ body {
|
||||
z-index: 2 !important;
|
||||
}
|
||||
|
||||
/* Responsive constraints to keep all heroes matching the home page carousel perfectly */
|
||||
@media (max-width: 1536px) {
|
||||
/* Responsive constraints mirror the home page carousel (.elementor-element-6c7cbcb
|
||||
.owl-carousel.owl-theme .content-item) EXACTLY so every page hero matches the
|
||||
home hero at all sizes. The home card is a fixed 800px on all widths >= 841px
|
||||
(large desktop, MacBook M1/Pro, and standard laptops alike) and only steps down
|
||||
to 600px at <= 840px. Earlier this card shrank at <= 1536px, which is why the
|
||||
About hero looked smaller than Home on MacBook M1/Pro (their ~1440-1512px logical
|
||||
width falls below 1536px). Match the home breakpoint instead. */
|
||||
@media (max-width: 840px) {
|
||||
.custom-standard-hero-container {
|
||||
padding: 10px 10px 10px 10px !important;
|
||||
}
|
||||
.custom-standard-hero-card {
|
||||
height: 600px !important;
|
||||
min-height: 600px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.custom-standard-hero-container {
|
||||
padding: 10px 10px 10px 10px !important;
|
||||
}
|
||||
.custom-standard-hero-card {
|
||||
height: 620px !important;
|
||||
min-height: 620px !important;
|
||||
border-radius: 25px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.custom-standard-hero-container {
|
||||
padding: 10px 10px 10px 10px !important;
|
||||
}
|
||||
.custom-standard-hero-card {
|
||||
height: 560px !important;
|
||||
min-height: 560px !important;
|
||||
border-radius: 22px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @next/next/no-css-tags */
|
||||
import type { Metadata } from "next";
|
||||
import { Manrope, Space_Grotesk, Syne, DM_Sans, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function LoadingScreen() {
|
||||
height={38}
|
||||
priority
|
||||
className="dm-loader__logo"
|
||||
style={{ height: "auto" }}
|
||||
style={{ width: "auto", height: "auto" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { motion, useMotionValue, useTransform, type MotionValue } from "framer-motion";
|
||||
import gsap from "gsap";
|
||||
@@ -24,6 +24,24 @@ function Counter({ mv }: { mv: MotionValue<number> }) {
|
||||
return <span ref={ref}>{Math.round(mv.get())}</span>;
|
||||
}
|
||||
|
||||
/** True only while a card's own opacity window is open (with a tiny buffer).
|
||||
* Lets us keep future/past story cards out of the DOM — and off the compositor
|
||||
* (each has `will-change`) — until their beat is actually on screen, so no
|
||||
* workflow state is rendered before activation. Visually identical, since a
|
||||
* card outside its window is opacity:0 anyway. */
|
||||
function useInWindow(mv: MotionValue<number>, threshold = 0.01): boolean {
|
||||
// `mv` is an external mutable store (a MotionValue). useTransform `.set()`s its
|
||||
// output synchronously while the PARENT renders, so a plain `.on("change") -> setState`
|
||||
// updates this component during the parent's render (React warns). useSyncExternalStore
|
||||
// is built for exactly this: it reads a snapshot and reconciles store-changes-during-
|
||||
// render safely. The snapshot is a primitive boolean, so it never re-renders needlessly.
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => mv.on("change", onStoreChange),
|
||||
() => mv.get() > threshold,
|
||||
() => mv.get() > threshold,
|
||||
);
|
||||
}
|
||||
|
||||
/** Active step index from scroll progress (−1 before the engine starts). */
|
||||
function stepFromProgress(p: number): number {
|
||||
let s = -1;
|
||||
@@ -67,6 +85,8 @@ function StoryCard({
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
// Don't mount this beat's card until its cross-fade window opens.
|
||||
if (!useInWindow(opacity)) return null;
|
||||
return (
|
||||
<motion.div className="dm-lb-card-story" style={{ opacity, y }}>
|
||||
<div className="dm-lb-card-story__head">
|
||||
@@ -139,7 +159,9 @@ export default function LogisticsBrainSection({ connected = false }: { connected
|
||||
trigger: el,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 0.5,
|
||||
// Match Workflow 1's responsiveness (0.4) so the camera + overlay track the
|
||||
// scroll with the same snappy feel — 0.5 made this section lag noticeably.
|
||||
scrub: 0.4,
|
||||
invalidateOnRefresh: true,
|
||||
onUpdate: (self) => {
|
||||
const p = self.progress;
|
||||
@@ -151,7 +173,7 @@ export default function LogisticsBrainSection({ connected = false }: { connected
|
||||
if (nstep !== lastStep) { lastStep = nstep; setStep(nstep); }
|
||||
},
|
||||
});
|
||||
const refresh = setTimeout(() => ScrollTrigger.refresh(), 300);
|
||||
const refresh = setTimeout(() => ScrollTrigger.refresh(), 120);
|
||||
return () => { clearTimeout(refresh); st.kill(); };
|
||||
}, [scroll]);
|
||||
|
||||
@@ -185,9 +207,14 @@ export default function LogisticsBrainSection({ connected = false }: { connected
|
||||
<div className="dm-lb-card">
|
||||
{mountScene && (
|
||||
<div className="dm-lb-canvas">
|
||||
<LogisticsBrainCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive} />
|
||||
<LogisticsBrainCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive && pinState === "pinned"} />
|
||||
</div>
|
||||
)}
|
||||
{/* Overlay is mounted only once the section is pinned/activated, so its
|
||||
content (intro hint, header, story cards) can never be seen during the
|
||||
approach ("before"), where the sticky sits at the top of the tall
|
||||
section just off the previous workflow's seam. */}
|
||||
{pinState !== "before" && (
|
||||
<div className="dm-lb-ui">
|
||||
{/* Persistent header: what this is + where we are in the workflow */}
|
||||
<motion.div className="dm-lb-top" style={{ opacity: headerOpacity }}>
|
||||
@@ -281,6 +308,7 @@ export default function LogisticsBrainSection({ connected = false }: { connected
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<style>{styles}</style>
|
||||
@@ -289,8 +317,13 @@ export default function LogisticsBrainSection({ connected = false }: { connected
|
||||
}
|
||||
|
||||
const styles = `
|
||||
.dm-lb { position: relative; height: 640vh; background: transparent; }
|
||||
.dm-lb-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden; }
|
||||
/* Scroll length tuned for pacing: ~77vh per engine step (was 107vh) so the 6
|
||||
beats complete in noticeably less scrolling — closer to Workflow 1's cadence
|
||||
and with far less perceived empty space between workflows. Beat windows are
|
||||
progress-based (0…1), so they stay correctly aligned at any height. */
|
||||
.dm-lb { position: relative; height: 460vh; background: transparent; }
|
||||
.dm-lb-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden;
|
||||
will-change: transform; transform: translateZ(0); backface-visibility: hidden; }
|
||||
.dm-lb.is-pinned .dm-lb-sticky { position: fixed; top: 0; left: 0; }
|
||||
.dm-lb.is-after .dm-lb-sticky { position: absolute; top: auto; bottom: 0; }
|
||||
|
||||
@@ -310,6 +343,9 @@ const styles = `
|
||||
.dm-lb.is-connected .dm-lb-card {
|
||||
top: 20px !important; left: 20px !important; right: 20px !important; bottom: 0 !important;
|
||||
border-radius: 28px 28px 0 0 !important; border-bottom: none !important;
|
||||
/* Flush against the Innovation card below — drop the heavy downward shadow so it
|
||||
doesn't cast a dark band onto that card's top edge (the two read as one container). */
|
||||
box-shadow: none !important;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-lb.is-connected .dm-lb-card {
|
||||
@@ -331,23 +367,23 @@ const styles = `
|
||||
.dm-lb-top { position: absolute; top: clamp(96px, 13vh, 128px); left: 0; right: 0; z-index: 5;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 0 16px; overflow: visible; }
|
||||
.dm-lb-eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 8px; font-size: 11px; line-height: 1.35; letter-spacing: 0.28em; text-transform: uppercase;
|
||||
color: #F2667A; padding: 9px 18px; border-radius: 999px; background: rgba(192,18,39,0.10);
|
||||
border: 1px solid rgba(226,53,66,0.32); backdrop-filter: blur(8px); white-space: nowrap; overflow: visible; }
|
||||
display: inline-flex; align-items: center; gap: 8px; font-size: 13px; line-height: 1.35; letter-spacing: 0.18em; font-weight: 700; text-transform: uppercase;
|
||||
color: #ffffff; padding: 9px 20px; border-radius: 999px; background: rgba(192,18,39,0.16);
|
||||
border: 1px solid rgba(226,53,66,0.45); backdrop-filter: blur(8px); white-space: nowrap; overflow: visible; }
|
||||
.dm-lb-dot { width: 6px; height: 6px; border-radius: 50%; background: #E2354A; box-shadow: 0 0 10px #E2354A; }
|
||||
|
||||
.dm-lb-rail { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; max-width: 940px; }
|
||||
.dm-lb-rail__step { display: inline-flex; align-items: center; gap: 7px; padding: 5px 11px; border-radius: 999px;
|
||||
.dm-lb-rail { display: flex; align-items: center; justify-content: center; flex-wrap: nowrap; max-width: min(1160px, 96vw); }
|
||||
.dm-lb-rail__step { display: inline-flex; align-items: center; gap: 8px; padding: 6px 13px; border-radius: 999px; flex-shrink: 0;
|
||||
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
|
||||
backdrop-filter: blur(6px); transition: all 0.45s cubic-bezier(0.22,1,0.36,1); }
|
||||
.dm-lb-rail__num { width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 10px; font-weight: 800; color: rgba(234,242,255,0.6); background: rgba(255,255,255,0.08); }
|
||||
.dm-lb-rail__title { font-size: 11px; font-weight: 600; letter-spacing: 0.03em; color: rgba(234,242,255,0.55); white-space: nowrap; }
|
||||
.dm-lb-rail__num { width: 20px; height: 20px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 800; color: rgba(255,255,255,0.9); background: rgba(255,255,255,0.12); }
|
||||
.dm-lb-rail__title { font-size: clamp(12.5px, 1.05vw, 14px); font-weight: 700; letter-spacing: 0.04em; color: rgba(255,255,255,0.95); white-space: nowrap; }
|
||||
.dm-lb-rail__step.is-current { background: rgba(192,18,39,0.18); border-color: rgba(226,53,66,0.55); box-shadow: 0 0 22px -6px rgba(226,53,66,0.7); }
|
||||
.dm-lb-rail__step.is-current .dm-lb-rail__num { background: linear-gradient(135deg,#E2354A,#C01227); color: #fff; }
|
||||
.dm-lb-rail__step.is-current .dm-lb-rail__title { color: #fff; }
|
||||
.dm-lb-rail__step.is-done .dm-lb-rail__num { background: #22C55E; color: #04130a; }
|
||||
.dm-lb-rail__step.is-done .dm-lb-rail__title { color: rgba(234,242,255,0.78); }
|
||||
.dm-lb-rail__step.is-done .dm-lb-rail__title { color: rgba(255,255,255,0.92); }
|
||||
.dm-lb-rail__line { width: 14px; height: 1px; background: rgba(255,255,255,0.12); margin: 0 3px; transition: background 0.45s ease; }
|
||||
.dm-lb-rail__line.is-on { background: linear-gradient(90deg,#22C55E,#E2354A); }
|
||||
|
||||
@@ -439,7 +475,7 @@ const styles = `
|
||||
.dm-lb-rail__line { width: 9px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-lb { height: 540vh; }
|
||||
.dm-lb { height: 400vh; }
|
||||
.dm-lb-kpis { gap: 12px; }
|
||||
.dm-lb-kpi { min-width: 96px; padding: 14px 14px; }
|
||||
.dm-lb-card-story { left: 0; right: 0; margin: 0 auto; width: calc(100% - 28px); bottom: clamp(20px, 5vh, 44px); padding: 14px 16px; }
|
||||
|
||||
@@ -36,6 +36,79 @@ const WORKFLOW_STEPS = [
|
||||
{ label: "Monitor", icon: "📊", activateAt: 5 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Bottom "Live Analytics" ticker. Self-contained: it owns its five fluctuation
|
||||
* timers and the state they mutate, so a tick re-renders only this ~10-node
|
||||
* subtree instead of the entire (canvas + overlay) OptimizationSection — which
|
||||
* previously re-reconciled on every timer fire, competing with scroll updates.
|
||||
*/
|
||||
const LiveInsightBar = React.memo(function LiveInsightBar() {
|
||||
const [orders, setOrders] = useState(59);
|
||||
const [accuracy, setAccuracy] = useState(98.7);
|
||||
const [activeVehicles, setActiveVehicles] = useState(5);
|
||||
const [carbon, setCarbon] = useState(-12.0);
|
||||
const [routeHealth, setRouteHealth] = useState(99.4);
|
||||
|
||||
useEffect(() => {
|
||||
// Pause every ticker while the tab is hidden — no point re-rendering offscreen.
|
||||
const guard = (fn: () => void) => () => {
|
||||
if (!document.hidden) fn();
|
||||
};
|
||||
const ordersInterval = setInterval(guard(() => {
|
||||
setOrders((prev) => prev + (Math.random() > 0.4 ? 1 : 0));
|
||||
}), 4500);
|
||||
const accuracyInterval = setInterval(guard(() => {
|
||||
setAccuracy((prev) => {
|
||||
const next = prev + (Math.random() - 0.5) * 0.15;
|
||||
return parseFloat(Math.min(99.1, Math.max(98.4, next)).toFixed(2));
|
||||
});
|
||||
}), 2800);
|
||||
const vehicleInterval = setInterval(guard(() => {
|
||||
setActiveVehicles((prev) => (prev === 5 ? (Math.random() > 0.5 ? 4 : 5) : (Math.random() > 0.3 ? 5 : 4)));
|
||||
}), 3500);
|
||||
const carbonInterval = setInterval(guard(() => {
|
||||
setCarbon((prev) => {
|
||||
const next = prev + (Math.random() - 0.5) * 0.2;
|
||||
return parseFloat(Math.min(-11.5, Math.max(-12.8, next)).toFixed(1));
|
||||
});
|
||||
}), 3200);
|
||||
const healthInterval = setInterval(guard(() => {
|
||||
setRouteHealth((prev) => {
|
||||
const next = prev + (Math.random() - 0.5) * 0.12;
|
||||
return parseFloat(Math.min(99.9, Math.max(98.8, next)).toFixed(2));
|
||||
});
|
||||
}), 2500);
|
||||
return () => {
|
||||
clearInterval(ordersInterval);
|
||||
clearInterval(accuracyInterval);
|
||||
clearInterval(vehicleInterval);
|
||||
clearInterval(carbonInterval);
|
||||
clearInterval(healthInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="dm-opt-insight"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
>
|
||||
<span className="dm-opt-insight__dot" />
|
||||
<span className="dm-opt-insight__text">Live Analytics: <strong>{orders} Orders</strong></span>
|
||||
<span className="dm-opt-insight__sep" />
|
||||
<span className="dm-opt-insight__text">AI Accuracy: <strong>{accuracy}%</strong></span>
|
||||
<span className="dm-opt-insight__sep" />
|
||||
<span className="dm-opt-insight__text">Fleet: <strong>{activeVehicles}/5 EV Active</strong></span>
|
||||
<span className="dm-opt-insight__sep" />
|
||||
<span className="dm-opt-insight__text">Route Health: <strong>{routeHealth}%</strong></span>
|
||||
<span className="dm-opt-insight__sep" />
|
||||
<span className="dm-opt-insight__text">Carbon: <strong>{carbon}%</strong></span>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default function OptimizationSection() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const progressRef = useRef(0);
|
||||
@@ -48,55 +121,6 @@ export default function OptimizationSection() {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [reduced, setReduced] = useState(false);
|
||||
|
||||
const [orders, setOrders] = useState(59);
|
||||
const [accuracy, setAccuracy] = useState(98.7);
|
||||
const [activeVehicles, setActiveVehicles] = useState(5);
|
||||
const [carbon, setCarbon] = useState(-12.0);
|
||||
const [routeHealth, setRouteHealth] = useState(99.4);
|
||||
|
||||
// Interval timers for high-fidelity live dashboard fluctuations
|
||||
useEffect(() => {
|
||||
const ordersInterval = setInterval(() => {
|
||||
setOrders((prev) => prev + (Math.random() > 0.4 ? 1 : 0));
|
||||
}, 4500);
|
||||
|
||||
const accuracyInterval = setInterval(() => {
|
||||
setAccuracy((prev) => {
|
||||
const delta = (Math.random() - 0.5) * 0.15;
|
||||
const next = prev + delta;
|
||||
return parseFloat(Math.min(99.1, Math.max(98.4, next)).toFixed(2));
|
||||
});
|
||||
}, 2800);
|
||||
|
||||
const vehicleInterval = setInterval(() => {
|
||||
setActiveVehicles((prev) => (prev === 5 ? (Math.random() > 0.5 ? 4 : 5) : (Math.random() > 0.3 ? 5 : 4)));
|
||||
}, 3500);
|
||||
|
||||
const carbonInterval = setInterval(() => {
|
||||
setCarbon((prev) => {
|
||||
const delta = (Math.random() - 0.5) * 0.2;
|
||||
const next = prev + delta;
|
||||
return parseFloat(Math.min(-11.5, Math.max(-12.8, next)).toFixed(1));
|
||||
});
|
||||
}, 3200);
|
||||
|
||||
const healthInterval = setInterval(() => {
|
||||
setRouteHealth((prev) => {
|
||||
const delta = (Math.random() - 0.5) * 0.12;
|
||||
const next = prev + delta;
|
||||
return parseFloat(Math.min(99.9, Math.max(98.8, next)).toFixed(2));
|
||||
});
|
||||
}, 2500);
|
||||
|
||||
return () => {
|
||||
clearInterval(ordersInterval);
|
||||
clearInterval(accuracyInterval);
|
||||
clearInterval(vehicleInterval);
|
||||
clearInterval(carbonInterval);
|
||||
clearInterval(healthInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Environment detection (client only).
|
||||
useEffect(() => {
|
||||
const mqMobile = window.matchMedia("(max-width: 767px)");
|
||||
@@ -184,7 +208,7 @@ export default function OptimizationSection() {
|
||||
},
|
||||
});
|
||||
|
||||
const refresh = setTimeout(() => ScrollTrigger.refresh(), 300);
|
||||
const refresh = setTimeout(() => ScrollTrigger.refresh(), 120);
|
||||
return () => {
|
||||
clearTimeout(refresh);
|
||||
st.kill();
|
||||
@@ -222,7 +246,11 @@ export default function OptimizationSection() {
|
||||
progress={progressRef}
|
||||
reduced={reduced}
|
||||
isMobile={isMobile}
|
||||
active={sceneActive}
|
||||
// Only run the render loop while the section is actually pinned
|
||||
// (filling the viewport). At a workflow seam two sections can both
|
||||
// satisfy their activeIo margin; without the pin gate their two
|
||||
// full-screen Bloom passes would run at once and drop FPS.
|
||||
active={sceneActive && pinState === "pinned"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -382,25 +410,9 @@ export default function OptimizationSection() {
|
||||
{/* KPI metrics */}
|
||||
<div className="dm-opt-foot">
|
||||
<MetricsPanel scroll={scroll} />
|
||||
{/* Bottom insight bar */}
|
||||
<motion.div
|
||||
className="dm-opt-insight"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
>
|
||||
<span className="dm-opt-insight__dot" />
|
||||
<span className="dm-opt-insight__text">Live Analytics: <strong>{orders} Orders</strong></span>
|
||||
<span className="dm-opt-insight__sep" />
|
||||
<span className="dm-opt-insight__text">AI Accuracy: <strong>{accuracy}%</strong></span>
|
||||
<span className="dm-opt-insight__sep" />
|
||||
<span className="dm-opt-insight__text">Fleet: <strong>{activeVehicles}/5 EV Active</strong></span>
|
||||
<span className="dm-opt-insight__sep" />
|
||||
<span className="dm-opt-insight__text">Route Health: <strong>{routeHealth}%</strong></span>
|
||||
<span className="dm-opt-insight__sep" />
|
||||
<span className="dm-opt-insight__text">Carbon: <strong>{carbon}%</strong></span>
|
||||
</motion.div>
|
||||
{/* Bottom insight bar — isolated so its live tickers don't re-render
|
||||
the whole section (and never fight the scroll handler). */}
|
||||
<LiveInsightBar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,6 +439,11 @@ const styles = `
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
/* Promote the pinned layer to its own GPU compositing layer so scroll-driven
|
||||
pin/scrub updates never trigger main-thread paints of the rest of the page. */
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.dm-opt.is-pinned .dm-opt-sticky { position: fixed; top: 0; left: 0; }
|
||||
.dm-opt.is-after .dm-opt-sticky { position: absolute; top: auto; bottom: 0; }
|
||||
|
||||
@@ -60,7 +60,9 @@ export default function MileTruthHero() {
|
||||
background-size: cover !important;
|
||||
background-position: center !important;
|
||||
background-repeat: no-repeat !important;
|
||||
min-height: 773px;
|
||||
/* Match the home page hero card (800px) so MileTruth has the same visual
|
||||
presence on large desktop, MacBook M1/Pro, and standard laptops. */
|
||||
min-height: 800px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import OptimizationSection from "../optimization/OptimizationSection";
|
||||
|
||||
export default function Workflow1() {
|
||||
const [activeSlide, setActiveSlide] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const slides = [
|
||||
{
|
||||
@@ -22,6 +23,16 @@ export default function Workflow1() {
|
||||
}
|
||||
];
|
||||
|
||||
// Auto-advance the carousel every 5s, infinite loop. Keyed on activeSlide so any
|
||||
// manual selection resets the timer; pauses while the user hovers the card.
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const id = setTimeout(() => {
|
||||
setActiveSlide((prev) => (prev + 1) % slides.length);
|
||||
}, 5000);
|
||||
return () => clearTimeout(id);
|
||||
}, [activeSlide, paused, slides.length]);
|
||||
|
||||
return (
|
||||
<section className="dm-wf1" aria-label="Workflow 1 — Impact of Optimisation & Performance">
|
||||
|
||||
@@ -30,7 +41,7 @@ export default function Workflow1() {
|
||||
|
||||
{/* ── Bottom sub-section: Performance content, flush + colour-matched to the
|
||||
optimisation section above so the whole workflow reads as one container ── */}
|
||||
<div className="dm-wf1-card">
|
||||
<div className="dm-wf1-card" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
||||
{/* Left Column: Overlapping Chevron Graphic */}
|
||||
<div className="dm-workflow-left">
|
||||
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
||||
@@ -105,6 +116,12 @@ const styles = `
|
||||
margin: 0 auto 0;
|
||||
}
|
||||
|
||||
/* Cancel the global "section { padding: 6rem 0 }" (custom-frontend.min.css): both
|
||||
this wrapper and the nested .dm-opt are sections, so that 96px top+bottom stacked
|
||||
into large empty bands above / between the workflows. These are full-bleed pinned
|
||||
experiences whose cards butt together via their own insets — no section padding. */
|
||||
.dm-wf1, .dm-wf1 .dm-opt { padding-top: 0; padding-bottom: 0; }
|
||||
|
||||
/* Performance card — aligned to the optimisation card (20px side insets),
|
||||
navy-matched, flat top, rounded bottom, pulled up to close the seam. */
|
||||
.dm-wf1-card {
|
||||
@@ -115,8 +132,10 @@ const styles = `
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-top: none;
|
||||
border-radius: 0 0 42px 42px;
|
||||
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5);
|
||||
padding: 48px 60px;
|
||||
/* No shadow: this card is flush under the optimisation card and merges with it as one
|
||||
continuous container — a shadow here would re-introduce a dark band at the seam. */
|
||||
box-shadow: none;
|
||||
padding: 36px 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import LogisticsBrainSection from "../logisticsbrain/LogisticsBrainSection";
|
||||
|
||||
export default function Workflow2() {
|
||||
const [activeSlide, setActiveSlide] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const slides = [
|
||||
{
|
||||
@@ -22,6 +23,16 @@ export default function Workflow2() {
|
||||
}
|
||||
];
|
||||
|
||||
// Auto-advance the carousel every 5s, infinite loop. Keyed on activeSlide so any
|
||||
// manual selection resets the timer; pauses while the user hovers the card.
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const id = setTimeout(() => {
|
||||
setActiveSlide((prev) => (prev + 1) % slides.length);
|
||||
}, 5000);
|
||||
return () => clearTimeout(id);
|
||||
}, [activeSlide, paused, slides.length]);
|
||||
|
||||
return (
|
||||
<section className="dm-wf2" aria-label="Workflow 2 — How Our Logistics Brain Works & Innovation">
|
||||
|
||||
@@ -30,7 +41,7 @@ export default function Workflow2() {
|
||||
|
||||
{/* ── Bottom sub-section: Innovation content, flush + colour-matched to the
|
||||
logistics-brain card above so the whole workflow reads as one container ── */}
|
||||
<div className="dm-wf2-card">
|
||||
<div className="dm-wf2-card" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
||||
{/* Left Column: Overlapping Chevron Graphic */}
|
||||
<div className="dm-workflow-left">
|
||||
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
||||
@@ -107,6 +118,12 @@ const styles = `
|
||||
margin: 0 auto 0;
|
||||
}
|
||||
|
||||
/* Cancel the global "section { padding: 6rem 0 }" (custom-frontend.min.css): both
|
||||
this wrapper and the nested .dm-lb are sections, so that 96px top+bottom stacked
|
||||
into large empty bands above / between the workflows. These are full-bleed pinned
|
||||
experiences whose cards butt together via their own insets — no section padding. */
|
||||
.dm-wf2, .dm-wf2 .dm-lb { padding-top: 0; padding-bottom: 0; }
|
||||
|
||||
/* Innovation card — aligned to the logistics-brain card (20px side insets),
|
||||
red/black-matched, flat top, rounded bottom, pulled up to close the seam. */
|
||||
.dm-wf2-card {
|
||||
@@ -117,8 +134,10 @@ const styles = `
|
||||
border: 1px solid rgba(192, 18, 39, 0.16);
|
||||
border-top: none;
|
||||
border-radius: 0 0 28px 28px;
|
||||
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5);
|
||||
padding: 48px 60px;
|
||||
/* No shadow: this card is flush under the logistics-brain card and merges with it as one
|
||||
continuous container — a shadow here would re-introduce a dark band at the seam. */
|
||||
box-shadow: none;
|
||||
padding: 36px 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import StrategySection from "../strategy/StrategySection";
|
||||
|
||||
export default function Workflow3() {
|
||||
const [activeSlide, setActiveSlide] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const slides = [
|
||||
{
|
||||
@@ -22,6 +23,16 @@ export default function Workflow3() {
|
||||
}
|
||||
];
|
||||
|
||||
// Auto-advance the carousel every 5s, infinite loop. Keyed on activeSlide so any
|
||||
// manual selection resets the timer; pauses while the user hovers the card.
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const id = setTimeout(() => {
|
||||
setActiveSlide((prev) => (prev + 1) % slides.length);
|
||||
}, 5000);
|
||||
return () => clearTimeout(id);
|
||||
}, [activeSlide, paused, slides.length]);
|
||||
|
||||
return (
|
||||
<section className="dm-wf3" aria-label="Workflow 3 — Happier Riders. Higher Fulfillment. & Strategy">
|
||||
|
||||
@@ -32,7 +43,7 @@ export default function Workflow3() {
|
||||
{/* ── Bottom sub-section: Strategy content, flush + pulled up to butt against
|
||||
the 3D card's flat bottom so the whole workflow reads as one container —
|
||||
the same connected structure used in Workflow 1 & 2 ── */}
|
||||
<div className="dm-wf3-card">
|
||||
<div className="dm-wf3-card" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
||||
{/* Left Column: Overlapping Chevron Graphic */}
|
||||
<div className="dm-workflow-left">
|
||||
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
||||
@@ -107,6 +118,12 @@ const styles = `
|
||||
margin: 0 auto 0;
|
||||
}
|
||||
|
||||
/* Cancel the global "section { padding: 6rem 0 }" (custom-frontend.min.css): both
|
||||
this wrapper and the nested .dm-st are sections, so that 96px top+bottom stacked
|
||||
into large empty bands above / between the workflows. These are full-bleed pinned
|
||||
experiences whose cards butt together via their own insets — no section padding. */
|
||||
.dm-wf3, .dm-wf3 .dm-st { padding-top: 0; padding-bottom: 0; }
|
||||
|
||||
.dm-wf3-card {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
@@ -115,8 +132,10 @@ const styles = `
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-top: none;
|
||||
border-radius: 0 0 28px 28px;
|
||||
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5);
|
||||
padding: 48px 60px;
|
||||
/* No shadow: this card is flush under the strategy 3D card and merges with it as one
|
||||
continuous container — a shadow here would re-introduce a dark band at the seam. */
|
||||
box-shadow: none;
|
||||
padding: 36px 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -123,7 +123,10 @@ function useLabelFade(i: number, progress: React.RefObject<number>, awake: boole
|
||||
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);
|
||||
// Full within ±0.2 of the district centre, fading to 0 by ±0.5 — i.e. exactly
|
||||
// at the stage boundary where `focused` flips and the labels unmount, so the
|
||||
// mount/unmount is never visible (labels are already at 0 opacity by then).
|
||||
const op = THREE.MathUtils.clamp(1 - (Math.abs(idx - i) - 0.2) / 0.3, 0, 1);
|
||||
for (const el of labels.current) el.style.opacity = String(op);
|
||||
});
|
||||
return register;
|
||||
@@ -321,7 +324,7 @@ function OrderPacket({ curve, offset }: { curve: THREE.Curve<THREE.Vector3>; off
|
||||
}
|
||||
|
||||
/** A vehicle parked on its own glowing route node (flat on the floor, no white pad). */
|
||||
function RiderAvatar({ rider, register, awake }: { rider: typeof RIDERS[number]; register: (el: HTMLElement | null) => void; awake: boolean }) {
|
||||
function RiderAvatar({ rider, register, awake, focused }: { rider: typeof RIDERS[number]; register: (el: HTMLElement | null) => void; awake: boolean; focused: boolean }) {
|
||||
return (
|
||||
<group position={[rider.x, 0, ROW_Z]}>
|
||||
<mesh position={[0, 0.014, 0]} rotation={[-Math.PI / 2, 0, 0]}><ringGeometry args={[0.52, 0.64, 36]} /><meshBasicMaterial color={GREEN} transparent opacity={0.6} side={THREE.DoubleSide} toneMapped={false} /></mesh>
|
||||
@@ -331,7 +334,7 @@ function RiderAvatar({ rider, register, awake }: { rider: typeof RIDERS[number];
|
||||
<GLBVehicle kind={rider.kind} awake={awake} />
|
||||
</Suspense>
|
||||
</group>
|
||||
{awake && (
|
||||
{focused && (
|
||||
<Html center distanceFactor={9} position={[0, 1.5, 0]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-chip" ref={register}>
|
||||
<span className="dm-st3d-chip__ico">{rider.icon}</span>
|
||||
@@ -346,7 +349,7 @@ function RiderAvatar({ rider, register, awake }: { rider: typeof RIDERS[number];
|
||||
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, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean }) {
|
||||
const IntakeHub = React.memo(function IntakeHub({ i, progress, reduced, awake, focused }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean; focused: boolean }) {
|
||||
const register = useLabelFade(i, progress, awake);
|
||||
const counter = useRef<HTMLSpanElement>(null);
|
||||
const halo = useRef<THREE.Mesh>(null);
|
||||
@@ -389,9 +392,9 @@ const IntakeHub = React.memo(function IntakeHub({ i, progress, reduced, awake }:
|
||||
{/* soft contact shadow grounding the fleet (baked once for performance) */}
|
||||
<ContactShadows position={[0, 0.02, ROW_Z]} scale={[7.5, 2.6]} resolution={512} blur={2.6} far={1.2} opacity={0.4} frames={1} color="#1e293b" />
|
||||
|
||||
{RIDERS.map((r) => <RiderAvatar key={r.id} rider={r} register={register} awake={awake} />)}
|
||||
{RIDERS.map((r) => <RiderAvatar key={r.id} rider={r} register={register} awake={awake} focused={focused} />)}
|
||||
|
||||
{awake && (
|
||||
{focused && (
|
||||
<>
|
||||
<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>
|
||||
@@ -422,7 +425,7 @@ const STRATS = [
|
||||
];
|
||||
const CORE: [number, number, number] = [0, 1.45, -2.7];
|
||||
|
||||
const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduced, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean }) {
|
||||
const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduced, awake, focused }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean; focused: boolean }) {
|
||||
const register = useLabelFade(i, progress, awake);
|
||||
const ring = useRef<THREE.Mesh>(null);
|
||||
const coreMesh = useRef<THREE.Mesh>(null);
|
||||
@@ -450,7 +453,7 @@ const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduc
|
||||
<mesh position={[0, 0.65, -2.7]}><cylinderGeometry args={[0.4, 0.62, 1.2, 24]} /><meshStandardMaterial color="#2a3350" metalness={0.5} roughness={0.4} /></mesh>
|
||||
<mesh ref={coreMesh} position={CORE}><icosahedronGeometry args={[0.6, 1]} /><meshStandardMaterial color={PURPLE} emissive={PURPLE} emissiveIntensity={1.3} toneMapped={false} flatShading /></mesh>
|
||||
<mesh ref={ring} position={CORE} rotation={[Math.PI / 2.4, 0, 0]}><torusGeometry args={[0.98, 0.03, 10, 44]} /><meshStandardMaterial color={PURPLE} emissive={PURPLE} emissiveIntensity={1.1} toneMapped={false} /></mesh>
|
||||
{awake && (
|
||||
{focused && (
|
||||
<Html center distanceFactor={9} position={[0, 2.5, -2.7]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-tag" style={{ ["--tc" as string]: PURPLE }} ref={register}>🤖 AI Engine</div>
|
||||
</Html>
|
||||
@@ -465,7 +468,7 @@ const StrategyNetwork = React.memo(function StrategyNetwork({ i, progress, reduc
|
||||
{/* flat glowing route node (consistent with the dispatch network) */}
|
||||
<mesh position={[l.end[0], 0.135, l.end[2]]} rotation={[-Math.PI / 2, 0, 0]}><ringGeometry args={[0.34, 0.44, 28]} /><meshBasicMaterial color={col} transparent opacity={l.unified ? 0.6 : 0.4} side={THREE.DoubleSide} toneMapped={false} /></mesh>
|
||||
<GlowNode position={l.end} color={col} size={l.unified ? 0.15 : 0.11} />
|
||||
{awake && (
|
||||
{focused && (
|
||||
<Html center distanceFactor={9} position={[l.end[0], l.end[1] + 0.62, l.end[2]]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className={`dm-st3d-tag ${l.unified ? "is-u" : "is-muted"}`} style={{ ["--tc" as string]: l.unified ? PURPLE : "#94a3b8" }} ref={register}>{l.name}</div>
|
||||
</Html>
|
||||
@@ -532,7 +535,7 @@ function DeliveryBadge({ pos, i, progress }: { pos: [number, number, number]; i:
|
||||
);
|
||||
}
|
||||
|
||||
const CityRouteMap = React.memo(function CityRouteMap({ i, progress, reduced, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean }) {
|
||||
const CityRouteMap = React.memo(function CityRouteMap({ i, progress, reduced, awake, focused }: { i: number; progress: React.RefObject<number>; reduced: boolean; awake: boolean; focused: boolean }) {
|
||||
const register = useLabelFade(i, progress, awake);
|
||||
// OPEN delivery path (Dispatch Hub → … → Delivery). Static "road"; only the truck moves.
|
||||
const route = useMemo(() => {
|
||||
@@ -571,7 +574,7 @@ const CityRouteMap = React.memo(function CityRouteMap({ i, progress, reduced, aw
|
||||
{/* delivery arrival pulse (only mounted/animated while this district is active) */}
|
||||
{awake && <DeliveryPulse pos={[delivery[0], 0.02, delivery[1]]} i={i} progress={progress} />}
|
||||
|
||||
{awake && (
|
||||
{focused && (
|
||||
<>
|
||||
<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>
|
||||
@@ -610,7 +613,7 @@ const KPIS = [
|
||||
{ n: "Battery", v: 100, a: 0.66 },
|
||||
];
|
||||
|
||||
const CommandCenter = React.memo(function CommandCenter({ i, progress, awake }: { i: number; progress: React.RefObject<number>; awake: boolean }) {
|
||||
const CommandCenter = React.memo(function CommandCenter({ i, progress, awake, focused }: { i: number; progress: React.RefObject<number>; awake: boolean; focused: boolean }) {
|
||||
const register = useLabelFade(i, progress, awake);
|
||||
const screens = useMemo(
|
||||
() =>
|
||||
@@ -631,7 +634,7 @@ const CommandCenter = React.memo(function CommandCenter({ i, progress, awake }:
|
||||
<RoundedBox args={[1.7, 1.15, 0.06]} radius={0.06} smoothness={2}>
|
||||
<meshStandardMaterial color="#ffffff" emissive={ORANGE} emissiveIntensity={0.18} metalness={0.1} roughness={0.4} transparent opacity={0.92} />
|
||||
</RoundedBox>
|
||||
{awake && (
|
||||
{focused && (
|
||||
<Html center distanceFactor={8} position={[0, 0, 0.05]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-kpi" style={{ ["--tc" as string]: ORANGE }} ref={register}>
|
||||
<span className="dm-st3d-kpi__n">{s.n}</span>
|
||||
@@ -643,7 +646,7 @@ const CommandCenter = React.memo(function CommandCenter({ i, progress, awake }:
|
||||
</group>
|
||||
))}
|
||||
|
||||
{awake && (
|
||||
{focused && (
|
||||
<Html center distanceFactor={9} position={[0, 3.1, -0.4]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-score" style={{ ["--tc" as string]: ORANGE }} ref={register}>Performance Grade <b>A</b> · 4.5 / 5</div>
|
||||
</Html>
|
||||
@@ -663,7 +666,7 @@ const PODIUM = [
|
||||
{ n: "Proximity", v: 64, x: 2.4, win: false },
|
||||
];
|
||||
|
||||
function Pillar({ p, register, awake }: { p: typeof PODIUM[number]; register: (el: HTMLElement | null) => void; awake: boolean }) {
|
||||
function Pillar({ p, register, focused }: { p: typeof PODIUM[number]; register: (el: HTMLElement | null) => void; focused: boolean }) {
|
||||
const h = 0.6 + (p.v / 100) * 2.2;
|
||||
const col = p.win ? RED : "#94a3b8";
|
||||
return (
|
||||
@@ -672,7 +675,7 @@ function Pillar({ p, register, awake }: { p: typeof PODIUM[number]; register: (e
|
||||
<boxGeometry args={[1.0, h, 1.0]} />
|
||||
<meshStandardMaterial color={col} emissive={col} emissiveIntensity={p.win ? 0.7 : 0.15} metalness={0.2} roughness={0.4} transparent opacity={p.win ? 0.96 : 0.7} toneMapped={false} />
|
||||
</mesh>
|
||||
{awake && (
|
||||
{focused && (
|
||||
<Html center distanceFactor={9} position={[0, h + 0.45, 0]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className={`dm-st3d-tag ${p.win ? "is-win" : "is-muted"}`} style={{ ["--tc" as string]: col }} ref={register}><b>{p.v}%</b> {p.n}</div>
|
||||
</Html>
|
||||
@@ -681,7 +684,7 @@ function Pillar({ p, register, awake }: { p: typeof PODIUM[number]; register: (e
|
||||
);
|
||||
}
|
||||
|
||||
const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, isMobile, awake }: { i: number; progress: React.RefObject<number>; reduced: boolean; isMobile: boolean; awake: boolean }) {
|
||||
const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, isMobile, awake, focused }: { i: number; progress: React.RefObject<number>; reduced: boolean; isMobile: boolean; awake: boolean; focused: boolean }) {
|
||||
const register = useLabelFade(i, progress, awake);
|
||||
const trophy = useRef<THREE.Group>(null);
|
||||
useFrame((state) => {
|
||||
@@ -697,7 +700,7 @@ const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, is
|
||||
<mesh position={[0, 0.12, -0.6]} rotation={[-Math.PI / 2, 0, 0]}><ringGeometry args={[4.1, 4.32, 56]} /><meshBasicMaterial color={RED} transparent opacity={0.35} side={THREE.DoubleSide} toneMapped={false} /></mesh>
|
||||
<mesh position={[-2.4, 0.14, -0.6]}><cylinderGeometry args={[0.85, 0.9, 0.14, 28]} /><meshStandardMaterial color={RED} emissive={RED} emissiveIntensity={0.9} toneMapped={false} /></mesh>
|
||||
|
||||
{PODIUM.map((p) => <Pillar key={p.n} p={p} register={register} awake={awake} />)}
|
||||
{PODIUM.map((p) => <Pillar key={p.n} p={p} register={register} focused={focused} />)}
|
||||
|
||||
<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]}>
|
||||
@@ -706,9 +709,9 @@ const WinnerPodium = React.memo(function WinnerPodium({ i, progress, reduced, is
|
||||
<mesh position={[0, -0.22, 0]}><cylinderGeometry args={[0.18, 0.22, 0.1, 16]} /><meshStandardMaterial color="#FFD45A" emissive="#FFB020" emissiveIntensity={1} toneMapped={false} /></mesh>
|
||||
</group>
|
||||
|
||||
{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" />}
|
||||
{awake && !reduced && <Sparkles count={isMobile ? 12 : 16} scale={[5, 4, 4]} position={[-2.4, 2.6, -0.6]} size={3.2} speed={0.5} opacity={0.9} color="#ff9aa9" />}
|
||||
|
||||
{awake && (
|
||||
{focused && (
|
||||
<Html center distanceFactor={9} position={[0.7, 3.0, -0.6]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-winner3d" ref={register}>
|
||||
<span className="dm-st3d-winner3d__top">🏆 Best Strategy</span>
|
||||
@@ -818,14 +821,18 @@ function Scene({ progress, reduced, isMobile, stage, active, perf }: { progress:
|
||||
<Floor />
|
||||
<DataRoad />
|
||||
|
||||
<IntakeHub i={0} progress={progress} reduced={reduced} awake={near(0)} />
|
||||
<StrategyNetwork i={1} progress={progress} reduced={reduced} awake={near(1)} />
|
||||
<CityRouteMap i={2} progress={progress} reduced={reduced} awake={near(2)} />
|
||||
<CommandCenter i={3} progress={progress} awake={near(3)} />
|
||||
<WinnerPodium i={4} progress={progress} reduced={reduced} isMobile={isMobile} awake={near(4)} />
|
||||
{/* `awake` (±1) keeps geometry/shaders/animation warm for a pop-free
|
||||
transition; `focused` (current district only) gates the expensive
|
||||
in-canvas <Html> labels so only ~one district's worth project + write
|
||||
to the DOM each frame instead of three. */}
|
||||
<IntakeHub i={0} progress={progress} reduced={reduced} awake={near(0)} focused={stage === 0} />
|
||||
<StrategyNetwork i={1} progress={progress} reduced={reduced} awake={near(1)} focused={stage === 1} />
|
||||
<CityRouteMap i={2} progress={progress} reduced={reduced} awake={near(2)} focused={stage === 2} />
|
||||
<CommandCenter i={3} progress={progress} awake={near(3)} focused={stage === 3} />
|
||||
<WinnerPodium i={4} progress={progress} reduced={reduced} isMobile={isMobile} awake={near(4)} focused={stage === 4} />
|
||||
|
||||
{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={16} scale={[18, 7, N * 13]} position={[0, 3, (-(N - 1) * 13) / 2]} size={2} speed={0.22} opacity={0.35} color="#9aa6c4" />
|
||||
)}
|
||||
|
||||
{heavyFx && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { motion, useMotionValue, useTransform, type MotionValue } from "framer-motion";
|
||||
import gsap from "gsap";
|
||||
@@ -12,6 +12,23 @@ const StrategyCanvas = dynamic(() => import("./StrategyCanvas"), { ssr: false })
|
||||
/** Center of each stage's scroll window (0…1). */
|
||||
const CENTER = (i: number) => i / (N - 1);
|
||||
|
||||
/** True only while a card's own opacity window is open (tiny buffer). Keeps
|
||||
* not-yet-reached stage cards out of the DOM / off the compositor until their
|
||||
* stage is on screen — no future workflow state is rendered before activation.
|
||||
* Visually identical: a card outside its window is opacity:0 regardless. */
|
||||
function useInWindow(mv: MotionValue<number>, threshold = 0.01): boolean {
|
||||
// `mv` is an external mutable store (a MotionValue). useTransform `.set()`s its
|
||||
// output synchronously while the PARENT renders, so a plain `.on("change") -> setState`
|
||||
// updates this component during the parent's render (React warns). useSyncExternalStore
|
||||
// is built for exactly this: it reads a snapshot and reconciles store-changes-during-
|
||||
// render safely. The snapshot is a primitive boolean, so it never re-renders needlessly.
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => mv.on("change", onStoreChange),
|
||||
() => mv.get() > threshold,
|
||||
() => mv.get() > threshold,
|
||||
);
|
||||
}
|
||||
|
||||
/** Persistent top rail: the 5 stages, current one highlighted. */
|
||||
function StageRail({ active }: { active: number }) {
|
||||
return (
|
||||
@@ -45,9 +62,13 @@ function StageCard({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const c = CENTER(i);
|
||||
const opacity = useTransform(scroll, [c - 0.14, c - 0.06, c + 0.06, c + 0.14], [0, 1, 1, 0]);
|
||||
const y = useTransform(scroll, [c - 0.14, c - 0.05], [34, 0]);
|
||||
// Window kept < the 0.25 per-stage span (5 stages) so two adjacent stage cards
|
||||
// never overlap — one stage's card is fully out before the next fades in.
|
||||
const opacity = useTransform(scroll, [c - 0.1, c - 0.05, c + 0.05, c + 0.1], [0, 1, 1, 0]);
|
||||
const y = useTransform(scroll, [c - 0.1, c - 0.05], [34, 0]);
|
||||
const s = STAGES[i];
|
||||
// Only mount the card while its stage's cross-fade window is open.
|
||||
if (!useInWindow(opacity)) return null;
|
||||
return (
|
||||
<motion.div
|
||||
className={`dm-st-card-story is-${side}`}
|
||||
@@ -123,7 +144,9 @@ export default function StrategySection({ connected = false }: { connected?: boo
|
||||
trigger: el,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 0.5,
|
||||
// Match Workflow 1's responsiveness (0.4) for consistent pacing across all
|
||||
// three workflows — 0.5 made the dolly feel slower / disconnected here.
|
||||
scrub: 0.4,
|
||||
invalidateOnRefresh: true,
|
||||
onUpdate: (self) => {
|
||||
const p = self.progress;
|
||||
@@ -135,7 +158,7 @@ export default function StrategySection({ connected = false }: { connected?: boo
|
||||
if (na !== lastActive) { lastActive = na; setActive(na); }
|
||||
},
|
||||
});
|
||||
const refresh = setTimeout(() => ScrollTrigger.refresh(), 300);
|
||||
const refresh = setTimeout(() => ScrollTrigger.refresh(), 120);
|
||||
return () => { clearTimeout(refresh); st.kill(); };
|
||||
}, [scroll]);
|
||||
|
||||
@@ -154,10 +177,14 @@ export default function StrategySection({ connected = false }: { connected?: boo
|
||||
<div className="dm-st-card">
|
||||
{mountScene && (
|
||||
<div className="dm-st-canvas">
|
||||
<StrategyCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive} stage={active} />
|
||||
<StrategyCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive && pinState === "pinned"} stage={active} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay mounts only once the section is pinned/activated — its content
|
||||
can never be seen during the approach ("before"), where the sticky sits
|
||||
at the top of the tall section near the previous workflow's seam. */}
|
||||
{pinState !== "before" && (
|
||||
<div className="dm-st-ui">
|
||||
{/* Persistent header */}
|
||||
<motion.div className="dm-st-top" style={{ opacity: headerOpacity }}>
|
||||
@@ -229,6 +256,7 @@ export default function StrategySection({ connected = false }: { connected?: boo
|
||||
</div>
|
||||
</StageCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<style>{styles}</style>
|
||||
@@ -237,8 +265,12 @@ export default function StrategySection({ connected = false }: { connected?: boo
|
||||
}
|
||||
|
||||
const styles = `
|
||||
.dm-st { position: relative; height: 720vh; background: transparent; }
|
||||
.dm-st-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden; }
|
||||
/* Scroll length tuned for pacing: ~100vh per stage (was 144vh) so the 5 stages
|
||||
complete in noticeably less scrolling and the workflow feels tighter / faster.
|
||||
Stage cross-fade windows are progress-based (0…1), so they stay aligned. */
|
||||
.dm-st { position: relative; height: 500vh; background: transparent; }
|
||||
.dm-st-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden;
|
||||
will-change: transform; transform: translateZ(0); backface-visibility: hidden; }
|
||||
.dm-st.is-pinned .dm-st-sticky { position: fixed; top: 0; left: 0; }
|
||||
.dm-st.is-after .dm-st-sticky { position: absolute; top: auto; bottom: 0; }
|
||||
|
||||
@@ -257,6 +289,9 @@ const styles = `
|
||||
.dm-st.is-connected .dm-st-card {
|
||||
top: 20px !important; left: 20px !important; right: 20px !important; bottom: 0 !important;
|
||||
border-radius: 28px 28px 0 0 !important; border-bottom: none !important;
|
||||
/* Flush against the Strategy card below — drop the heavy downward shadow so it
|
||||
doesn't cast a dark band onto that card's top edge (the two read as one container). */
|
||||
box-shadow: none !important;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-st.is-connected .dm-st-card {
|
||||
@@ -397,7 +432,7 @@ const styles = `
|
||||
.dm-st-rail__line { width: 9px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-st { height: 640vh; }
|
||||
.dm-st { height: 420vh; }
|
||||
.dm-st-card-story { left: 0 !important; right: 0 !important; margin: 0 auto; width: calc(100% - 28px);
|
||||
bottom: clamp(18px, 4vh, 40px); padding: 15px 16px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user