{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"orbit-gallery","type":"registry:component","title":"Orbit Gallery","description":"A gallery of images on rotating rings.","meta":{"pro":false},"docs":"Usage: https://atelier-ui.com/r/orbit-gallery.md\nDocs: https://atelier-ui.com/docs/components/background/orbit-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/orbit-gallery/orbit-gallery.tsx","type":"registry:component","target":"components/orbit-gallery/orbit-gallery.tsx","content":"\"use client\"\n\nimport { shaderMaterial, useTexture } from \"@react-three/drei\"\nimport { extend, type ThreeElement, useFrame } from \"@react-three/fiber\"\nimport type { Easing } from \"motion\"\nimport { animate } from \"motion/react\"\nimport {\n    type ComponentRef,\n    type RefObject,\n    useCallback,\n    useEffect,\n    useMemo,\n    useRef,\n    useState,\n} from \"react\"\nimport * as THREE 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 ANIMATION_EASING = [0.7, 0, 0.1, 1] as Easing\nconst REVEAL_SPEED_BOOST = 50\nconst SELECT_SPEED_BOOST = 10\nconst RING_DOWNSCALE = 0.8\nconst TILE_ASPECT = 0.8\nconst FOCUS_TILE_SIZE = 5\nconst FOCUS_TILE_HIDDEN_SCALE = 2\nconst DISTORTION_AMOUNT = 0.5\nconst DISPERSION_AMOUNT = 5\n\nconst DEFAULT_PROPS = {\n    radius: 2.8,\n    rings: 3,\n    ringGap: 1.6,\n    tileHeight: 0.7,\n    cornerRadius: 0.08,\n    spinSpeed: 1,\n    spinStagger: 0.2,\n    wheel: true,\n    wheelMultiplier: 3,\n    revealDuration: 2,\n    focusDuration: 1,\n}\n\ntype TileProps = {\n    texture: THREE.Texture\n    angle: number\n    radius: number\n    isSelected: boolean\n    ready: boolean\n    onSelect: () => void\n} & Pick<typeof DEFAULT_PROPS, \"tileHeight\" | \"cornerRadius\" | \"revealDuration\" | \"focusDuration\">\n\ntype RingProps = {\n    radius: number\n    count: number\n    offset: number\n    speed: number\n    scale: number\n    textures: THREE.Texture[]\n    isSelected: boolean\n    ready: boolean\n    onSelect: (index: number) => void\n    speedFactor: { current: number }\n    revealBoost: { current: number }\n} & Pick<typeof DEFAULT_PROPS, \"tileHeight\" | \"cornerRadius\" | \"revealDuration\" | \"focusDuration\">\n\ntype FocusTileProps = {\n    texture: THREE.Texture | null\n    cornerRadius: number\n    focusDuration: number\n    onDismiss: () => void\n}\n\ntype OrbitSceneProps = {\n    sources: string[]\n    surface: RefObject<HTMLElement | null>\n    activeIndex: number | null\n    onSelect: (index: number) => void\n    onDismiss: () => void\n    onReady?: () => void\n} & typeof DEFAULT_PROPS\n\ntype PlaneMesh<T extends THREE.Material> = THREE.Mesh<THREE.PlaneGeometry, T>\n\nexport type OrbitGalleryProps = {\n    items: {\n        src: string\n        alt: string\n    }[]\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        orbitTileMaterial: ThreeElement<typeof OrbitTileMaterial>\n    }\n}\n\n/*\n * Shader material for each tile.\n * Draws the image, the rounded mask and the dispersion blur.\n */\nconst OrbitTileMaterial = shaderMaterial(\n    {\n        uMap: new THREE.Texture(),\n        uTileSize: new THREE.Vector2(1, 1),\n        uUvScale: new THREE.Vector2(1, 1),\n        uUvOffset: new THREE.Vector2(0, 0),\n        uRadius: 0,\n        uOpacity: 1,\n        uReveal: 1,\n        uDistortion: 0,\n        uDispersion: 0,\n    },\n    /* glsl */ `\n        varying vec2 vUv;\n        void main() {\n            vUv = uv;\n            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n        }\n    `,\n    /* glsl */ `\n        uniform sampler2D uMap;\n        uniform vec2 uTileSize;\n        uniform vec2 uUvScale;\n        uniform vec2 uUvOffset;\n        uniform float uRadius;\n        uniform float uOpacity;\n        uniform float uReveal;\n        uniform float uDistortion;\n        uniform float uDispersion;\n        varying vec2 vUv;\n\n        const int BLUR_SAMPLES = 16;\n        const float RGB_SHIFT = 0.35;\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        vec4 sampleMap(vec2 uv) {\n            return texture2D(uMap, uUvOffset + uv * uUvScale);\n        }\n\n        float roundBoxMask(vec2 uv) {\n            vec2 point = (uv - 0.5) * uTileSize;\n            vec2 halfSize = uTileSize * 0.5;\n            float radius = min(uRadius, min(halfSize.x, halfSize.y));\n            float boxDistance = sdRoundBox(point, halfSize, radius);\n            float boxAntialias = fwidth(boxDistance);\n            return smoothstep(boxAntialias, -boxAntialias, boxDistance);\n        }\n\n        void main() {\n            vec2 centered = vUv - 0.5;\n            vec2 uv = 0.5 + centered * (1.0 + uDistortion * (0.5 - dot(centered, centered)));\n\n            vec4 texel = sampleMap(uv);\n            vec3 color = texel.rgb;\n            float alpha = texel.a;\n\n            if (uDispersion > 0.0) {\n                vec2 offset = uv - 0.5;\n                float amount = uDispersion * dot(centered, centered);\n                vec3 blurred = vec3(0.0);\n                float total = 0.0;\n\n                for (int sampleIndex = 0; sampleIndex < BLUR_SAMPLES; sampleIndex++) {\n                    float progress = float(sampleIndex) / float(BLUR_SAMPLES - 1);\n                    float weight = 1.0 - progress * 0.6;\n\n                    float scale = 1.0 - amount * progress;\n                    float spread = RGB_SHIFT * amount * progress;\n\n                    blurred.r += sampleMap(0.5 + offset * (scale + spread)).r * weight;\n                    blurred.g += sampleMap(0.5 + offset * scale).g * weight;\n                    blurred.b += sampleMap(0.5 + offset * (scale - spread)).b * weight;\n\n                    total += weight;\n                }\n\n                color = blurred / total;\n            }\n\n            float mask = roundBoxMask(uv);\n\n            alpha *= mask;\n\n            gl_FragColor = vec4(color, alpha * uOpacity * uReveal);\n        }\n    `,\n)\n\nextend({ OrbitTileMaterial })\n\n/*\n * Image tile placed on a ring.\n * Handles the reveal, the fade and hover (opacity) transitions and click selection.\n */\nfunction Tile({\n    texture,\n    angle,\n    radius,\n    tileHeight,\n    cornerRadius,\n    isSelected,\n    ready,\n    revealDuration,\n    focusDuration,\n    onSelect,\n}: TileProps) {\n    const [hovered, setHovered] = useState(false)\n    const meshRef = useRef<PlaneMesh<InstanceType<typeof OrbitTileMaterial>>>(null)\n    const wasSelected = useRef(isSelected)\n    const width = tileHeight * TILE_ASPECT\n\n    const crop = useMemo(() => {\n        const image = texture.image as HTMLImageElement\n        const imageAspect = image.width / image.height\n        const scale =\n            imageAspect > TILE_ASPECT\n                ? new THREE.Vector2(TILE_ASPECT / imageAspect, 1)\n                : new THREE.Vector2(1, imageAspect / TILE_ASPECT)\n        return {\n            scale,\n            offset: new THREE.Vector2((1 - scale.x) / 2, (1 - scale.y) / 2),\n        }\n    }, [texture])\n\n    const tilePlacement = useMemo(() => {\n        return {\n            position: new THREE.Vector3(Math.cos(angle) * radius, Math.sin(angle) * radius, 0),\n            rotation: angle - Math.PI / 2,\n        }\n    }, [angle, radius])\n\n    useEffect(() => {\n        function tileFadeAnimation() {\n            const material = meshRef.current?.material\n            if (!material) return\n            const selectionChanged = wasSelected.current !== isSelected\n            wasSelected.current = isSelected\n            const controls = animate(\n                material,\n                { uOpacity: isSelected ? 0 : hovered ? 0.7 : 1 },\n                selectionChanged\n                    ? { duration: focusDuration * 0.3, ease: ANIMATION_EASING, delay: 0.2 }\n                    : { duration: hovered ? 0.2 : 0.3 },\n            )\n            return () => controls.stop()\n        }\n\n        return tileFadeAnimation()\n    }, [isSelected, hovered, focusDuration])\n\n    useEffect(() => {\n        if (!ready) return\n        function tileRevealAnimation() {\n            const material = meshRef.current?.material\n            if (!material) return\n            const controls = animate(\n                material,\n                { uReveal: 1 },\n                { duration: revealDuration, ease: ANIMATION_EASING },\n            )\n            return () => controls.stop()\n        }\n\n        return tileRevealAnimation()\n    }, [ready, revealDuration])\n\n    return (\n        <group position={tilePlacement.position} rotation-z={tilePlacement.rotation}>\n            <mesh\n                ref={meshRef}\n                raycast={isSelected ? () => null : THREE.Mesh.prototype.raycast}\n                onClick={(event) => {\n                    event.stopPropagation()\n                    onSelect()\n                }}\n                onPointerOver={() => setHovered(true)}\n                onPointerOut={() => setHovered(false)}\n            >\n                <planeGeometry args={[width, tileHeight]} />\n                <orbitTileMaterial\n                    key={OrbitTileMaterial.key}\n                    uMap={texture}\n                    uTileSize={new THREE.Vector2(width, tileHeight)}\n                    uUvScale={crop.scale}\n                    uUvOffset={crop.offset}\n                    uRadius={cornerRadius}\n                    uReveal={0}\n                    transparent\n                    depthWrite={false}\n                />\n            </mesh>\n        </group>\n    )\n}\n\n/*\n * One rotating ring of tiles.\n * Handles the spin and the fade-out when a tile is selected.\n */\nfunction Ring({\n    textures,\n    radius,\n    count,\n    offset,\n    speed,\n    scale: ringScale,\n    tileHeight,\n    cornerRadius,\n    isSelected,\n    ready,\n    revealDuration,\n    focusDuration,\n    onSelect,\n    speedFactor,\n    revealBoost,\n}: RingProps) {\n    const groupRef = useRef<THREE.Group>(null)\n    const selectBoost = useRef(0)\n\n    const tiles = useMemo(() => {\n        return Array.from({ length: count }, (_, index) => {\n            const textureIndex = (index + offset) % textures.length\n            return {\n                angle: (index / count) * TAU,\n                textureIndex,\n                texture: textures[textureIndex],\n            }\n        })\n    }, [count, offset, textures])\n\n    useEffect(() => {\n        function ringFadeAnimation() {\n            const group = groupRef.current\n            if (!group) return\n            const scale = isSelected ? ringScale : 1\n            const controls = animate([\n                [\n                    group.scale,\n                    { x: scale, y: scale, z: scale },\n                    { duration: focusDuration * 0.8, ease: ANIMATION_EASING },\n                ],\n                [\n                    selectBoost,\n                    { current: isSelected ? SELECT_SPEED_BOOST : 1 },\n                    { duration: focusDuration * 0.3, ease: \"linear\", at: 0 },\n                ],\n            ])\n            return () => controls.stop()\n        }\n\n        return ringFadeAnimation()\n    }, [isSelected, ringScale, focusDuration])\n\n    useFrame((_, delta) => {\n        const group = groupRef.current\n        if (!group) return\n\n        const boost = selectBoost.current + revealBoost.current\n        const direction = Math.sign(speed)\n        const rmp = (speed + direction * boost) * speedFactor.current\n        group.rotation.z += (rmp * TAU * delta) / 60\n    })\n\n    return (\n        <group ref={groupRef}>\n            {tiles.map((tile, index) => (\n                <Tile\n                    key={index}\n                    texture={tile.texture}\n                    angle={tile.angle}\n                    radius={radius}\n                    tileHeight={tileHeight}\n                    cornerRadius={cornerRadius}\n                    isSelected={isSelected}\n                    ready={ready}\n                    revealDuration={revealDuration}\n                    focusDuration={focusDuration}\n                    onSelect={() => onSelect(tile.textureIndex)}\n                />\n            ))}\n        </group>\n    )\n}\n\n/*\n * Enlarged tile shown when an image is selected.\n * Handles the zoom, distortion and fade transitions.\n */\nfunction FocusTile({ texture, cornerRadius, focusDuration, onDismiss }: FocusTileProps) {\n    const [displayed, setDisplayed] = useState<THREE.Texture | null>(null)\n    const meshRef = useRef<PlaneMesh<InstanceType<typeof OrbitTileMaterial>>>(null)\n\n    useEffect(() => {\n        if (texture) setDisplayed(texture)\n    }, [texture])\n\n    useEffect(() => {\n        function focusTileFadeAnimation() {\n            const mesh = meshRef.current\n            if (!mesh) return\n            const scale = texture ? 1 : FOCUS_TILE_HIDDEN_SCALE\n            const controls = animate([\n                [\n                    mesh.material,\n                    { uOpacity: texture ? 1 : 0 },\n                    { duration: focusDuration * 0.8, ease: ANIMATION_EASING },\n                ],\n                [\n                    mesh.material,\n                    { uDistortion: texture ? 0 : DISTORTION_AMOUNT },\n                    { duration: focusDuration * 0.8, ease: ANIMATION_EASING, at: 0 },\n                ],\n                [\n                    mesh.material,\n                    { uDispersion: texture ? 0 : DISPERSION_AMOUNT },\n                    { duration: focusDuration * 0.8, ease: ANIMATION_EASING, at: 0 },\n                ],\n                [\n                    mesh.scale,\n                    { x: scale, y: scale },\n                    { duration: focusDuration * 0.7, ease: ANIMATION_EASING, at: 0 },\n                ],\n            ])\n            if (!texture) controls.then(() => setDisplayed(null))\n            return () => controls.stop()\n        }\n\n        return focusTileFadeAnimation()\n    }, [texture, displayed, focusDuration])\n\n    if (!displayed) return null\n\n    const image = displayed.image as HTMLImageElement\n    const width = FOCUS_TILE_SIZE * (image.width / image.height)\n\n    return (\n        <mesh\n            ref={meshRef}\n            position-z={1}\n            scale={[FOCUS_TILE_HIDDEN_SCALE, FOCUS_TILE_HIDDEN_SCALE, 1]}\n            raycast={texture ? THREE.Mesh.prototype.raycast : () => null}\n            onPointerOver={(event) => event.stopPropagation()}\n            onClick={(event) => {\n                event.stopPropagation()\n                onDismiss()\n            }}\n        >\n            <planeGeometry args={[width, FOCUS_TILE_SIZE]} />\n            <orbitTileMaterial\n                key={OrbitTileMaterial.key}\n                uMap={displayed}\n                uTileSize={new THREE.Vector2(width, FOCUS_TILE_SIZE)}\n                uRadius={cornerRadius}\n                uOpacity={0}\n                uDistortion={DISTORTION_AMOUNT}\n                uDispersion={DISPERSION_AMOUNT}\n                transparent\n                depthWrite={false}\n            />\n        </mesh>\n    )\n}\n\n/*\n * Builds the ring configs and loads the textures.\n * Tracks the selected tile and handles dismissal.\n */\nfunction OrbitScene({\n    sources,\n    surface,\n    activeIndex,\n    onSelect,\n    onDismiss,\n    radius,\n    rings,\n    ringGap,\n    tileHeight,\n    cornerRadius,\n    spinSpeed,\n    spinStagger,\n    wheel,\n    wheelMultiplier,\n    revealDuration,\n    focusDuration,\n    onReady,\n}: OrbitSceneProps) {\n    const textures = useTexture(sources)\n    const speedFactor = useRef(1)\n    const revealBoost = useRef(REVEAL_SPEED_BOOST)\n    const ready = useWebglReady({ onReady })\n    const selected = activeIndex !== null ? textures[activeIndex] : null\n\n    const select = useCallback(\n        (index: number) => {\n            onSelect(index)\n            surface.current?.style.removeProperty(\"cursor\")\n        },\n        [onSelect, surface],\n    )\n\n    useEffect(() => {\n        if (!ready) return\n        function revealSpinAnimation() {\n            const controls = animate(\n                revealBoost,\n                { current: 0 },\n                { duration: revealDuration, ease: ANIMATION_EASING },\n            )\n            return () => controls.stop()\n        }\n\n        return revealSpinAnimation()\n    }, [ready, revealDuration])\n\n    useEffect(() => {\n        const target = surface.current\n        if (!target || !wheel) return\n\n        const onWheel = (event: WheelEvent) => {\n            event.preventDefault()\n            speedFactor.current += event.deltaY * 0.01 * wheelMultiplier\n        }\n        target.addEventListener(\"wheel\", onWheel)\n        return () => target.removeEventListener(\"wheel\", onWheel)\n    }, [surface, wheel, wheelMultiplier])\n\n    useEffect(() => {\n        const onKeyDown = (event: KeyboardEvent) => {\n            if (event.key === \"Escape\") onDismiss()\n        }\n        window.addEventListener(\"keydown\", onKeyDown)\n        return () => window.removeEventListener(\"keydown\", onKeyDown)\n    }, [onDismiss])\n\n    useFrame((_, delta) => {\n        speedFactor.current = THREE.MathUtils.damp(\n            speedFactor.current,\n            Math.sign(speedFactor.current) || 1,\n            8,\n            delta,\n        )\n    })\n\n    const ringConfigs = useMemo(\n        () =>\n            Array.from({ length: rings }, (_, ring) => {\n                const ringRadius = radius + ring * ringGap\n\n                return {\n                    radius: ringRadius,\n                    count: Math.max(3, Math.round(sources.length * (ringRadius / radius))),\n                    offset: Math.round((ring * sources.length) / rings),\n                    speed: spinSpeed * spinStagger ** ring,\n                    scale: 1 - RING_DOWNSCALE / (ring + 1),\n                }\n            }),\n        [radius, rings, ringGap, sources.length, spinSpeed, spinStagger],\n    )\n\n    return (\n        <group\n            onPointerMissed={onDismiss}\n            onPointerOver={() => surface.current?.style.setProperty(\"cursor\", \"pointer\")}\n            onPointerOut={() => surface.current?.style.removeProperty(\"cursor\")}\n        >\n            {ringConfigs.map((config, index) => (\n                <Ring\n                    key={index}\n                    textures={textures}\n                    radius={config.radius}\n                    count={config.count}\n                    offset={config.offset}\n                    speed={config.speed}\n                    scale={config.scale}\n                    tileHeight={tileHeight}\n                    cornerRadius={cornerRadius}\n                    isSelected={selected !== null}\n                    ready={ready}\n                    revealDuration={revealDuration}\n                    focusDuration={focusDuration}\n                    onSelect={select}\n                    speedFactor={speedFactor}\n                    revealBoost={revealBoost}\n                />\n            ))}\n\n            <FocusTile\n                texture={selected}\n                cornerRadius={cornerRadius}\n                focusDuration={focusDuration}\n                onDismiss={onDismiss}\n            />\n        </group>\n    )\n}\n\n/*\n * Public component for the gallery.\n * Takes the images and renders the WebGL scene.\n */\nexport function OrbitGallery({\n    items,\n    className,\n    onActiveChange,\n    mode,\n    priority,\n    zIndex,\n    transparent,\n    ...rest\n}: OrbitGalleryProps) {\n    const surface = useRef<ComponentRef<\"div\">>(null)\n    const sceneProps = { ...DEFAULT_PROPS, ...rest }\n    const [activeIndex, setActiveIndex] = useState<number | null>(null)\n\n    const dismiss = useCallback(() => {\n        setActiveIndex(null)\n    }, [])\n\n    useEffect(() => {\n        onActiveChange?.(activeIndex)\n    }, [activeIndex, onActiveChange])\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={activeIndex === index}\n                            onClick={() => setActiveIndex(index)}\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                    <OrbitScene\n                        {...sceneProps}\n                        surface={surface}\n                        sources={items.map((image) => image.src)}\n                        activeIndex={activeIndex}\n                        onSelect={setActiveIndex}\n                        onDismiss={dismiss}\n                    />\n                </WebglScene>\n            )}\n        </div>\n    )\n}\n"}]}