{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "InfiniteMenu-TS-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.tsx",
			"content": "import { type CSSProperties, type FC, useRef, useState, useEffect, type MutableRefObject } 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  public a: number;\n  public b: number;\n  public c: number;\n\n  constructor(a: number, b: number, c: number) {\n    this.a = a;\n    this.b = b;\n    this.c = c;\n  }\n}\n\nclass Vertex {\n  public position: vec3;\n  public normal: vec3;\n  public uv: vec2;\n\n  constructor(x: number, y: number, z: number) {\n    this.position = vec3.fromValues(x, y, z);\n    this.normal = vec3.create();\n    this.uv = vec2.create();\n  }\n}\n\nclass Geometry {\n  public vertices: Vertex[];\n  public faces: Face[];\n\n  constructor() {\n    this.vertices = [];\n    this.faces = [];\n  }\n\n  public addVertex(...args: number[]): this {\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  public addFace(...args: number[]): this {\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  public get lastVertex(): Vertex {\n    return this.vertices[this.vertices.length - 1];\n  }\n\n  public subdivide(divisions = 1): this {\n    const midPointCache: Record<string, number> = {};\n    let f = this.faces;\n\n    for (let div = 0; div < divisions; ++div) {\n      const newFaces = new Array<Face>(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  public spherize(radius = 1): this {\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  public get data(): {\n    vertices: Float32Array;\n    indices: Uint16Array;\n    normals: Float32Array;\n    uvs: Float32Array;\n  } {\n    return {\n      vertices: this.vertexData,\n      indices: this.indexData,\n      normals: this.normalData,\n      uvs: this.uvData\n    };\n  }\n\n  public get vertexData(): Float32Array {\n    return new Float32Array(this.vertices.flatMap(v => Array.from(v.position)));\n  }\n\n  public get normalData(): Float32Array {\n    return new Float32Array(this.vertices.flatMap(v => Array.from(v.normal)));\n  }\n\n  public get uvData(): Float32Array {\n    return new Float32Array(this.vertices.flatMap(v => Array.from(v.uv)));\n  }\n\n  public get indexData(): Uint16Array {\n    return new Uint16Array(this.faces.flatMap(f => [f.a, f.b, f.c]));\n  }\n\n  public getMidPoint(ndxA: number, ndxB: number, cache: Record<string, number>): number {\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    const safeSteps = Math.max(4, steps);\n    const alpha = (2 * Math.PI) / safeSteps;\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 < safeSteps; ++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, safeSteps, 1);\n  }\n}\n\nfunction createShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {\n  const shader = gl.createShader(type);\n  if (!shader) return null;\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(\n  gl: WebGL2RenderingContext,\n  shaderSources: [string, string],\n  transformFeedbackVaryings?: string[] | null,\n  attribLocations?: Record<string, number>\n): WebGLProgram | null {\n  const program = gl.createProgram();\n  if (!program) return null;\n\n  [gl.VERTEX_SHADER, gl.FRAGMENT_SHADER].forEach((type, ndx) => {\n    const shader = createShader(gl, type, shaderSources[ndx]);\n    if (shader) {\n      gl.attachShader(program, shader);\n    }\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      if (Object.prototype.hasOwnProperty.call(attribLocations, attrib)) {\n        gl.bindAttribLocation(program, attribLocations[attrib], attrib);\n      }\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(\n  gl: WebGL2RenderingContext,\n  bufLocNumElmPairs: Array<[WebGLBuffer, number, number]>,\n  indices?: Uint16Array\n): WebGLVertexArrayObject | null {\n  const va = gl.createVertexArray();\n  if (!va) return null;\n\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    if (indexBuffer) {\n      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);\n      gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);\n    }\n  }\n\n  gl.bindVertexArray(null);\n  return va;\n}\n\nfunction resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): boolean {\n  const dpr = Math.min(2, window.devicePixelRatio || 1);\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: WebGL2RenderingContext, sizeOrData: number | ArrayBufferView, usage: number): WebGLBuffer {\n  const buf = gl.createBuffer();\n  if (!buf) {\n    throw new Error('Failed to create WebGL buffer.');\n  }\n  gl.bindBuffer(gl.ARRAY_BUFFER, buf);\n\n  if (typeof sizeOrData === 'number') {\n    gl.bufferData(gl.ARRAY_BUFFER, sizeOrData, usage);\n  } else {\n    gl.bufferData(gl.ARRAY_BUFFER, sizeOrData, usage);\n  }\n\n  gl.bindBuffer(gl.ARRAY_BUFFER, null);\n  return buf;\n}\n\nfunction createAndSetupTexture(\n  gl: WebGL2RenderingContext,\n  minFilter: number,\n  magFilter: number,\n  wrapS: number,\n  wrapT: number\n): WebGLTexture {\n  const texture = gl.createTexture();\n  if (!texture) {\n    throw new Error('Failed to create WebGL texture.');\n  }\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\ntype UpdateCallback = (deltaTime: number) => void;\n\nclass ArcballControl {\n  private canvas: HTMLCanvasElement;\n  private updateCallback: UpdateCallback;\n\n  public isPointerDown = false;\n  public orientation = quat.create();\n  public pointerRotation = quat.create();\n  public rotationVelocity = 0;\n  public rotationAxis = vec3.fromValues(1, 0, 0);\n\n  public snapDirection = vec3.fromValues(0, 0, -1);\n  public snapTargetDirection: vec3 | null = null;\n\n  private pointerPos = vec2.create();\n  private previousPointerPos = vec2.create();\n  private _rotationVelocity = 0;\n  private _combinedQuat = quat.create();\n\n  private readonly EPSILON = 0.1;\n  private readonly IDENTITY_QUAT = quat.create();\n\n  constructor(canvas: HTMLCanvasElement, updateCallback?: UpdateCallback) {\n    this.canvas = canvas;\n    this.updateCallback = updateCallback || (() => undefined);\n\n    canvas.addEventListener('pointerdown', (e: PointerEvent) => {\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: PointerEvent) => {\n      if (this.isPointerDown) {\n        vec2.set(this.pointerPos, e.clientX, e.clientY);\n      }\n    });\n\n    canvas.style.touchAction = 'none';\n  }\n\n  public update(deltaTime: number, targetFrameDuration = 16): void {\n    const timeScale = deltaTime / targetFrameDuration + 0.00001;\n    let angleFactor = timeScale;\n    const 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  private quatFromVectors(a: vec3, b: vec3, out: quat, angleFactor = 1): { q: quat; axis: vec3; angle: number } {\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  private project(pos: vec2): vec3 {\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\ninterface MenuItem {\n  image: string;\n  link: string;\n  title: string;\n  description: string;\n}\n\ntype ActiveItemCallback = (index: number) => void;\ntype MovementChangeCallback = (isMoving: boolean) => void;\ntype InitCallback = (instance: InfiniteGridMenu) => void;\n\ninterface Camera {\n  matrix: mat4;\n  near: number;\n  far: number;\n  fov: number;\n  aspect: number;\n  position: vec3;\n  up: vec3;\n  matrices: {\n    view: mat4;\n    projection: mat4;\n    inversProjection: mat4;\n  };\n}\n\nclass InfiniteGridMenu {\n  private gl: WebGL2RenderingContext | null = null;\n  private discProgram: WebGLProgram | null = null;\n  private discVAO: WebGLVertexArrayObject | null = null;\n  private discBuffers!: {\n    vertices: Float32Array;\n    indices: Uint16Array;\n    normals: Float32Array;\n    uvs: Float32Array;\n  };\n  private icoGeo!: IcosahedronGeometry;\n  private discGeo!: DiscGeometry;\n  private worldMatrix = mat4.create();\n  private tex: WebGLTexture | null = null;\n  private control!: ArcballControl;\n\n  private discLocations!: {\n    aModelPosition: number;\n    aModelUvs: number;\n    aInstanceMatrix: number;\n    uWorldMatrix: WebGLUniformLocation | null;\n    uViewMatrix: WebGLUniformLocation | null;\n    uProjectionMatrix: WebGLUniformLocation | null;\n    uCameraPosition: WebGLUniformLocation | null;\n    uScaleFactor: WebGLUniformLocation | null;\n    uRotationAxisVelocity: WebGLUniformLocation | null;\n    uTex: WebGLUniformLocation | null;\n    uFrames: WebGLUniformLocation | null;\n    uItemCount: WebGLUniformLocation | null;\n    uAtlasSize: WebGLUniformLocation | null;\n  };\n\n  private viewportSize = vec2.create();\n  private drawBufferSize = vec2.create();\n\n  private discInstances!: {\n    matricesArray: Float32Array;\n    matrices: Float32Array[];\n    buffer: WebGLBuffer | null;\n  };\n\n  private instancePositions: vec3[] = [];\n  private DISC_INSTANCE_COUNT = 0;\n  private atlasSize = 1;\n\n  private _time = 0;\n  private _deltaTime = 0;\n  private _deltaFrames = 0;\n  private _frames = 0;\n\n  private movementActive = false;\n\n  private TARGET_FRAME_DURATION = 1000 / 60;\n  private SPHERE_RADIUS = 2;\n\n  public camera: 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  public smoothRotationVelocity = 0;\n  public scaleFactor = 1.0;\n\n  constructor(\n    private canvas: HTMLCanvasElement,\n    private items: MenuItem[],\n    private onActiveItemChange: ActiveItemCallback,\n    private onMovementChange: MovementChangeCallback,\n    onInit?: InitCallback,\n    scale: number = 1.0\n  ) {\n    this.scaleFactor = scale;\n    this.camera.position[2] = 3 * scale;\n    this.init(onInit);\n  }\n\n  public resize(): void {\n    const needsResize = resizeCanvasToDisplaySize(this.canvas);\n    if (!this.gl) return;\n    if (needsResize) {\n      this.gl.viewport(0, 0, this.gl.drawingBufferWidth, this.gl.drawingBufferHeight);\n    }\n    this.updateProjectionMatrix();\n  }\n\n  public run(time = 0): void {\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  private init(onInit?: InitCallback): void {\n    const gl = this.canvas.getContext('webgl2', {\n      antialias: true,\n      alpha: true\n    });\n    if (!gl) {\n      throw new Error('No WebGL 2 context!');\n    }\n    this.gl = gl;\n\n    vec2.set(this.viewportSize, this.canvas.clientWidth, this.canvas.clientHeight);\n    vec2.clone(this.drawBufferSize);\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    this.initTexture();\n\n    this.control = new ArcballControl(this.canvas, deltaTime => this.onControlUpdate(deltaTime));\n\n    this.updateCameraMatrix();\n    this.updateProjectionMatrix();\n\n    this.resize();\n\n    if (onInit) {\n      onInit(this);\n    }\n  }\n\n  private initTexture(): void {\n    if (!this.gl) return;\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 cellSize = 512;\n    const canvas = document.createElement('canvas');\n    const ctx = canvas.getContext('2d')!;\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<HTMLImageElement>(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  private initDiscInstances(count: number): void {\n    if (!this.gl || !this.discVAO) return;\n    const gl = this.gl;\n\n    const matricesArray = new Float32Array(count * 16);\n    const matrices: Float32Array[] = [];\n    for (let i = 0; i < count; ++i) {\n      const instanceMatrixArray = new Float32Array(matricesArray.buffer, i * 16 * 4, 16);\n      mat4.identity(instanceMatrixArray as unknown as mat4);\n      matrices.push(instanceMatrixArray);\n    }\n\n    this.discInstances = {\n      matricesArray,\n      matrices,\n      buffer: gl.createBuffer()\n    };\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\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  private animate(deltaTime: number): void {\n    if (!this.gl) return;\n    this.control.update(deltaTime, this.TARGET_FRAME_DURATION);\n\n    const 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\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\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    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.discInstances.buffer);\n    this.gl.bufferSubData(this.gl.ARRAY_BUFFER, 0, this.discInstances.matricesArray);\n    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);\n\n    this.smoothRotationVelocity = this.control.rotationVelocity;\n  }\n\n  private render(): void {\n    if (!this.gl || !this.discProgram) return;\n    const gl = this.gl;\n\n    gl.useProgram(this.discProgram);\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\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    gl.bindVertexArray(null);\n  }\n\n  private updateCameraMatrix(): void {\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  private updateProjectionMatrix(): void {\n    if (!this.gl) return;\n    const canvasEl = this.gl.canvas as HTMLCanvasElement;\n    this.camera.aspect = canvasEl.clientWidth / canvasEl.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  private onControlUpdate(deltaTime: number): void {\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  private findNearestVertexIndex(): number {\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 = 0;\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  private getVertexWorldPosition(index: number): vec3 {\n    const nearestVertexPos = this.instancePositions[index];\n    return vec3.transformQuat(vec3.create(), nearestVertexPos, this.control.orientation);\n  }\n}\n\nconst defaultItems: MenuItem[] = [\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\ninterface InfiniteMenuProps {\n  items?: MenuItem[];\n  scale?: number;\n  backgroundColor?: string;\n}\n\nconst InfiniteMenu: FC<InfiniteMenuProps> = ({ items = [], scale = 1.0, backgroundColor = '#000000' }) => {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null) as MutableRefObject<HTMLCanvasElement | null>;\n  const [activeItem, setActiveItem] = useState<MenuItem | null>(null);\n  const [isMoving, setIsMoving] = useState<boolean>(false);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    let sketch: InfiniteGridMenu | null = null;\n\n    const handleActiveItem = (index: number) => {\n      if (!items.length) return;\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      } as CSSProperties}\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\nexport default InfiniteMenu;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"gl-matrix@^3.4.3"
	]
}