Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions src/components/camera/camera-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<THREE.PerspectiveCamera | null>
export type MeshRef = React.RefObject<THREE.Mesh | null>

Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
18 changes: 11 additions & 7 deletions src/components/layout/footer-content.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -80,13 +81,16 @@ export const FooterContent = ({ data }: { data: QueryType }) => {
/>

<div className="col-span-full row-start-3 flex flex-col justify-end gap-y-2 lg:hidden">
<SocialLinks
className="col-start-1 col-end-5 row-start-2 lg:hidden"
links={{
...data.company.social,
linkedIn: data.company.social.linkedIn || ""
}}
/>
<div className="flex items-center justify-between">
<SocialLinks
className="col-start-1 col-end-5 row-start-2 lg:hidden"
links={{
...data.company.social,
linkedIn: data.company.social.linkedIn || ""
}}
/>
<GyroscopeToggle />
</div>
<Copyright className="text-left" />
</div>

Expand Down
70 changes: 70 additions & 0 deletions src/components/layout/gyroscope-toggle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
onClick={handleClick}
disabled={isDenied}
className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
isActive
? "border-brand-g1 bg-brand-g1/10 text-brand-g1"
: "border-brand-w1/30 text-brand-w2 hover:border-brand-w1/50",
isDenied && "cursor-not-allowed opacity-50"
)}
aria-label={
isActive ? "Disable gyroscope camera" : "Enable gyroscope camera"
}
>
<GyroscopeIcon className="h-4 w-4" />
<span className="xs:inline hidden">
{isDenied
? "Gyroscope Denied"
: isActive
? "Gyroscope On"
: "Enable Gyroscope"}
</span>
<span className="xs:hidden">
{isDenied ? "Denied" : isActive ? "On" : "Gyro"}
</span>
</button>
)
}

const GyroscopeIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M16.466 7.5C15.643 4.237 13.952 2 12 2 9.239 2 7 6.477 7 12s2.239 10 5 10c.342 0 .677-.069 1-.2" />
<path d="m15.194 13.707 3.814 1.86-1.86 3.814" />
<path d="M19 15.57c-1.804.885-4.274 1.43-7 1.43-5.523 0-10-2.239-10-5s4.477-5 10-5c4.838 0 8.873 1.718 9.8 4" />
</svg>
)
180 changes: 180 additions & 0 deletions src/hooks/use-device-orientation.ts
Original file line number Diff line number Diff line change
@@ -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<number | null>(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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Permission "default" treated as "denied" prevents retry

Low Severity

The requestPermission function handles a "default" result (when the user dismisses the permission dialog without choosing) the same as "denied", setting permission state to "denied". Since the toggle button is disabled when permission is "denied", users who dismiss the dialog cannot retry requesting permission without reloading the page. The type explicitly includes "default" as a possible return value, suggesting this case was anticipated but handled incorrectly.

Fix in Cursor Fix in Web

}
} 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
}
}
23 changes: 23 additions & 0 deletions src/store/gyroscope-store.ts
Original file line number Diff line number Diff line change
@@ -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<GyroscopeStore>((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 })
}))