{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "InfiniteMenu-JS-CSS",
	"title": "InfiniteMenu",
	"description": "Horizontally looping menu effect that scrolls endlessly with seamless wrap.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "InfiniteMenu.css",
			"target": "@components/InfiniteMenu.css",
			"content": "/* Note: this CSS is only an example, you can overlay whatever you want using the activeItem logic */\n\n#infinite-grid-menu-canvas {\n  cursor: grab;\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n  position: relative;\n  outline: none;\n}\n\n#infinite-grid-menu-canvas:active {\n  cursor: grabbing;\n}\n\n.action-button {\n  position: absolute;\n  left: 50%;\n  z-index: 10;\n  width: 60px;\n  height: 60px;\n  display: grid;\n  place-items: center;\n  background: #5227ff;\n  border: none;\n  border-radius: 50%;\n  cursor: pointer;\n  border: 5px solid var(--infinite-menu-background, #000);\n}\n\n.face-title {\n  user-select: none;\n  position: absolute;\n  font-weight: 900;\n  font-size: 3rem;\n  left: 1.6em;\n  top: 50%;\n}\n\n.action-button-icon {\n  user-select: none;\n  position: relative;\n  color: #fff;\n  top: 2px;\n  font-size: 26px;\n}\n\n.face-title {\n  position: absolute;\n  top: 50%;\n  transform: translate(20%, -50%);\n}\n\n.face-title.active {\n  opacity: 1;\n  transform: translate(20%, -50%);\n  pointer-events: auto;\n  transition: 0.5s ease;\n}\n\n.face-title.inactive {\n  pointer-events: none;\n  opacity: 0;\n  transition: 0.1s ease;\n}\n\n.face-description {\n  user-select: none;\n  position: absolute;\n  max-width: 10ch;\n  top: 50%;\n  font-size: 1.2rem;\n  right: 1%;\n  transform: translate(0, -50%);\n}\n\n.face-description.active {\n  opacity: 1;\n  transform: translate(-90%, -50%);\n  pointer-events: auto;\n  transition: 0.5s ease;\n}\n\n.face-description.inactive {\n  pointer-events: none;\n  transform: translate(-60%, -50%);\n  opacity: 0;\n  transition: 0.1s ease;\n}\n\n.action-button {\n  position: absolute;\n  left: 50%;\n}\n\n.action-button.active {\n  bottom: 3.8em;\n  transform: translateX(-50%) scale(1);\n  opacity: 1;\n  pointer-events: auto;\n  transition: 0.5s ease;\n}\n\n.action-button.inactive {\n  bottom: -80px;\n  transform: translateX(-50%) scale(0);\n  opacity: 0;\n  pointer-events: none;\n  transition: 0.1s ease;\n}\n\n@media (max-width: 1500px) {\n  .face-title,\n  .face-description {\n    display: none;\n  }\n}\n"
		},
		{
			"type": "registry:component",
			"path": "InfiniteMenu.jsx",
			"content": "import { useEffect, useRef, useState } from 'react';\nimport { mat4, quat, vec2, vec3 } from 'gl-matrix';\nimport './InfiniteMenu.css';\n\nconst discVertShaderSource = `#version 300 es\n\nuniform mat4 uWorldMatrix;\nuniform mat4 uViewMatrix;\nuniform mat4 uProjectionMatrix;\nuniform vec3 uCameraPosition;\nuniform vec4 uRotationAxisVelocity;\n\nin vec3 aModelPosition;\nin vec3 aModelNormal;\nin vec2 aModelUvs;\nin mat4 aInstanceMatrix;\n\nout vec2 vUvs;\nout float vAlpha;\nflat out int vInstanceId;\n\n#define PI 3.141593\n\nvoid main() {\n    vec4 worldPosition = uWorldMatrix * aInstanceMatrix * vec4(aModelPosition, 1.);\n\n    vec3 centerPos = (uWorldMatrix * aInstanceMatrix * vec4(0., 0., 0., 1.)).xyz;\n    float radius = length(centerPos.xyz);\n\n    if (gl_VertexID > 0) {\n        vec3 rotationAxis = uRotationAxisVelocity.xyz;\n        float rotationVelocity = min(.15, uRotationAxisVelocity.w * 15.);\n        vec3 stretchDir = normalize(cross(centerPos, rotationAxis));\n        vec3 relativeVertexPos = normalize(worldPosition.xyz - centerPos);\n        float strength = dot(stretchDir, relativeVertexPos);\n        float invAbsStrength = min(0., abs(strength) - 1.);\n        strength = rotationVelocity * sign(strength) * abs(invAbsStrength * invAbsStrength * invAbsStrength + 1.);\n        worldPosition.xyz += stretchDir * strength;\n    }\n\n    worldPosition.xyz = radius * normalize(worldPosition.xyz);\n\n    gl_Position = uProjectionMatrix * uViewMatrix * worldPosition;\n\n    vAlpha = smoothstep(0.5, 1., normalize(worldPosition.xyz).z) * .9 + .1;\n    vUvs = aModelUvs;\n    vInstanceId = gl_InstanceID;\n}\n`;\n\nconst discFragShaderSource = `#version 300 es\nprecision highp float;\n\nuniform sampler2D uTex;\nuniform int uItemCount;\nuniform int uAtlasSize;\n\nout vec4 outColor;\n\nin vec2 vUvs;\nin float vAlpha;\nflat in int vInstanceId;\n\nvoid main() {\n    int itemIndex = vInstanceId % uItemCount;\n    int cellsPerRow = uAtlasSize;\n    int cellX = itemIndex % cellsPerRow;\n    int cellY = itemIndex / cellsPerRow;\n    vec2 cellSize = vec2(1.0) / vec2(float(cellsPerRow));\n    vec2 cellOffset = vec2(float(cellX), float(cellY)) * cellSize;\n\n    ivec2 texSize = textureSize(uTex, 0);\n    float imageAspect = float(texSize.x) / float(texSize.y);\n    float containerAspect = 1.0;\n    \n    float scale = max(imageAspect / containerAspect, \n                     containerAspect / imageAspect);\n    \n    vec2 st = vec2(vUvs.x, 1.0 - vUvs.y);\n    st = (st - 0.5) * scale + 0.5;\n    \n    st = clamp(st, 0.0, 1.0);\n    \n    st = st * cellSize + cellOffset;\n    \n    outColor = texture(uTex, st);\n    outColor.a *= vAlpha;\n}\n`;\n\nclass Face {\n  constructor(a, b, c) {\n    this.a = a;\n    this.b = b;\n    this.c = c;\n  }\n}\n\nclass Vertex {\n  constructor(x, y, z) {\n    this.position = vec3.fromValues(x, y, z);\n    this.normal = vec3.create();\n    this.uv = vec2.create();\n  }\n}\n\nclass Geometry {\n  constructor() {\n    this.vertices = [];\n    this.faces = [];\n  }\n\n  addVertex(...args) {\n    for (let i = 0; i < args.length; i += 3) {\n      this.vertices.push(new Vertex(args[i], args[i + 1], args[i + 2]));\n    }\n    return this;\n  }\n\n  addFace(...args) {\n    for (let i = 0; i < args.length; i += 3) {\n      this.faces.push(new Face(args[i], args[i + 1], args[i + 2]));\n    }\n    return this;\n  }\n\n  get lastVertex() {\n    return this.vertices[this.vertices.length - 1];\n  }\n\n  subdivide(divisions = 1) {\n    const midPointCache = {};\n    let f = this.faces;\n\n    for (let div = 0; div < divisions; ++div) {\n      const newFaces = new Array(f.length * 4);\n\n      f.forEach((face, ndx) => {\n        const mAB = this.getMidPoint(face.a, face.b, midPointCache);\n        const mBC = this.getMidPoint(face.b, face.c, midPointCache);\n        const mCA = this.getMidPoint(face.c, face.a, midPointCache);\n\n        const i = ndx * 4;\n        newFaces[i + 0] = new Face(face.a, mAB, mCA);\n        newFaces[i + 1] = new Face(face.b, mBC, mAB);\n        newFaces[i + 2] = new Face(face.c, mCA, mBC);\n        newFaces[i + 3] = new Face(mAB, mBC, mCA);\n      });\n\n      f = newFaces;\n    }\n\n    this.faces = f;\n    return this;\n  }\n\n  spherize(radius = 1) {\n    this.vertices.forEach(vertex => {\n      vec3.normalize(vertex.normal, vertex.position);\n      vec3.scale(vertex.position, vertex.normal, radius);\n    });\n    return this;\n  }\n\n  get data() {\n    return {\n      vertices: this.vertexData,\n      indices: this.indexData,\n      normals: this.normalData,\n      uvs: this.uvData\n    };\n  }\n\n  get vertexData() {\n    return new Float32Array(this.vertices.flatMap(v => Array.from(v.position)));\n  }\n\n  get normalData() {\n    return new Float32Array(this.vertices.flatMap(v => Array.from(v.normal)));\n  }\n\n  get uvData() {\n    return new Float32Array(this.vertices.flatMap(v => Array.from(v.uv)));\n  }\n\n  get indexData() {\n    return new Uint16Array(this.faces.flatMap(f => [f.a, f.b, f.c]));\n  }\n\n  getMidPoint(ndxA, ndxB, cache) {\n    const cacheKey = ndxA < ndxB ? `k_${ndxB}_${ndxA}` : `k_${ndxA}_${ndxB}`;\n    if (Object.prototype.hasOwnProperty.call(cache, cacheKey)) {\n      return cache[cacheKey];\n    }\n    const a = this.vertices[ndxA].position;\n    const b = this.vertices[ndxB].position;\n    const ndx = this.vertices.length;\n    cache[cacheKey] = ndx;\n    this.addVertex((a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5);\n    return ndx;\n  }\n}\n\nclass IcosahedronGeometry extends Geometry {\n  constructor() {\n    super();\n    const t = Math.sqrt(5) * 0.5 + 0.5;\n    this.addVertex(\n      -1,\n      t,\n      0,\n      1,\n      t,\n      0,\n      -1,\n      -t,\n      0,\n      1,\n      -t,\n      0,\n      0,\n      -1,\n      t,\n      0,\n      1,\n      t,\n      0,\n      -1,\n      -t,\n      0,\n      1,\n      -t,\n      t,\n      0,\n      -1,\n      t,\n      0,\n      1,\n      -t,\n      0,\n      -1,\n      -t,\n      0,\n      1\n    ).addFace(\n      0,\n      11,\n      5,\n      0,\n      5,\n      1,\n      0,\n      1,\n      7,\n      0,\n      7,\n      10,\n      0,\n      10,\n      11,\n      1,\n      5,\n      9,\n      5,\n      11,\n      4,\n      11,\n      10,\n      2,\n      10,\n      7,\n      6,\n      7,\n      1,\n      8,\n      3,\n      9,\n      4,\n      3,\n      4,\n      2,\n      3,\n      2,\n      6,\n      3,\n      6,\n      8,\n      3,\n      8,\n      9,\n      4,\n      9,\n      5,\n      2,\n      4,\n      11,\n      6,\n      2,\n      10,\n      8,\n      6,\n      7,\n      9,\n      8,\n      1\n    );\n  }\n}\n\nclass DiscGeometry extends Geometry {\n  constructor(steps = 4, radius = 1) {\n    super();\n    steps = Math.max(4, steps);\n\n    const alpha = (2 * Math.PI) / steps;\n\n    this.addVertex(0, 0, 0);\n    this.lastVertex.uv[0] = 0.5;\n    this.lastVertex.uv[1] = 0.5;\n\n    for (let i = 0; i < steps; ++i) {\n      const x = Math.cos(alpha * i);\n      const y = Math.sin(alpha * i);\n      this.addVertex(radius * x, radius * y, 0);\n      this.lastVertex.uv[0] = x * 0.5 + 0.5;\n      this.lastVertex.uv[1] = y * 0.5 + 0.5;\n\n      if (i > 0) {\n        this.addFace(0, i, i + 1);\n      }\n    }\n    this.addFace(0, steps, 1);\n  }\n}\n\nfunction createShader(gl, type, source) {\n  const shader = gl.createShader(type);\n  gl.shaderSource(shader, source);\n  gl.compileShader(shader);\n  const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);\n\n  if (success) {\n    return shader;\n  }\n\n  console.error(gl.getShaderInfoLog(shader));\n  gl.deleteShader(shader);\n  return null;\n}\n\nfunction createProgram(gl, shaderSources, transformFeedbackVaryings, attribLocations) {\n  const program = gl.createProgram();\n\n  [gl.VERTEX_SHADER, gl.FRAGMENT_SHADER].forEach((type, ndx) => {\n    const shader = createShader(gl, type, shaderSources[ndx]);\n    if (shader) gl.attachShader(program, shader);\n  });\n\n  if (transformFeedbackVaryings) {\n    gl.transformFeedbackVaryings(program, transformFeedbackVaryings, gl.SEPARATE_ATTRIBS);\n  }\n\n  if (attribLocations) {\n    for (const attrib in attribLocations) {\n      gl.bindAttribLocation(program, attribLocations[attrib], attrib);\n    }\n  }\n\n  gl.linkProgram(program);\n  const success = gl.getProgramParameter(program, gl.LINK_STATUS);\n\n  if (success) {\n    return program;\n  }\n\n  console.error(gl.getProgramInfoLog(program));\n  gl.deleteProgram(program);\n  return null;\n}\n\nfunction makeVertexArray(gl, bufLocNumElmPairs, indices) {\n  const va = gl.createVertexArray();\n  gl.bindVertexArray(va);\n\n  for (const [buffer, loc, numElem] of bufLocNumElmPairs) {\n    if (loc === -1) continue;\n    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n    gl.enableVertexAttribArray(loc);\n    gl.vertexAttribPointer(loc, numElem, gl.FLOAT, false, 0, 0);\n  }\n\n  if (indices) {\n    const indexBuffer = gl.createBuffer();\n    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);\n    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);\n  }\n\n  gl.bindVertexArray(null);\n  return va;\n}\n\nfunction resizeCanvasToDisplaySize(canvas) {\n  const dpr = Math.min(2, window.devicePixelRatio);\n  const displayWidth = Math.round(canvas.clientWidth * dpr);\n  const displayHeight = Math.round(canvas.clientHeight * dpr);\n  const needResize = canvas.width !== displayWidth || canvas.height !== displayHeight;\n  if (needResize) {\n    canvas.width = displayWidth;\n    canvas.height = displayHeight;\n  }\n  return needResize;\n}\n\nfunction makeBuffer(gl, sizeOrData, usage) {\n  const buf = gl.createBuffer();\n  gl.bindBuffer(gl.ARRAY_BUFFER, buf);\n  gl.bufferData(gl.ARRAY_BUFFER, sizeOrData, usage);\n  gl.bindBuffer(gl.ARRAY_BUFFER, null);\n  return buf;\n}\n\nfunction createAndSetupTexture(gl, minFilter, magFilter, wrapS, wrapT) {\n  const texture = gl.createTexture();\n  gl.bindTexture(gl.TEXTURE_2D, texture);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrapS);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrapT);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, minFilter);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, magFilter);\n  return texture;\n}\n\nclass ArcballControl {\n  isPointerDown = false;\n  orientation = quat.create();\n  pointerRotation = quat.create();\n  rotationVelocity = 0;\n  rotationAxis = vec3.fromValues(1, 0, 0);\n  snapDirection = vec3.fromValues(0, 0, -1);\n  snapTargetDirection;\n  EPSILON = 0.1;\n  IDENTITY_QUAT = quat.create();\n\n  constructor(canvas, updateCallback) {\n    this.canvas = canvas;\n    this.updateCallback = updateCallback || (() => null);\n\n    this.pointerPos = vec2.create();\n    this.previousPointerPos = vec2.create();\n    this._rotationVelocity = 0;\n    this._combinedQuat = quat.create();\n\n    canvas.addEventListener('pointerdown', e => {\n      vec2.set(this.pointerPos, e.clientX, e.clientY);\n      vec2.copy(this.previousPointerPos, this.pointerPos);\n      this.isPointerDown = true;\n    });\n    canvas.addEventListener('pointerup', () => {\n      this.isPointerDown = false;\n    });\n    canvas.addEventListener('pointerleave', () => {\n      this.isPointerDown = false;\n    });\n    canvas.addEventListener('pointermove', e => {\n      if (this.isPointerDown) {\n        vec2.set(this.pointerPos, e.clientX, e.clientY);\n      }\n    });\n\n    canvas.style.touchAction = 'none';\n  }\n\n  update(deltaTime, targetFrameDuration = 16) {\n    const timeScale = deltaTime / targetFrameDuration + 0.00001;\n    let angleFactor = timeScale;\n    let snapRotation = quat.create();\n\n    if (this.isPointerDown) {\n      const INTENSITY = 0.3 * timeScale;\n      const ANGLE_AMPLIFICATION = 5 / timeScale;\n\n      const midPointerPos = vec2.sub(vec2.create(), this.pointerPos, this.previousPointerPos);\n      vec2.scale(midPointerPos, midPointerPos, INTENSITY);\n\n      if (vec2.sqrLen(midPointerPos) > this.EPSILON) {\n        vec2.add(midPointerPos, this.previousPointerPos, midPointerPos);\n\n        const p = this.#project(midPointerPos);\n        const q = this.#project(this.previousPointerPos);\n        const a = vec3.normalize(vec3.create(), p);\n        const b = vec3.normalize(vec3.create(), q);\n\n        vec2.copy(this.previousPointerPos, midPointerPos);\n\n        angleFactor *= ANGLE_AMPLIFICATION;\n\n        this.quatFromVectors(a, b, this.pointerRotation, angleFactor);\n      } else {\n        quat.slerp(this.pointerRotation, this.pointerRotation, this.IDENTITY_QUAT, INTENSITY);\n      }\n    } else {\n      const INTENSITY = 0.1 * timeScale;\n      quat.slerp(this.pointerRotation, this.pointerRotation, this.IDENTITY_QUAT, INTENSITY);\n\n      if (this.snapTargetDirection) {\n        const SNAPPING_INTENSITY = 0.2;\n        const a = this.snapTargetDirection;\n        const b = this.snapDirection;\n        const sqrDist = vec3.squaredDistance(a, b);\n        const distanceFactor = Math.max(0.1, 1 - sqrDist * 10);\n        angleFactor *= SNAPPING_INTENSITY * distanceFactor;\n        this.quatFromVectors(a, b, snapRotation, angleFactor);\n      }\n    }\n\n    const combinedQuat = quat.multiply(quat.create(), snapRotation, this.pointerRotation);\n    this.orientation = quat.multiply(quat.create(), combinedQuat, this.orientation);\n    quat.normalize(this.orientation, this.orientation);\n\n    const RA_INTENSITY = 0.8 * timeScale;\n    quat.slerp(this._combinedQuat, this._combinedQuat, combinedQuat, RA_INTENSITY);\n    quat.normalize(this._combinedQuat, this._combinedQuat);\n\n    const rad = Math.acos(this._combinedQuat[3]) * 2.0;\n    const s = Math.sin(rad / 2.0);\n    let rv = 0;\n    if (s > 0.000001) {\n      rv = rad / (2 * Math.PI);\n      this.rotationAxis[0] = this._combinedQuat[0] / s;\n      this.rotationAxis[1] = this._combinedQuat[1] / s;\n      this.rotationAxis[2] = this._combinedQuat[2] / s;\n    }\n\n    const RV_INTENSITY = 0.5 * timeScale;\n    this._rotationVelocity += (rv - this._rotationVelocity) * RV_INTENSITY;\n    this.rotationVelocity = this._rotationVelocity / timeScale;\n\n    this.updateCallback(deltaTime);\n  }\n\n  quatFromVectors(a, b, out, angleFactor = 1) {\n    const axis = vec3.cross(vec3.create(), a, b);\n    vec3.normalize(axis, axis);\n    const d = Math.max(-1, Math.min(1, vec3.dot(a, b)));\n    const angle = Math.acos(d) * angleFactor;\n    quat.setAxisAngle(out, axis, angle);\n    return { q: out, axis, angle };\n  }\n\n  #project(pos) {\n    const r = 2;\n    const w = this.canvas.clientWidth;\n    const h = this.canvas.clientHeight;\n    const s = Math.max(w, h) - 1;\n\n    const x = (2 * pos[0] - w - 1) / s;\n    const y = (2 * pos[1] - h - 1) / s;\n    let z = 0;\n    const xySq = x * x + y * y;\n    const rSq = r * r;\n\n    if (xySq <= rSq / 2.0) {\n      z = Math.sqrt(rSq - xySq);\n    } else {\n      z = rSq / Math.sqrt(xySq);\n    }\n    return vec3.fromValues(-x, y, z);\n  }\n}\n\nclass InfiniteGridMenu {\n  TARGET_FRAME_DURATION = 1000 / 60;\n  SPHERE_RADIUS = 2;\n\n  #time = 0;\n  #deltaTime = 0;\n  #deltaFrames = 0;\n  #frames = 0;\n\n  camera = {\n    matrix: mat4.create(),\n    near: 0.1,\n    far: 40,\n    fov: Math.PI / 4,\n    aspect: 1,\n    position: vec3.fromValues(0, 0, 3),\n    up: vec3.fromValues(0, 1, 0),\n    matrices: {\n      view: mat4.create(),\n      projection: mat4.create(),\n      inversProjection: mat4.create()\n    }\n  };\n\n  nearestVertexIndex = null;\n  smoothRotationVelocity = 0;\n  scaleFactor = 1.0;\n  movementActive = false;\n\n  constructor(canvas, items, onActiveItemChange, onMovementChange, onInit = null, scale = 1.0) {\n    this.canvas = canvas;\n    this.items = items || [];\n    this.onActiveItemChange = onActiveItemChange || (() => {});\n    this.onMovementChange = onMovementChange || (() => {});\n    this.scaleFactor = scale;\n    this.camera.position[2] = 3 * scale;\n    this.#init(onInit);\n  }\n\n  resize() {\n    this.viewportSize = vec2.set(this.viewportSize || vec2.create(), this.canvas.clientWidth, this.canvas.clientHeight);\n\n    const gl = this.gl;\n    const needsResize = resizeCanvasToDisplaySize(gl.canvas);\n    if (needsResize) {\n      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);\n    }\n\n    this.#updateProjectionMatrix(gl);\n  }\n\n  run(time = 0) {\n    this.#deltaTime = Math.min(32, time - this.#time);\n    this.#time = time;\n    this.#deltaFrames = this.#deltaTime / this.TARGET_FRAME_DURATION;\n    this.#frames += this.#deltaFrames;\n\n    this.#animate(this.#deltaTime);\n    this.#render();\n\n    requestAnimationFrame(t => this.run(t));\n  }\n\n  #init(onInit) {\n    this.gl = this.canvas.getContext('webgl2', { antialias: true, alpha: true });\n    const gl = this.gl;\n    if (!gl) {\n      throw new Error('No WebGL 2 context!');\n    }\n\n    this.viewportSize = vec2.fromValues(this.canvas.clientWidth, this.canvas.clientHeight);\n    this.drawBufferSize = vec2.clone(this.viewportSize);\n\n    this.discProgram = createProgram(gl, [discVertShaderSource, discFragShaderSource], null, {\n      aModelPosition: 0,\n      aModelNormal: 1,\n      aModelUvs: 2,\n      aInstanceMatrix: 3\n    });\n\n    this.discLocations = {\n      aModelPosition: gl.getAttribLocation(this.discProgram, 'aModelPosition'),\n      aModelUvs: gl.getAttribLocation(this.discProgram, 'aModelUvs'),\n      aInstanceMatrix: gl.getAttribLocation(this.discProgram, 'aInstanceMatrix'),\n      uWorldMatrix: gl.getUniformLocation(this.discProgram, 'uWorldMatrix'),\n      uViewMatrix: gl.getUniformLocation(this.discProgram, 'uViewMatrix'),\n      uProjectionMatrix: gl.getUniformLocation(this.discProgram, 'uProjectionMatrix'),\n      uCameraPosition: gl.getUniformLocation(this.discProgram, 'uCameraPosition'),\n      uScaleFactor: gl.getUniformLocation(this.discProgram, 'uScaleFactor'),\n      uRotationAxisVelocity: gl.getUniformLocation(this.discProgram, 'uRotationAxisVelocity'),\n      uTex: gl.getUniformLocation(this.discProgram, 'uTex'),\n      uFrames: gl.getUniformLocation(this.discProgram, 'uFrames'),\n      uItemCount: gl.getUniformLocation(this.discProgram, 'uItemCount'),\n      uAtlasSize: gl.getUniformLocation(this.discProgram, 'uAtlasSize')\n    };\n\n    this.discGeo = new DiscGeometry(56, 1);\n    this.discBuffers = this.discGeo.data;\n    this.discVAO = makeVertexArray(\n      gl,\n      [\n        [makeBuffer(gl, this.discBuffers.vertices, gl.STATIC_DRAW), this.discLocations.aModelPosition, 3],\n        [makeBuffer(gl, this.discBuffers.uvs, gl.STATIC_DRAW), this.discLocations.aModelUvs, 2]\n      ],\n      this.discBuffers.indices\n    );\n\n    this.icoGeo = new IcosahedronGeometry();\n    this.icoGeo.subdivide(1).spherize(this.SPHERE_RADIUS);\n    this.instancePositions = this.icoGeo.vertices.map(v => v.position);\n    this.DISC_INSTANCE_COUNT = this.icoGeo.vertices.length;\n    this.#initDiscInstances(this.DISC_INSTANCE_COUNT);\n\n    this.worldMatrix = mat4.create();\n    this.#initTexture();\n\n    this.control = new ArcballControl(this.canvas, deltaTime => this.#onControlUpdate(deltaTime));\n\n    this.#updateCameraMatrix();\n    this.#updateProjectionMatrix(gl);\n    this.resize();\n\n    if (onInit) onInit(this);\n  }\n\n  #initTexture() {\n    const gl = this.gl;\n    this.tex = createAndSetupTexture(gl, gl.LINEAR, gl.LINEAR, gl.CLAMP_TO_EDGE, gl.CLAMP_TO_EDGE);\n\n    const itemCount = Math.max(1, this.items.length);\n    this.atlasSize = Math.ceil(Math.sqrt(itemCount));\n    const canvas = document.createElement('canvas');\n    const ctx = canvas.getContext('2d');\n    const cellSize = 512;\n\n    canvas.width = this.atlasSize * cellSize;\n    canvas.height = this.atlasSize * cellSize;\n\n    Promise.all(\n      this.items.map(\n        item =>\n          new Promise(resolve => {\n            const img = new Image();\n            img.crossOrigin = 'anonymous';\n            img.onload = () => resolve(img);\n            img.src = item.image;\n          })\n      )\n    ).then(images => {\n      images.forEach((img, i) => {\n        const x = (i % this.atlasSize) * cellSize;\n        const y = Math.floor(i / this.atlasSize) * cellSize;\n        ctx.drawImage(img, x, y, cellSize, cellSize);\n      });\n\n      gl.bindTexture(gl.TEXTURE_2D, this.tex);\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);\n      gl.generateMipmap(gl.TEXTURE_2D);\n    });\n  }\n\n  #initDiscInstances(count) {\n    const gl = this.gl;\n    this.discInstances = {\n      matricesArray: new Float32Array(count * 16),\n      matrices: [],\n      buffer: gl.createBuffer()\n    };\n    for (let i = 0; i < count; ++i) {\n      const instanceMatrixArray = new Float32Array(this.discInstances.matricesArray.buffer, i * 16 * 4, 16);\n      instanceMatrixArray.set(mat4.create());\n      this.discInstances.matrices.push(instanceMatrixArray);\n    }\n    gl.bindVertexArray(this.discVAO);\n    gl.bindBuffer(gl.ARRAY_BUFFER, this.discInstances.buffer);\n    gl.bufferData(gl.ARRAY_BUFFER, this.discInstances.matricesArray.byteLength, gl.DYNAMIC_DRAW);\n    const mat4AttribSlotCount = 4;\n    const bytesPerMatrix = 16 * 4;\n    for (let j = 0; j < mat4AttribSlotCount; ++j) {\n      const loc = this.discLocations.aInstanceMatrix + j;\n      gl.enableVertexAttribArray(loc);\n      gl.vertexAttribPointer(loc, 4, gl.FLOAT, false, bytesPerMatrix, j * 4 * 4);\n      gl.vertexAttribDivisor(loc, 1);\n    }\n    gl.bindBuffer(gl.ARRAY_BUFFER, null);\n    gl.bindVertexArray(null);\n  }\n\n  #animate(deltaTime) {\n    const gl = this.gl;\n    this.control.update(deltaTime, this.TARGET_FRAME_DURATION);\n\n    let positions = this.instancePositions.map(p => vec3.transformQuat(vec3.create(), p, this.control.orientation));\n    const scale = 0.25;\n    const SCALE_INTENSITY = 0.6;\n    positions.forEach((p, ndx) => {\n      const s = (Math.abs(p[2]) / this.SPHERE_RADIUS) * SCALE_INTENSITY + (1 - SCALE_INTENSITY);\n      const finalScale = s * scale;\n      const matrix = mat4.create();\n      mat4.multiply(matrix, matrix, mat4.fromTranslation(mat4.create(), vec3.negate(vec3.create(), p)));\n      mat4.multiply(matrix, matrix, mat4.targetTo(mat4.create(), [0, 0, 0], p, [0, 1, 0]));\n      mat4.multiply(matrix, matrix, mat4.fromScaling(mat4.create(), [finalScale, finalScale, finalScale]));\n      mat4.multiply(matrix, matrix, mat4.fromTranslation(mat4.create(), [0, 0, -this.SPHERE_RADIUS]));\n\n      mat4.copy(this.discInstances.matrices[ndx], matrix);\n    });\n\n    gl.bindBuffer(gl.ARRAY_BUFFER, this.discInstances.buffer);\n    gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.discInstances.matricesArray);\n    gl.bindBuffer(gl.ARRAY_BUFFER, null);\n\n    this.smoothRotationVelocity = this.control.rotationVelocity;\n  }\n\n  #render() {\n    const gl = this.gl;\n    gl.useProgram(this.discProgram);\n\n    gl.enable(gl.CULL_FACE);\n    gl.enable(gl.DEPTH_TEST);\n\n    gl.clearColor(0, 0, 0, 0);\n    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);\n\n    gl.uniformMatrix4fv(this.discLocations.uWorldMatrix, false, this.worldMatrix);\n    gl.uniformMatrix4fv(this.discLocations.uViewMatrix, false, this.camera.matrices.view);\n    gl.uniformMatrix4fv(this.discLocations.uProjectionMatrix, false, this.camera.matrices.projection);\n    gl.uniform3f(\n      this.discLocations.uCameraPosition,\n      this.camera.position[0],\n      this.camera.position[1],\n      this.camera.position[2]\n    );\n    gl.uniform4f(\n      this.discLocations.uRotationAxisVelocity,\n      this.control.rotationAxis[0],\n      this.control.rotationAxis[1],\n      this.control.rotationAxis[2],\n      this.smoothRotationVelocity * 1.1\n    );\n\n    gl.uniform1i(this.discLocations.uItemCount, this.items.length);\n    gl.uniform1i(this.discLocations.uAtlasSize, this.atlasSize);\n\n    gl.uniform1f(this.discLocations.uFrames, this.#frames);\n    gl.uniform1f(this.discLocations.uScaleFactor, this.scaleFactor);\n    gl.uniform1i(this.discLocations.uTex, 0);\n    gl.activeTexture(gl.TEXTURE0);\n    gl.bindTexture(gl.TEXTURE_2D, this.tex);\n\n    gl.bindVertexArray(this.discVAO);\n    gl.drawElementsInstanced(\n      gl.TRIANGLES,\n      this.discBuffers.indices.length,\n      gl.UNSIGNED_SHORT,\n      0,\n      this.DISC_INSTANCE_COUNT\n    );\n  }\n\n  #updateCameraMatrix() {\n    mat4.targetTo(this.camera.matrix, this.camera.position, [0, 0, 0], this.camera.up);\n    mat4.invert(this.camera.matrices.view, this.camera.matrix);\n  }\n\n  #updateProjectionMatrix(gl) {\n    this.camera.aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;\n    const height = this.SPHERE_RADIUS * 0.35;\n    const distance = this.camera.position[2];\n    if (this.camera.aspect > 1) {\n      this.camera.fov = 2 * Math.atan(height / distance);\n    } else {\n      this.camera.fov = 2 * Math.atan(height / this.camera.aspect / distance);\n    }\n    mat4.perspective(\n      this.camera.matrices.projection,\n      this.camera.fov,\n      this.camera.aspect,\n      this.camera.near,\n      this.camera.far\n    );\n    mat4.invert(this.camera.matrices.inversProjection, this.camera.matrices.projection);\n  }\n\n  #onControlUpdate(deltaTime) {\n    const timeScale = deltaTime / this.TARGET_FRAME_DURATION + 0.0001;\n    let damping = 5 / timeScale;\n    let cameraTargetZ = 3 * this.scaleFactor;\n\n    const isMoving = this.control.isPointerDown || Math.abs(this.smoothRotationVelocity) > 0.01;\n\n    if (isMoving !== this.movementActive) {\n      this.movementActive = isMoving;\n      this.onMovementChange(isMoving);\n    }\n\n    if (!this.control.isPointerDown) {\n      const nearestVertexIndex = this.#findNearestVertexIndex();\n      const itemIndex = nearestVertexIndex % Math.max(1, this.items.length);\n      this.onActiveItemChange(itemIndex);\n      const snapDirection = vec3.normalize(vec3.create(), this.#getVertexWorldPosition(nearestVertexIndex));\n      this.control.snapTargetDirection = snapDirection;\n    } else {\n      cameraTargetZ += this.control.rotationVelocity * 80 + 2.5;\n      damping = 7 / timeScale;\n    }\n\n    this.camera.position[2] += (cameraTargetZ - this.camera.position[2]) / damping;\n    this.#updateCameraMatrix();\n  }\n\n  #findNearestVertexIndex() {\n    const n = this.control.snapDirection;\n    const inversOrientation = quat.conjugate(quat.create(), this.control.orientation);\n    const nt = vec3.transformQuat(vec3.create(), n, inversOrientation);\n\n    let maxD = -1;\n    let nearestVertexIndex;\n    for (let i = 0; i < this.instancePositions.length; ++i) {\n      const d = vec3.dot(nt, this.instancePositions[i]);\n      if (d > maxD) {\n        maxD = d;\n        nearestVertexIndex = i;\n      }\n    }\n    return nearestVertexIndex;\n  }\n\n  #getVertexWorldPosition(index) {\n    const nearestVertexPos = this.instancePositions[index];\n    return vec3.transformQuat(vec3.create(), nearestVertexPos, this.control.orientation);\n  }\n}\n\nconst defaultItems = [\n  {\n    image:\n      'https://images.unsplash.com/photo-1782977389500-dd7adad33ebe?q=80&w=600&h=600&fit=crop&sat=-100&auto=format',\n    link: 'https://google.com/',\n    title: '',\n    description: ''\n  }\n];\n\nexport default function InfiniteMenu({ items = [], scale = 1.0, backgroundColor = '#000000' }) {\n  const canvasRef = useRef(null);\n  const [activeItem, setActiveItem] = useState(null);\n  const [isMoving, setIsMoving] = useState(false);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    let sketch;\n\n    const handleActiveItem = index => {\n      const itemIndex = index % items.length;\n      setActiveItem(items[itemIndex]);\n    };\n\n    if (canvas) {\n      sketch = new InfiniteGridMenu(\n        canvas,\n        items.length ? items : defaultItems,\n        handleActiveItem,\n        setIsMoving,\n        sk => sk.run(),\n        scale\n      );\n    }\n\n    const handleResize = () => {\n      if (sketch) {\n        sketch.resize();\n      }\n    };\n\n    window.addEventListener('resize', handleResize);\n    handleResize();\n\n    return () => {\n      window.removeEventListener('resize', handleResize);\n    };\n  }, [items, scale]);\n\n  const handleButtonClick = () => {\n    if (!activeItem?.link) return;\n    if (activeItem.link.startsWith('http')) {\n      window.open(activeItem.link, '_blank');\n    } else {\n      console.log('Internal route:', activeItem.link);\n    }\n  };\n\n  return (\n    <div\n      style={{\n        position: 'relative',\n        width: '100%',\n        height: '100%',\n        backgroundColor,\n        '--infinite-menu-background': backgroundColor\n      }}\n    >\n      <canvas id=\"infinite-grid-menu-canvas\" ref={canvasRef} />\n\n      {activeItem && (\n        <>\n          <h2 className={`face-title ${isMoving ? 'inactive' : 'active'}`}>{activeItem.title}</h2>\n\n          <p className={`face-description ${isMoving ? 'inactive' : 'active'}`}> {activeItem.description}</p>\n\n          <div onClick={handleButtonClick} className={`action-button ${isMoving ? 'inactive' : 'active'}`}>\n            <p className=\"action-button-icon\">&#x2197;</p>\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"gl-matrix@^3.4.3"
	]
}