diff --git a/eslint.config.mjs b/eslint.config.mjs index d74721c..fb43e27 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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/**", ]), ]); diff --git a/package-lock.json b/package-lock.json index 0cc86a6..fe319e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 4dde4b8..3a6814c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/models/3d_scene_final.glb b/public/models/3d_scene_final.glb new file mode 100644 index 0000000..c7eb88f Binary files /dev/null and b/public/models/3d_scene_final.glb differ diff --git a/src/animations/SmoothScroll.tsx b/src/animations/SmoothScroll.tsx index a3cfae5..7eed2b7 100644 --- a/src/animations/SmoothScroll.tsx +++ b/src/animations/SmoothScroll.tsx @@ -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(); diff --git a/src/app/how-it-works/page.tsx b/src/app/how-it-works/page.tsx index 1c72f96..b159c78 100644 --- a/src/app/how-it-works/page.tsx +++ b/src/app/how-it-works/page.tsx @@ -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() {
- - - + {/* 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. */} +
diff --git a/src/modules/how-it-works-3d/Experience3D.jsx b/src/modules/how-it-works-3d/Experience3D.jsx new file mode 100644 index 0000000..fbef78e --- /dev/null +++ b/src/modules/how-it-works-3d/Experience3D.jsx @@ -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 ( +
+ {/* Pinned stage: canvas + HTML overlays. Stays fixed across the scroll. */} +
+
= 0.92 ? 0.85 : 1.0, + transition: 'opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1)', + }} + > + {mountScene && ( + + )} +
+ + {/* In-experience section navigation */} + + + {/* Story stage text panels (revealed at their scroll ranges) */} +
+ = 0.02 && scrollProgress < 0.14} /> + = 0.38 && scrollProgress < 0.50} /> + = 0.80 && scrollProgress < 0.92} /> + = 0.94} /> +
+
+ + {/* GSAP scroll system: 900vh in-flow spacer that gives the section its + height, drives scroll progress, and reports pin state. */} + +
+ ) +} diff --git a/src/modules/how-it-works-3d/Experience3DLoader.tsx b/src/modules/how-it-works-3d/Experience3DLoader.tsx new file mode 100644 index 0000000..f5b35ff --- /dev/null +++ b/src/modules/how-it-works-3d/Experience3DLoader.tsx @@ -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: () =>
, +}); + +export default function Experience3DLoader() { + return ; +} diff --git a/src/modules/how-it-works-3d/animations/cameraTimeline.js b/src/modules/how-it-works-3d/animations/cameraTimeline.js new file mode 100644 index 0000000..733361c --- /dev/null +++ b/src/modules/how-it-works-3d/animations/cameraTimeline.js @@ -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 +} diff --git a/src/modules/how-it-works-3d/animations/dashboardAnimation.js b/src/modules/how-it-works-3d/animations/dashboardAnimation.js new file mode 100644 index 0000000..91a64b0 --- /dev/null +++ b/src/modules/how-it-works-3d/animations/dashboardAnimation.js @@ -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 + } + }) +} diff --git a/src/modules/how-it-works-3d/animations/truckTimeline.js b/src/modules/how-it-works-3d/animations/truckTimeline.js new file mode 100644 index 0000000..5a14f60 --- /dev/null +++ b/src/modules/how-it-works-3d/animations/truckTimeline.js @@ -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 +} diff --git a/src/modules/how-it-works-3d/animations/wheelAnimation.js b/src/modules/how-it-works-3d/animations/wheelAnimation.js new file mode 100644 index 0000000..4c798da --- /dev/null +++ b/src/modules/how-it-works-3d/animations/wheelAnimation.js @@ -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 + } + }) +} diff --git a/src/modules/how-it-works-3d/components/CameraRig.jsx b/src/modules/how-it-works-3d/components/CameraRig.jsx new file mode 100644 index 0000000..e0eb3b8 --- /dev/null +++ b/src/modules/how-it-works-3d/components/CameraRig.jsx @@ -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 +} diff --git a/src/modules/how-it-works-3d/components/Experience.jsx b/src/modules/how-it-works-3d/components/Experience.jsx new file mode 100644 index 0000000..4afd992 --- /dev/null +++ b/src/modules/how-it-works-3d/components/Experience.jsx @@ -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 ( + + + + + + ) +}) + +export default React.memo(function Experience({ dashboardRefs, wheelRefs, truckRef }) { + return ( +
+ + + + {/* Soft shadows */} + + + {/* Dynamic ambient and shadow-tracking directional lights */} + + + {/* Focused street lights along the road */} + + + {/* Environment preset */} + + + {/* Main 3D logistics scene model */} + + + {/* Delivery truck model animation controller */} + + + {/* Dynamic camera rig with damping and target interpolation */} + + + {/* 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. */} + + + + + +
+ ) +}) + diff --git a/src/modules/how-it-works-3d/components/ScrollRig.jsx b/src/modules/how-it-works-3d/components/ScrollRig.jsx new file mode 100644 index 0000000..25a39cc --- /dev/null +++ b/src/modules/how-it-works-3d/components/ScrollRig.jsx @@ -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 ( +
+ ) +} diff --git a/src/modules/how-it-works-3d/components/StreetLights.jsx b/src/modules/how-it-works-3d/components/StreetLights.jsx new file mode 100644 index 0000000..4da3b14 --- /dev/null +++ b/src/modules/how-it-works-3d/components/StreetLights.jsx @@ -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 ( + + {/* Spotlight casting cone of light downward */} + + {/* Glowing bulb mesh placed exactly at the light coordinates */} + + + + + + + ) +} + +export default React.memo(function StreetLights() { + return ( + + {streetLightsData.map((light, index) => ( + + ))} + + ) +}) diff --git a/src/modules/how-it-works-3d/components/TruckAnimation.jsx b/src/modules/how-it-works-3d/components/TruckAnimation.jsx new file mode 100644 index 0000000..39e26dc --- /dev/null +++ b/src/modules/how-it-works-3d/components/TruckAnimation.jsx @@ -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 +} diff --git a/src/modules/how-it-works-3d/components/sections/Analytics.jsx b/src/modules/how-it-works-3d/components/sections/Analytics.jsx new file mode 100644 index 0000000..9b8a2e0 --- /dev/null +++ b/src/modules/how-it-works-3d/components/sections/Analytics.jsx @@ -0,0 +1,46 @@ +import React from 'react' +import RevealCard from '../ui/RevealCard' + +export default function Analytics({ active }) { + return ( + +
Workflow
+

Doormile Insights

+

3-Mile Logistics Ecosystem

+ +
+
+
+ 01 +
+
+
+

First Mile

+

Incoming shipments are securely loaded, checked, and consolidated at initial fulfillment hubs.

+
+
+ +
+
+ 02 +
+
+
+

Mid Mile

+

Consolidated goods travel between primary distribution nodes via optimized express transit corridors.

+
+
+ +
+
+ 03 +
+
+

Last Mile

+

Local delivery units organize doorstep routes to transport packages to final customers.

+
+
+
+
+ ) +} diff --git a/src/modules/how-it-works-3d/components/sections/FirstMile.jsx b/src/modules/how-it-works-3d/components/sections/FirstMile.jsx new file mode 100644 index 0000000..59d2bb4 --- /dev/null +++ b/src/modules/how-it-works-3d/components/sections/FirstMile.jsx @@ -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 ( + +
Stage 01
+

{config.title}

+

{config.subtitle}

+

{config.description}

+
+
+ 14,250 + Parcels Processed +
+
+ 99.98% + Sorting Accuracy +
+
+
+ ) +} diff --git a/src/modules/how-it-works-3d/components/sections/LastMile.jsx b/src/modules/how-it-works-3d/components/sections/LastMile.jsx new file mode 100644 index 0000000..48e3e84 --- /dev/null +++ b/src/modules/how-it-works-3d/components/sections/LastMile.jsx @@ -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 ( + +
Stage 03
+

{config.title}

+

{config.subtitle}

+

{config.description}

+
+
+ 12.5 min + Avg. Delivery window +
+
+ 99.4% + On-Time Rate +
+
+ +
+ ) +} diff --git a/src/modules/how-it-works-3d/components/sections/MidMile.jsx b/src/modules/how-it-works-3d/components/sections/MidMile.jsx new file mode 100644 index 0000000..52301f0 --- /dev/null +++ b/src/modules/how-it-works-3d/components/sections/MidMile.jsx @@ -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 ( + +
Stage 02
+

{config.title}

+

{config.subtitle}

+

{config.description}

+
+
+ 4.2 hr + Avg. Transit Time +
+
+ 220 kw + Solar Output (Self-powered) +
+
+ +
+ ) +} diff --git a/src/modules/how-it-works-3d/components/ui/Hero.jsx b/src/modules/how-it-works-3d/components/ui/Hero.jsx new file mode 100644 index 0000000..3c11cb2 --- /dev/null +++ b/src/modules/how-it-works-3d/components/ui/Hero.jsx @@ -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 ( +
+ {/* Dynamic mouse scrolling indicator */} +
+
+
+
+
Scroll to start
+
+
+ ) +} diff --git a/src/modules/how-it-works-3d/components/ui/Navbar.jsx b/src/modules/how-it-works-3d/components/ui/Navbar.jsx new file mode 100644 index 0000000..9b53dea --- /dev/null +++ b/src/modules/how-it-works-3d/components/ui/Navbar.jsx @@ -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 ( + + ) +} diff --git a/src/modules/how-it-works-3d/components/ui/RevealCard.jsx b/src/modules/how-it-works-3d/components/ui/RevealCard.jsx new file mode 100644 index 0000000..ade6544 --- /dev/null +++ b/src/modules/how-it-works-3d/components/ui/RevealCard.jsx @@ -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 ( +
+ {children} +
+ ) +} diff --git a/src/modules/how-it-works-3d/constants/cameraPositions.js b/src/modules/how-it-works-3d/constants/cameraPositions.js new file mode 100644 index 0000000..d028963 --- /dev/null +++ b/src/modules/how-it-works-3d/constants/cameraPositions.js @@ -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), + }, +] diff --git a/src/modules/how-it-works-3d/constants/colors.js b/src/modules/how-it-works-3d/constants/colors.js new file mode 100644 index 0000000..db090bf --- /dev/null +++ b/src/modules/how-it-works-3d/constants/colors.js @@ -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)', +} diff --git a/src/modules/how-it-works-3d/constants/sectionConfig.js b/src/modules/how-it-works-3d/constants/sectionConfig.js new file mode 100644 index 0000000..0b9ead5 --- /dev/null +++ b/src/modules/how-it-works-3d/constants/sectionConfig.js @@ -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, + }, +] diff --git a/src/modules/how-it-works-3d/curves/truckPath.js b/src/modules/how-it-works-3d/curves/truckPath.js new file mode 100644 index 0000000..3a50526 --- /dev/null +++ b/src/modules/how-it-works-3d/curves/truckPath.js @@ -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) diff --git a/src/modules/how-it-works-3d/hooks/useCameraAnimation.js b/src/modules/how-it-works-3d/hooks/useCameraAnimation.js new file mode 100644 index 0000000..bf6ea1e --- /dev/null +++ b/src/modules/how-it-works-3d/hooks/useCameraAnimation.js @@ -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 diff --git a/src/modules/how-it-works-3d/hooks/useScrollProgress.js b/src/modules/how-it-works-3d/hooks/useScrollProgress.js new file mode 100644 index 0000000..418a2e1 --- /dev/null +++ b/src/modules/how-it-works-3d/hooks/useScrollProgress.js @@ -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 diff --git a/src/modules/how-it-works-3d/hooks/useTruckMovement.js b/src/modules/how-it-works-3d/hooks/useTruckMovement.js new file mode 100644 index 0000000..08fbb5d --- /dev/null +++ b/src/modules/how-it-works-3d/hooks/useTruckMovement.js @@ -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 diff --git a/src/modules/how-it-works-3d/models/Scene3D.jsx b/src/modules/how-it-works-3d/models/Scene3D.jsx new file mode 100644 index 0000000..d119eb1 --- /dev/null +++ b/src/modules/how-it-works-3d/models/Scene3D.jsx @@ -0,0 +1,11596 @@ +/* +Auto-generated by: https://github.com/pmndrs/gltfjsx +*/ + +import React, { useRef } from 'react' +import { useGLTF } from '@react-three/drei' + +export function Model(props) { + const { nodes, materials } = useGLTF('/models/3d_scene_final.glb') + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +useGLTF.preload('/models/3d_scene_final.glb') + + + + diff --git a/src/modules/how-it-works-3d/store/useSceneStore.js b/src/modules/how-it-works-3d/store/useSceneStore.js new file mode 100644 index 0000000..0acf5b4 --- /dev/null +++ b/src/modules/how-it-works-3d/store/useSceneStore.js @@ -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 }), +})) diff --git a/src/modules/how-it-works-3d/styles/experience.css b/src/modules/how-it-works-3d/styles/experience.css new file mode 100644 index 0000000..c1e8a81 --- /dev/null +++ b/src/modules/how-it-works-3d/styles/experience.css @@ -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; + } +} diff --git a/src/modules/how-it-works-3d/utils/audioHelper.js b/src/modules/how-it-works-3d/utils/audioHelper.js new file mode 100644 index 0000000..25a2acf --- /dev/null +++ b/src/modules/how-it-works-3d/utils/audioHelper.js @@ -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); + } +}; diff --git a/src/modules/how-it-works-3d/utils/easing.js b/src/modules/how-it-works-3d/utils/easing.js new file mode 100644 index 0000000..dc38e26 --- /dev/null +++ b/src/modules/how-it-works-3d/utils/easing.js @@ -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) +} diff --git a/src/modules/how-it-works-3d/utils/helpers.js b/src/modules/how-it-works-3d/utils/helpers.js new file mode 100644 index 0000000..93903db --- /dev/null +++ b/src/modules/how-it-works-3d/utils/helpers.js @@ -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 +}