{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sphere-gallery","type":"registry:component","title":"Sphere Gallery","description":"A gallery of images mapped onto a sphere.","meta":{"pro":false},"docs":"Usage: https://atelier-ui.com/r/sphere-gallery.md\nDocs: https://atelier-ui.com/docs/components/background/sphere-gallery","dependencies":["three","@types/three","@react-three/fiber","@react-three/drei","motion"],"registryDependencies":["https://atelier-ui.com/r/webgl-scene.json","https://atelier-ui.com/r/webgl-provider.json","https://atelier-ui.com/r/agent-rules.json"],"files":[{"path":"registry/sphere-gallery/sphere-gallery.tsx","type":"registry:component","target":"components/sphere-gallery/sphere-gallery.tsx","content":"\"use client\"\n\nimport { shaderMaterial, useFBO, useTexture } from \"@react-three/drei\"\nimport { createPortal, extend, type ThreeElement, useFrame, useThree } from \"@react-three/fiber\"\nimport type { Easing } from \"motion\"\nimport { animate } from \"motion/react\"\nimport {\n    type ComponentRef,\n    type ReactNode,\n    type RefObject,\n    useCallback,\n    useEffect,\n    useLayoutEffect,\n    useMemo,\n    useRef,\n    useState,\n} from \"react\"\nimport * as THREE from \"three\"\nimport { MathUtils } from \"three\"\nimport { useWebglReady } from \"../webgl-provider/webgl-provider\"\nimport { WebglScene, type WebglSceneProps } from \"../webgl-scene/webgl-scene\"\n\nconst TAU = Math.PI * 2\nconst MAX_TILT = Math.PI / 4\nconst SCENE_DISTANCE = 0.1 as const\nconst INITIAL_DISTANCE = 2 as const\nconst PEEK_DEPTH = 1.5 as const\nconst PEEK_MARGIN = 0.15 as const\nconst FOCUS_EASING = [0.7, 0.03, 0.26, 0.99] as Easing\nconst REVEAL_EASING = [0.4, 0.2, 0.15, 1] as Easing\n\nconst DEFAULT_PROPS = {\n    rows: 7,\n    columns: 12,\n    latitudeRange: 85,\n    gap: 0.01,\n    padding: 0.03,\n    cornerRadius: 0.02,\n    lensBlur: 0.4,\n    fov: 70,\n    tileColor: \"#F8F8F8\" as string | null,\n    sphereColor: \"#ffffff\" as string,\n    reveal: true,\n    revealDuration: 2,\n    focusDuration: 1,\n    focusScale: 1.7,\n    mouseParallax: 0.2,\n}\n\ntype LayoutTile = {\n    position: THREE.Vector3\n    quaternion: THREE.Quaternion\n    longitude: number\n    latitude: number\n    width: number\n    height: number\n    span: THREE.Vector2\n}\n\ntype TileProps = {\n    texture: THREE.Texture\n    tile: LayoutTile\n    index: number\n    activeTile: number | null\n    ready: boolean\n    interactive: boolean\n    setPointer: (on: boolean) => void\n    onSelect: () => void\n} & Pick<\n    typeof DEFAULT_PROPS,\n    | \"gap\"\n    | \"padding\"\n    | \"cornerRadius\"\n    | \"tileColor\"\n    | \"reveal\"\n    | \"revealDuration\"\n    | \"focusDuration\"\n    | \"focusScale\"\n>\n\ntype PeekSlot = {\n    index: number\n    texture: THREE.Texture\n    position: THREE.Vector3\n    quaternion: THREE.Quaternion\n    width: number\n    height: number\n}\n\ntype PeekTileProps = {\n    slot: PeekSlot | null\n    setPointer: (on: boolean) => void\n    onNavigate: (index: number) => void\n    focusDuration: number\n}\n\ntype SphereSceneProps = {\n    sources: string[]\n    surface: RefObject<HTMLElement | null>\n    activeTile: number | null\n    onSelect: (index: number) => void\n    onNavigate: (index: number) => void\n    onDismiss: () => void\n    onReady?: () => void\n} & Omit<typeof DEFAULT_PROPS, \"lensBlur\">\n\nexport type SphereGalleryItem = {\n    src: string\n    alt: string\n}\n\nexport type SphereGalleryProps = {\n    items: SphereGalleryItem[]\n    className?: string\n    onActiveChange?: (index: number | null) => void\n    onReady?: () => void\n} & Partial<typeof DEFAULT_PROPS> &\n    Pick<WebglSceneProps, \"mode\" | \"priority\" | \"zIndex\" | \"transparent\">\n\ndeclare module \"@react-three/fiber\" {\n    interface ThreeElements {\n        tileMaterial: ThreeElement<typeof TileMaterial>\n        lensBlurMaterial: ThreeElement<typeof LensBlurMaterial>\n    }\n}\n\n/*\n * Shader material for each tile.\n * Draws the image, the rounded mask and the dissolve effect.\n */\nconst TileMaterial = shaderMaterial(\n    {\n        uMap: new THREE.Texture(),\n        uFocus: 0,\n        uLatitude: 0,\n        uAngularSpan: new THREE.Vector2(1, 1),\n        uImageAspect: 1,\n        uTileSize: new THREE.Vector2(1, 1),\n        uGap: 0,\n        uPadding: 0.1,\n        uRadius: 0.1,\n        uBackground: new THREE.Color(\"#d4d4d4\"),\n        uBackgroundAlpha: 1,\n        uDissolve: 0,\n        uSeed: 0,\n        uOpacity: 1,\n        uReveal: 1,\n    },\n    /* glsl */ `\n        uniform float uFocus;\n        uniform float uLatitude;\n        uniform vec2 uAngularSpan;\n        varying vec2 vUv;\n\n        void main() {\n            vUv = uv;\n\n            float longitude = (uv.x - 0.5) * uAngularSpan.x;\n            float latitude = uLatitude + (uv.y - 0.5) * uAngularSpan.y;\n\n            vec3 spherePosition = vec3(\n                cos(latitude) * sin(longitude),\n                sin(latitude) * cos(uLatitude) - cos(latitude) * sin(uLatitude) * cos(longitude),\n                1.0 - cos(latitude) * cos(uLatitude) * cos(longitude) - sin(latitude) * sin(uLatitude)\n            );\n\n            vec3 morphedPosition = mix(spherePosition, position, uFocus);\n\n            gl_Position = projectionMatrix * modelViewMatrix * vec4(morphedPosition, 1.0);\n        }\n    `,\n    /* glsl */ `\n        uniform sampler2D uMap;\n        uniform float uImageAspect;\n        uniform vec2 uTileSize;\n        uniform float uGap;\n        uniform float uPadding;\n        uniform float uRadius;\n        uniform vec3 uBackground;\n        uniform float uBackgroundAlpha;\n        uniform float uDissolve;\n        uniform float uSeed;\n        uniform float uOpacity;\n        uniform float uReveal;\n\n        varying vec2 vUv;\n\n        float sdRoundBox(vec2 point, vec2 halfSize, float radius) {\n            vec2 corner = abs(point) - halfSize + radius;\n            return min(max(corner.x, corner.y), 0.0) + length(max(corner, 0.0)) - radius;\n        }\n\n        float hash(vec2 point) {\n            return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453);\n        }\n\n        float valueNoise(vec2 point) {\n            vec2 cell = floor(point);\n            vec2 offset = fract(point);\n            float bottomLeft = hash(cell);\n            float bottomRight = hash(cell + vec2(1.0, 0.0));\n            float topLeft = hash(cell + vec2(0.0, 1.0));\n            float topRight = hash(cell + vec2(1.0, 1.0));\n            vec2 smoothed = offset * offset * (3.0 - 2.0 * offset);\n            return mix(bottomLeft, bottomRight, smoothed.x)\n                + (topLeft - bottomLeft) * smoothed.y * (1.0 - smoothed.x)\n                + (topRight - bottomRight) * smoothed.x * smoothed.y;\n        }\n\n        float fbm(vec2 point) {\n            float value = 0.0;\n            float amplitude = 0.5;\n            for (int octave = 0; octave < 3; octave++) {\n                value += amplitude * valueNoise(point);\n                point *= 2.0;\n                amplitude *= 0.5;\n            }\n            return value;\n        }\n\n        void main() {\n            vec2 point = (vUv - 0.5) * uTileSize;\n            vec2 halfTile = uTileSize * 0.5 - uGap;\n            vec2 content = halfTile - uPadding;\n\n            float imageHalfHeight = min(content.x / uImageAspect, content.y);\n            vec2 imageHalfSize = vec2(imageHalfHeight * uImageAspect, imageHalfHeight);\n            vec2 imageUv = point / (imageHalfSize * 2.0) + 0.5;\n\n            bool inside = all(greaterThanEqual(imageUv, vec2(0.0))) &&\n                all(lessThanEqual(imageUv, vec2(1.0)));\n\n            vec3 color = inside ? texture2D(uMap, imageUv).rgb : uBackground;\n\n            float boxDistance = sdRoundBox(point, halfTile, uRadius);\n            float boxAntialias = fwidth(boxDistance);\n            float alpha = smoothstep(boxAntialias, -boxAntialias, boxDistance);\n\n            alpha *= inside ? 1.0 : uBackgroundAlpha;\n\n            if (uDissolve > 0.0) {\n                float threshold = mix(-0.1, 0.95, uDissolve);\n                float noise = fbm(vUv * 3.0 + uSeed * 7.13);\n                float edgeDistance = noise - threshold;\n                float edgeAntialias = fwidth(edgeDistance);\n                alpha *= smoothstep(-edgeAntialias, edgeAntialias, edgeDistance);\n\n                float rim = 1.0 - smoothstep(0.0, 0.1, edgeDistance);\n                color += rim * 0.4;\n            }\n\n            gl_FragColor = vec4(color, alpha * uOpacity * uReveal);\n        }\n    `,\n)\n\n/*\n * Full-screen lens-blur shader.\n * Used by the post-processing pass over the rendered scene.\n */\nconst LensBlurMaterial = shaderMaterial(\n    {\n        uScene: new THREE.Texture(),\n        uStrength: 0.16,\n        uRadius: 0.3,\n        uSmoothness: 0.5,\n        uDispersion: 0.35,\n        uMotion: 0,\n        uMotionStrength: 0.4,\n    },\n    /* glsl */ `\n        varying vec2 vUv;\n\n        void main() {\n            vUv = uv;\n            gl_Position = vec4(position.xy, 0.0, 1.0);\n        }\n    `,\n    /* glsl */ `\n        uniform sampler2D uScene;\n        uniform float uStrength;\n        uniform float uRadius;\n        uniform float uSmoothness;\n        uniform float uDispersion;\n        uniform float uMotion;\n        uniform float uMotionStrength;\n\n        varying vec2 vUv;\n\n        const int SAMPLES = 24;\n\n        void main() {\n            vec2 toCenter = vUv - 0.5;\n            float distanceFromCenter = length(toCenter);\n\n            float radius = uRadius * (1.0 - 0.7 * uMotion);\n            float mask = smoothstep(radius, radius + uSmoothness, distanceFromCenter);\n            float amount = mask * mask * uStrength + mask * uMotion * uMotionStrength;\n\n            if (amount <= 0.0) {\n                gl_FragColor = texture2D(uScene, vUv);\n                return;\n            }\n\n            vec3 color = vec3(0.0);\n            float alpha = 0.0;\n            float total = 0.0;\n\n            for (int sampleIndex = 0; sampleIndex < SAMPLES; sampleIndex++) {\n                float progress = float(sampleIndex) / float(SAMPLES - 1);\n                float weight = 1.0 - progress * 0.6;\n\n                float scale = 1.0 - amount * progress;\n                float spread = uDispersion * amount * progress;\n\n                vec4 mid = texture2D(uScene, 0.5 + toCenter * scale);\n                color.r += texture2D(uScene, 0.5 + toCenter * (scale + spread)).r * weight;\n                color.g += mid.g * weight;\n                color.b += texture2D(uScene, 0.5 + toCenter * (scale - spread)).b * weight;\n                alpha += mid.a * weight;\n\n                total += weight;\n            }\n\n            gl_FragColor = vec4(color / total, alpha / total);\n        }\n    `,\n)\n\nextend({ TileMaterial, LensBlurMaterial })\n\ntype PostProcessingProps = {\n    surface: RefObject<HTMLElement | null>\n    children: ReactNode\n    strength: number\n    active: boolean\n}\n\n/*\n * Renders the scene into an off-screen buffer.\n * Applies the lens-blur pass and tracks camera motion.\n */\nfunction PostProcessing({ surface, children, strength, active }: PostProcessingProps) {\n    const gl = useThree((state) => state.gl)\n    const camera = useThree((state) => state.camera)\n    const contentScene = useMemo(() => new THREE.Scene(), [])\n    const screenRef =\n        useRef<THREE.Mesh<THREE.PlaneGeometry, InstanceType<typeof LensBlurMaterial>>>(null)\n    const bounds = useRef({ width: 0, height: 0 })\n    const motion = useRef({ previousZ: null as number | null, strength: 0 })\n    const fbo = useFBO(1, 1, { samples: 4 })\n\n    useLayoutEffect(() => {\n        const target = surface.current\n        if (!target) return\n\n        const measure = () => {\n            const rect = target.getBoundingClientRect()\n            bounds.current.width = rect.width\n            bounds.current.height = rect.height\n        }\n\n        measure()\n        const resizeObserver = new ResizeObserver(measure)\n        resizeObserver.observe(target)\n        return () => resizeObserver.disconnect()\n    }, [surface])\n\n    useFrame((_, delta) => {\n        const screen = screenRef.current\n        const { width, height } = bounds.current\n        if (!screen) return\n\n        screen.material.uStrength = MathUtils.damp(\n            screen.material.uStrength,\n            active ? 0 : strength,\n            5,\n            delta,\n        )\n\n        const cameraZ = camera.position.z\n        const previousZ = motion.current.previousZ\n        motion.current.previousZ = cameraZ\n\n        if (delta > 0 && previousZ !== null) {\n            const speed = Math.abs(cameraZ - previousZ) / delta\n            const target = Math.min(speed * 0.1, 1)\n            motion.current.strength = MathUtils.damp(motion.current.strength, target, 14, delta)\n            screen.material.uMotion = motion.current.strength\n        }\n\n        const pixelRatio = gl.getPixelRatio()\n        const fboWidth = Math.ceil(width * pixelRatio)\n        const fboHeight = Math.ceil(height * pixelRatio)\n        if (fbo.width !== fboWidth || fbo.height !== fboHeight) {\n            fbo.setSize(fboWidth, fboHeight)\n        }\n\n        const aspect = width / height\n\n        if (camera instanceof THREE.PerspectiveCamera && camera.aspect !== aspect) {\n            camera.aspect = aspect\n            camera.updateProjectionMatrix()\n        }\n\n        const previousClearAlpha = gl.getClearAlpha()\n        gl.setRenderTarget(fbo)\n        gl.setClearAlpha(0)\n        gl.clear()\n        gl.render(contentScene, camera)\n        gl.setRenderTarget(null)\n        gl.setClearAlpha(previousClearAlpha)\n    }, -1)\n\n    return (\n        <>\n            {createPortal(children, contentScene)}\n\n            <mesh ref={screenRef} frustumCulled={false}>\n                <planeGeometry args={[2, 2]} />\n                <lensBlurMaterial\n                    key={LensBlurMaterial.key}\n                    uScene={fbo.texture}\n                    transparent\n                    premultipliedAlpha\n                    depthTest={false}\n                    depthWrite={false}\n                />\n            </mesh>\n        </>\n    )\n}\n\n/*\n * One image tile placed on the sphere.\n * Handles hover, focus and dissolve transitions.\n */\nfunction Tile({\n    texture,\n    tile,\n    gap,\n    padding,\n    cornerRadius,\n    tileColor,\n    index,\n    activeTile,\n    ready,\n    interactive,\n    setPointer,\n    onSelect,\n    reveal,\n    revealDuration,\n    focusDuration,\n    focusScale,\n}: TileProps) {\n    const [hovered, setHovered] = useState(false)\n    const meshRef = useRef<THREE.Mesh<THREE.PlaneGeometry, InstanceType<typeof TileMaterial>>>(null)\n    const image = texture.image as HTMLImageElement\n    const focused = activeTile === index\n    const dissolving = activeTile !== null && !focused\n\n    useEffect(() => {\n        function tileFocusAnimation() {\n            const mesh = meshRef.current\n            const material = mesh?.material\n            if (!mesh || !material) return\n\n            const scale = focused ? focusScale : 1\n\n            const controls = animate([\n                [\n                    material,\n                    { uFocus: focused ? 1 : 0 },\n                    { duration: focusDuration, ease: FOCUS_EASING },\n                ],\n                [\n                    material,\n                    { uDissolve: dissolving ? 1 : 0 },\n                    { duration: focusDuration, ease: FOCUS_EASING, at: 0 },\n                ],\n                [\n                    material,\n                    { uGap: focused ? 0 : gap },\n                    { duration: focusDuration, ease: FOCUS_EASING, at: 0 },\n                ],\n                [\n                    material,\n                    { uPadding: focused ? 0 : padding },\n                    { duration: focusDuration, ease: FOCUS_EASING, at: 0 },\n                ],\n                [\n                    material,\n                    { uRadius: focused ? 0 : cornerRadius },\n                    { duration: focusDuration, ease: FOCUS_EASING, at: 0 },\n                ],\n                [\n                    material,\n                    { uBackgroundAlpha: focused || dissolving || !tileColor ? 0 : 1 },\n                    { duration: focusDuration, ease: FOCUS_EASING, at: 0 },\n                ],\n                [\n                    mesh.scale,\n                    { x: scale, y: scale, z: scale },\n                    { duration: focusDuration, ease: FOCUS_EASING, at: 0 },\n                ],\n            ])\n            return () => controls.stop()\n        }\n\n        return tileFocusAnimation()\n    }, [cornerRadius, dissolving, focused, gap, padding, tileColor, focusDuration, focusScale])\n\n    useEffect(() => {\n        if (!reveal || !ready) return\n        function tileRevealAnimation() {\n            const material = meshRef.current?.material\n            if (!material) return\n            const controls = animate(\n                material,\n                { uReveal: 1, uDissolve: [0.5, 0] },\n                { duration: revealDuration * 1.2, ease: REVEAL_EASING },\n            )\n            return () => controls.stop()\n        }\n        return tileRevealAnimation()\n    }, [reveal, ready, revealDuration])\n\n    useEffect(() => {\n        function tileHoverAnimation() {\n            const material = meshRef.current?.material\n            if (!material) return\n            const controls = animate(\n                material,\n                { uOpacity: hovered && !focused ? 0.7 : 1 },\n                { duration: hovered && !focused ? 0.2 : 0.3 },\n            )\n            return () => controls.stop()\n        }\n\n        return tileHoverAnimation()\n    }, [hovered, focused])\n\n    return (\n        <mesh\n            ref={meshRef}\n            position={tile.position}\n            quaternion={tile.quaternion}\n            raycast={\n                interactive && (focused || activeTile === null)\n                    ? THREE.Mesh.prototype.raycast\n                    : () => null\n            }\n            onClick={(event) => {\n                event.stopPropagation()\n                onSelect()\n                setPointer(false)\n            }}\n            onPointerOver={(event) => {\n                event.stopPropagation()\n                setHovered(true)\n                setPointer(!focused)\n            }}\n            onPointerOut={() => {\n                setHovered(false)\n                setPointer(false)\n            }}\n        >\n            <planeGeometry args={[tile.width, tile.height, 24, 24]} />\n            <tileMaterial\n                key={TileMaterial.key}\n                uMap={texture}\n                uDissolve={0}\n                uReveal={0}\n                uLatitude={tile.latitude}\n                uAngularSpan={tile.span}\n                uImageAspect={image.width / image.height}\n                uTileSize={new THREE.Vector2(tile.width, tile.height)}\n                uGap={gap}\n                uPadding={padding}\n                uRadius={cornerRadius}\n                uBackground={new THREE.Color(tileColor ?? \"#000000\")}\n                uBackgroundAlpha={tileColor ? 1 : 0}\n                uSeed={index}\n                side={THREE.DoubleSide}\n                transparent\n                depthWrite={false}\n            />\n        </mesh>\n    )\n}\n\n/*\n * Peek tile preview shown beside once a tile is focused.\n * Lets the user navigate to the previous or next image.\n */\nfunction PeekTile({ slot, setPointer, onNavigate, focusDuration }: PeekTileProps) {\n    const [displayedTile, setDisplayedTile] = useState<PeekSlot | null>(null)\n    const [hovered, setHovered] = useState(false)\n\n    const meshRef = useRef<THREE.Mesh<THREE.PlaneGeometry, InstanceType<typeof TileMaterial>>>(null)\n    const image = displayedTile?.texture.image as HTMLImageElement\n\n    useEffect(() => {\n        function peekTileDissolveAnimation() {\n            const material = meshRef.current?.material\n\n            if (displayedTile !== slot) {\n                if (!material || displayedTile?.index === slot?.index) {\n                    setDisplayedTile(slot)\n                    return\n                }\n\n                const controls = animate(\n                    material,\n                    { uDissolve: 1 },\n                    { duration: focusDuration * 0.8, ease: FOCUS_EASING },\n                )\n                controls.then(() => setDisplayedTile(slot))\n                return () => {\n                    controls.stop()\n                }\n            }\n\n            if (!material) return\n\n            const controls = animate(\n                material,\n                { uDissolve: 0 },\n                { duration: focusDuration * 0.8, ease: FOCUS_EASING },\n            )\n            return () => controls.stop()\n        }\n        return peekTileDissolveAnimation()\n    }, [slot, displayedTile, focusDuration])\n\n    useEffect(() => {\n        function tileHoverAnimation() {\n            const material = meshRef.current?.material\n            if (!material) return\n            const controls = animate(\n                material,\n                { uOpacity: hovered ? 0.7 : 1 },\n                { duration: hovered ? 0.2 : 0.3 },\n            )\n            return () => controls.stop()\n        }\n\n        return tileHoverAnimation()\n    }, [hovered])\n\n    if (displayedTile === null) return null\n\n    return (\n        <mesh\n            ref={meshRef}\n            position={displayedTile.position}\n            quaternion={displayedTile.quaternion}\n            onClick={(event) => {\n                event.stopPropagation()\n                onNavigate(displayedTile.index)\n            }}\n            onPointerOver={(event) => {\n                event.stopPropagation()\n                setHovered(true)\n                setPointer(true)\n            }}\n            onPointerOut={() => {\n                setHovered(false)\n                setPointer(false)\n            }}\n        >\n            <planeGeometry args={[displayedTile.width, displayedTile.height]} />\n            <tileMaterial\n                uMap={displayedTile.texture}\n                uFocus={1}\n                uDissolve={1}\n                uImageAspect={image.width / image.height}\n                uTileSize={new THREE.Vector2(displayedTile.width, displayedTile.height)}\n                uPadding={0}\n                uRadius={0}\n                uBackgroundAlpha={0}\n                uSeed={displayedTile.index}\n                side={THREE.DoubleSide}\n                transparent\n                depthWrite={false}\n            />\n        </mesh>\n    )\n}\n\n/*\n * Builds the sphere tile layout.\n * Handles drag rotation, the reveal and focus animations.\n */\nfunction SphereScene({\n    sources,\n    surface,\n    activeTile,\n    onSelect,\n    onNavigate,\n    onDismiss,\n    onReady,\n    rows,\n    columns,\n    latitudeRange,\n    gap,\n    padding,\n    cornerRadius,\n    tileColor,\n    sphereColor,\n    fov,\n    reveal,\n    revealDuration,\n    focusDuration,\n    focusScale,\n    mouseParallax,\n}: SphereSceneProps) {\n    const [revealComplete, setRevealComplete] = useState(false)\n    const interactive = !reveal || revealComplete\n    const orientation = useRef({\n        spin: 0,\n        tilt: 0,\n        targetSpin: 0,\n        targetTilt: 0,\n    }).current\n    const pointerOffset = useRef(new THREE.Vector2())\n    const parallax = useRef(new THREE.Vector2())\n    const dragMoved = useRef(false)\n    const dragging = useRef(false)\n    const pointerOnTile = useRef(false)\n    const animating = useRef(false)\n    const groupRef = useRef<THREE.Group>(null)\n    const sphereMaterialRef = useRef<THREE.MeshBasicMaterial>(null)\n    const textures = useTexture(sources)\n    const camera = useThree((state) => state.camera)\n    const size = useThree((state) => state.size)\n    const ready = useWebglReady({ onReady })\n\n    const setPointer = useCallback(\n        (on: boolean) => {\n            pointerOnTile.current = on\n            const element = surface.current\n            if (!element || dragging.current) return\n            element.style.cursor = on ? \"pointer\" : \"\"\n        },\n        [surface],\n    )\n\n    const focusDistanceFor = useCallback(\n        (tile: LayoutTile) => {\n            const tileSize = Math.max(tile.width, tile.height) * 1.9\n            return tileSize / 2 / Math.tan(MathUtils.degToRad(fov) / 2)\n        },\n        [fov],\n    )\n\n    const tileLayout = useMemo(() => {\n        const tiles: LayoutTile[] = []\n\n        const orienter = new THREE.Object3D()\n        const latRange = MathUtils.degToRad(latitudeRange)\n        const latSpan = (latRange * 2) / rows\n\n        for (let row = 0; row < rows; row++) {\n            const latitude = -latRange + (row + 0.5) * latSpan\n            const cosLat = Math.cos(latitude)\n\n            const ringColumns = Math.max(1, Math.round(columns * cosLat))\n            const lonSpan = TAU / ringColumns\n            const span = new THREE.Vector2(lonSpan, latSpan)\n\n            for (let col = 0; col < ringColumns; col++) {\n                const longitude = (col + (row % 2) / 2) * lonSpan\n\n                const position = new THREE.Vector3(\n                    cosLat * Math.cos(longitude),\n                    Math.sin(latitude),\n                    cosLat * Math.sin(longitude),\n                )\n\n                orienter.position.copy(position)\n                orienter.lookAt(0, 0, 0)\n\n                tiles.push({\n                    position,\n                    quaternion: orienter.quaternion.clone(),\n                    longitude,\n                    latitude,\n                    width: lonSpan * cosLat,\n                    height: latSpan,\n                    span,\n                })\n            }\n        }\n\n        return tiles\n    }, [rows, columns, latitudeRange])\n\n    const select = (index: number) => {\n        if (dragMoved.current) return\n        onSelect(index)\n    }\n\n    const navigate = (index: number) => {\n        if (dragMoved.current) return\n        onNavigate(index)\n    }\n\n    const peekSlots = useMemo(() => {\n        if (activeTile === null) return { previous: null, next: null }\n\n        const focused = tileLayout[activeTile]\n        const sideAxis = new THREE.Vector3(1, 0, 0).applyQuaternion(focused.quaternion)\n        const depthAxis = new THREE.Vector3(0, 0, 1).applyQuaternion(focused.quaternion)\n        const total = tileLayout.length\n\n        const peekDistance = focusDistanceFor(focused) + focused.width * PEEK_DEPTH\n        const sideOffset =\n            Math.tan(MathUtils.degToRad(fov) / 2) * peekDistance * (size.width / size.height) -\n            focused.width / 2 -\n            focused.width * PEEK_MARGIN\n\n        const slot = (side: number): PeekSlot => {\n            const index = (activeTile + side + total) % total\n            const neighbor = tileLayout[index]\n            return {\n                index,\n                texture: textures[index % textures.length],\n                position: focused.position\n                    .clone()\n                    .addScaledVector(sideAxis, side * sideOffset)\n                    .addScaledVector(depthAxis, -focused.width * PEEK_DEPTH),\n                quaternion: focused.quaternion,\n                width: neighbor.width,\n                height: neighbor.height,\n            }\n        }\n\n        return {\n            previous: slot(-1),\n            next: slot(1),\n        }\n    }, [activeTile, tileLayout, textures, focusDistanceFor, fov, size.width, size.height])\n\n    useEffect(() => {\n        const element = surface.current\n        if (!element || !interactive) return\n\n        const drag = { active: false, x: 0, y: 0, spin: 0, tilt: 0 }\n        const DRAG_THRESHOLD = 6\n\n        const beginDrag = (event: PointerEvent) => {\n            drag.active = true\n            drag.x = event.clientX\n            drag.y = event.clientY\n            drag.spin = orientation.targetSpin\n            drag.tilt = orientation.targetTilt\n            dragMoved.current = false\n        }\n\n        const rotate = (event: PointerEvent) => {\n            const rect = element.getBoundingClientRect()\n\n            pointerOffset.current.set(\n                MathUtils.clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1),\n                MathUtils.clamp(((event.clientY - rect.top) / rect.height) * 2 - 1, -1, 1),\n            )\n\n            if (!drag.active) return\n\n            const deltaX = event.clientX - drag.x\n            const deltaY = event.clientY - drag.y\n\n            if (Math.abs(deltaX) > DRAG_THRESHOLD || Math.abs(deltaY) > DRAG_THRESHOLD) {\n                dragMoved.current = true\n            }\n\n            if (activeTile !== null) return\n\n            if (dragMoved.current && !dragging.current) {\n                dragging.current = true\n                element.style.cursor = \"grabbing\"\n            }\n            const width = element.offsetWidth\n\n            orientation.targetSpin = drag.spin - (deltaX / width) * TAU\n            orientation.targetTilt = MathUtils.clamp(\n                drag.tilt - (deltaY / width) * Math.PI,\n                -MAX_TILT,\n                MAX_TILT,\n            )\n        }\n\n        const endDrag = () => {\n            drag.active = false\n            dragging.current = false\n            element.style.cursor = pointerOnTile.current ? \"pointer\" : \"\"\n        }\n\n        element.addEventListener(\"pointerdown\", beginDrag)\n        window.addEventListener(\"pointermove\", rotate)\n        window.addEventListener(\"pointerup\", endDrag)\n        window.addEventListener(\"pointercancel\", endDrag)\n\n        return () => {\n            element.removeEventListener(\"pointerdown\", beginDrag)\n            window.removeEventListener(\"pointermove\", rotate)\n            window.removeEventListener(\"pointerup\", endDrag)\n            window.removeEventListener(\"pointercancel\", endDrag)\n        }\n    }, [surface, activeTile, orientation, interactive])\n\n    useEffect(() => {\n        if (!reveal || !ready) return\n\n        function revealSequenceAnimation() {\n            camera.position.z = INITIAL_DISTANCE\n            if (camera instanceof THREE.PerspectiveCamera && camera.fov !== fov) {\n                camera.fov = fov\n\n                camera.updateProjectionMatrix()\n            }\n            const group = groupRef.current\n\n            if (!group) return\n\n            const sphereMaterial = sphereMaterialRef.current\n            if (!sphereMaterial) return\n\n            const controls = animate([\n                [\n                    group.scale,\n                    { x: 1, y: 1, z: 1 },\n                    { duration: revealDuration, ease: REVEAL_EASING },\n                ],\n                [\n                    sphereMaterial,\n                    { opacity: 0.5 },\n                    { duration: revealDuration, ease: REVEAL_EASING, at: 0 },\n                ],\n                [\n                    group.rotation,\n                    { x: 0 },\n                    { duration: revealDuration, ease: REVEAL_EASING, at: 0 },\n                ],\n                [\n                    group.rotation,\n                    { y: 0 },\n                    { duration: revealDuration, ease: REVEAL_EASING, at: 0 },\n                ],\n                [\n                    camera.position,\n                    { z: SCENE_DISTANCE },\n                    {\n                        duration: revealDuration * 0.7,\n                        ease: FOCUS_EASING,\n                        at: revealDuration * 0.7,\n                    },\n                ],\n            ])\n\n            controls.then(() => {\n                pointerOffset.current.set(0, 0)\n                setRevealComplete(true)\n            })\n\n            return () => {\n                controls.stop()\n            }\n        }\n\n        return revealSequenceAnimation()\n    }, [camera, fov, reveal, ready, revealDuration])\n\n    useEffect(() => {\n        function focusOnTileSequence() {\n            if (!revealComplete) return\n            const group = groupRef.current\n            if (!group) return\n\n            const focused = activeTile !== null ? tileLayout[activeTile] : null\n\n            animating.current = true\n\n            let spinAngle = group.rotation.y\n            let tiltAngle = 0\n            let zDistance = SCENE_DISTANCE * Math.max(1, size.height / size.width)\n            let parallaxX = pointerOffset.current.x * mouseParallax\n            let parallaxY = pointerOffset.current.y * mouseParallax\n\n            if (focused) {\n                const spin = focused.longitude + Math.PI / 2\n                spinAngle = spin + Math.round((group.rotation.y - spin) / TAU) * TAU\n                tiltAngle = -focused.latitude\n                zDistance = focusDistanceFor(focused) - 1\n                parallaxX = 0\n                parallaxY = 0\n            }\n\n            const controls = animate([\n                [\n                    group.rotation,\n                    {\n                        x: [group.rotation.x, tiltAngle + parallaxY],\n                        y: [group.rotation.y, spinAngle + parallaxX],\n                    },\n                    { duration: focusDuration, ease: FOCUS_EASING },\n                ],\n                [\n                    camera.position,\n                    { z: [camera.position.z, zDistance] },\n                    { duration: focusDuration, ease: FOCUS_EASING, at: 0 },\n                ],\n            ])\n\n            controls.then(() => {\n                animating.current = false\n                parallax.current.set(parallaxX, parallaxY)\n                orientation.spin = group.rotation.y - parallax.current.x\n                orientation.tilt = group.rotation.x - parallax.current.y\n                orientation.targetSpin = orientation.spin\n                orientation.targetTilt = tiltAngle\n            })\n\n            return () => controls.stop()\n        }\n\n        return focusOnTileSequence()\n    }, [\n        revealComplete,\n        camera,\n        focusDistanceFor,\n        activeTile,\n        tileLayout,\n        orientation,\n        size,\n        focusDuration,\n        mouseParallax,\n    ])\n\n    useFrame((_state, delta) => {\n        const group = groupRef.current\n\n        if (!group) return\n\n        if (revealComplete && activeTile === null && !animating.current) {\n            orientation.spin = MathUtils.damp(orientation.spin, orientation.targetSpin, 12, delta)\n            orientation.tilt = MathUtils.damp(orientation.tilt, orientation.targetTilt, 12, delta)\n\n            const offset = pointerOffset.current\n\n            parallax.current.x = MathUtils.damp(\n                parallax.current.x,\n                offset.x * mouseParallax,\n                4,\n                delta,\n            )\n            parallax.current.y = MathUtils.damp(\n                parallax.current.y,\n                offset.y * mouseParallax,\n                4,\n                delta,\n            )\n\n            group.rotation.set(\n                orientation.tilt + parallax.current.y,\n                orientation.spin + parallax.current.x,\n                0,\n            )\n        }\n    })\n\n    return (\n        <group rotation-x={1} rotation-y={3} scale={0.5} ref={groupRef} onPointerMissed={onDismiss}>\n            <mesh>\n                <sphereGeometry args={[0.99, 64, 64]} />\n\n                <meshBasicMaterial\n                    ref={sphereMaterialRef}\n                    transparent={true}\n                    opacity={0}\n                    color={sphereColor}\n                />\n            </mesh>\n\n            {tileLayout.map((tile, i) => (\n                <Tile\n                    key={i}\n                    texture={textures[i % textures.length]}\n                    tile={tile}\n                    gap={gap}\n                    padding={padding}\n                    cornerRadius={cornerRadius}\n                    tileColor={tileColor}\n                    index={i}\n                    activeTile={activeTile}\n                    ready={ready}\n                    interactive={interactive}\n                    setPointer={setPointer}\n                    onSelect={() => select(i)}\n                    reveal={reveal}\n                    revealDuration={revealDuration}\n                    focusDuration={focusDuration}\n                    focusScale={focusScale}\n                />\n            ))}\n\n            <PeekTile\n                slot={peekSlots.previous}\n                setPointer={setPointer}\n                onNavigate={navigate}\n                focusDuration={focusDuration}\n            />\n            <PeekTile\n                slot={peekSlots.next}\n                setPointer={setPointer}\n                onNavigate={navigate}\n                focusDuration={focusDuration}\n            />\n        </group>\n    )\n}\n\n/*\n * Public component for the gallery.\n * Takes the images, the overlay slot and renders the WebGL scene.\n */\nexport function SphereGallery({\n    items,\n    className,\n    onActiveChange,\n    onReady,\n    mode,\n    priority,\n    zIndex,\n    transparent,\n    ...rest\n}: SphereGalleryProps) {\n    const surface = useRef<ComponentRef<\"div\">>(null)\n    const { lensBlur, ...sceneProps } = { ...DEFAULT_PROPS, ...rest }\n    const [activeTile, setActiveTile] = useState<number | null>(null)\n\n    const allyIndex = activeTile === null ? null : activeTile % items.length\n\n    const select = useCallback((index: number) => {\n        setActiveTile((current) => (current !== null ? null : index))\n    }, [])\n\n    const dismiss = useCallback(() => {\n        setActiveTile(null)\n    }, [])\n\n    useEffect(() => {\n        onActiveChange?.(allyIndex)\n    }, [allyIndex, onActiveChange])\n\n    useEffect(() => {\n        const onKeyDown = (event: KeyboardEvent) => {\n            if (event.key === \"Escape\") dismiss()\n        }\n        window.addEventListener(\"keydown\", onKeyDown)\n\n        return () => {\n            window.removeEventListener(\"keydown\", onKeyDown)\n        }\n    }, [dismiss])\n\n    return (\n        <div ref={surface} className={`touch-none select-none ${className ?? \"\"}`}>\n            {/* Basic SEO/accessibility layer */}\n            <ul className=\"sr-only\">\n                {items.map((image, index) => (\n                    <li key={image.src}>\n                        <button\n                            type=\"button\"\n                            aria-current={allyIndex === index}\n                            onClick={() => {\n                                if (activeTile !== null) setActiveTile(index)\n                                else select(index)\n                            }}\n                        >\n                            <img src={image.src} alt={image.alt} />\n                        </button>\n                    </li>\n                ))}\n            </ul>\n\n            {items.length > 0 && (\n                <WebglScene\n                    track={surface}\n                    mode={mode}\n                    priority={priority}\n                    zIndex={zIndex}\n                    transparent={transparent}\n                >\n                    <PostProcessing\n                        surface={surface}\n                        strength={lensBlur}\n                        active={activeTile !== null}\n                    >\n                        <SphereScene\n                            {...sceneProps}\n                            sources={items.map((image) => image.src)}\n                            surface={surface}\n                            activeTile={activeTile}\n                            onSelect={select}\n                            onNavigate={setActiveTile}\n                            onDismiss={dismiss}\n                            onReady={onReady}\n                        />\n                    </PostProcessing>\n                </WebglScene>\n            )}\n        </div>\n    )\n}\n"}]}