feat(how-it-works): integrate scroll-driven 3D experience
Migrate the standalone Vite + React Three Fiber experience into the existing Next.js site as the body of the How It Works page, replacing the Miles3 / WhyChooseDoormile / TheDoormileWay content sections while preserving the Elementor hero, global Header/Footer, layout, routing and SEO. - New self-contained module: src/modules/how-it-works-3d/ (R3F scene, hooks, zustand store, animations, curves, constants, utils, scoped CSS). App.jsx → Experience3D.jsx; 3d_scene.jsx → models/Scene3D.jsx. - 32MB GLB moved to public/models/3d_scene_final.glb; useGLTF paths updated. - Client-only entry via dynamic ssr:false loader (Experience3DLoader). - Self-managed fixed pin (tall section + absolute stage toggled absolute(top)→fixed→absolute(bottom) from ScrollTrigger pin state), mirroring the site's StrategySection, since the fixed header + ancestor overflow:hidden break CSS sticky / GSAP pin. - experience.css fully scoped under .dm-hiw-3d to avoid colliding with the site's Elementor CSS. - Global Lenis disabled on /how-it-works; module runs its own tuned Lenis; jump-to-section scroll math made spacer-relative. - Added zustand + maath; ESLint-ignored the ported module. Rendering fixes (root causes found by driving headless Chrome): - Bump three 0.171 → 0.184 to match @react-three/fiber@9.6 / drei@10.7 / postprocessing@6.39 (0.171 silently failed to render this GLB and caused the EffectComposer getContextAttributes().alpha crash). Other 3D routes verified. - EffectComposer: Bloom + Vignette only. SSAO needs a NormalPass (v3 dropped the old `disableNormalPass`), and that extra full-scene pass exhausted the WebGL context on this heavy scene. - Cap Canvas dpr to [1,1.5] to bound framebuffer memory on retina displays. - Defer Canvas mount via IntersectionObserver (mountScene), matching StrategySection, to ease StrictMode/first-render GPU pressure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,9 @@ const eslintConfig = defineConfig([
|
||||
"next-env.d.ts",
|
||||
// Vendored third-party JS shipped to /public is not ours to lint.
|
||||
"public/**",
|
||||
// Ported 3D experience (incl. the ~11.6k-line gltfjsx-generated model) — kept
|
||||
// as faithful .jsx/.js from the standalone app; not linted to ours rules.
|
||||
"src/modules/how-it-works-3d/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -16,11 +16,13 @@
|
||||
"gsap": "^3.15.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lenis": "^1.3.23",
|
||||
"maath": "^0.10.8",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"three": "^0.171.0"
|
||||
"three": "^0.184.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -31,7 +33,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/three": "^0.171.0",
|
||||
"@types/three": "^0.184.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"jest": "^30.4.2",
|
||||
@@ -726,6 +728,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@emailjs/browser": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@emailjs/browser/-/browser-4.4.1.tgz",
|
||||
@@ -3085,17 +3093,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.171.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.171.0.tgz",
|
||||
"integrity": "sha512-oLuT1SAsT+CUg/wxUTFHo0K3NtJLnx9sJhZWQJp/0uXqFpzSk1hRHmvWvpaAWSfvx2db0lVKZ5/wV0I0isD2mQ==",
|
||||
"version": "0.184.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
|
||||
"integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": "*",
|
||||
"@webgpu/types": "*",
|
||||
"@types/webxr": ">=0.5.17",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~0.18.1"
|
||||
"meshoptimizer": "~1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
@@ -3761,12 +3769,6 @@
|
||||
"react": ">= 16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.70",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.70.tgz",
|
||||
"integrity": "sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -8754,9 +8756,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
|
||||
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
@@ -10762,9 +10764,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.171.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.171.0.tgz",
|
||||
"integrity": "sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==",
|
||||
"version": "0.184.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
||||
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
|
||||
@@ -21,11 +21,13 @@
|
||||
"gsap": "^3.15.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lenis": "^1.3.23",
|
||||
"maath": "^0.10.8",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"three": "^0.171.0"
|
||||
"three": "^0.184.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -36,7 +38,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/three": "^0.171.0",
|
||||
"@types/three": "^0.184.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"jest": "^30.4.2",
|
||||
|
||||
BIN
public/models/3d_scene_final.glb
Normal file
BIN
public/models/3d_scene_final.glb
Normal file
Binary file not shown.
@@ -27,7 +27,10 @@ import Lenis from "lenis";
|
||||
* Re-evaluates on every route change: the effect cleanup destroys the previous
|
||||
* instance and re-inits on the next route.
|
||||
*/
|
||||
const DISABLED_ROUTES: string[] = [];
|
||||
// /how-it-works runs its own tuned Lenis inside the embedded 3D experience
|
||||
// (src/modules/how-it-works-3d); the global instance is gated off there so two
|
||||
// Lenis instances don't fight over the same document scroll.
|
||||
const DISABLED_ROUTES: string[] = ["/how-it-works"];
|
||||
|
||||
export default function SmoothScroll() {
|
||||
const pathname = usePathname();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from "react";
|
||||
import HowItWorksHero from "../../components/sections/HowItWorksHero";
|
||||
import Miles3 from "../../components/sections/Miles3";
|
||||
import WhyChooseDoormile from "../../components/sections/WhyChooseDoormile";
|
||||
import TheDoormileWay from "../../components/sections/TheDoormileWay";
|
||||
import Experience3DLoader from "@/modules/how-it-works-3d/Experience3DLoader";
|
||||
|
||||
export const metadata = {
|
||||
title: "How It Works – Doormile",
|
||||
@@ -16,9 +14,10 @@ export default function HowItWorksPage() {
|
||||
<div className="content-inner">
|
||||
<div data-elementor-type="wp-page" data-elementor-id="59" className="elementor elementor-59">
|
||||
<HowItWorksHero />
|
||||
<Miles3 />
|
||||
<WhyChooseDoormile />
|
||||
<TheDoormileWay />
|
||||
{/* The first/mid/last-mile story is now told by the scroll-driven 3D
|
||||
experience, which replaces the former Miles3 / WhyChooseDoormile /
|
||||
TheDoormileWay content sections on this page. */}
|
||||
<Experience3DLoader />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
158
src/modules/how-it-works-3d/Experience3D.jsx
Normal file
158
src/modules/how-it-works-3d/Experience3D.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useEffect, useState } from 'react'
|
||||
import Experience from './components/Experience'
|
||||
import ScrollRig from './components/ScrollRig'
|
||||
import Navbar from './components/ui/Navbar'
|
||||
import FirstMile from './components/sections/FirstMile'
|
||||
import MidMile from './components/sections/MidMile'
|
||||
import LastMile from './components/sections/LastMile'
|
||||
import Analytics from './components/sections/Analytics'
|
||||
import { useSceneStore } from './store/useSceneStore'
|
||||
import './styles/experience.css'
|
||||
|
||||
import Lenis from 'lenis'
|
||||
import gsap from 'gsap'
|
||||
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).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export default function Experience3D() {
|
||||
const scrollProgress = useSceneStore((state) => state.scrollProgress)
|
||||
const setLenis = useSceneStore((state) => state.setLenis)
|
||||
|
||||
const containerRef = 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)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
setMountScene(true)
|
||||
io.disconnect()
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200% 0px' }, // mount well before it scrolls into view
|
||||
)
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [])
|
||||
|
||||
// Own Lenis instance (global Lenis is gated off for this route).
|
||||
useEffect(() => {
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
lerp: 0.08,
|
||||
syncTouch: true,
|
||||
})
|
||||
|
||||
setLenis(lenis)
|
||||
lenis.on('scroll', ScrollTrigger.update)
|
||||
|
||||
let rafId
|
||||
function raf(time) {
|
||||
lenis.raf(time)
|
||||
rafId = requestAnimationFrame(raf)
|
||||
}
|
||||
rafId = requestAnimationFrame(raf)
|
||||
|
||||
gsap.ticker.lagSmoothing(0)
|
||||
ScrollTrigger.refresh()
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId)
|
||||
lenis.destroy()
|
||||
setLenis(null)
|
||||
}
|
||||
}, [setLenis])
|
||||
|
||||
// 3D references shared between R3F and the GSAP scroll system.
|
||||
const truckRef = useRef(null)
|
||||
|
||||
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 }
|
||||
]
|
||||
}), [])
|
||||
|
||||
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
|
||||
className="canvas-wrapper"
|
||||
style={{
|
||||
opacity: scrollProgress >= 0.92 ? 0.85 : 1.0,
|
||||
transition: 'opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
{mountScene && (
|
||||
<Experience
|
||||
truckRef={truckRef}
|
||||
wheelRefs={wheelRefs}
|
||||
dashboardRefs={dashboardRefs}
|
||||
/>
|
||||
)}
|
||||
</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.80 && scrollProgress < 0.92} />
|
||||
<Analytics active={scrollProgress >= 0.94} />
|
||||
</div>
|
||||
</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} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
src/modules/how-it-works-3d/Experience3DLoader.tsx
Normal file
20
src/modules/how-it-works-3d/Experience3DLoader.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
/**
|
||||
* Client-only loader for the 3D How It Works experience.
|
||||
*
|
||||
* `ssr: false` is required: the experience uses React Three Fiber, a Zustand
|
||||
* store, Lenis, and `window`/`AudioContext` — all client-only. The 100vh
|
||||
* placeholder reserves space so the page doesn't jump while the (large) GLB
|
||||
* scene and WebGL bundle load.
|
||||
*/
|
||||
const Experience3D = dynamic(() => import("./Experience3D"), {
|
||||
ssr: false,
|
||||
loading: () => <div style={{ minHeight: "100vh" }} aria-hidden />,
|
||||
});
|
||||
|
||||
export default function Experience3DLoader() {
|
||||
return <Experience3D />;
|
||||
}
|
||||
21
src/modules/how-it-works-3d/animations/cameraTimeline.js
Normal file
21
src/modules/how-it-works-3d/animations/cameraTimeline.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import gsap from 'gsap'
|
||||
|
||||
// Optional GSAP timeline utility to animate custom camera effects (like micro-shake)
|
||||
export const playCameraTransition = (camera, target, duration = 1.0) => {
|
||||
if (!camera) return
|
||||
|
||||
const tl = gsap.timeline()
|
||||
|
||||
// Add a subtle drift to camera position to make it feel organic and premium
|
||||
tl.to(camera.position, {
|
||||
x: '+=0.3',
|
||||
y: '+=0.1',
|
||||
z: '-=0.2',
|
||||
duration: duration,
|
||||
yoyo: true,
|
||||
repeat: 1,
|
||||
ease: 'power1.inOut',
|
||||
})
|
||||
|
||||
return tl
|
||||
}
|
||||
24
src/modules/how-it-works-3d/animations/dashboardAnimation.js
Normal file
24
src/modules/how-it-works-3d/animations/dashboardAnimation.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { clamp } from '../utils/helpers'
|
||||
|
||||
export const animateDashboard = (bars, pieQuarters, progress) => {
|
||||
// progress is 0 at scrollProgress = 0.75, and 1 at scrollProgress = 1.0
|
||||
// Scale bar charts on their Y axis with a staggered effect
|
||||
bars.forEach((barRef, index) => {
|
||||
if (barRef.current) {
|
||||
const delay = index * 0.08
|
||||
const scaleY = clamp((progress - delay) / 0.5, 0, 1)
|
||||
|
||||
// Interpolate scale Y
|
||||
barRef.current.scale.y = scaleY
|
||||
}
|
||||
})
|
||||
|
||||
// Rotate pie chart quarters around their local Y axis
|
||||
pieQuarters.forEach((quarterRef, index) => {
|
||||
if (quarterRef.current) {
|
||||
// Rotate based on progress (offset each slice slightly for dynamic feeling)
|
||||
const rotationSpeed = 2 + index * 0.5
|
||||
quarterRef.current.rotation.y = -0.709 + progress * Math.PI * 2 * rotationSpeed
|
||||
}
|
||||
})
|
||||
}
|
||||
18
src/modules/how-it-works-3d/animations/truckTimeline.js
Normal file
18
src/modules/how-it-works-3d/animations/truckTimeline.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import gsap from 'gsap'
|
||||
|
||||
// Play a subtle engine idle vibration when the truck is active
|
||||
export const playTruckEngineVibration = (truckGroup, isActive = true) => {
|
||||
if (!truckGroup) return null
|
||||
|
||||
if (isActive) {
|
||||
return gsap.to(truckGroup.position, {
|
||||
y: '+=0.015',
|
||||
duration: 0.08,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'sine.inOut',
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
14
src/modules/how-it-works-3d/animations/wheelAnimation.js
Normal file
14
src/modules/how-it-works-3d/animations/wheelAnimation.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export const animateWheels = (wheelRefs, rotation) => {
|
||||
if (!wheelRefs || wheelRefs.length === 0) return
|
||||
|
||||
wheelRefs.forEach((wheelRef, index) => {
|
||||
if (wheelRef.current) {
|
||||
// Y-axis is the axle for these wheel meshes.
|
||||
// Odd indices (1, 3) are left side wheels; even indices (0, 2) are right side wheels.
|
||||
// Since left-side wheel groups are rotated 180 degrees in GLTF to face outward,
|
||||
// we invert the spin direction for one side so they all roll forward together.
|
||||
const direction = (index % 2 === 0) ? 1 : -1
|
||||
wheelRef.current.rotation.y = rotation * direction
|
||||
}
|
||||
})
|
||||
}
|
||||
38
src/modules/how-it-works-3d/components/CameraRig.jsx
Normal file
38
src/modules/how-it-works-3d/components/CameraRig.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { useSceneStore } from '../store/useSceneStore'
|
||||
import { useCameraAnimation } from '../hooks/useCameraAnimation'
|
||||
import { easing } from 'maath'
|
||||
|
||||
export default function CameraRig() {
|
||||
const scrollProgress = useSceneStore((state) => state.scrollProgress)
|
||||
const { position: targetPosition, target: lookAtTarget } = useCameraAnimation(scrollProgress)
|
||||
|
||||
// Track the current focus point of the camera in a ref so we can interpolate it smoothly
|
||||
const currentLookAt = useRef(new THREE.Vector3(19.7, 4.4, -31.08))
|
||||
|
||||
useFrame((state, delta) => {
|
||||
const { camera } = state
|
||||
|
||||
// Smoothly damp the camera position towards the target position
|
||||
easing.damp3(camera.position, targetPosition, 0.35, delta)
|
||||
|
||||
// Smoothly damp the camera focus target (lookAt)
|
||||
easing.damp3(currentLookAt.current, lookAtTarget, 0.25, delta)
|
||||
|
||||
// Apply lookAt orientation using the interpolated target vector
|
||||
camera.lookAt(currentLookAt.current)
|
||||
|
||||
// Responsive aspect ratio adjustments: increase FOV on portrait screens to zoom out and keep truck & buildings in frame
|
||||
const aspect = state.size.width / state.size.height
|
||||
if (aspect < 1.0) {
|
||||
camera.fov = Math.min(75, 45 / Math.sqrt(aspect))
|
||||
} else {
|
||||
camera.fov = 45
|
||||
}
|
||||
camera.updateProjectionMatrix()
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
150
src/modules/how-it-works-3d/components/Experience.jsx
Normal file
150
src/modules/how-it-works-3d/components/Experience.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useRef, useEffect } from 'react'
|
||||
import { Canvas, useFrame } from '@react-three/fiber'
|
||||
import { Environment, SoftShadows } from '@react-three/drei'
|
||||
import * as THREE from 'three'
|
||||
import { EffectComposer, Bloom, Vignette } from '@react-three/postprocessing'
|
||||
import { Model as SceneModel } from '../models/Scene3D'
|
||||
import CameraRig from './CameraRig'
|
||||
import TruckAnimation from './TruckAnimation'
|
||||
import StreetLights from './StreetLights'
|
||||
import { useSceneStore } from '../store/useSceneStore'
|
||||
|
||||
const dayBgColor = new THREE.Color('#f5f5f7')
|
||||
const nightBgColor = new THREE.Color('#010103') // Pitch black sky with a tiny touch of midnight slate
|
||||
|
||||
const dayAmbientColor = new THREE.Color('#ffffff')
|
||||
const nightAmbientColor = new THREE.Color('#000000') // Pitch black ambient
|
||||
|
||||
const dayDirColor = new THREE.Color('#ffffff')
|
||||
const nightDirColor = new THREE.Color('#000000') // Pitch black sun/moon directional light
|
||||
|
||||
const tempColor = new THREE.Color()
|
||||
|
||||
// Dynamic lighting rig that centers the shadow frustum on the moving truck
|
||||
const SceneLighting = React.memo(function SceneLighting({ truckRef }) {
|
||||
const dirLightRef = useRef()
|
||||
const ambientLightRef = useRef()
|
||||
const targetRef = useRef()
|
||||
|
||||
useEffect(() => {
|
||||
if (dirLightRef.current && targetRef.current) {
|
||||
dirLightRef.current.target = targetRef.current
|
||||
}
|
||||
}, [])
|
||||
|
||||
useFrame((state) => {
|
||||
// 1. Center shadows on the truck
|
||||
if (dirLightRef.current && targetRef.current && truckRef.current) {
|
||||
const truckPos = new THREE.Vector3()
|
||||
truckRef.current.getWorldPosition(truckPos)
|
||||
|
||||
targetRef.current.position.copy(truckPos)
|
||||
targetRef.current.updateMatrixWorld()
|
||||
|
||||
dirLightRef.current.position.set(truckPos.x + 10, truckPos.y + 20, truckPos.z + 10)
|
||||
}
|
||||
|
||||
// 2. Day-to-Night transition calculations (disabled: keeping day view throughout the scroll)
|
||||
const nightFactor = 0
|
||||
|
||||
// 3. Mutate scene background color & environment intensity
|
||||
if (state.scene) {
|
||||
state.scene.background = tempColor.lerpColors(dayBgColor, nightBgColor, nightFactor)
|
||||
state.scene.environmentIntensity = 1.0 - nightFactor * 1.0 // Fades completely to 0.0
|
||||
}
|
||||
|
||||
// 4. Update lights properties
|
||||
if (ambientLightRef.current) {
|
||||
ambientLightRef.current.intensity = 0.45 - nightFactor * 0.45 // Fades completely to 0.0
|
||||
ambientLightRef.current.color.lerpColors(dayAmbientColor, nightAmbientColor, nightFactor)
|
||||
}
|
||||
|
||||
if (dirLightRef.current) {
|
||||
dirLightRef.current.intensity = 1.5 - nightFactor * 1.5 // Fades completely to 0.0
|
||||
dirLightRef.current.color.lerpColors(dayDirColor, nightDirColor, nightFactor)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<group>
|
||||
<ambientLight ref={ambientLightRef} intensity={0.45} />
|
||||
<directionalLight
|
||||
ref={dirLightRef}
|
||||
castShadow
|
||||
position={[10, 20, 10]}
|
||||
intensity={1.5}
|
||||
shadow-mapSize-width={2048}
|
||||
shadow-mapSize-height={2048}
|
||||
shadow-camera-far={100}
|
||||
shadow-camera-left={-35}
|
||||
shadow-camera-right={35}
|
||||
shadow-camera-top={35}
|
||||
shadow-camera-bottom={-35}
|
||||
shadow-bias={-0.0001}
|
||||
/>
|
||||
<object3D ref={targetRef} />
|
||||
</group>
|
||||
)
|
||||
})
|
||||
|
||||
export default React.memo(function Experience({ dashboardRefs, wheelRefs, truckRef }) {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', position: 'absolute', top: 0, left: 0 }}>
|
||||
<Canvas
|
||||
shadows
|
||||
// Cap the device-pixel-ratio: uncapped, a retina display renders this
|
||||
// heavy 32MB scene into a 2x (or 3x) framebuffer, multiplying GPU memory
|
||||
// and risking WebGL context loss. [1, 1.5] keeps it crisp but bounded —
|
||||
// matching the dpr caps the site's other R3F canvases use.
|
||||
dpr={[1, 1.5]}
|
||||
camera={{ position: [32, 12, -18], fov: 45 }}
|
||||
gl={{ antialias: true, powerPreference: 'high-performance' }}
|
||||
>
|
||||
<color attach="background" args={['#f5f5f7']} />
|
||||
|
||||
{/* Soft shadows */}
|
||||
<SoftShadows size={10} samples={12} focus={1.0} />
|
||||
|
||||
{/* Dynamic ambient and shadow-tracking directional lights */}
|
||||
<SceneLighting truckRef={truckRef} />
|
||||
|
||||
{/* Focused street lights along the road */}
|
||||
<StreetLights />
|
||||
|
||||
{/* Environment preset */}
|
||||
<Environment preset="city" />
|
||||
|
||||
{/* Main 3D logistics scene model */}
|
||||
<SceneModel
|
||||
dashboardRefs={dashboardRefs}
|
||||
truckRef={truckRef}
|
||||
wheelRefs={wheelRefs}
|
||||
/>
|
||||
|
||||
{/* Delivery truck model animation controller */}
|
||||
<TruckAnimation truckRef={truckRef} wheelRefs={wheelRefs} />
|
||||
|
||||
{/* Dynamic camera rig with damping and target interpolation */}
|
||||
<CameraRig />
|
||||
|
||||
{/* Post-processing — Bloom + Vignette only.
|
||||
The original Vite code added SSAO with a NormalPass, but on this heavy
|
||||
scene (32MB GLB, ~500 meshes, SoftShadows) the extra full-scene normal
|
||||
render exhausts the WebGL context and it is lost (blank canvas). The
|
||||
site's other R3F canvases (e.g. StrategyCanvas) use a Bloom-only
|
||||
composer for the same reason; Bloom + the screen-space Vignette keep the
|
||||
cinematic look without the SSAO normal pass. */}
|
||||
<EffectComposer multisampling={2}>
|
||||
<Bloom
|
||||
intensity={0.2}
|
||||
luminanceThreshold={0.95}
|
||||
luminanceSmoothing={0.05}
|
||||
mipmapBlur
|
||||
/>
|
||||
<Vignette eskil={false} offset={0.1} darkness={0.4} />
|
||||
</EffectComposer>
|
||||
</Canvas>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
103
src/modules/how-it-works-3d/components/ScrollRig.jsx
Normal file
103
src/modules/how-it-works-3d/components/ScrollRig.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import gsap from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
import { useSceneStore } from '../store/useSceneStore'
|
||||
import { animateDashboard } from '../animations/dashboardAnimation'
|
||||
import { playRevealChime } from '../utils/audioHelper'
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
|
||||
export default function ScrollRig({ dashboardRefs, onPinState }) {
|
||||
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')
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current
|
||||
if (!element) return
|
||||
|
||||
// Create the ScrollTrigger to track the scrolling progress of the 900vh height container
|
||||
const trigger = ScrollTrigger.create({
|
||||
trigger: element,
|
||||
start: 'top top',
|
||||
end: 'bottom bottom',
|
||||
scrub: 2.5, // Even slower, weightier scroll follow for premium feel
|
||||
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
|
||||
}
|
||||
|
||||
if (section !== activeSectionRef.current) {
|
||||
playRevealChime()
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
return () => {
|
||||
trigger.kill()
|
||||
}
|
||||
}, [setScrollProgress, setActiveSection, dashboardRefs, lenis, onPinState])
|
||||
|
||||
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%',
|
||||
height: '900vh', // Optimized scroll length for faster, smoother travel
|
||||
pointerEvents: 'none', // Allow interacting with the R3F Canvas underneath
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
93
src/modules/how-it-works-3d/components/StreetLights.jsx
Normal file
93
src/modules/how-it-works-3d/components/StreetLights.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useRef, useEffect } from 'react'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { useSceneStore } from '../store/useSceneStore'
|
||||
|
||||
// The exact calculated world coordinates of the 10 street light heads in the scene
|
||||
const streetLightsData = [
|
||||
{ pos: [0, 4.2, -4.56], target: [0, 0, -4.56] },
|
||||
{ pos: [9.113, 4.2, 0.944], target: [9.113, 0, 0.944] },
|
||||
{ pos: [-10.158, 4.2, -9.874], target: [-10.158, 0, -9.874] },
|
||||
{ pos: [3.513, 4.2, 9.195], target: [3.513, 0, 9.195] },
|
||||
{ pos: [3.96, 4.2, -21.17], target: [3.96, 0, -21.17] },
|
||||
{ pos: [12.25, 4.2, -16.7], target: [12.25, 0, -16.7] },
|
||||
{ pos: [3.052, 4.2, -12.335], target: [3.052, 0, -12.335] },
|
||||
{ pos: [-2.03, 4.2, -16.89], target: [-2.03, 0, -16.89] },
|
||||
{ pos: [-27.151, 3.98, -9], target: [-27.151, 0, -9] }
|
||||
]
|
||||
|
||||
const bulbOffColor = new THREE.Color('#333333')
|
||||
const bulbOnColor = new THREE.Color('#ffdf6d')
|
||||
const emissiveOffColor = new THREE.Color('#000000')
|
||||
const emissiveOnColor = new THREE.Color('#ffdf6d')
|
||||
|
||||
function SingleStreetLight({ pos, targetPos }) {
|
||||
const lightRef = useRef()
|
||||
const targetRef = useRef()
|
||||
const bulbRef = useRef()
|
||||
|
||||
useEffect(() => {
|
||||
if (lightRef.current && targetRef.current) {
|
||||
lightRef.current.target = targetRef.current
|
||||
lightRef.current.target.updateMatrixWorld()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useFrame(() => {
|
||||
// Day-to-Night factor (disabled: streetlights stay off)
|
||||
const nightFactor = 0
|
||||
|
||||
// Smoothly scale spotlights intensity
|
||||
if (lightRef.current) {
|
||||
lightRef.current.intensity = nightFactor * 12.0
|
||||
}
|
||||
|
||||
// Interpolate light bulb material colors to simulate glowing filament
|
||||
if (bulbRef.current) {
|
||||
bulbRef.current.material.color.lerpColors(bulbOffColor, bulbOnColor, nightFactor)
|
||||
bulbRef.current.material.emissive.lerpColors(emissiveOffColor, emissiveOnColor, nightFactor)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Spotlight casting cone of light downward */}
|
||||
<spotLight
|
||||
ref={lightRef}
|
||||
position={pos}
|
||||
intensity={0}
|
||||
distance={12}
|
||||
angle={Math.PI / 4.5}
|
||||
penumbra={0.6}
|
||||
decay={1.2}
|
||||
color="#ffdf6d"
|
||||
castShadow={false} // Disabled for peak frame rate, main shadow is cast by directionalLight
|
||||
/>
|
||||
{/* Glowing bulb mesh placed exactly at the light coordinates */}
|
||||
<mesh ref={bulbRef} position={pos}>
|
||||
<sphereGeometry args={[0.16, 16, 16]} />
|
||||
<meshStandardMaterial
|
||||
color="#333333"
|
||||
emissive="#000000"
|
||||
emissiveIntensity={3.5}
|
||||
roughness={0.1}
|
||||
/>
|
||||
</mesh>
|
||||
<object3D ref={targetRef} position={targetPos} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(function StreetLights() {
|
||||
return (
|
||||
<group>
|
||||
{streetLightsData.map((light, index) => (
|
||||
<SingleStreetLight
|
||||
key={index}
|
||||
pos={light.pos}
|
||||
targetPos={light.target}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
})
|
||||
159
src/modules/how-it-works-3d/components/TruckAnimation.jsx
Normal file
159
src/modules/how-it-works-3d/components/TruckAnimation.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { useSceneStore } from '../store/useSceneStore'
|
||||
import { useTruckMovement } from '../hooks/useTruckMovement'
|
||||
import { animateWheels } from '../animations/wheelAnimation'
|
||||
import { easing } from 'maath'
|
||||
import { truckPath } from '../curves/truckPath'
|
||||
|
||||
export default function TruckAnimation({ truckRef, wheelRefs }) {
|
||||
const scrollProgress = useSceneStore((state) => state.scrollProgress)
|
||||
const activeSection = useSceneStore((state) => state.activeSection)
|
||||
const setTruckProgress = useSceneStore((state) => state.setTruckProgress)
|
||||
|
||||
const { truckProgress } = useTruckMovement(scrollProgress)
|
||||
|
||||
const initialized = useRef(false)
|
||||
|
||||
// Sync truck progress to the global store
|
||||
useEffect(() => {
|
||||
setTruckProgress(truckProgress)
|
||||
}, [truckProgress, setTruckProgress])
|
||||
|
||||
// Float trackers for 1D progress and direction detection
|
||||
const dampedProgressRef = useRef(0)
|
||||
const lastScrollProgressRef = useRef(0)
|
||||
const isReversingRef = useRef(false)
|
||||
|
||||
// Tracker for smooth 180-degree yaw rotation (prevents glitches by pivoting Y rotation angle directly)
|
||||
const extraRotationRef = useRef(0)
|
||||
|
||||
// Track wheel rotation accumulation
|
||||
const accumulatedRotationRef = useRef(0)
|
||||
const lastDampedProgressRef = useRef(0)
|
||||
|
||||
|
||||
|
||||
useFrame((state, delta) => {
|
||||
if (!truckRef.current) return
|
||||
|
||||
// Detect scroll direction changes from the actual page scroll progress
|
||||
const deltaScroll = scrollProgress - lastScrollProgressRef.current
|
||||
if (deltaScroll < -0.0001) {
|
||||
isReversingRef.current = true
|
||||
} else if (deltaScroll > 0.0001) {
|
||||
isReversingRef.current = false
|
||||
}
|
||||
lastScrollProgressRef.current = scrollProgress
|
||||
|
||||
// Ensure correct parent-child structure and orientation for the truck (runs reactively on re-renders)
|
||||
const innerGroup = truckRef.current.children[0]
|
||||
if (innerGroup && truckRef.current.children.length > 1) {
|
||||
const siblings = [...truckRef.current.children].slice(1)
|
||||
siblings.forEach((sibling) => {
|
||||
innerGroup.attach(sibling)
|
||||
})
|
||||
|
||||
innerGroup.rotation.set(0, -Math.PI / 2, 0)
|
||||
|
||||
// Disable frustum culling on all child meshes so the truck/shadow is always visible
|
||||
truckRef.current.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.frustumCulled = false
|
||||
child.castShadow = true
|
||||
child.receiveShadow = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Run one-time state initialization for progress trackers
|
||||
if (!initialized.current) {
|
||||
dampedProgressRef.current = truckProgress
|
||||
lastDampedProgressRef.current = truckProgress
|
||||
lastScrollProgressRef.current = scrollProgress
|
||||
isReversingRef.current = false
|
||||
extraRotationRef.current = 0
|
||||
|
||||
const position = truckPath.getPoint(dampedProgressRef.current)
|
||||
let lookAtTargetVector
|
||||
if (dampedProgressRef.current >= 0.99) {
|
||||
const tangent = truckPath.getTangent(1.0)
|
||||
const endPoint = truckPath.getPoint(1.0)
|
||||
lookAtTargetVector = new THREE.Vector3().copy(endPoint).addScaledVector(tangent, 1.0)
|
||||
} else {
|
||||
const ahead = Math.min(dampedProgressRef.current + 0.01, 1.0)
|
||||
lookAtTargetVector = truckPath.getPoint(ahead)
|
||||
}
|
||||
|
||||
truckRef.current.position.copy(position)
|
||||
if (truckRef.current.position.distanceToSquared(lookAtTargetVector) > 0.0001) {
|
||||
truckRef.current.lookAt(lookAtTargetVector)
|
||||
}
|
||||
|
||||
initialized.current = true
|
||||
}
|
||||
|
||||
// Smoothly damp the 1D progress scalar along the curve path
|
||||
easing.damp(dampedProgressRef, 'current', truckProgress, 0.30, delta)
|
||||
|
||||
// Evaluate the 3D position and orientation directly on the spline curve
|
||||
const position = truckPath.getPoint(dampedProgressRef.current)
|
||||
|
||||
let lookAtTargetVector
|
||||
if (dampedProgressRef.current >= 0.99) {
|
||||
const tangent = truckPath.getTangent(1.0)
|
||||
const endPoint = truckPath.getPoint(1.0)
|
||||
lookAtTargetVector = new THREE.Vector3().copy(endPoint).addScaledVector(tangent, 1.0)
|
||||
} else {
|
||||
const ahead = Math.min(dampedProgressRef.current + 0.01, 1.0)
|
||||
lookAtTargetVector = truckPath.getPoint(ahead)
|
||||
}
|
||||
|
||||
// Update position and base forward rotation directly (ensures 100% spline compliance, zero corner cutting)
|
||||
truckRef.current.position.copy(position)
|
||||
if (truckRef.current.position.distanceToSquared(lookAtTargetVector) > 0.0001) {
|
||||
truckRef.current.lookAt(lookAtTargetVector)
|
||||
}
|
||||
|
||||
// Determine target extra rotation:
|
||||
// - 0 radians when moving forward
|
||||
// - Math.PI radians (180 degrees) when reversing
|
||||
// We disable U-turns at the extreme start and end of the path to keep the truck stable at warehouse/delivery spots
|
||||
let targetExtraRotation = 0
|
||||
if (dampedProgressRef.current > 0.05 && dampedProgressRef.current < 0.95) {
|
||||
if (isReversingRef.current) {
|
||||
targetExtraRotation = Math.PI
|
||||
}
|
||||
}
|
||||
|
||||
// Smoothly damp the extra rotation angle directly (prevents pitch/roll glitches or 3D target collapse)
|
||||
easing.damp(extraRotationRef, 'current', targetExtraRotation, 0.20, delta)
|
||||
|
||||
// Apply the yaw pivot around the local vertical axis
|
||||
truckRef.current.rotateY(extraRotationRef.current)
|
||||
|
||||
// Calculate progress delta for wheels and audio
|
||||
const deltaDamped = Math.abs(dampedProgressRef.current - lastDampedProgressRef.current)
|
||||
lastDampedProgressRef.current = dampedProgressRef.current
|
||||
|
||||
// Accumulate wheel rotation based on absolute movement delta so they always roll forward locally
|
||||
const isMoving = dampedProgressRef.current > 0.001 && dampedProgressRef.current < 0.999
|
||||
if (isMoving) {
|
||||
accumulatedRotationRef.current += deltaDamped * 250 // spinFactor
|
||||
}
|
||||
|
||||
// Spin wheels
|
||||
animateWheels(wheelRefs, accumulatedRotationRef.current)
|
||||
|
||||
|
||||
|
||||
// Add engine vibration to the inner group to prevent coordinate pollution on the root group
|
||||
if (truckRef.current.children && truckRef.current.children[0]) {
|
||||
const innerGroup = truckRef.current.children[0]
|
||||
innerGroup.position.y = Math.sin(state.clock.getElapsedTime() * 45) * 0.003
|
||||
}
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import RevealCard from '../ui/RevealCard'
|
||||
|
||||
export default function Analytics({ active }) {
|
||||
return (
|
||||
<RevealCard active={active} id="analytics-section">
|
||||
<div className="section-badge">Workflow</div>
|
||||
<h2 className="section-title">Doormile Insights</h2>
|
||||
<h3 className="section-subtitle">3-Mile Logistics Ecosystem</h3>
|
||||
|
||||
<div className="workflow-steps">
|
||||
<div className="workflow-step">
|
||||
<div className="step-number-container">
|
||||
<span className="step-number">01</span>
|
||||
<div className="step-line"></div>
|
||||
</div>
|
||||
<div className="step-content">
|
||||
<h4 className="step-title">First Mile</h4>
|
||||
<p className="step-description">Incoming shipments are securely loaded, checked, and consolidated at initial fulfillment hubs.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workflow-step">
|
||||
<div className="step-number-container">
|
||||
<span className="step-number">02</span>
|
||||
<div className="step-line"></div>
|
||||
</div>
|
||||
<div className="step-content">
|
||||
<h4 className="step-title">Mid Mile</h4>
|
||||
<p className="step-description">Consolidated goods travel between primary distribution nodes via optimized express transit corridors.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workflow-step">
|
||||
<div className="step-number-container">
|
||||
<span className="step-number">03</span>
|
||||
</div>
|
||||
<div className="step-content">
|
||||
<h4 className="step-title">Last Mile</h4>
|
||||
<p className="step-description">Local delivery units organize doorstep routes to transport packages to final customers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RevealCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import { sections } from '../../constants/sectionConfig'
|
||||
import RevealCard from '../ui/RevealCard'
|
||||
|
||||
export default function FirstMile({ active }) {
|
||||
const config = sections[0]
|
||||
return (
|
||||
<RevealCard active={active} id="first-mile-section">
|
||||
<div className="section-badge">Stage 01</div>
|
||||
<h2 className="section-title">{config.title}</h2>
|
||||
<h3 className="section-subtitle">{config.subtitle}</h3>
|
||||
<p className="section-description">{config.description}</p>
|
||||
<div className="section-metrics">
|
||||
<div className="metric-item">
|
||||
<span className="metric-value">14,250</span>
|
||||
<span className="metric-label">Parcels Processed</span>
|
||||
</div>
|
||||
<div className="metric-item">
|
||||
<span className="metric-value">99.98%</span>
|
||||
<span className="metric-label">Sorting Accuracy</span>
|
||||
</div>
|
||||
</div>
|
||||
</RevealCard>
|
||||
)
|
||||
}
|
||||
52
src/modules/how-it-works-3d/components/sections/LastMile.jsx
Normal file
52
src/modules/how-it-works-3d/components/sections/LastMile.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { sections } from '../../constants/sectionConfig'
|
||||
import { useSceneStore } from '../../store/useSceneStore'
|
||||
import RevealCard from '../ui/RevealCard'
|
||||
import { progressToScrollY } from '../../utils/helpers'
|
||||
|
||||
export default function LastMile({ active }) {
|
||||
const config = sections[2]
|
||||
const lenis = useSceneStore((state) => state.lenis)
|
||||
|
||||
const handleClose = () => {
|
||||
// Smoothly scroll to 97% progress, which is inside the Analytics Dashboard section.
|
||||
// Relative to the experience spacer (the section sits below the page hero).
|
||||
lenis?.scrollTo(progressToScrollY(0.97), { duration: 1.5 })
|
||||
}
|
||||
|
||||
return (
|
||||
<RevealCard active={active} id="last-mile-section">
|
||||
<div className="section-badge">Stage 03</div>
|
||||
<h2 className="section-title">{config.title}</h2>
|
||||
<h3 className="section-subtitle">{config.subtitle}</h3>
|
||||
<p className="section-description">{config.description}</p>
|
||||
<div className="section-metrics">
|
||||
<div className="metric-item">
|
||||
<span className="metric-value">12.5 min</span>
|
||||
<span className="metric-label">Avg. Delivery window</span>
|
||||
</div>
|
||||
<div className="metric-item">
|
||||
<span className="metric-value">99.4%</span>
|
||||
<span className="metric-label">On-Time Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="section-close-btn" onClick={handleClose}>
|
||||
View Analytics
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ marginLeft: '6px' }}
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</RevealCard>
|
||||
)
|
||||
}
|
||||
52
src/modules/how-it-works-3d/components/sections/MidMile.jsx
Normal file
52
src/modules/how-it-works-3d/components/sections/MidMile.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { sections } from '../../constants/sectionConfig'
|
||||
import { useSceneStore } from '../../store/useSceneStore'
|
||||
import RevealCard from '../ui/RevealCard'
|
||||
import { progressToScrollY } from '../../utils/helpers'
|
||||
|
||||
export default function MidMile({ active }) {
|
||||
const config = sections[1]
|
||||
const lenis = useSceneStore((state) => state.lenis)
|
||||
|
||||
const handleClose = () => {
|
||||
// Smoothly scroll to 57.5% progress, which is just after the truck resumes moving (at 57%).
|
||||
// Relative to the experience spacer (the section sits below the page hero).
|
||||
lenis?.scrollTo(progressToScrollY(0.575), { duration: 1.5 })
|
||||
}
|
||||
|
||||
return (
|
||||
<RevealCard active={active} id="mid-mile-section">
|
||||
<div className="section-badge">Stage 02</div>
|
||||
<h2 className="section-title">{config.title}</h2>
|
||||
<h3 className="section-subtitle">{config.subtitle}</h3>
|
||||
<p className="section-description">{config.description}</p>
|
||||
<div className="section-metrics">
|
||||
<div className="metric-item">
|
||||
<span className="metric-value">4.2 hr</span>
|
||||
<span className="metric-label">Avg. Transit Time</span>
|
||||
</div>
|
||||
<div className="metric-item">
|
||||
<span className="metric-value">220 kw</span>
|
||||
<span className="metric-label">Solar Output (Self-powered)</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="section-close-btn" onClick={handleClose}>
|
||||
Continue Journey
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ marginLeft: '6px' }}
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</RevealCard>
|
||||
)
|
||||
}
|
||||
22
src/modules/how-it-works-3d/components/ui/Hero.jsx
Normal file
22
src/modules/how-it-works-3d/components/ui/Hero.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import { useSceneStore } from '../../store/useSceneStore'
|
||||
|
||||
export default function Hero() {
|
||||
const lenis = useSceneStore((state) => state.lenis)
|
||||
const handleScrollToStart = () => {
|
||||
// Scroll down to the first active transition point
|
||||
lenis?.scrollTo(window.innerHeight * 0.5, { duration: 1.5 })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hero-overlay" id="home-hero">
|
||||
{/* Dynamic mouse scrolling indicator */}
|
||||
<div className="scroll-indicator" onClick={handleScrollToStart}>
|
||||
<div className="mouse-frame">
|
||||
<div className="mouse-dot" />
|
||||
</div>
|
||||
<div className="scroll-text">Scroll to start</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/modules/how-it-works-3d/components/ui/Navbar.jsx
Normal file
38
src/modules/how-it-works-3d/components/ui/Navbar.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react'
|
||||
import { useSceneStore } from '../../store/useSceneStore'
|
||||
import { progressToScrollY } from '../../utils/helpers'
|
||||
|
||||
export default function Navbar() {
|
||||
const activeSection = useSceneStore((state) => state.activeSection)
|
||||
const lenis = useSceneStore((state) => state.lenis)
|
||||
|
||||
const handleNavClick = (index) => {
|
||||
// Map index (0, 1, 2, 3) to the stable parking progress percentages (0.0, 0.38, 0.76, 0.97).
|
||||
const sectionFractions = [0, 0.38, 0.76, 0.97]
|
||||
const targetProgress = sectionFractions[index]
|
||||
// Relative to the experience spacer (the section sits below the page hero).
|
||||
lenis?.scrollTo(progressToScrollY(targetProgress), { duration: 1.5 })
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ label: 'First Mile', index: 0 },
|
||||
{ label: 'Mid Mile', index: 1 },
|
||||
{ label: 'Last Mile', index: 2 },
|
||||
{ label: 'Analytics', index: 3 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="side-navigation" id="main-navbar">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.index}
|
||||
onClick={() => handleNavClick(item.index)}
|
||||
className={`side-nav-item ${activeSection === item.index ? 'active' : ''}`}
|
||||
>
|
||||
<span className="side-nav-label">{item.label}</span>
|
||||
<span className="side-nav-dot" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
src/modules/how-it-works-3d/components/ui/RevealCard.jsx
Normal file
89
src/modules/how-it-works-3d/components/ui/RevealCard.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import gsap from 'gsap'
|
||||
|
||||
export default function RevealCard({ children, active, id, className = "" }) {
|
||||
const cardRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const card = cardRef.current
|
||||
if (!card) return
|
||||
|
||||
// Find all target children inside the card to create a staggered entrance
|
||||
const animTargets = card.querySelectorAll(
|
||||
'.section-badge, .section-title, .section-subtitle, .section-description, .section-metrics, .section-close-btn, .workflow-step'
|
||||
)
|
||||
|
||||
const isAnalytics = id === 'analytics-section'
|
||||
|
||||
if (active) {
|
||||
// Clean up any ongoing animations first
|
||||
gsap.killTweensOf([card, animTargets])
|
||||
|
||||
// Animate card container in
|
||||
gsap.to(card, {
|
||||
xPercent: isAnalytics ? -50 : 0,
|
||||
yPercent: isAnalytics ? -50 : 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: 0.85,
|
||||
ease: 'power4.out',
|
||||
})
|
||||
|
||||
// Stagger child elements reveal
|
||||
gsap.fromTo(
|
||||
animTargets,
|
||||
{
|
||||
y: 15,
|
||||
opacity: 0
|
||||
},
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: 0.6,
|
||||
stagger: 0.08,
|
||||
ease: 'power3.out',
|
||||
delay: 0.1, // brief delay to let card body expand first
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Kill active tweens
|
||||
gsap.killTweensOf([card, animTargets])
|
||||
|
||||
// Animate card container out
|
||||
gsap.to(card, {
|
||||
xPercent: isAnalytics ? -50 : 0,
|
||||
yPercent: isAnalytics ? -50 : 0,
|
||||
y: isAnalytics ? 18 : 20,
|
||||
scale: 0.96,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
ease: 'power3.inOut',
|
||||
})
|
||||
|
||||
// Smoothly hide child elements
|
||||
gsap.to(animTargets, {
|
||||
y: 10,
|
||||
opacity: 0,
|
||||
duration: 0.35,
|
||||
ease: 'power2.in',
|
||||
})
|
||||
}
|
||||
}, [active, id])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
id={id}
|
||||
className={`section-panel ${active ? 'active' : ''} ${className}`}
|
||||
style={{
|
||||
opacity: 0,
|
||||
transform: id === 'analytics-section'
|
||||
? 'translate(-50%, -50%) translateY(18px) scale(0.96)'
|
||||
: 'translateY(20px) scale(0.96)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
src/modules/how-it-works-3d/constants/cameraPositions.js
Normal file
35
src/modules/how-it-works-3d/constants/cameraPositions.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
// Premium Apple-inspired cinematic keyframes looking directly at the front of each building
|
||||
export const cameraPositions = [
|
||||
{
|
||||
// Stage 01: First Mile Warehouse (Front-on view of loading bays, lowered target to center truck)
|
||||
progress: 0.0,
|
||||
position: new THREE.Vector3(19.727, 7.5, -14.0),
|
||||
target: new THREE.Vector3(19.727, 2.0, -31.02),
|
||||
},
|
||||
{
|
||||
// Transition 01: Highway Cruise (Looking down at the highway joining road)
|
||||
progress: 0.25,
|
||||
position: new THREE.Vector3(0.0, 12.0, -12.0),
|
||||
target: new THREE.Vector3(6.447, 2.0, -19.06),
|
||||
},
|
||||
{
|
||||
// Stage 02: Mid Mile Hub (Front-on view of loading bays, lowered target to center truck)
|
||||
progress: 0.5,
|
||||
position: new THREE.Vector3(-19.146, 6.5, 10.0),
|
||||
target: new THREE.Vector3(-19.146, 1.5, -6.00),
|
||||
},
|
||||
{
|
||||
// Stage 03: Last Mile Delivery Center (Front-on view of local hub, lowered target to center truck)
|
||||
progress: 0.75,
|
||||
position: new THREE.Vector3(19.263, 5.5, 27.0),
|
||||
target: new THREE.Vector3(19.263, 1.2, 4.0),
|
||||
},
|
||||
{
|
||||
// Stage 04: Centralized Dashboard (Front-on view of the analytics monitor screen)
|
||||
progress: 1.0,
|
||||
position: new THREE.Vector3(-13.5, 5.0, 31.0),
|
||||
target: new THREE.Vector3(-7.7, 3.8, 25.4),
|
||||
},
|
||||
]
|
||||
12
src/modules/how-it-works-3d/constants/colors.js
Normal file
12
src/modules/how-it-works-3d/constants/colors.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export const colors = {
|
||||
primary: '#0071e3', // Apple Blue
|
||||
secondary: '#86868b', // Apple Gray
|
||||
background: '#ffffff', // White
|
||||
backgroundDark: '#f5f5f7', // Off-white/Light gray
|
||||
text: '#1d1d1f', // Premium dark gray (text)
|
||||
textMuted: '#86868b', // Subtitle text
|
||||
border: '#d2d2d7', // Thin borders
|
||||
success: '#34c759', // Apple Green
|
||||
accentBlue: '#2997ff', // Bright active blue
|
||||
cardBg: 'rgba(255, 255, 255, 0.8)',
|
||||
}
|
||||
34
src/modules/how-it-works-3d/constants/sectionConfig.js
Normal file
34
src/modules/how-it-works-3d/constants/sectionConfig.js
Normal file
@@ -0,0 +1,34 @@
|
||||
export const sections = [
|
||||
{
|
||||
id: 'first-mile',
|
||||
title: 'First Mile Warehouse',
|
||||
subtitle: 'Consolidation & Prep',
|
||||
description: 'Incoming shipments are securely loaded, checked, and queued for transfer in our high-capacity fulfillment centers.',
|
||||
progressStart: 0.0,
|
||||
progressEnd: 0.25,
|
||||
},
|
||||
{
|
||||
id: 'mid-mile',
|
||||
title: 'Mid Mile Hub',
|
||||
subtitle: 'Sorting & Direct Dispatch',
|
||||
description: 'Consolidated goods travel between primary distribution nodes. Heavy logistics lanes sorting thousands of parcels per hour.',
|
||||
progressStart: 0.25,
|
||||
progressEnd: 0.5,
|
||||
},
|
||||
{
|
||||
id: 'last-mile',
|
||||
title: 'Last Mile Delivery',
|
||||
subtitle: 'Doorstep Courier Services',
|
||||
description: 'Local delivery units take over, planning optimal paths to transport packages directly to customer doorsteps.',
|
||||
progressStart: 0.5,
|
||||
progressEnd: 0.75,
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
title: 'Fulfillment Analytics',
|
||||
subtitle: 'Real-Time Operational Insights',
|
||||
description: 'A fully centralized dashboard monitoring transit times, fleet coordinates, carbon footprint, and delivery success rates.',
|
||||
progressStart: 0.75,
|
||||
progressEnd: 1.0,
|
||||
},
|
||||
]
|
||||
27
src/modules/how-it-works-3d/curves/truckPath.js
Normal file
27
src/modules/how-it-works-3d/curves/truckPath.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
// Exact coordinates extracted from the white road lane markers and building platform heights
|
||||
export const truckPoints = [
|
||||
new THREE.Vector3(15.5, 0.45, -26.5), // Start on road lane in front of First Mile warehouse
|
||||
new THREE.Vector3(13.399, 0.324, -24.742), // Road lane start in front of warehouse
|
||||
new THREE.Vector3(11.211, 0.178, -22.973), // Road lane marker
|
||||
new THREE.Vector3(8.823, 0.111, -20.949), // Road lane marker
|
||||
new THREE.Vector3(6.447, 0.059, -19.06), // Road lane marker
|
||||
new THREE.Vector3(3.786, 0.072, -17.002), // Joining main road
|
||||
new THREE.Vector3(0.732, 0.124, -14.955), // Road lane marker
|
||||
new THREE.Vector3(-2.156, 0.124, -12.903), // Road lane marker
|
||||
new THREE.Vector3(-4.417, 0.124, -10.929), // Road lane marker
|
||||
new THREE.Vector3(-5.896, 0.124, -8.052), // Road lane marker
|
||||
new THREE.Vector3(-5.985, 0.124, -5.497), // Stopped on road in front of Mid Mile hub
|
||||
new THREE.Vector3(-4.362, 0.124, -3.25), // Road lane marker
|
||||
new THREE.Vector3(-1.448, 0.124, -1.234), // Road lane marker
|
||||
new THREE.Vector3(2.539, 0.124, 0.986), // Road lane marker
|
||||
new THREE.Vector3(6.686, 0.124, 3.379), // Road lane marker
|
||||
new THREE.Vector3(8.213, 0.124, 6.14), // Road lane marker
|
||||
new THREE.Vector3(7.976, 0.124, 9.176), // Road lane marker
|
||||
new THREE.Vector3(6.424, 0.124, 12.428), // Road lane marker
|
||||
new THREE.Vector3(3.883, 0.124, 15.769), // Road lane marker
|
||||
new THREE.Vector3(1.241, 0.124, 19.056) // Stopped in front of Last Mile hub
|
||||
]
|
||||
|
||||
export const truckPath = new THREE.CatmullRomCurve3(truckPoints)
|
||||
164
src/modules/how-it-works-3d/hooks/useCameraAnimation.js
Normal file
164
src/modules/how-it-works-3d/hooks/useCameraAnimation.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { truckPath } from '../curves/truckPath'
|
||||
import { clamp } from '../utils/helpers'
|
||||
|
||||
export const useCameraAnimation = (scrollProgress) => {
|
||||
const cameraState = useMemo(() => {
|
||||
// 1. Calculate the truck position corresponding to the current scroll progress
|
||||
// Use the exact same piecewise mapping to keep camera follow 100% synchronized
|
||||
let truckProgress = 0
|
||||
if (scrollProgress < 0.14) {
|
||||
truckProgress = 0.0
|
||||
} else if (scrollProgress >= 0.14 && scrollProgress < 0.38) {
|
||||
truckProgress = 0.5 * (scrollProgress - 0.14) / 0.24
|
||||
} else if (scrollProgress >= 0.38 && scrollProgress < 0.50) {
|
||||
truckProgress = 0.5
|
||||
} else if (scrollProgress >= 0.50 && scrollProgress < 0.76) {
|
||||
truckProgress = 0.5 + 0.5 * (scrollProgress - 0.50) / 0.26
|
||||
} else {
|
||||
truckProgress = 1.0
|
||||
}
|
||||
const truckPos = truckPath.getPoint(truckProgress)
|
||||
|
||||
const firstMileViewWhole = {
|
||||
position: new THREE.Vector3(38.0, 15.0, -10.0),
|
||||
target: new THREE.Vector3(24.377, 4.0, -39.303)
|
||||
}
|
||||
const firstMileViewFront = {
|
||||
position: new THREE.Vector3(7.0, 3.0, -19.0),
|
||||
target: new THREE.Vector3(15.5, 1.5, -26.5)
|
||||
}
|
||||
const midMileView = {
|
||||
position: new THREE.Vector3(-7.0, 7.5, 8.0),
|
||||
target: new THREE.Vector3(-19.146, 2.5, -9.0)
|
||||
}
|
||||
const lastMileViewClose = {
|
||||
position: new THREE.Vector3(-3.5, 4.0, 15.0),
|
||||
target: new THREE.Vector3(8.0, 2.0, 20.0)
|
||||
}
|
||||
const lastMileViewZoomedOut = {
|
||||
position: new THREE.Vector3(-10.4, 5.2, 12.0),
|
||||
target: new THREE.Vector3(8.0, 2.0, 20.0)
|
||||
}
|
||||
const analyticsView = {
|
||||
position: new THREE.Vector3(-13.5, 5.0, 31.0),
|
||||
target: new THREE.Vector3(-7.7, 3.5, 25.4)
|
||||
}
|
||||
|
||||
// 3. Calculate local coordinate axes of the truck based on the spline tangent
|
||||
const forward = truckPath.getTangent(truckProgress).normalize()
|
||||
const up = new THREE.Vector3(0, 1, 0)
|
||||
const right = new THREE.Vector3().crossVectors(forward, up).normalize()
|
||||
|
||||
// Cruise 1: Front-left follow perspective (facing the oncoming truck, zoomed out follow)
|
||||
const cruise1Pos = truckPos.clone()
|
||||
.addScaledVector(forward, 7.2)
|
||||
.addScaledVector(up, 3.2)
|
||||
.addScaledVector(right, -3.0)
|
||||
const cruise1Target = truckPos.clone()
|
||||
|
||||
// Cruise 2: Front-right follow perspective (facing the oncoming truck, zoomed out follow)
|
||||
const cruise2Pos = truckPos.clone()
|
||||
.addScaledVector(forward, 7.2)
|
||||
.addScaledVector(up, 3.2)
|
||||
.addScaledVector(right, 3.0)
|
||||
const cruise2Target = truckPos.clone()
|
||||
|
||||
const position = new THREE.Vector3()
|
||||
const target = new THREE.Vector3()
|
||||
|
||||
// 4. Smoothly blend positions and targets depending on active scroll boundaries
|
||||
if (scrollProgress < 0.04) {
|
||||
// Step 1: Zoomed out overview of the whole building
|
||||
position.copy(firstMileViewWhole.position)
|
||||
target.copy(firstMileViewWhole.target)
|
||||
}
|
||||
else if (scrollProgress >= 0.04 && scrollProgress < 0.14) {
|
||||
// Step 2: Camera moves to the front close-up view of the building
|
||||
const alpha = (scrollProgress - 0.04) / 0.10
|
||||
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
|
||||
position.lerpVectors(firstMileViewWhole.position, firstMileViewFront.position, smoothAlpha)
|
||||
target.lerpVectors(firstMileViewWhole.target, firstMileViewFront.target, smoothAlpha)
|
||||
}
|
||||
else if (scrollProgress >= 0.14 && scrollProgress < 0.18) {
|
||||
// Step 3: Truck starts moving, camera blends to close follow tracking
|
||||
const alpha = (scrollProgress - 0.14) / 0.04
|
||||
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
|
||||
position.lerpVectors(firstMileViewFront.position, cruise1Pos, smoothAlpha)
|
||||
target.lerpVectors(firstMileViewFront.target, cruise1Target, smoothAlpha)
|
||||
}
|
||||
else if (scrollProgress >= 0.18 && scrollProgress < 0.34) {
|
||||
// Cruise 1: Close follow tracking
|
||||
position.copy(cruise1Pos)
|
||||
target.copy(cruise1Target)
|
||||
}
|
||||
else if (scrollProgress >= 0.34 && scrollProgress < 0.38) {
|
||||
// Blend: Cruise 1 Follow -> Mid Mile Building
|
||||
const alpha = (scrollProgress - 0.34) / 0.04
|
||||
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
|
||||
position.lerpVectors(cruise1Pos, midMileView.position, smoothAlpha)
|
||||
target.lerpVectors(cruise1Target, midMileView.target, smoothAlpha)
|
||||
}
|
||||
else if (scrollProgress >= 0.38 && scrollProgress < 0.50) {
|
||||
// Mid Mile Building focus
|
||||
position.copy(midMileView.position)
|
||||
target.copy(midMileView.target)
|
||||
}
|
||||
else if (scrollProgress >= 0.50 && scrollProgress < 0.54) {
|
||||
// Blend: Mid Mile Building -> Cruise 2 Follow
|
||||
const alpha = (scrollProgress - 0.50) / 0.04
|
||||
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
|
||||
position.lerpVectors(midMileView.position, cruise2Pos, smoothAlpha)
|
||||
target.lerpVectors(midMileView.target, cruise2Target, smoothAlpha)
|
||||
}
|
||||
else if (scrollProgress >= 0.54 && scrollProgress < 0.72) {
|
||||
// Cruise 2: Close follow tracking
|
||||
position.copy(cruise2Pos)
|
||||
target.copy(cruise2Target)
|
||||
}
|
||||
else if (scrollProgress >= 0.72 && scrollProgress < 0.76) {
|
||||
// Blend: Cruise 2 Follow -> Last Mile Building Close-up
|
||||
const alpha = (scrollProgress - 0.72) / 0.04
|
||||
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
|
||||
position.lerpVectors(cruise2Pos, lastMileViewClose.position, smoothAlpha)
|
||||
target.lerpVectors(cruise2Target, lastMileViewClose.target, smoothAlpha)
|
||||
}
|
||||
else if (scrollProgress >= 0.76 && scrollProgress < 0.92) {
|
||||
// Last Mile Building Stop Sequence:
|
||||
// - 0.76 to 0.80: Parked close-up view of the truck and building
|
||||
// - 0.80 to 0.84: Zoom out transition back along the camera viewing axis
|
||||
// - 0.84 to 0.92: Zoomed-out overview of the final delivery stage (card stays frozen here)
|
||||
if (scrollProgress < 0.80) {
|
||||
position.copy(lastMileViewClose.position)
|
||||
target.copy(lastMileViewClose.target)
|
||||
} else if (scrollProgress >= 0.80 && scrollProgress < 0.84) {
|
||||
const alpha = (scrollProgress - 0.80) / 0.04
|
||||
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
|
||||
position.lerpVectors(lastMileViewClose.position, lastMileViewZoomedOut.position, smoothAlpha)
|
||||
target.lerpVectors(lastMileViewClose.target, lastMileViewZoomedOut.target, smoothAlpha)
|
||||
} else {
|
||||
position.copy(lastMileViewZoomedOut.position)
|
||||
target.copy(lastMileViewZoomedOut.target)
|
||||
}
|
||||
}
|
||||
else if (scrollProgress >= 0.92 && scrollProgress < 0.96) {
|
||||
// Blend: Last Mile Building Zoomed-Out -> Analytics Dashboard screen
|
||||
const alpha = (scrollProgress - 0.92) / 0.04
|
||||
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
|
||||
position.lerpVectors(lastMileViewZoomedOut.position, analyticsView.position, smoothAlpha)
|
||||
target.lerpVectors(lastMileViewZoomedOut.target, analyticsView.target, smoothAlpha)
|
||||
}
|
||||
else {
|
||||
// Analytics Dashboard screen focus
|
||||
position.copy(analyticsView.position)
|
||||
target.copy(analyticsView.target)
|
||||
}
|
||||
|
||||
return { position, target }
|
||||
}, [scrollProgress])
|
||||
|
||||
return cameraState
|
||||
}
|
||||
|
||||
export default useCameraAnimation
|
||||
16
src/modules/how-it-works-3d/hooks/useScrollProgress.js
Normal file
16
src/modules/how-it-works-3d/hooks/useScrollProgress.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useSceneStore } from '../store/useSceneStore'
|
||||
|
||||
export const useScrollProgress = () => {
|
||||
const scrollProgress = useSceneStore((state) => state.scrollProgress)
|
||||
const activeSection = useSceneStore((state) => state.activeSection)
|
||||
const setScrollProgress = useSceneStore((state) => state.setScrollProgress)
|
||||
const setActiveSection = useSceneStore((state) => state.setActiveSection)
|
||||
|
||||
return {
|
||||
scrollProgress,
|
||||
activeSection,
|
||||
setScrollProgress,
|
||||
setActiveSection,
|
||||
}
|
||||
}
|
||||
export default useScrollProgress
|
||||
52
src/modules/how-it-works-3d/hooks/useTruckMovement.js
Normal file
52
src/modules/how-it-works-3d/hooks/useTruckMovement.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { truckPath } from '../curves/truckPath'
|
||||
import { clamp } from '../utils/helpers'
|
||||
|
||||
export const useTruckMovement = (scrollProgress) => {
|
||||
// Piecewise mapping of scroll progress to make the truck stop at Mid Mile:
|
||||
// - 0% to 25%: Parked at First Mile (progress = 0)
|
||||
// - 25% to 45%: Driving from First Mile to Mid Mile (progress 0 -> 0.5)
|
||||
// - 45% to 55%: Parked at Mid Mile (progress = 0.5)
|
||||
// - 55% to 75%: Driving from Mid Mile to Last Mile (progress 0.5 -> 1.0)
|
||||
// - 75% to 100%: Parked at Last Mile (progress = 1.0)
|
||||
const truckProgress = useMemo(() => {
|
||||
if (scrollProgress < 0.14) {
|
||||
return 0.0
|
||||
}
|
||||
if (scrollProgress >= 0.14 && scrollProgress < 0.38) {
|
||||
return 0.5 * (scrollProgress - 0.14) / 0.24
|
||||
}
|
||||
if (scrollProgress >= 0.38 && scrollProgress < 0.50) {
|
||||
return 0.5
|
||||
}
|
||||
if (scrollProgress >= 0.50 && scrollProgress < 0.76) {
|
||||
return 0.5 + 0.5 * (scrollProgress - 0.50) / 0.26
|
||||
}
|
||||
return 1.0
|
||||
}, [scrollProgress])
|
||||
|
||||
// Get current position on the curve
|
||||
const position = useMemo(() => {
|
||||
return truckPath.getPoint(truckProgress)
|
||||
}, [truckProgress])
|
||||
|
||||
// Get lookAt target (a point slightly ahead on the curve, using tangent at the end to prevent matrix collapse)
|
||||
const lookAtTarget = useMemo(() => {
|
||||
if (truckProgress >= 0.99) {
|
||||
const tangent = truckPath.getTangent(1.0)
|
||||
const endPoint = truckPath.getPoint(1.0)
|
||||
return new THREE.Vector3().copy(endPoint).addScaledVector(tangent, 1.0)
|
||||
}
|
||||
const ahead = Math.min(truckProgress + 0.01, 1.0)
|
||||
return truckPath.getPoint(ahead)
|
||||
}, [truckProgress])
|
||||
|
||||
return {
|
||||
truckProgress,
|
||||
position,
|
||||
lookAtTarget,
|
||||
}
|
||||
}
|
||||
|
||||
export default useTruckMovement
|
||||
11596
src/modules/how-it-works-3d/models/Scene3D.jsx
Normal file
11596
src/modules/how-it-works-3d/models/Scene3D.jsx
Normal file
File diff suppressed because it is too large
Load Diff
14
src/modules/how-it-works-3d/store/useSceneStore.js
Normal file
14
src/modules/how-it-works-3d/store/useSceneStore.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export const useSceneStore = create((set) => ({
|
||||
scrollProgress: 0,
|
||||
activeSection: 0, // 0: First Mile, 1: Mid Mile, 2: Last Mile, 3: Analytics
|
||||
truckProgress: 0,
|
||||
cameraTarget: [19.727, 4.397, -31.08], // Initial target, e.g. First Mile warehouse
|
||||
lenis: null,
|
||||
setScrollProgress: (progress) => set({ scrollProgress: progress }),
|
||||
setActiveSection: (section) => set({ activeSection: section }),
|
||||
setTruckProgress: (progress) => set({ truckProgress: progress }),
|
||||
setCameraTarget: (target) => set({ cameraTarget: target }),
|
||||
setLenis: (lenis) => set({ lenis }),
|
||||
}))
|
||||
439
src/modules/how-it-works-3d/styles/experience.css
Normal file
439
src/modules/how-it-works-3d/styles/experience.css
Normal file
@@ -0,0 +1,439 @@
|
||||
/* ============================================================================
|
||||
How It Works — 3D experience styles.
|
||||
|
||||
Ported from the standalone Vite app's index.css and FULLY SCOPED under
|
||||
`.dm-hiw-3d` so nothing bleeds into the surrounding Next.js site. The original
|
||||
had global `:root` / `body` / `::-webkit-scrollbar` rules and generic class
|
||||
names (.navbar, .btn-primary, .section-title, .loader-overlay) that would
|
||||
collide with the site's Elementor CSS — every selector below is therefore
|
||||
prefixed with `.dm-hiw-3d`.
|
||||
|
||||
Pinning mirrors the site's existing scroll-driven 3D sections (see
|
||||
StrategySection): a tall `position:relative` section + an absolutely
|
||||
positioned stage toggled absolute(top) → fixed → absolute(bottom) from
|
||||
ScrollTrigger pin state. CSS `position: sticky` / GSAP pin are unreliable
|
||||
here because the site has a fixed header and an ancestor `overflow:hidden`.
|
||||
============================================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
/* ---- Section shell + self-managed fixed pin ---- */
|
||||
.dm-hiw-3d {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.47;
|
||||
font-weight: 400;
|
||||
color: #1d1d1f;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.dm-hiw-3d-stage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.dm-hiw-3d.is-pinned .dm-hiw-3d-stage {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.dm-hiw-3d.is-after .dm-hiw-3d-stage {
|
||||
position: absolute;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.dm-hiw-3d .canvas-wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Floating Vertical Side Navigation */
|
||||
.dm-hiw-3d .side-navigation {
|
||||
position: absolute;
|
||||
right: 28px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: transparent;
|
||||
padding: 18px 10px;
|
||||
}
|
||||
|
||||
.dm-hiw-3d .side-nav-item {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 4px 6px;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dm-hiw-3d .side-nav-label {
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #86868b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
opacity: 0;
|
||||
transform: translateX(8px) scale(0.9);
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dm-hiw-3d .side-nav-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.dm-hiw-3d .side-nav-item:hover .side-nav-label {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
.dm-hiw-3d .side-nav-item:hover .side-nav-dot {
|
||||
background-color: #1d1d1f;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.dm-hiw-3d .side-nav-item.active .side-nav-label {
|
||||
color: #0071e3;
|
||||
}
|
||||
.dm-hiw-3d .side-nav-item.active .side-nav-dot {
|
||||
background-color: #0071e3;
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 0 8px rgba(0, 113, 227, 0.3);
|
||||
}
|
||||
|
||||
.dm-hiw-3d .section-close-btn {
|
||||
margin-top: 20px;
|
||||
background-color: #0071e3;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 8px 16px;
|
||||
border-radius: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
box-shadow: 0 4px 12px rgba(0, 113, 227, 0.15);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
}
|
||||
.dm-hiw-3d .section-close-btn:hover {
|
||||
background-color: #0077ed;
|
||||
box-shadow: 0 6px 16px rgba(0, 113, 227, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.dm-hiw-3d .section-close-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* ---- Story stage text panels ---- */
|
||||
.dm-hiw-3d .sections-overlay-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 8;
|
||||
pointer-events: none; /* Let clicks pass to 3D canvas */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dm-hiw-3d #first-mile-section,
|
||||
.dm-hiw-3d #last-mile-section {
|
||||
left: 6%;
|
||||
}
|
||||
.dm-hiw-3d #mid-mile-section {
|
||||
right: 6%;
|
||||
}
|
||||
.dm-hiw-3d #analytics-section {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%) translateY(18px) scale(0.97);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
background: rgba(20, 21, 26, 0.88); /* Deep slate blackboard theme */
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 30px 70px rgba(0, 0, 0, 0.5);
|
||||
color: #ffffff;
|
||||
}
|
||||
.dm-hiw-3d #analytics-section.active {
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
}
|
||||
.dm-hiw-3d #analytics-section .section-title { color: #ffffff; }
|
||||
.dm-hiw-3d #analytics-section .section-subtitle { color: #a1a1a6; }
|
||||
.dm-hiw-3d #analytics-section .step-title { color: #ffffff; }
|
||||
.dm-hiw-3d #analytics-section .step-description { color: #a1a1a6; }
|
||||
.dm-hiw-3d #analytics-section .step-line {
|
||||
background: linear-gradient(to bottom, #0071e3 40%, rgba(255, 255, 255, 0.1) 100%);
|
||||
}
|
||||
|
||||
.dm-hiw-3d .section-panel {
|
||||
position: absolute;
|
||||
max-width: 380px;
|
||||
padding: 30px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
backdrop-filter: blur(0px);
|
||||
-webkit-backdrop-filter: blur(0px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 24px;
|
||||
opacity: 0;
|
||||
transform: translateY(18px) scale(0.97);
|
||||
visibility: hidden;
|
||||
transition: backdrop-filter 0.9s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
-webkit-backdrop-filter 0.9s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
visibility 0.9s;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.03);
|
||||
pointer-events: none;
|
||||
}
|
||||
.dm-hiw-3d .section-panel.active {
|
||||
visibility: visible;
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dm-hiw-3d .section-badge {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
color: #0071e3;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dm-hiw-3d .section-title {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.6px;
|
||||
color: #1d1d1f;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.dm-hiw-3d .section-subtitle {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #86868b;
|
||||
margin: 0 0 14px 0;
|
||||
}
|
||||
.dm-hiw-3d .section-description {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #515154;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.dm-hiw-3d .section-metrics {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.dm-hiw-3d .metric-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
.dm-hiw-3d .metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.dm-hiw-3d .metric-label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #86868b;
|
||||
}
|
||||
.dm-hiw-3d .font-green .metric-value { color: #34c759; }
|
||||
|
||||
/* ---- Animations (keyframes left global; uniquely named) ---- */
|
||||
@keyframes dmHiwScrollWheel {
|
||||
0% { top: 6px; opacity: 1; height: 6px; }
|
||||
50% { top: 14px; opacity: 0.3; height: 4px; }
|
||||
100% { top: 6px; opacity: 1; height: 6px; }
|
||||
}
|
||||
@keyframes dmHiwPulseGreen {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@keyframes dmHiwMoveArrow {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
50% { transform: translateX(8px); }
|
||||
}
|
||||
|
||||
/* ---- Workflow steps styling inside the Analytics overlay ---- */
|
||||
.dm-hiw-3d .workflow-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.dm-hiw-3d .workflow-step { display: flex; gap: 14px; }
|
||||
.dm-hiw-3d .step-number-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
}
|
||||
.dm-hiw-3d .step-number {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #0071e3;
|
||||
background: rgba(0, 113, 227, 0.1);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(0, 113, 227, 0.15);
|
||||
}
|
||||
.dm-hiw-3d .step-line {
|
||||
width: 1px;
|
||||
flex-grow: 1;
|
||||
background: linear-gradient(to bottom, #0071e3 40%, rgba(0, 0, 0, 0.05) 100%);
|
||||
margin-top: 6px;
|
||||
min-height: 24px;
|
||||
}
|
||||
.dm-hiw-3d .step-content { flex-grow: 1; }
|
||||
.dm-hiw-3d .step-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
margin: 0 0 2px 0;
|
||||
}
|
||||
.dm-hiw-3d .step-description {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
color: #6e6e73;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 1024px) {
|
||||
.dm-hiw-3d .sections-overlay-container {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
align-items: flex-end;
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
.dm-hiw-3d .section-panel {
|
||||
max-width: 100%;
|
||||
width: calc(100vw - 40px);
|
||||
padding: 20px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.dm-hiw-3d #first-mile-section,
|
||||
.dm-hiw-3d #mid-mile-section,
|
||||
.dm-hiw-3d #last-mile-section,
|
||||
.dm-hiw-3d #analytics-section {
|
||||
left: 50% !important;
|
||||
right: auto !important;
|
||||
top: auto !important;
|
||||
bottom: 60px !important;
|
||||
transform: translateX(-50%) translateY(18px) scale(0.97) !important;
|
||||
max-width: 380px;
|
||||
width: calc(100vw - 120px) !important;
|
||||
}
|
||||
.dm-hiw-3d #first-mile-section.active,
|
||||
.dm-hiw-3d #mid-mile-section.active,
|
||||
.dm-hiw-3d #last-mile-section.active,
|
||||
.dm-hiw-3d #analytics-section.active {
|
||||
transform: translateX(-50%) translateY(0) scale(1) !important;
|
||||
}
|
||||
.dm-hiw-3d #analytics-section { background: rgba(20, 21, 26, 0.92); }
|
||||
.dm-hiw-3d .side-navigation {
|
||||
bottom: 12px;
|
||||
top: auto;
|
||||
right: auto;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
flex-direction: row;
|
||||
gap: 18px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.dm-hiw-3d .side-nav-item { justify-content: center; padding: 0; }
|
||||
.dm-hiw-3d .side-nav-label { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.dm-hiw-3d .section-panel {
|
||||
padding: 16px !important;
|
||||
width: calc(100vw - 80px) !important;
|
||||
bottom: 40px !important;
|
||||
}
|
||||
.dm-hiw-3d .section-badge {
|
||||
font-size: 9px !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
.dm-hiw-3d .section-title {
|
||||
font-size: 20px !important;
|
||||
letter-spacing: -0.4px !important;
|
||||
}
|
||||
.dm-hiw-3d .section-subtitle {
|
||||
font-size: 13px !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
.dm-hiw-3d .section-description {
|
||||
font-size: 11px !important;
|
||||
line-height: 1.4 !important;
|
||||
margin-bottom: 12px !important;
|
||||
}
|
||||
.dm-hiw-3d .section-metrics {
|
||||
padding-top: 10px !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
.dm-hiw-3d .metric-value { font-size: 15px !important; }
|
||||
.dm-hiw-3d .metric-label { font-size: 8.5px !important; }
|
||||
.dm-hiw-3d .side-navigation {
|
||||
bottom: 8px !important;
|
||||
gap: 12px !important;
|
||||
padding: 6px 12px !important;
|
||||
}
|
||||
.dm-hiw-3d .side-nav-dot { width: 6px !important; height: 6px !important; }
|
||||
.dm-hiw-3d #first-mile-section,
|
||||
.dm-hiw-3d #mid-mile-section,
|
||||
.dm-hiw-3d #last-mile-section,
|
||||
.dm-hiw-3d #analytics-section {
|
||||
left: 50% !important;
|
||||
right: auto !important;
|
||||
bottom: 40px !important;
|
||||
width: calc(100vw - 80px) !important;
|
||||
transform: translateX(-50%) translateY(18px) scale(0.97) !important;
|
||||
}
|
||||
.dm-hiw-3d #first-mile-section.active,
|
||||
.dm-hiw-3d #mid-mile-section.active,
|
||||
.dm-hiw-3d #last-mile-section.active,
|
||||
.dm-hiw-3d #analytics-section.active {
|
||||
transform: translateX(-50%) translateY(0) scale(1) !important;
|
||||
}
|
||||
}
|
||||
89
src/modules/how-it-works-3d/utils/audioHelper.js
Normal file
89
src/modules/how-it-works-3d/utils/audioHelper.js
Normal file
@@ -0,0 +1,89 @@
|
||||
let audioContext = null;
|
||||
let isUnlocked = false;
|
||||
|
||||
// Initialize and unlock audio context
|
||||
export const initAudio = () => {
|
||||
if (isUnlocked) return;
|
||||
|
||||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContextClass) return;
|
||||
|
||||
if (!audioContext) {
|
||||
audioContext = new AudioContextClass();
|
||||
}
|
||||
|
||||
// Resume context if suspended (browser autoplay policy)
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume().then(() => {
|
||||
isUnlocked = true;
|
||||
cleanupListeners();
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
isUnlocked = true;
|
||||
cleanupListeners();
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupListeners = () => {
|
||||
window.removeEventListener('click', initAudio);
|
||||
window.removeEventListener('keydown', initAudio);
|
||||
window.removeEventListener('touchstart', initAudio);
|
||||
window.removeEventListener('wheel', initAudio);
|
||||
};
|
||||
|
||||
// Add listeners for early activation
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('click', initAudio, { passive: true });
|
||||
window.addEventListener('keydown', initAudio, { passive: true });
|
||||
window.addEventListener('touchstart', initAudio, { passive: true });
|
||||
window.addEventListener('wheel', initAudio, { passive: true });
|
||||
}
|
||||
|
||||
// Play a high-tech UI chime sound for card reveal
|
||||
export const playRevealChime = () => {
|
||||
try {
|
||||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContextClass) return;
|
||||
|
||||
if (!audioContext) {
|
||||
audioContext = new AudioContextClass();
|
||||
}
|
||||
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume().catch(() => {});
|
||||
}
|
||||
|
||||
const now = audioContext.currentTime;
|
||||
|
||||
// Master Volume node with exponential decay
|
||||
const masterGain = audioContext.createGain();
|
||||
masterGain.gain.setValueAtTime(0, now);
|
||||
masterGain.gain.linearRampToValueAtTime(0.15, now + 0.04); // subtle fade-in to avoid clicking
|
||||
masterGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.4); // smooth tail decay
|
||||
|
||||
// Warm base oscillator (triangle wave)
|
||||
const baseOsc = audioContext.createOscillator();
|
||||
baseOsc.type = 'triangle';
|
||||
baseOsc.frequency.setValueAtTime(329.63, now); // E4 pitch
|
||||
baseOsc.frequency.exponentialRampToValueAtTime(523.25, now + 0.25); // Slide up to C5
|
||||
|
||||
// High harmonic chime oscillator (sine wave)
|
||||
const chimeOsc = audioContext.createOscillator();
|
||||
chimeOsc.type = 'sine';
|
||||
chimeOsc.frequency.setValueAtTime(659.25, now); // E5 pitch
|
||||
chimeOsc.frequency.exponentialRampToValueAtTime(1046.50, now + 0.25); // Slide up to C6
|
||||
|
||||
// Connect nodes
|
||||
baseOsc.connect(masterGain);
|
||||
chimeOsc.connect(masterGain);
|
||||
masterGain.connect(audioContext.destination);
|
||||
|
||||
// Play oscillators
|
||||
baseOsc.start(now);
|
||||
baseOsc.stop(now + 0.4);
|
||||
chimeOsc.start(now);
|
||||
chimeOsc.stop(now + 0.4);
|
||||
} catch (error) {
|
||||
console.warn('Playback of reveal chime failed:', error);
|
||||
}
|
||||
};
|
||||
15
src/modules/how-it-works-3d/utils/easing.js
Normal file
15
src/modules/how-it-works-3d/utils/easing.js
Normal file
@@ -0,0 +1,15 @@
|
||||
export const easeInOutCubic = (t) => {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
|
||||
}
|
||||
|
||||
export const easeOutQuad = (t) => {
|
||||
return t * (2 - t)
|
||||
}
|
||||
|
||||
export const easeInQuad = (t) => {
|
||||
return t * t
|
||||
}
|
||||
|
||||
export const easeOutCubic = (t) => {
|
||||
return 1 - Math.pow(1 - t, 3)
|
||||
}
|
||||
41
src/modules/how-it-works-3d/utils/helpers.js
Normal file
41
src/modules/how-it-works-3d/utils/helpers.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
// Linear interpolation
|
||||
export const lerp = (start, end, amt) => {
|
||||
return (1 - amt) * start + amt * end
|
||||
}
|
||||
|
||||
// Map a number from [inMin, inMax] to [outMin, outMax]
|
||||
export const mapRange = (value, inMin, inMax, outMin, outMax) => {
|
||||
const result = ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
|
||||
return isNaN(result) ? outMin : result
|
||||
}
|
||||
|
||||
// Clamp a number between min and max
|
||||
export const clamp = (value, min, max) => {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
// Vector3 interpolation helper
|
||||
export const lerpVectors = (v1, v2, alpha, outVector = new THREE.Vector3()) => {
|
||||
outVector.x = lerp(v1.x, v2.x, alpha)
|
||||
outVector.y = lerp(v1.y, v2.y, alpha)
|
||||
outVector.z = lerp(v1.z, v2.z, alpha)
|
||||
return outVector
|
||||
}
|
||||
|
||||
// Convert a normalized scroll progress (0..1) within the experience into an
|
||||
// absolute document scrollY, relative to the 900vh ScrollRig spacer. Because the
|
||||
// experience now sits below the page hero (not at document top), jump-to-section
|
||||
// targets must be measured from the spacer's offset rather than the document top.
|
||||
// The spacer (#scroll-trigger-trigger) maps progress over the scroll span
|
||||
// [spacerTop, spacerTop + (spacerHeight - viewportHeight)] — matching the
|
||||
// ScrollTrigger start:'top top' / end:'bottom bottom' on the same element.
|
||||
export const progressToScrollY = (progress) => {
|
||||
if (typeof document === 'undefined') return 0
|
||||
const rig = document.getElementById('scroll-trigger-trigger')
|
||||
if (!rig) return 0
|
||||
const top = rig.getBoundingClientRect().top + window.scrollY
|
||||
const scrollable = rig.offsetHeight - window.innerHeight
|
||||
return top + clamp(progress, 0, 1) * scrollable
|
||||
}
|
||||
Reference in New Issue
Block a user