fix how it work loading screen
This commit is contained in:
@@ -9,6 +9,7 @@ import MidMile from './components/sections/MidMile'
|
||||
import LastMile from './components/sections/LastMile'
|
||||
import Promise from './components/sections/Promise'
|
||||
import { useSceneStore } from './store/useSceneStore'
|
||||
import { useDeviceCaps } from './utils/deviceTier'
|
||||
import './styles/experience.css'
|
||||
|
||||
import Lenis from 'lenis'
|
||||
@@ -18,39 +19,98 @@ import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
|
||||
/**
|
||||
* Experience3D
|
||||
* ---------------------------------------------------------------------------
|
||||
* The full scroll-driven 3D logistics story, ported from the standalone Vite
|
||||
* app's App.jsx and embedded as the body of the How It Works page (below the
|
||||
* existing Elementor hero, above the global Footer).
|
||||
* Experience3D — the scroll-driven 3D logistics story embedded in the How It
|
||||
* Works page.
|
||||
*
|
||||
* Two integration changes vs. the standalone app:
|
||||
* 1. Self-managed fixed pin. The site has a fixed header and an ancestor with
|
||||
* `overflow:hidden`, both of which break CSS `position: sticky`. So this is
|
||||
* a tall `position:relative` section (`.dm-hiw-3d`, its height supplied by
|
||||
* the 900vh ScrollRig spacer) with an absolutely-positioned `.dm-hiw-3d-stage`
|
||||
* toggled absolute(top) → fixed → absolute(bottom) via the ScrollTrigger pin
|
||||
* state — the same approach the site's other 3D sections use (StrategySection).
|
||||
* 2. The global Lenis is disabled on `/how-it-works` (SmoothScroll.tsx) so the
|
||||
* experience runs its own tuned Lenis here without a second instance fighting
|
||||
* it. The internal "Scroll to start" Hero overlay is dropped because the page
|
||||
* keeps the Elementor HowItWorksHero above this section.
|
||||
* PERF refactor (this pass):
|
||||
* 1. Device tiering. `useDeviceCaps()` classifies desktop / tablet / mobile and
|
||||
* a `fallback` flag. The tier flows into the Canvas (dpr/shadows/AA),
|
||||
* Scene3D (LOD visibility), and ScrollRig (scroll length).
|
||||
* 2. Static fallback. Reduced-motion / no-WebGL / very-low-memory devices get a
|
||||
* poster instead of a live WebGL scene.
|
||||
* 3. No per-frame React renders at this level. This component no longer
|
||||
* subscribes to `scrollProgress`; the end-of-scroll canvas fade is applied
|
||||
* imperatively, and the story overlays live in <StorySections>, which only
|
||||
* re-renders when a section boolean flips.
|
||||
* 4. Touch-aware smooth scroll. Lenis runs on desktop only (driven by a
|
||||
* dedicated rAF, not gsap.ticker; syncTouch off). Touch devices use native
|
||||
* momentum scrolling — emulated inertia on a heavy WebGL page is the main
|
||||
* cause of mobile/tablet scroll lag.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Story text panels. Isolated so its boolean subscriptions don't re-render the
|
||||
* whole experience: each selector returns a boolean, so React only re-renders
|
||||
* this small component when a section actually enters/leaves its range.
|
||||
*/
|
||||
function StorySections() {
|
||||
const firstActive = useSceneStore((s) => s.scrollProgress >= 0.02 && s.scrollProgress < 0.14)
|
||||
const midActive = useSceneStore((s) => s.scrollProgress >= 0.38 && s.scrollProgress < 0.50)
|
||||
const lastActive = useSceneStore((s) => s.scrollProgress >= 0.78 && s.scrollProgress < 0.875)
|
||||
const promiseActive = useSceneStore((s) => s.scrollProgress >= 0.90)
|
||||
|
||||
return (
|
||||
<div className="sections-overlay-container">
|
||||
<FirstMile active={firstActive} />
|
||||
<MidMile active={midActive} />
|
||||
<LastMile active={lastActive} />
|
||||
<Promise active={promiseActive} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Lightweight poster shown when a live scene isn't appropriate/possible. */
|
||||
function StaticFallback() {
|
||||
return (
|
||||
<section
|
||||
className="dm-hiw-3d-fallback"
|
||||
style={{
|
||||
position: 'relative',
|
||||
minHeight: '70vh',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
background:
|
||||
'linear-gradient(180deg, #eef1f5 0%, #dfe5ec 55%, #cfd7e0 100%)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Optional poster image; if it 404s we keep the gradient + caption. */}
|
||||
<img
|
||||
src="/images/hiw-3d-fallback.jpg"
|
||||
alt="Doormile delivery journey — first mile to last mile"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
<div style={{ position: 'relative', padding: '2rem clamp(1rem, 5vw, 4rem)', maxWidth: 720 }}>
|
||||
<p style={{ fontWeight: 700, fontSize: 'clamp(1.25rem, 3vw, 2rem)', lineHeight: 1.2, margin: 0 }}>
|
||||
From first mile to last mile, every delivery tracked.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Experience3D() {
|
||||
const scrollProgress = useSceneStore((state) => state.scrollProgress)
|
||||
const setLenis = useSceneStore((state) => state.setLenis)
|
||||
const caps = useDeviceCaps() // null until mounted on the client
|
||||
|
||||
const containerRef = useRef(null)
|
||||
const canvasWrapperRef = useRef(null)
|
||||
const [pinState, setPinState] = useState('before')
|
||||
// Defer mounting the WebGL Canvas until the section nears the viewport. This
|
||||
// mirrors the site's other 3D sections (StrategySection's `mountScene`): besides
|
||||
// saving the heavy 32MB scene until needed, it keeps the Canvas out of React
|
||||
// StrictMode's initial synchronous double-mount, which otherwise creates and
|
||||
// immediately loses the WebGL context in dev ("THREE.WebGLRenderer: Context Lost"),
|
||||
// leaving a blank canvas. Once mounted it stays mounted.
|
||||
const [mountScene, setMountScene] = useState(false)
|
||||
|
||||
const tier = caps?.tier ?? 'desktop'
|
||||
const useFallback = caps?.fallback ?? false
|
||||
const isTouch = caps?.isTouch ?? false
|
||||
const liveScene = caps != null && !useFallback
|
||||
|
||||
// Defer mounting the WebGL Canvas until the section nears the viewport.
|
||||
useEffect(() => {
|
||||
if (!liveScene) return
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const io = new IntersectionObserver(
|
||||
@@ -64,104 +124,107 @@ export default function Experience3D() {
|
||||
)
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [])
|
||||
}, [liveScene])
|
||||
|
||||
// Refresh ScrollTrigger when the scene actually mounts. WebGL canvas mounting
|
||||
// can block the main thread and shift elements, so a refresh here is critical.
|
||||
// Refresh ScrollTrigger once the scene mounts (canvas creation can shift layout).
|
||||
useEffect(() => {
|
||||
if (mountScene) {
|
||||
const timer = setTimeout(() => {
|
||||
ScrollTrigger.refresh()
|
||||
}, 150)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
if (!mountScene) return
|
||||
const timer = setTimeout(() => ScrollTrigger.refresh(), 150)
|
||||
return () => clearTimeout(timer)
|
||||
}, [mountScene])
|
||||
|
||||
// Own Lenis instance (global Lenis is gated off for this route).
|
||||
// Smooth scroll — DESKTOP ONLY. Touch devices keep native momentum (native is
|
||||
// smoother than emulated inertia on a heavy WebGL page, and avoids the
|
||||
// touch-scroll lag). Driven by a dedicated rAF rather than gsap.ticker.
|
||||
useEffect(() => {
|
||||
if (!liveScene || isTouch) return
|
||||
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
lerp: 0.08,
|
||||
syncTouch: true,
|
||||
syncTouch: false, // never emulate touch inertia
|
||||
})
|
||||
|
||||
setLenis(lenis)
|
||||
lenis.on('scroll', ScrollTrigger.update)
|
||||
|
||||
// Drive Lenis using GSAP's ticker to ensure synchronization with ScrollTrigger
|
||||
const tickerCb = (time) => lenis.raf(time * 1000)
|
||||
gsap.ticker.add(tickerCb)
|
||||
gsap.ticker.lagSmoothing(0)
|
||||
let rafId
|
||||
const raf = (time) => {
|
||||
lenis.raf(time)
|
||||
rafId = requestAnimationFrame(raf)
|
||||
}
|
||||
rafId = requestAnimationFrame(raf)
|
||||
ScrollTrigger.refresh()
|
||||
|
||||
return () => {
|
||||
gsap.ticker.remove(tickerCb)
|
||||
cancelAnimationFrame(rafId)
|
||||
lenis.destroy()
|
||||
setLenis(null)
|
||||
}
|
||||
}, [setLenis])
|
||||
}, [liveScene, isTouch, setLenis])
|
||||
|
||||
// End-of-scroll canvas fade — applied imperatively so it never triggers a
|
||||
// React render. Subscribes to the store but only touches the DOM on flip.
|
||||
useEffect(() => {
|
||||
if (!mountScene) return
|
||||
const el = canvasWrapperRef.current
|
||||
if (!el) return
|
||||
let lastDim = null
|
||||
const apply = (p) => {
|
||||
const dim = p >= 0.92
|
||||
if (dim !== lastDim) {
|
||||
lastDim = dim
|
||||
el.style.opacity = dim ? '0.85' : '1'
|
||||
}
|
||||
}
|
||||
apply(useSceneStore.getState().scrollProgress)
|
||||
const unsub = useSceneStore.subscribe((s) => apply(s.scrollProgress))
|
||||
return unsub
|
||||
}, [mountScene])
|
||||
|
||||
// 3D references shared between R3F and the GSAP scroll system.
|
||||
const truckRef = useRef(null)
|
||||
const wheelRefs = React.useMemo(
|
||||
() => [{ current: null }, { current: null }, { current: null }, { current: null }],
|
||||
[],
|
||||
)
|
||||
// Kept for API compatibility (Scene3D no longer wires these; dashboard refs
|
||||
// were never attached in the generated model — the animation is a no-op).
|
||||
const dashboardRefs = React.useMemo(
|
||||
() => ({ bars: [], floorBars: [], pieQuarters: [] }),
|
||||
[],
|
||||
)
|
||||
|
||||
const wheelRefs = React.useMemo(() => [
|
||||
{ current: null }, // FR
|
||||
{ current: null }, // FL
|
||||
{ current: null }, // RL
|
||||
{ current: null }, // RR
|
||||
], [])
|
||||
|
||||
const dashboardRefs = React.useMemo(() => ({
|
||||
bars: [
|
||||
{ current: null }, { current: null }, { current: null },
|
||||
{ current: null }, { current: null }, { current: null }
|
||||
],
|
||||
floorBars: [
|
||||
{ current: null }, { current: null }, { current: null },
|
||||
{ current: null }, { current: null }
|
||||
],
|
||||
pieQuarters: [
|
||||
{ current: null }, { current: null }, { current: null }, { current: null }
|
||||
]
|
||||
}), [])
|
||||
// Pre-mount (caps unknown) / fallback: render a reserved placeholder or poster.
|
||||
if (caps == null) {
|
||||
return <div ref={containerRef} className="dm-hiw-3d" style={{ minHeight: '100vh' }} aria-hidden />
|
||||
}
|
||||
if (useFallback) {
|
||||
return <StaticFallback />
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`dm-hiw-3d is-${pinState}`}>
|
||||
{/* Pinned stage: canvas + HTML overlays. Stays fixed across the scroll. */}
|
||||
<div className="dm-hiw-3d-stage">
|
||||
<div
|
||||
ref={canvasWrapperRef}
|
||||
className="canvas-wrapper"
|
||||
style={{
|
||||
opacity: scrollProgress >= 0.92 ? 0.85 : 1.0,
|
||||
transition: 'opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
style={{ transition: 'opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1)' }}
|
||||
>
|
||||
{mountScene && (
|
||||
<Experience
|
||||
truckRef={truckRef}
|
||||
wheelRefs={wheelRefs}
|
||||
dashboardRefs={dashboardRefs}
|
||||
tier={tier}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* In-experience section navigation */}
|
||||
<Navbar />
|
||||
|
||||
{/* Story stage text panels (revealed at their scroll ranges) */}
|
||||
<div className="sections-overlay-container">
|
||||
<FirstMile active={scrollProgress >= 0.02 && scrollProgress < 0.14} />
|
||||
<MidMile active={scrollProgress >= 0.38 && scrollProgress < 0.50} />
|
||||
<LastMile active={scrollProgress >= 0.78 && scrollProgress < 0.875} />
|
||||
{/* Final card: reveals as the journey closes (fills the slot the old
|
||||
workflow timeline card used to occupy — no blank gap). */}
|
||||
<Promise active={scrollProgress >= 0.90} />
|
||||
</div>
|
||||
<StorySections />
|
||||
</div>
|
||||
|
||||
{/* GSAP scroll system: 900vh in-flow spacer that gives the section its
|
||||
height, drives scroll progress, and reports pin state. */}
|
||||
<ScrollRig dashboardRefs={dashboardRefs} onPinState={setPinState} />
|
||||
<ScrollRig dashboardRefs={dashboardRefs} onPinState={setPinState} tier={tier} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user