fix how it work loading issue
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useEffect, useState } from 'react'
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import Experience from './components/Experience'
|
||||
import ScrollRig from './components/ScrollRig'
|
||||
import Navbar from './components/ui/Navbar'
|
||||
@@ -44,7 +44,9 @@ gsap.registerPlugin(ScrollTrigger)
|
||||
* 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)
|
||||
// First Mile is active from the very top (progress 0) so its card is visible
|
||||
// the instant the user enters the section — no scroll required.
|
||||
const firstActive = useSceneStore((s) => 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)
|
||||
@@ -59,6 +61,46 @@ function StorySections() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Branded loading state shown over the stage while the GLB scene loads. Never
|
||||
* a blank canvas — fades out once the scene signals ready. */
|
||||
function BrandedLoader({ hidden }) {
|
||||
return (
|
||||
<div
|
||||
className="dm-hiw-3d-loader"
|
||||
aria-hidden={hidden}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '18px',
|
||||
background: 'linear-gradient(180deg, #f5f5f7 0%, #e9edf2 100%)',
|
||||
opacity: hidden ? 0 : 1,
|
||||
pointerEvents: hidden ? 'none' : 'auto',
|
||||
transition: 'opacity 0.6s ease',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: '50%',
|
||||
border: '3px solid rgba(192,18,39,0.18)',
|
||||
borderTopColor: '#c01227',
|
||||
animation: 'dm-hiw-spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontWeight: 600, letterSpacing: '0.01em', color: '#1f1f1f', fontSize: '0.95rem' }}>
|
||||
Loading Doormile Experience…
|
||||
</span>
|
||||
<style>{`@keyframes dm-hiw-spin{to{transform:rotate(360deg)}}`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Lightweight poster shown when a live scene isn't appropriate/possible. */
|
||||
function StaticFallback() {
|
||||
return (
|
||||
@@ -102,6 +144,10 @@ export default function Experience3D() {
|
||||
const canvasWrapperRef = useRef(null)
|
||||
const [pinState, setPinState] = useState('before')
|
||||
const [mountScene, setMountScene] = useState(false)
|
||||
const [sceneReady, setSceneReady] = useState(false)
|
||||
|
||||
// Stable callback handed to the in-Canvas readiness signal.
|
||||
const handleSceneReady = useCallback(() => setSceneReady(true), [])
|
||||
|
||||
const tier = caps?.tier ?? 'desktop'
|
||||
const useFallback = caps?.fallback ?? false
|
||||
@@ -126,12 +172,9 @@ export default function Experience3D() {
|
||||
return () => io.disconnect()
|
||||
}, [liveScene])
|
||||
|
||||
// Refresh ScrollTrigger once the scene mounts (canvas creation can shift layout).
|
||||
useEffect(() => {
|
||||
if (!mountScene) return
|
||||
const timer = setTimeout(() => ScrollTrigger.refresh(), 150)
|
||||
return () => clearTimeout(timer)
|
||||
}, [mountScene])
|
||||
// (ScrollTrigger refreshing is owned by ScrollRig now — it refreshes on the
|
||||
// next frame, on every layout settle via ResizeObserver/fonts.ready, and again
|
||||
// when `ready` flips true. No arbitrary timeouts.)
|
||||
|
||||
// Smooth scroll — DESKTOP ONLY. Touch devices keep native momentum (native is
|
||||
// smoother than emulated inertia on a heavy WebGL page, and avoids the
|
||||
@@ -216,15 +259,20 @@ export default function Experience3D() {
|
||||
wheelRefs={wheelRefs}
|
||||
dashboardRefs={dashboardRefs}
|
||||
tier={tier}
|
||||
onReady={handleSceneReady}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Branded loader while the GLB loads — no blank canvas. Mounts with the
|
||||
Canvas, fades out the moment the scene is ready. */}
|
||||
{mountScene && <BrandedLoader hidden={sceneReady} />}
|
||||
|
||||
<Navbar />
|
||||
<StorySections />
|
||||
</div>
|
||||
|
||||
<ScrollRig dashboardRefs={dashboardRefs} onPinState={setPinState} tier={tier} />
|
||||
<ScrollRig dashboardRefs={dashboardRefs} onPinState={setPinState} tier={tier} ready={sceneReady} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useEffect } from 'react'
|
||||
import React, { useRef, useEffect, Suspense } from 'react'
|
||||
import { Canvas, useFrame } from '@react-three/fiber'
|
||||
import { Environment, SoftShadows } from '@react-three/drei'
|
||||
import * as THREE from 'three'
|
||||
@@ -78,7 +78,28 @@ const SceneLighting = React.memo(function SceneLighting({ truckRef, shadows, sha
|
||||
)
|
||||
})
|
||||
|
||||
function Experience({ dashboardRefs, wheelRefs, truckRef, tier = 'desktop' }) {
|
||||
/**
|
||||
* Fires `onReady` once the GLB has resolved (this only mounts inside the Suspense
|
||||
* boundary, after the model loaded and Scene3D's useLayoutEffect wired the refs)
|
||||
* AND the first real frame has painted (double rAF = one composited frame). This
|
||||
* is the deterministic "scene ready" signal — no timeouts.
|
||||
*/
|
||||
function SceneReadySignal({ onReady }) {
|
||||
useEffect(() => {
|
||||
let r1 = 0
|
||||
let r2 = 0
|
||||
r1 = requestAnimationFrame(() => {
|
||||
r2 = requestAnimationFrame(() => onReady?.())
|
||||
})
|
||||
return () => {
|
||||
cancelAnimationFrame(r1)
|
||||
cancelAnimationFrame(r2)
|
||||
}
|
||||
}, [onReady])
|
||||
return null
|
||||
}
|
||||
|
||||
function Experience({ dashboardRefs, wheelRefs, truckRef, tier = 'desktop', onReady }) {
|
||||
const cfg = TIER[tier] ?? TIER.desktop
|
||||
|
||||
return (
|
||||
@@ -103,16 +124,24 @@ function Experience({ dashboardRefs, wheelRefs, truckRef, tier = 'desktop' }) {
|
||||
{/* Decorative street spotlights (visually off in the day scene) — desktop only. */}
|
||||
{cfg.streetLights && <StreetLights />}
|
||||
|
||||
{/* Image-based lighting. The "city" HDR is a network fetch + GPU cost; on
|
||||
mobile we skip it and lean on the hemisphere fill below instead. */}
|
||||
{/* Image-based lighting in its OWN boundary. The "city" HDR is an
|
||||
external fetch; keeping it separate means scene readiness is gated on
|
||||
the (local, fast) GLB only — the HDR can pop in a beat later without
|
||||
delaying the loader→ready handoff. Mobile skips it for a cheap fill. */}
|
||||
{cfg.environment ? (
|
||||
<Environment preset="city" />
|
||||
<Suspense fallback={null}>
|
||||
<Environment preset="city" />
|
||||
</Suspense>
|
||||
) : (
|
||||
<hemisphereLight args={['#ffffff', '#9aa0a6', 0.9]} />
|
||||
)}
|
||||
|
||||
{/* Main scene — single <primitive>, tier drives LOD visibility + shadows. */}
|
||||
<SceneModel dashboardRefs={dashboardRefs} truckRef={truckRef} wheelRefs={wheelRefs} tier={tier} />
|
||||
{/* Main scene + readiness signal. SceneReadySignal mounts only once the
|
||||
GLB has resolved (same boundary) → deterministic "ready". */}
|
||||
<Suspense fallback={null}>
|
||||
<SceneModel dashboardRefs={dashboardRefs} truckRef={truckRef} wheelRefs={wheelRefs} tier={tier} />
|
||||
<SceneReadySignal onReady={onReady} />
|
||||
</Suspense>
|
||||
|
||||
<TruckAnimation truckRef={truckRef} wheelRefs={wheelRefs} />
|
||||
<CameraRig />
|
||||
|
||||
@@ -13,100 +13,132 @@ gsap.registerPlugin(ScrollTrigger)
|
||||
// less laggy travel (and less time touch-scrolling a heavy WebGL page on mobile).
|
||||
const SCROLL_HEIGHT_VH = { desktop: 600, tablet: 550, mobile: 500 }
|
||||
|
||||
export default function ScrollRig({ dashboardRefs, onPinState, tier = 'desktop' }) {
|
||||
/**
|
||||
* ScrollRig
|
||||
* ---------------------------------------------------------------------------
|
||||
* Owns the single ScrollTrigger that drives the whole experience (progress, pin
|
||||
* state, active section).
|
||||
*
|
||||
* FIRST-LOAD CORRECTNESS (the bug this fixes):
|
||||
* The stage is pinned by a manual `position: fixed` toggle driven by this
|
||||
* trigger. On a client-side route navigation (Home → /how-it-works) the trigger
|
||||
* could be created against a not-yet-settled layout (the heavy Elementor hero +
|
||||
* web fonts above it are still loading), so its start/end were wrong and it never
|
||||
* fired — the section didn't pin and `scrollProgress` never advanced, so cards
|
||||
* and section activation were dead until a manual refresh (which loads against a
|
||||
* settled, cached layout).
|
||||
*
|
||||
* The fix is lifecycle-driven, NOT timeout-based:
|
||||
* - `onRefresh` re-syncs the store from the trigger's CURRENT progress every
|
||||
* time the layout settles, so state is correct without needing a scroll event.
|
||||
* ScrollTrigger fires onRefresh on creation too, so First Mile is correct on
|
||||
* mount.
|
||||
* - A debounced ResizeObserver on the document refreshes whenever the page
|
||||
* height changes (hero/fonts/images settling, scene mounting, orientation).
|
||||
* - `document.fonts.ready` triggers a refresh once web fonts are applied.
|
||||
* - The `ready` prop (scene loaded) triggers a final refresh.
|
||||
*/
|
||||
export default function ScrollRig({ dashboardRefs, onPinState, tier = 'desktop', ready = false }) {
|
||||
const setScrollProgress = useSceneStore((state) => state.setScrollProgress)
|
||||
const setActiveSection = useSceneStore((state) => state.setActiveSection)
|
||||
const lenis = useSceneStore((state) => state.lenis)
|
||||
const containerRef = useRef(null)
|
||||
const activeSectionRef = useRef(0)
|
||||
const pinStateRef = useRef('before')
|
||||
const triggerRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current
|
||||
if (!element) return
|
||||
|
||||
// Create the ScrollTrigger to track the scrolling progress of the 900vh height container
|
||||
// Single place that maps a progress value → all derived state. Called from
|
||||
// BOTH onUpdate (scroll) and onRefresh (layout settle) so the store is always
|
||||
// correct for the current scroll position, even with no scroll interaction.
|
||||
const syncProgress = (progress) => {
|
||||
setScrollProgress(progress)
|
||||
|
||||
const ns = progress <= 0.0002 ? 'before' : progress >= 0.9998 ? 'after' : 'pinned'
|
||||
if (ns !== pinStateRef.current) {
|
||||
pinStateRef.current = ns
|
||||
onPinState?.(ns)
|
||||
}
|
||||
|
||||
let section = 0
|
||||
if (progress >= 0.92) section = 3
|
||||
else if (progress >= 0.5) section = 2
|
||||
else if (progress >= 0.12) section = 1
|
||||
if (section !== activeSectionRef.current) {
|
||||
activeSectionRef.current = section
|
||||
setActiveSection(section)
|
||||
}
|
||||
|
||||
if (dashboardRefs) {
|
||||
const dp = progress >= 0.92 ? (progress - 0.92) / 0.08 : 0
|
||||
animateDashboard(dashboardRefs.bars || [], dashboardRefs.pieQuarters || [], dp)
|
||||
}
|
||||
}
|
||||
|
||||
const trigger = ScrollTrigger.create({
|
||||
trigger: element,
|
||||
start: 'top top',
|
||||
end: 'bottom bottom',
|
||||
scrub: 2.5, // Even slower, weightier scroll follow for premium feel
|
||||
scrub: 2.5,
|
||||
invalidateOnRefresh: true,
|
||||
onUpdate: (self) => {
|
||||
const progress = self.progress
|
||||
setScrollProgress(progress)
|
||||
|
||||
// Report pin state so the parent toggles the stage between
|
||||
// absolute(top) → fixed → absolute(bottom). Mirrors StrategySection.
|
||||
const ns = progress <= 0.0002 ? 'before' : progress >= 0.9998 ? 'after' : 'pinned'
|
||||
if (ns !== pinStateRef.current) {
|
||||
pinStateRef.current = ns
|
||||
onPinState?.(ns)
|
||||
}
|
||||
|
||||
// Determine the active stage section
|
||||
// Section 0 (First Mile): 0% to 12%
|
||||
// Section 1 (Mid Mile): 12% to 50%
|
||||
// Section 2 (Last Mile): 50% to 76%
|
||||
// Section 3 (Analytics): 76% to 100%
|
||||
let section = 0
|
||||
if (progress >= 0.92) {
|
||||
section = 3
|
||||
} else if (progress >= 0.50) {
|
||||
section = 2
|
||||
} else if (progress >= 0.12) {
|
||||
section = 1
|
||||
}
|
||||
|
||||
// Only push to the store when the section actually changes — calling
|
||||
// setActiveSection every frame needlessly re-ran Navbar's subscriber.
|
||||
if (section !== activeSectionRef.current) {
|
||||
activeSectionRef.current = section
|
||||
setActiveSection(section)
|
||||
}
|
||||
|
||||
// Trigger dashboard animations inside R3F when entering the analytics stage (progress >= 0.92)
|
||||
if (dashboardRefs) {
|
||||
if (progress >= 0.92) {
|
||||
const dashboardProgress = (progress - 0.92) / 0.08
|
||||
animateDashboard(
|
||||
dashboardRefs.bars || [],
|
||||
dashboardRefs.pieQuarters || [],
|
||||
dashboardProgress
|
||||
)
|
||||
} else {
|
||||
// Keep reset when out of analytics section
|
||||
animateDashboard(
|
||||
dashboardRefs.bars || [],
|
||||
dashboardRefs.pieQuarters || [],
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onUpdate: (self) => syncProgress(self.progress),
|
||||
// Re-sync after every refresh (incl. the initial one) so state reflects the
|
||||
// settled layout immediately — this is what makes first-load work without
|
||||
// a manual page refresh.
|
||||
onRefresh: (self) => syncProgress(self.progress),
|
||||
})
|
||||
const refreshTimeout = setTimeout(() => {
|
||||
ScrollTrigger.refresh()
|
||||
}, 150)
|
||||
triggerRef.current = trigger
|
||||
|
||||
// Refresh on the next frame (after the browser has laid the new DOM out) —
|
||||
// a paint-synced wait, not an arbitrary millisecond delay.
|
||||
const raf = requestAnimationFrame(() => ScrollTrigger.refresh())
|
||||
|
||||
// Catch late layout shifts: the Elementor hero + web fonts + images above
|
||||
// settle asynchronously on a fresh navigation, changing the document height
|
||||
// and therefore this trigger's start/end. Refresh (debounced to one per
|
||||
// frame) whenever that happens.
|
||||
let roRaf = 0
|
||||
const ro = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(roRaf)
|
||||
roRaf = requestAnimationFrame(() => ScrollTrigger.refresh())
|
||||
})
|
||||
ro.observe(document.documentElement)
|
||||
|
||||
// Web fonts change text metrics → the hero's height → our start position.
|
||||
let fontsCancelled = false
|
||||
if (document.fonts?.ready) {
|
||||
document.fonts.ready.then(() => {
|
||||
if (!fontsCancelled) ScrollTrigger.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
cancelAnimationFrame(roRaf)
|
||||
ro.disconnect()
|
||||
fontsCancelled = true
|
||||
trigger.kill()
|
||||
clearTimeout(refreshTimeout)
|
||||
triggerRef.current = null
|
||||
}
|
||||
}, [setScrollProgress, setActiveSection, dashboardRefs, lenis, onPinState])
|
||||
}, [setScrollProgress, setActiveSection, dashboardRefs, onPinState])
|
||||
|
||||
// When the 3D scene finishes loading it can shift layout / it confirms the
|
||||
// experience is fully mounted — do a final authoritative refresh.
|
||||
useEffect(() => {
|
||||
if (ready) ScrollTrigger.refresh()
|
||||
}, [ready])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
id="scroll-trigger-trigger"
|
||||
style={{
|
||||
// In normal flow so it gives the `.dm-hiw-3d` section its 900vh height
|
||||
// (the footer follows cleanly after it). The pinned stage is a separate
|
||||
// absolutely/fixed-positioned sibling.
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
// Tier-driven scroll length (was a fixed 900vh). Shorter travel = less
|
||||
// scrub lag, especially while touch-scrolling on mobile/tablet.
|
||||
// scrub lag, especially while touch-scrolling a heavy WebGL page.
|
||||
height: `${SCROLL_HEIGHT_VH[tier] ?? 600}vh`,
|
||||
pointerEvents: 'none', // Allow interacting with the R3F Canvas underneath
|
||||
zIndex: 0,
|
||||
|
||||
@@ -32,6 +32,7 @@ import React, { useLayoutEffect } from 'react'
|
||||
import { useGLTF } from '@react-three/drei'
|
||||
|
||||
const GLB = '/models/3d_scene_final.glb'
|
||||
const DRACO_PATH = '/draco/' // self-hosted decoder (public/draco/)
|
||||
|
||||
// Tyre meshes in the exact order the rig expects: [FR, FL, RL, RR]. Order is
|
||||
// load-bearing — animateWheels() flips spin direction by index parity.
|
||||
@@ -61,7 +62,10 @@ const BG_TREE_MAT_RX = /background_tree_atlas/i
|
||||
const matName = (o) => (Array.isArray(o.material) ? o.material[0]?.name : o.material?.name) || ''
|
||||
|
||||
export function Model({ truckRef, wheelRefs, tier = 'desktop', /* dashboardRefs (unused) */ ...props }) {
|
||||
const { scene } = useGLTF(GLB)
|
||||
// String arg = self-hosted Draco decoder path: the GLB is Draco-compressed
|
||||
// geometry + WebP textures (31 MB → 3.7 MB). Self-hosting (vs the gstatic CDN)
|
||||
// keeps this static-export site free of an external runtime dependency.
|
||||
const { scene } = useGLTF(GLB, DRACO_PATH)
|
||||
|
||||
// useLayoutEffect: wire refs + prune before first paint so TruckAnimation /
|
||||
// CameraRig (which run in useFrame) see a fully-configured graph on frame 1.
|
||||
@@ -125,4 +129,4 @@ export function Model({ truckRef, wheelRefs, tier = 'desktop', /* dashboardRefs
|
||||
return <primitive object={scene} {...props} dispose={null} />
|
||||
}
|
||||
|
||||
useGLTF.preload(GLB)
|
||||
useGLTF.preload(GLB, DRACO_PATH)
|
||||
|
||||
Reference in New Issue
Block a user