diff --git a/app/device-lab/DeviceLabLoader.tsx b/app/device-lab/DeviceLabLoader.tsx new file mode 100644 index 0000000..2b53166 --- /dev/null +++ b/app/device-lab/DeviceLabLoader.tsx @@ -0,0 +1,117 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useEffect, useState } from "react"; +import type { DeviceLabProps } from "@/components/device-lab/DeviceLab"; +import styles from "@/components/device-lab/device-lab.module.css"; + +const DeviceLab = dynamic( + () => import("@/components/device-lab/DeviceLab"), + { ssr: false, loading: () => null }, +); + +const PENDANT_SLOTS = Array.from({ length: 8 }, (_, index) => index); + +function PendantOutline() { + return ( + + ); +} + +function FabricationLoader({ progress, exiting }: { + progress: number; + exiting: boolean; +}) { + return ( +
+ + ); +} + +export default function DeviceLabLoader() { + const [progress, setProgress] = useState(0); + const [sceneReady, setSceneReady] = useState(false); + const [minimumElapsed, setMinimumElapsed] = useState(false); + const [exiting, setExiting] = useState(false); + const [loaderRemoved, setLoaderRemoved] = useState(false); + + useEffect(() => { + const minimumTimer = window.setTimeout(() => setMinimumElapsed(true), 1350); + return () => window.clearTimeout(minimumTimer); + }, []); + + useEffect(() => { + const startedAt = window.performance.now(); + const progressTimer = window.setInterval(() => { + setProgress((current) => { + if (sceneReady && minimumElapsed) { + return Math.min(100, current + Math.max(1, Math.ceil((100 - current) * 0.22))); + } + + const elapsed = window.performance.now() - startedAt; + const staged = Math.floor(92 * (1 - Math.exp(-elapsed / 720))); + return Math.max(current, Math.min(92, staged)); + }); + }, 45); + + return () => window.clearInterval(progressTimer); + }, [minimumElapsed, sceneReady]); + + useEffect(() => { + if (!sceneReady || progress < 100) return; + const exitTimer = window.setTimeout(() => setExiting(true), 180); + const removeTimer = window.setTimeout(() => setLoaderRemoved(true), 900); + return () => { + window.clearTimeout(exitTimer); + window.clearTimeout(removeTimer); + }; + }, [progress, sceneReady]); + + useEffect(() => { + if (loaderRemoved) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [loaderRemoved]); + + return ( + <> + setSceneReady(true)} /> + {!loaderRemoved && } + + ); +} diff --git a/app/device-lab/page.tsx b/app/device-lab/page.tsx new file mode 100644 index 0000000..1501d06 --- /dev/null +++ b/app/device-lab/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import DeviceLabLoader from "./DeviceLabLoader"; + +export const metadata: Metadata = { + title: "Omi Wearable — Unofficial Interactive Concept", + description: "An unofficial interactive Three.js product-story concept for the Omi wearable.", + robots: { + index: false, + follow: false, + }, +}; + +export default function DeviceLabPage() { + return ; +} diff --git a/components/device-lab/DeviceLab.tsx b/components/device-lab/DeviceLab.tsx new file mode 100644 index 0000000..7356d8f --- /dev/null +++ b/components/device-lab/DeviceLab.tsx @@ -0,0 +1,811 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Canvas } from "@react-three/fiber"; +import { + AnimatePresence, + LayoutGroup, + motion, + useMotionValueEvent, + useReducedMotion, + useScroll, + useSpring, + useTransform, +} from "framer-motion"; +import * as THREE from "three"; +import DeviceStage from "./DeviceStage"; +import { createDefaultLabSettings } from "./device-settings"; +import styles from "./device-lab.module.css"; + +export interface DeviceLabProps { + onReady?: () => void; +} + +export default function DeviceLab({ onReady }: DeviceLabProps) { + const reduceMotion = useReducedMotion() ?? false; + const [mobileOptimized, setMobileOptimized] = useState(false); + const readySignaled = useRef(false); + const settings = useMemo(createDefaultLabSettings, []); + const scrollTrack = useRef(null); + const { scrollYProgress } = useScroll({ + target: scrollTrack, + offset: ["start start", "end end"], + }); + // Keep the existing 100svh transitions, but absorb roughly one wheel notch + // at each composition before the next camera move begins. + const heldScrollProgress = useTransform( + scrollYProgress, + [0, 1 / 28, 1 / 3, 31 / 84, 2 / 3, 59 / 84, 1], + [0, 0, 1 / 3, 1 / 3, 2 / 3, 2 / 3, 1], + ); + const cameraProgress = useSpring(heldScrollProgress, { + stiffness: 86, + damping: 24, + mass: 0.42, + restDelta: 0.0001, + }); + const frameOneOpacity = useTransform(cameraProgress, [0, 0.25, 0.31], [1, 1, 0]); + const frameTwoOpacity = useTransform( + cameraProgress, + [0.18, 0.27, 0.47, 0.58], + [0, 1, 1, 0], + ); + const frameTwoY = useTransform(cameraProgress, [0.18, 0.3, 0.56], [52, 0, -42]); + const frameTwoScale = useTransform(cameraProgress, [0.18, 0.3, 0.56], [0.97, 1, 0.985]); + const frameThreeOpacity = useTransform( + cameraProgress, + [0.47, 0.58, 0.74, 0.87], + [0, 1, 1, 0], + ); + const frameThreeY = useTransform(cameraProgress, [0.47, 0.61, 0.86], [48, 0, -38]); + const frameThreeScale = useTransform(cameraProgress, [0.47, 0.61, 0.86], [0.975, 1, 0.99]); + const frameFourOpacity = useTransform(cameraProgress, [0.76, 0.88, 1], [0, 1, 1]); + const frameFourY = useTransform(cameraProgress, [0.76, 0.9, 1], [48, 0, 0]); + const letterOExit = useTransform(cameraProgress, [0.13, 0.255], [0, 520]); + const letterMExit = useTransform(cameraProgress, [0.145, 0.27], [0, 520]); + const letterIExit = useTransform(cameraProgress, [0.16, 0.285], [0, 520]); + const physicsRelease = useTransform(cameraProgress, [0.105, 0.205], [0, 1]); + const navProgressScale = useTransform(cameraProgress, [0, 1], [0.08, 1]); + const [isAwake, setIsAwake] = useState(false); + const [activeFrame, setActiveFrame] = useState(0); + const [filmOpen, setFilmOpen] = useState(false); + const [thoughtHintVisible, setThoughtHintVisible] = useState(false); + const [messageOpen, setMessageOpen] = useState(false); + const [messageConnected, setMessageConnected] = useState(false); + const [purchaseDetailsOpen, setPurchaseDetailsOpen] = useState(false); + const thoughtHideTimer = useRef(null); + + const signalReady = useCallback(() => { + if (readySignaled.current) return; + readySignaled.current = true; + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => onReady?.()); + }); + }, [onReady]); + + useEffect(() => { + const query = window.matchMedia("(max-width: 820px), (pointer: coarse)"); + const updateMobileMode = () => setMobileOptimized(query.matches); + updateMobileMode(); + query.addEventListener("change", updateMobileMode); + return () => query.removeEventListener("change", updateMobileMode); + }, []); + + useMotionValueEvent(cameraProgress, "change", (progress) => { + const nextFrame = Math.min(3, Math.max(0, Math.round(progress * 3))); + setActiveFrame((currentFrame) => currentFrame === nextFrame ? currentFrame : nextFrame); + }); + const goToFrame = useCallback((frameIndex: number) => { + const track = scrollTrack.current; + if (!track) return; + + const trackTop = track.getBoundingClientRect().top + window.scrollY; + const scrollableDistance = Math.max(0, track.offsetHeight - window.innerHeight); + window.scrollTo({ + top: trackTop + scrollableDistance * (frameIndex / 3), + behavior: reduceMotion ? "auto" : "smooth", + }); + }, [reduceMotion]); + const clearThoughtHide = useCallback(() => { + if (thoughtHideTimer.current === null) return; + window.clearTimeout(thoughtHideTimer.current); + thoughtHideTimer.current = null; + }, []); + const scheduleThoughtHide = useCallback((delay = 900) => { + clearThoughtHide(); + thoughtHideTimer.current = window.setTimeout(() => { + setThoughtHintVisible(false); + thoughtHideTimer.current = null; + }, delay); + }, [clearThoughtHide]); + const openOmiMessage = useCallback(() => { + if (activeFrame !== 1) return; + clearThoughtHide(); + setThoughtHintVisible(false); + setMessageConnected(false); + setMessageOpen(true); + }, [activeFrame, clearThoughtHide]); + + useEffect(() => { + if (activeFrame !== 1) { + clearThoughtHide(); + setThoughtHintVisible(false); + setMessageOpen(false); + setMessageConnected(false); + setFilmOpen(false); + return; + } + + if (isAwake) { + clearThoughtHide(); + setThoughtHintVisible(true); + } else if (!messageOpen) { + scheduleThoughtHide(); + } + }, [activeFrame, clearThoughtHide, isAwake, messageOpen, scheduleThoughtHide]); + + useEffect(() => { + if (activeFrame !== 3) setPurchaseDetailsOpen(false); + }, [activeFrame]); + + useEffect(() => () => clearThoughtHide(), [clearThoughtHide]); + + useEffect(() => { + if (!filmOpen) return; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setFilmOpen(false); + }; + window.addEventListener("keydown", closeOnEscape); + return () => { + document.body.style.overflow = previousOverflow; + window.removeEventListener("keydown", closeOnEscape); + }; + }, [filmOpen]); + const navFrames = ["Device", "Intelligence", "Memory", "Get Omi"]; + const wordmarkLetters = [ + { character: "o", x: 12, length: 304, exitY: letterOExit }, + { character: "m", x: 326, length: 500, exitY: letterMExit }, + { character: "i", x: 848, length: 138, exitY: letterIExit }, + ]; + + return ( +
+
+