diff --git a/src/components/camera/camera-hooks.tsx b/src/components/camera/camera-hooks.tsx index 793ca9bb2..e78e89f24 100644 --- a/src/components/camera/camera-hooks.tsx +++ b/src/components/camera/camera-hooks.tsx @@ -8,6 +8,7 @@ import type { ICameraConfig } from "@/components/navigation-handler/navigation.i import { useNavigationStore } from "@/components/navigation-handler/navigation-store" import { useMedia } from "@/hooks/use-media" import { useFrameCallback } from "@/hooks/use-pausable-time" +import { useGyroscopeStore } from "@/store/gyroscope-store" import { easeInOutCubic } from "@/utils/animations" import { @@ -20,6 +21,10 @@ import { const ANIMATION_DURATION = 1 const ANIMATION_DURATION_FROM_404 = 4 +const GYRO_PAN_HORIZONTAL = 5 +const GYRO_PAN_VERTICAL = 4 +const GYRO_SMOOTHING = 0.8 + export type CameraRef = React.RefObject export type MeshRef = React.RefObject @@ -233,12 +238,26 @@ export const useCameraMovement = ( if (!plane || !boundary || !basePosition || !np || !cameraConfig) return + // Get gyroscope state for mobile camera control + const gyroState = useGyroscopeStore.getState() + const useGyroscope = + gyroState.isEnabled && gyroState.permission === "granted" + + // Use gyroscope orientation on mobile, pointer on desktop + const inputX = useGyroscope + ? -gyroState.orientationX * GYRO_PAN_HORIZONTAL + : pointer.x + const inputY = useGyroscope ? gyroState.orientationY * GYRO_PAN_VERTICAL : 0 + b.maxOffset = (boundary.scale.x - plane.scale.x) / 2 b.rightVector = calculateMovementVectors(basePosition, cameraConfig) - b.offset = pointer.x * b.maxOffset * offsetMultiplier + b.offset = inputX * b.maxOffset * offsetMultiplier b.pos.x = b.rightVector.x * b.offset b.pos.z = b.rightVector.z * b.offset + + // Vertical offset from gyroscope (Y axis movement) + const verticalOffset = inputY * b.maxOffset * offsetMultiplier b.targetPosition.x = basePosition[0] + b.pos.x b.targetPosition.z = basePosition[2] + b.pos.z b.planePosition.x = plane.position.x @@ -248,14 +267,23 @@ export const useCameraMovement = ( plane.position.setZ(np.z) if (!selected && cameraConfig?.offsetMultiplier !== 0) { - newDelta.set(b.pos.x, 0, b.pos.z) - newLookAtDelta.set(b.pos.x / divisor, 0, b.pos.z) + newDelta.set(b.pos.x, verticalOffset, b.pos.z) + newLookAtDelta.set(b.pos.x / divisor, verticalOffset / divisor, b.pos.z) + + // Use gyroscope smoothing when active, otherwise default smoothing + const posSmoothing = useGyroscope ? GYRO_SMOOTHING : 0.5 + const lookAtSmoothing = useGyroscope ? GYRO_SMOOTHING * 0.5 : 0.25 - easing.damp3(panTargetDelta, newDelta, 0.5, dt) - easing.damp3(panLookAtDelta, newLookAtDelta, 0.25, dt) + easing.damp3(panTargetDelta, newDelta, posSmoothing, dt) + easing.damp3(panLookAtDelta, newLookAtDelta, lookAtSmoothing, dt) } else { - easing.damp3(panTargetDelta, 0, 0.5, dt) - easing.damp3(panLookAtDelta, 0, 0.25, dt) + easing.damp3(panTargetDelta, 0, useGyroscope ? GYRO_SMOOTHING : 0.5, dt) + easing.damp3( + panLookAtDelta, + 0, + useGyroscope ? GYRO_SMOOTHING * 0.5 : 0.25, + dt + ) } if (cameraConfig) { diff --git a/src/components/layout/footer-content.tsx b/src/components/layout/footer-content.tsx index 8920f65f9..68aaef09a 100644 --- a/src/components/layout/footer-content.tsx +++ b/src/components/layout/footer-content.tsx @@ -1,5 +1,6 @@ import { cn } from "@/utils/cn" +import { GyroscopeToggle } from "./gyroscope-toggle" import type { QueryType } from "./query" import { Copyright, InternalLinks, SocialLinks } from "./shared-sections" import { StayConnected } from "./stay-connected" @@ -80,13 +81,16 @@ export const FooterContent = ({ data }: { data: QueryType }) => { />
- +
+ + +
diff --git a/src/components/layout/gyroscope-toggle.tsx b/src/components/layout/gyroscope-toggle.tsx new file mode 100644 index 000000000..064221043 --- /dev/null +++ b/src/components/layout/gyroscope-toggle.tsx @@ -0,0 +1,70 @@ +"use client" + +import { useDeviceOrientation } from "@/hooks/use-device-orientation" +import { cn } from "@/utils/cn" + +export const GyroscopeToggle = () => { + const { permission, isEnabled, requestPermission, setIsEnabled } = + useDeviceOrientation() + + if (permission === "unsupported") return null + + const handleClick = async () => { + if (permission === "granted") { + setIsEnabled(!isEnabled) + } else if (permission === "prompt") { + await requestPermission() + } + } + + const isActive = permission === "granted" && isEnabled + const isDenied = permission === "denied" + + return ( + + ) +} + +const GyroscopeIcon = ({ className }: { className?: string }) => ( + + + + + +) diff --git a/src/hooks/use-device-orientation.ts b/src/hooks/use-device-orientation.ts new file mode 100644 index 000000000..b21b382a1 --- /dev/null +++ b/src/hooks/use-device-orientation.ts @@ -0,0 +1,180 @@ +import { useCallback, useEffect, useRef } from "react" + +import { useGyroscopeStore } from "@/store/gyroscope-store" + +interface DeviceOrientationEventWithPermission extends DeviceOrientationEvent { + requestPermission?: () => Promise<"granted" | "denied" | "default"> +} + +declare global { + interface DeviceOrientationEvent { + requestPermission?: () => Promise<"granted" | "denied" | "default"> + } +} + +const SMOOTHING = 0.1 +const GAMMA_MAX = 30 +const BETA_RANGE = 25 +const STORAGE_KEY = "gyroscope-enabled" + +export const useDeviceOrientation = () => { + const { permission, setPermission, isEnabled, setIsEnabled, setOrientation } = + useGyroscopeStore() + + const smoothedRef = useRef({ x: 0, y: 0 }) + const initialBetaRef = useRef(null) + const hasCheckedPermission = useRef(false) + + const handleOrientation = useCallback( + (event: DeviceOrientationEvent) => { + const { gamma, beta } = event + + if (gamma === null || beta === null) return + + if (initialBetaRef.current === null) { + initialBetaRef.current = beta + } + + const normalizedX = Math.max(-1, Math.min(1, gamma / GAMMA_MAX)) + + const betaOffset = beta - (initialBetaRef.current ?? 45) + const normalizedY = Math.max(-1, Math.min(1, betaOffset / BETA_RANGE)) + + smoothedRef.current.x += (normalizedX - smoothedRef.current.x) * SMOOTHING + smoothedRef.current.y += (normalizedY - smoothedRef.current.y) * SMOOTHING + + setOrientation(smoothedRef.current.x, smoothedRef.current.y) + }, + [setOrientation] + ) + + const requestPermission = useCallback(async () => { + const DeviceOrientationEventTyped = + DeviceOrientationEvent as unknown as DeviceOrientationEventWithPermission & { + requestPermission?: () => Promise<"granted" | "denied" | "default"> + } + + if (typeof DeviceOrientationEventTyped.requestPermission === "function") { + try { + const result = await DeviceOrientationEventTyped.requestPermission() + if (result === "granted") { + setPermission("granted") + setIsEnabled(true) + localStorage.setItem(STORAGE_KEY, "true") + return true + } else { + setPermission("denied") + localStorage.removeItem(STORAGE_KEY) + return false + } + } catch { + setPermission("denied") + localStorage.removeItem(STORAGE_KEY) + return false + } + } else if ("DeviceOrientationEvent" in window) { + setPermission("granted") + setIsEnabled(true) + localStorage.setItem(STORAGE_KEY, "true") + return true + } else { + setPermission("unsupported") + return false + } + }, [setPermission, setIsEnabled]) + + const resetCalibration = useCallback(() => { + initialBetaRef.current = null + smoothedRef.current = { x: 0, y: 0 } + setOrientation(0, 0) + }, [setOrientation]) + + useEffect(() => { + if (hasCheckedPermission.current) return + hasCheckedPermission.current = true + + if (!("DeviceOrientationEvent" in window)) { + setPermission("unsupported") + return + } + + const wasEnabled = localStorage.getItem(STORAGE_KEY) === "true" + + const DeviceOrientationEventTyped = + DeviceOrientationEvent as unknown as DeviceOrientationEventWithPermission & { + requestPermission?: () => Promise<"granted" | "denied" | "default"> + } + + const requiresPermission = + typeof DeviceOrientationEventTyped.requestPermission === "function" + + if (!requiresPermission) { + setPermission("granted") + if (wasEnabled) { + // Test if actual gyroscope hardware exists by listening for events + // Desktop browsers have DeviceOrientationEvent but no hardware, so no events fire + const testHandler = (e: DeviceOrientationEvent) => { + if (e.gamma !== null) { + setIsEnabled(true) + window.removeEventListener("deviceorientation", testHandler, true) + } + } + + window.addEventListener("deviceorientation", testHandler, true) + + // If no events fire within 500ms, hardware likely doesn't exist - clean up localStorage + setTimeout(() => { + window.removeEventListener("deviceorientation", testHandler, true) + if (!useGyroscopeStore.getState().isEnabled) { + localStorage.removeItem(STORAGE_KEY) + } + }, 500) + } + } else if (wasEnabled) { + const testHandler = (e: DeviceOrientationEvent) => { + if (e.gamma !== null) { + setPermission("granted") + setIsEnabled(true) + window.removeEventListener("deviceorientation", testHandler, true) + } + } + + window.addEventListener("deviceorientation", testHandler, true) + + setTimeout(() => { + window.removeEventListener("deviceorientation", testHandler, true) + }, 500) + } + }, [setPermission, setIsEnabled]) + + useEffect(() => { + if (permission === "granted") { + if (isEnabled) { + localStorage.setItem(STORAGE_KEY, "true") + } else { + localStorage.removeItem(STORAGE_KEY) + } + } + }, [isEnabled, permission]) + + useEffect(() => { + if (!isEnabled || permission !== "granted") return + + window.addEventListener("deviceorientation", handleOrientation, true) + + return () => { + window.removeEventListener("deviceorientation", handleOrientation, true) + initialBetaRef.current = null + smoothedRef.current = { x: 0, y: 0 } + setOrientation(0, 0) + } + }, [isEnabled, permission, handleOrientation, setOrientation]) + + return { + permission, + isEnabled, + requestPermission, + setIsEnabled, + resetCalibration + } +} diff --git a/src/store/gyroscope-store.ts b/src/store/gyroscope-store.ts new file mode 100644 index 000000000..cca685df5 --- /dev/null +++ b/src/store/gyroscope-store.ts @@ -0,0 +1,23 @@ +import { create } from "zustand" + +type GyroscopePermission = "prompt" | "granted" | "denied" | "unsupported" + +interface GyroscopeStore { + permission: GyroscopePermission + setPermission: (permission: GyroscopePermission) => void + isEnabled: boolean + setIsEnabled: (enabled: boolean) => void + orientationX: number + orientationY: number + setOrientation: (x: number, y: number) => void +} + +export const useGyroscopeStore = create((set) => ({ + permission: "prompt", + setPermission: (permission) => set({ permission }), + isEnabled: false, + setIsEnabled: (enabled) => set({ isEnabled: enabled }), + orientationX: 0, + orientationY: 0, + setOrientation: (x, y) => set({ orientationX: x, orientationY: y }) +}))