mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
1 line
36 KiB
JSON
1 line
36 KiB
JSON
{"name":"InfiniteMenu","title":"InfiniteMenu","description":"Horizontally looping menu effect that scrolls endlessly with seamless wrap.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';\nimport { mat4, quat, vec2, vec3 } from 'gl-matrix';\n\ntype InfiniteMenuItem = {\n image: string;\n link?: string;\n title?: string;\n description?: string;\n};\n\ntype InfiniteMenuProps = {\n items?: InfiniteMenuItem[];\n scale?: number;\n};\n\nconst DEFAULT_ITEMS: InfiniteMenuItem[] = [\n {\n image: 'https://picsum.photos/900/900?grayscale',\n link: 'https://google.com/',\n title: '',\n description: ''\n }\n];\n\nconst props = withDefaults(defineProps<InfiniteMenuProps>(), {\n scale: 1.0\n});\n\n// Refs\nconst canvasRef = ref<HTMLCanvasElement>();\nconst activeItem = ref<InfiniteMenuItem | null>(null);\nconst isMoving = ref(false);\nconst resolvedItems = computed(() => (props.items?.length ? props.items : DEFAULT_ITEMS));\n\n// WebGL variables\nlet animationId: number | null = null;\nlet infiniteMenu: InfiniteGridMenu | null = null;\n\n// Shader sources\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(\n public a: number,\n public b: number,\n public c: number\n ) {}\n}\n\nclass Vertex {\n position: vec3;\n normal: vec3;\n 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 vertices: Vertex[] = [];\n faces: Face[] = [];\n\n 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 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 get lastVertex(): Vertex {\n return this.vertices[this.vertices.length - 1];\n }\n\n 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(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): 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 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(): Float32Array {\n return new Float32Array(this.vertices.flatMap(v => Array.from(v.position)));\n }\n\n get normalData(): Float32Array {\n return new Float32Array(this.vertices.flatMap(v => Array.from(v.normal)));\n }\n\n get uvData(): Float32Array {\n return new Float32Array(this.vertices.flatMap(v => Array.from(v.uv)));\n }\n\n get indexData(): Uint16Array {\n return new Uint16Array(this.faces.flatMap(f => [f.a, f.b, f.c]));\n }\n\n 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 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: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {\n const shader = gl.createShader(type);\n if (!shader) return null;\n\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[],\n transformFeedbackVaryings?: string[],\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) 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(\n gl: WebGL2RenderingContext,\n bufLocNumElmPairs: [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(\n gl: WebGL2RenderingContext,\n sizeOrData: number | ArrayBuffer | ArrayBufferView,\n usage: number\n): WebGLBuffer | null {\n const buf = gl.createBuffer();\n if (!buf) return null;\n\n gl.bindBuffer(gl.ARRAY_BUFFER, buf);\n if (typeof sizeOrData === 'number') {\n gl.bufferData(gl.ARRAY_BUFFER, sizeOrData, usage);\n } else if (sizeOrData instanceof ArrayBuffer) {\n gl.bufferData(gl.ARRAY_BUFFER, sizeOrData, usage);\n } else {\n gl.bufferData(gl.ARRAY_BUFFER, sizeOrData, usage);\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 | null {\n const texture = gl.createTexture();\n if (!texture) return null;\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\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?: vec3;\n EPSILON = 0.1;\n IDENTITY_QUAT = quat.create();\n\n private pointerPos = vec2.create();\n private previousPointerPos = vec2.create();\n private _rotationVelocity = 0;\n private _combinedQuat = quat.create();\n\n constructor(\n private canvas: HTMLCanvasElement,\n private updateCallback?: (deltaTime: number) => void\n ) {\n this.setupEventListeners();\n }\n\n private setupEventListeners() {\n this.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\n this.canvas.addEventListener('pointerup', () => {\n this.isPointerDown = false;\n });\n\n this.canvas.addEventListener('pointerleave', () => {\n this.isPointerDown = false;\n });\n\n this.canvas.addEventListener('pointermove', e => {\n if (this.isPointerDown) {\n vec2.set(this.pointerPos, e.clientX, e.clientY);\n }\n });\n\n this.canvas.style.touchAction = 'none';\n }\n\n update(deltaTime: number, targetFrameDuration = 16) {\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 quatFromVectors(a: vec3, b: vec3, out: quat, 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 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\nclass InfiniteGridMenu {\n private TARGET_FRAME_DURATION = 1000 / 60;\n private SPHERE_RADIUS = 2;\n private time = 0;\n private deltaTime = 0;\n private deltaFrames = 0;\n private frames = 0;\n\n private 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 private nearestVertexIndex: number | null = null;\n private smoothRotationVelocity = 0;\n private scaleFactor = 1.0;\n private movementActive = false;\n\n private discProgram: WebGLProgram | null = null;\n private discLocations: Record<string, WebGLUniformLocation | number | null> = {};\n private discGeo!: DiscGeometry;\n private discBuffers: Record<string, Float32Array | Uint16Array> = {};\n private discVAO: WebGLVertexArrayObject | null = null;\n private icoGeo!: IcosahedronGeometry;\n private instancePositions: vec3[] = [];\n private DISC_INSTANCE_COUNT = 0;\n private discInstances: {\n matricesArray: Float32Array;\n matrices: Float32Array[];\n buffer: WebGLBuffer | null;\n } = {\n matricesArray: new Float32Array(0),\n matrices: [],\n buffer: null\n };\n private worldMatrix = mat4.create();\n private tex: WebGLTexture | null = null;\n private atlasSize = 0;\n private control!: ArcballControl;\n private viewportSize!: vec2;\n\n constructor(\n private canvas: HTMLCanvasElement,\n private items: InfiniteMenuItem[],\n private onActiveItemChange: (index: number) => void,\n private onMovementChange: (isMoving: boolean) => void,\n private onInit?: (menu: InfiniteGridMenu) => void,\n scale: number = 3.0\n ) {\n this.scaleFactor = scale;\n this.camera.position[2] = scale;\n this.init();\n }\n\n private init() {\n this.gl = this.canvas.getContext('webgl2', { antialias: true, alpha: false }) as WebGL2RenderingContext;\n if (!this.gl) {\n throw new Error('No WebGL 2 context!');\n }\n\n this.viewportSize = vec2.fromValues(this.canvas.clientWidth, this.canvas.clientHeight);\n\n this.discProgram = createProgram(this.gl, [discVertShaderSource, discFragShaderSource], undefined, {\n aModelPosition: 0,\n aModelNormal: 1,\n aModelUvs: 2,\n aInstanceMatrix: 3\n });\n\n if (!this.discProgram) {\n throw new Error('Failed to create shader program');\n }\n\n this.discLocations = {\n aModelPosition: this.gl.getAttribLocation(this.discProgram, 'aModelPosition'),\n aModelUvs: this.gl.getAttribLocation(this.discProgram, 'aModelUvs'),\n aInstanceMatrix: this.gl.getAttribLocation(this.discProgram, 'aInstanceMatrix'),\n uWorldMatrix: this.gl.getUniformLocation(this.discProgram, 'uWorldMatrix'),\n uViewMatrix: this.gl.getUniformLocation(this.discProgram, 'uViewMatrix'),\n uProjectionMatrix: this.gl.getUniformLocation(this.discProgram, 'uProjectionMatrix'),\n uCameraPosition: this.gl.getUniformLocation(this.discProgram, 'uCameraPosition'),\n uRotationAxisVelocity: this.gl.getUniformLocation(this.discProgram, 'uRotationAxisVelocity'),\n uTex: this.gl.getUniformLocation(this.discProgram, 'uTex'),\n uItemCount: this.gl.getUniformLocation(this.discProgram, 'uItemCount'),\n uAtlasSize: this.gl.getUniformLocation(this.discProgram, 'uAtlasSize'),\n uFrames: this.gl.getUniformLocation(this.discProgram, 'uFrames'),\n uScaleFactor: this.gl.getUniformLocation(this.discProgram, 'uScaleFactor')\n };\n\n this.discGeo = new DiscGeometry(56, 1);\n this.discBuffers = this.discGeo.data;\n this.discVAO = makeVertexArray(\n this.gl,\n [\n [\n makeBuffer(this.gl, this.discBuffers.vertices, this.gl.STATIC_DRAW)!,\n this.discLocations.aModelPosition as number,\n 3\n ],\n [makeBuffer(this.gl, this.discBuffers.uvs, this.gl.STATIC_DRAW)!, this.discLocations.aModelUvs as number, 2]\n ],\n this.discBuffers.indices as Uint16Array\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.initTexture();\n\n this.control = new ArcballControl(this.canvas, deltaTime => this.onControlUpdate(deltaTime));\n\n this.updateCameraMatrix();\n this.updateProjectionMatrix();\n this.resize();\n\n this.onInit?.(this);\n }\n\n private initTexture() {\n if (!this.gl) return;\n\n this.tex = createAndSetupTexture(\n this.gl,\n this.gl.LINEAR,\n this.gl.LINEAR,\n this.gl.CLAMP_TO_EDGE,\n this.gl.CLAMP_TO_EDGE\n );\n if (!this.tex) return;\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 if (!ctx) return;\n\n const cellSize = 512;\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.onerror = () => resolve(img); // Continue even if image fails\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 try {\n ctx.drawImage(img, x, y, cellSize, cellSize);\n } catch {\n // Skip failed images\n }\n });\n\n if (this.gl && this.tex) {\n this.gl.bindTexture(this.gl.TEXTURE_2D, this.tex);\n this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, canvas);\n this.gl.generateMipmap(this.gl.TEXTURE_2D);\n }\n });\n }\n\n private initDiscInstances(count: number) {\n if (!this.gl) return;\n\n this.discInstances = {\n matricesArray: new Float32Array(count * 16),\n matrices: [] as Float32Array[],\n buffer: this.gl.createBuffer()\n };\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\n this.gl.bindVertexArray(this.discVAO);\n this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.discInstances.buffer);\n this.gl.bufferData(this.gl.ARRAY_BUFFER, this.discInstances.matricesArray.byteLength, this.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 as number) + j;\n this.gl.enableVertexAttribArray(loc);\n this.gl.vertexAttribPointer(loc, 4, this.gl.FLOAT, false, bytesPerMatrix, j * 4 * 4);\n this.gl.vertexAttribDivisor(loc, 1);\n }\n this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);\n this.gl.bindVertexArray(null);\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();\n this.render();\n\n animationId = requestAnimationFrame(t => this.run(t));\n }\n\n resize() {\n this.viewportSize = vec2.set(this.viewportSize || vec2.create(), this.canvas.clientWidth, this.canvas.clientHeight);\n\n if (!this.gl) return;\n\n const needsResize = resizeCanvasToDisplaySize(this.canvas);\n if (needsResize) {\n this.gl.viewport(0, 0, this.gl.drawingBufferWidth, this.gl.drawingBufferHeight);\n }\n\n this.updateProjectionMatrix();\n }\n\n private animate() {\n if (!this.gl) return;\n\n this.control.update(this.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 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() {\n if (!this.gl) return;\n\n this.gl.useProgram(this.discProgram);\n\n this.gl.enable(this.gl.CULL_FACE);\n this.gl.enable(this.gl.DEPTH_TEST);\n this.gl.enable(this.gl.BLEND);\n this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA);\n\n this.gl.clearColor(0, 0, 0, 0);\n this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);\n\n this.gl.uniformMatrix4fv(this.discLocations.uWorldMatrix, false, this.worldMatrix);\n this.gl.uniformMatrix4fv(this.discLocations.uViewMatrix, false, this.camera.matrices.view);\n this.gl.uniformMatrix4fv(this.discLocations.uProjectionMatrix, false, this.camera.matrices.projection);\n this.gl.uniform3f(\n this.discLocations.uCameraPosition,\n this.camera.position[0],\n this.camera.position[1],\n this.camera.position[2]\n );\n this.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 const itemCountLocation = this.discLocations.uItemCount as WebGLUniformLocation | null;\n if (itemCountLocation !== null) {\n this.gl.uniform1i(itemCountLocation, this.items.length);\n }\n\n const atlasSizeLocation = this.discLocations.uAtlasSize as WebGLUniformLocation | null;\n if (atlasSizeLocation !== null) {\n this.gl.uniform1i(atlasSizeLocation, this.atlasSize);\n }\n\n const framesLocation = this.discLocations.uFrames as WebGLUniformLocation | null;\n if (framesLocation !== null) {\n this.gl.uniform1f(framesLocation, this.frames);\n }\n\n const scaleFactorLocation = this.discLocations.uScaleFactor as WebGLUniformLocation | null;\n if (scaleFactorLocation !== null) {\n this.gl.uniform1f(scaleFactorLocation, this.scaleFactor);\n }\n\n const textureLocation = this.discLocations.uTex as WebGLUniformLocation | null;\n if (textureLocation !== null) {\n this.gl.uniform1i(textureLocation, 0);\n }\n this.gl.activeTexture(this.gl.TEXTURE0);\n this.gl.bindTexture(this.gl.TEXTURE_2D, this.tex);\n\n this.gl.bindVertexArray(this.discVAO);\n this.gl.drawElementsInstanced(\n this.gl.TRIANGLES,\n this.discBuffers.indices.length,\n this.gl.UNSIGNED_SHORT,\n 0,\n this.DISC_INSTANCE_COUNT\n );\n }\n\n private 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 private updateProjectionMatrix() {\n if (!this.gl) return;\n\n this.camera.aspect = this.gl.canvas.width / this.gl.canvas.height;\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) {\n const timeScale = deltaTime / this.TARGET_FRAME_DURATION + 0.0001;\n let damping = 5 / timeScale;\n let cameraTargetZ = 3;\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 destroy() {\n if (animationId) {\n cancelAnimationFrame(animationId);\n animationId = null;\n }\n }\n\n private gl: WebGL2RenderingContext | null = null;\n}\n\n// Event handlers\nconst handleActiveItem = (index: number) => {\n const items = resolvedItems.value;\n if (!items.length) return;\n const itemIndex = index % items.length;\n activeItem.value = items[itemIndex];\n};\n\nconst handleButtonClick = () => {\n if (!activeItem.value?.link) return;\n if (activeItem.value.link.startsWith('http')) {\n window.open(activeItem.value.link, '_blank');\n } else {\n console.log('Internal route:', activeItem.value.link);\n }\n};\n\n// Lifecycle\nonMounted(() => {\n if (!canvasRef.value) return;\n\n try {\n infiniteMenu = new InfiniteGridMenu(\n canvasRef.value,\n resolvedItems.value,\n handleActiveItem,\n moving => {\n isMoving.value = moving;\n },\n menu => menu.run()\n );\n\n const handleResize = () => {\n infiniteMenu?.resize();\n };\n\n window.addEventListener('resize', handleResize);\n handleResize();\n\n // Cleanup function stored for unmount\n onBeforeUnmount(() => {\n window.removeEventListener('resize', handleResize);\n infiniteMenu?.destroy();\n });\n } catch (error) {\n console.error('Failed to initialize InfiniteMenu:', error);\n }\n});\n\nwatch(\n () => props.items,\n () => {\n // Reinitialize on items change\n if (infiniteMenu && canvasRef.value) {\n infiniteMenu.destroy();\n infiniteMenu = new InfiniteGridMenu(\n canvasRef.value,\n resolvedItems.value,\n handleActiveItem,\n moving => {\n isMoving.value = moving;\n },\n menu => menu.run()\n );\n }\n },\n { deep: true }\n);\n\nwatch(\n () => props.scale,\n () => {\n if (infiniteMenu && canvasRef.value) {\n infiniteMenu.destroy();\n infiniteMenu = new InfiniteGridMenu(\n canvasRef.value,\n resolvedItems.value,\n handleActiveItem,\n moving => {\n isMoving.value = moving;\n },\n menu => menu.run(),\n\n props.scale\n );\n }\n }\n);\n</script>\n\n<template>\n <div class=\"relative w-full h-full\">\n <canvas ref=\"canvasRef\" class=\"outline-none w-full h-full overflow-hidden cursor-grab active:cursor-grabbing\" />\n\n <template v-if=\"activeItem\">\n <h2\n :class=\"[\n 'select-none absolute font-black text-6xl top-1/2 text-white transition-all duration-500 ease-in-out hidden xl:block',\n isMoving\n ? 'pointer-events-none opacity-0 transition-all duration-100 ease-in-out'\n : 'opacity-100 pointer-events-auto'\n ]\"\n :style=\"{\n left: '1.6em',\n transform: 'translate(20%, -50%)'\n }\"\n >\n {{ activeItem.title }}\n </h2>\n\n <p\n :class=\"[\n 'select-none absolute top-1/2 text-2xl text-white/80 transition-all ease-in-out hidden xl:block',\n isMoving ? 'pointer-events-none opacity-0 duration-100' : 'opacity-100 pointer-events-auto duration-500'\n ]\"\n :style=\"{\n right: '1%',\n maxWidth: '10ch',\n transform: isMoving ? 'translate(-60%, -50%)' : 'translate(-90%, -50%)'\n }\"\n >\n {{ activeItem.description }}\n </p>\n\n <div\n @click=\"handleButtonClick\"\n :class=\"[\n 'absolute left-1/2 z-10 grid place-items-center bg-purple-600 rounded-full cursor-pointer border-4 border-black transition-all ease-in-out',\n isMoving ? 'pointer-events-none opacity-0 duration-100' : 'opacity-100 pointer-events-auto duration-500'\n ]\"\n :style=\"{\n width: '60px',\n height: '60px',\n bottom: isMoving ? '-80px' : '61px',\n transform: isMoving ? 'translateX(-50%) scale(0)' : 'translateX(-50%) scale(1)'\n }\"\n >\n <p class=\"relative text-white text-2xl select-none\" :style=\"{ top: '2px' }\">↗</p>\n </div>\n </template>\n </div>\n</template>\n","path":"InfiniteMenu/InfiniteMenu.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"gl-matrix","version":"^3.4.3"}],"devDependencies":[],"categories":["Components"]} |