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:
2026-06-08 19:58:34 +05:30
parent e93785f2b6
commit 3d53f82e7b
37 changed files with 13694 additions and 29 deletions

View File

@@ -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
View File

@@ -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": {

View File

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

Binary file not shown.

View File

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

View File

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

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

View 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 />;
}

View 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
}

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

View 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
}

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

View 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
}

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

View 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,
}}
/>
)
}

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

View 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
}

View File

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

View File

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

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

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

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

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

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

View 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),
},
]

View 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)',
}

View 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,
},
]

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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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 }),
}))

View 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;
}
}

View 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);
}
};

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

View 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
}