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
12 changes: 8 additions & 4 deletions src/components/layout/footer-content.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { PortableTextBlock } from "@/service/sanity/types"
import { cn } from "@/utils/cn"

import { GyroscopeToggle } from "./gyroscope-toggle"
import { Copyright, InternalLinks, SocialLinks } from "./shared-sections"
import { StayConnected } from "./stay-connected"

Expand Down Expand Up @@ -103,10 +104,13 @@ export const FooterContent = ({
/>

<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={socialLinks}
/>
<div className="flex items-center justify-between">
<SocialLinks
className="col-start-1 col-end-5 row-start-2 lg:hidden"
links={socialLinks}
/>
<GyroscopeToggle />
</div>
<Copyright year={year} className="text-left" />
</div>

Expand Down
68 changes: 68 additions & 0 deletions src/components/layout/gyroscope-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"use client"

import { useDeviceOrientation } from "@/hooks/use-device-orientation"
import { useMedia } from "@/hooks/use-media"
import { cn } from "@/utils/cn"

export const GyroscopeToggle = () => {
const { permission, isEnabled, requestPermission, setIsEnabled } =
useDeviceOrientation()

// Desktop browsers expose DeviceOrientationEvent without gyroscope
// hardware — only offer the toggle on touch-first devices
const isCoarsePointer = useMedia("(pointer: coarse)")

if (!isCoarsePointer || 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>
{isDenied ? "Gyro denied" : isActive ? "Gyro on" : "Gyro off"}
</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>
)
173 changes: 173 additions & 0 deletions src/hooks/use-device-orientation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useRef } from "react"

import { useGyroscopeStore } from "@/store/gyroscope-store"

// iOS Safari exposes requestPermission as a static on the constructor
type DeviceOrientationEventCtor = typeof 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 DeviceOrientationEventCtor

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 {
// "default" means the user dismissed the dialog without choosing —
// keep "prompt" so tapping the toggle can re-request
setPermission(result === "denied" ? "denied" : "prompt")
localStorage.removeItem(STORAGE_KEY)
return false
Comment thread
ragojose marked this conversation as resolved.
}
} 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 DeviceOrientationEventCtor

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)
}
Comment thread
git-chad marked this conversation as resolved.
} 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)
}
Comment thread
git-chad marked this conversation as resolved.
}, [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 })
}))