fix how it work loading issue

This commit is contained in:
2026-06-10 14:12:36 +05:30
parent 10d73b6d31
commit 1e6653de96
8 changed files with 345 additions and 83 deletions

View File

@@ -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>
)
}

View File

@@ -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 />

View File

@@ -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,

View File

@@ -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)