Files
vue-bits/public/r/CircularGallery.json
David Haz e621971723 jsrepo v3
2025-12-15 23:50:24 +02:00

1 line
21 KiB
JSON

{"name":"CircularGallery","title":"CircularGallery","description":"Circular orbit gallery rotating images.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<template>\n <div ref=\"containerRef\" class=\"w-full h-full overflow-hidden cursor-grab active:cursor-grabbing\" />\n</template>\n\n<script setup lang=\"ts\">\nimport { onMounted, onUnmounted, watch, useTemplateRef } from 'vue';\nimport { Camera, Mesh, Plane, Program, Renderer, Texture, Transform } from 'ogl';\n\ninterface CircularGalleryProps {\n items?: { image: string; text: string }[];\n bend?: number;\n textColor?: string;\n borderRadius?: number;\n font?: string;\n scrollSpeed?: number;\n scrollEase?: number;\n}\n\nconst props = withDefaults(defineProps<CircularGalleryProps>(), {\n bend: 3,\n textColor: '#ffffff',\n borderRadius: 0.05,\n font: 'bold 30px Figtree',\n scrollSpeed: 2,\n scrollEase: 0.05\n});\n\nconst containerRef = useTemplateRef<HTMLDivElement>('containerRef');\nlet app: App | null = null;\n\ntype GL = Renderer['gl'];\n\nfunction debounce<T extends (...args: unknown[]) => void>(func: T, wait: number) {\n let timeout: number;\n return function (this: unknown, ...args: Parameters<T>) {\n window.clearTimeout(timeout);\n timeout = window.setTimeout(() => func.apply(this, args), wait);\n };\n}\n\nfunction lerp(p1: number, p2: number, t: number): number {\n return p1 + (p2 - p1) * t;\n}\n\nfunction autoBind<T extends object>(instance: T): void {\n const proto = Object.getPrototypeOf(instance) as Record<string, unknown> | null;\n if (!proto) return;\n Object.getOwnPropertyNames(proto).forEach(key => {\n if (key !== 'constructor') {\n const desc = Object.getOwnPropertyDescriptor(proto, key);\n if (desc && typeof desc.value === 'function') {\n const fn = desc.value as (...args: unknown[]) => unknown;\n (instance as Record<string, unknown>)[key] = fn.bind(instance);\n }\n }\n });\n}\n\nfunction getFontSize(font: string): number {\n const match = font.match(/(\\d+)px/);\n return match ? parseInt(match[1], 10) : 30;\n}\n\nfunction createTextTexture(\n gl: GL,\n text: string,\n font: string = 'bold 30px monospace',\n color: string = 'black'\n): { texture: Texture; width: number; height: number } {\n const canvas = document.createElement('canvas');\n const context = canvas.getContext('2d');\n if (!context) throw new Error('Could not get 2d context');\n\n context.font = font;\n const metrics = context.measureText(text);\n const textWidth = Math.ceil(metrics.width);\n const fontSize = getFontSize(font);\n const textHeight = Math.ceil(fontSize * 1.2);\n\n canvas.width = textWidth + 20;\n canvas.height = textHeight + 20;\n\n context.font = font;\n context.fillStyle = color;\n context.textBaseline = 'middle';\n context.textAlign = 'center';\n context.clearRect(0, 0, canvas.width, canvas.height);\n context.fillText(text, canvas.width / 2, canvas.height / 2);\n\n const texture = new Texture(gl, { generateMipmaps: false });\n texture.image = canvas;\n return { texture, width: canvas.width, height: canvas.height };\n}\n\ninterface TitleProps {\n gl: GL;\n plane: Mesh;\n renderer: Renderer;\n text: string;\n textColor?: string;\n font?: string;\n}\n\nclass Title {\n gl: GL;\n plane: Mesh;\n renderer: Renderer;\n text: string;\n textColor: string;\n font: string;\n mesh!: Mesh;\n\n constructor({ gl, plane, renderer, text, textColor = '#545050', font = '30px sans-serif' }: TitleProps) {\n autoBind(this);\n this.gl = gl;\n this.plane = plane;\n this.renderer = renderer;\n this.text = text;\n this.textColor = textColor;\n this.font = font;\n this.createMesh();\n }\n\n createMesh() {\n const { texture, width, height } = createTextTexture(this.gl, this.text, this.font, this.textColor);\n const geometry = new Plane(this.gl);\n const program = new Program(this.gl, {\n vertex: `\n attribute vec3 position;\n attribute vec2 uv;\n uniform mat4 modelViewMatrix;\n uniform mat4 projectionMatrix;\n varying vec2 vUv;\n void main() {\n vUv = uv;\n gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n }\n `,\n fragment: `\n precision highp float;\n uniform sampler2D tMap;\n varying vec2 vUv;\n void main() {\n vec4 color = texture2D(tMap, vUv);\n if (color.a < 0.1) discard;\n gl_FragColor = color;\n }\n `,\n uniforms: { tMap: { value: texture } },\n transparent: true\n });\n this.mesh = new Mesh(this.gl, { geometry, program });\n const aspect = width / height;\n const textHeightScaled = this.plane.scale.y * 0.15;\n const textWidthScaled = textHeightScaled * aspect;\n this.mesh.scale.set(textWidthScaled, textHeightScaled, 1);\n this.mesh.position.y = -this.plane.scale.y * 0.5 - textHeightScaled * 0.5 - 0.05;\n this.mesh.setParent(this.plane);\n }\n}\n\ninterface ScreenSize {\n width: number;\n height: number;\n}\n\ninterface Viewport {\n width: number;\n height: number;\n}\n\ninterface MediaProps {\n geometry: Plane;\n gl: GL;\n image: string;\n index: number;\n length: number;\n renderer: Renderer;\n scene: Transform;\n screen: ScreenSize;\n text: string;\n viewport: Viewport;\n bend: number;\n textColor: string;\n borderRadius?: number;\n font?: string;\n}\n\nclass Media {\n extra: number = 0;\n geometry: Plane;\n gl: GL;\n image: string;\n index: number;\n length: number;\n renderer: Renderer;\n scene: Transform;\n screen: ScreenSize;\n text: string;\n viewport: Viewport;\n bend: number;\n textColor: string;\n borderRadius: number;\n font?: string;\n program!: Program;\n plane!: Mesh;\n title!: Title;\n scale!: number;\n padding!: number;\n width!: number;\n widthTotal!: number;\n x!: number;\n speed: number = 0;\n isBefore: boolean = false;\n isAfter: boolean = false;\n\n constructor({\n geometry,\n gl,\n image,\n index,\n length,\n renderer,\n scene,\n screen,\n text,\n viewport,\n bend,\n textColor,\n borderRadius = 0,\n font\n }: MediaProps) {\n this.geometry = geometry;\n this.gl = gl;\n this.image = image;\n this.index = index;\n this.length = length;\n this.renderer = renderer;\n this.scene = scene;\n this.screen = screen;\n this.text = text;\n this.viewport = viewport;\n this.bend = bend;\n this.textColor = textColor;\n this.borderRadius = borderRadius;\n this.font = font;\n this.createShader();\n this.createMesh();\n this.createTitle();\n this.onResize();\n }\n\n createShader() {\n const texture = new Texture(this.gl, { generateMipmaps: false });\n this.program = new Program(this.gl, {\n depthTest: false,\n depthWrite: false,\n vertex: `\n precision highp float;\n attribute vec3 position;\n attribute vec2 uv;\n uniform mat4 modelViewMatrix;\n uniform mat4 projectionMatrix;\n uniform float uTime;\n uniform float uSpeed;\n varying vec2 vUv;\n void main() {\n vUv = uv;\n vec3 p = position;\n p.z = (sin(p.x * 4.0 + uTime) * 1.5 + cos(p.y * 2.0 + uTime) * 1.5) * (0.1 + uSpeed * 0.5);\n gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);\n }\n `,\n fragment: `\n precision highp float;\n uniform vec2 uImageSizes;\n uniform vec2 uPlaneSizes;\n uniform sampler2D tMap;\n uniform float uBorderRadius;\n varying vec2 vUv;\n \n float roundedBoxSDF(vec2 p, vec2 b, float r) {\n vec2 d = abs(p) - b;\n return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r;\n }\n \n void main() {\n vec2 ratio = vec2(\n min((uPlaneSizes.x / uPlaneSizes.y) / (uImageSizes.x / uImageSizes.y), 1.0),\n min((uPlaneSizes.y / uPlaneSizes.x) / (uImageSizes.y / uImageSizes.x), 1.0)\n );\n vec2 uv = vec2(\n vUv.x * ratio.x + (1.0 - ratio.x) * 0.5,\n vUv.y * ratio.y + (1.0 - ratio.y) * 0.5\n );\n vec4 color = texture2D(tMap, uv);\n \n float d = roundedBoxSDF(vUv - 0.5, vec2(0.5 - uBorderRadius), uBorderRadius);\n if(d > 0.0) {\n discard;\n }\n \n gl_FragColor = vec4(color.rgb, 1.0);\n }\n `,\n uniforms: {\n tMap: { value: texture },\n uPlaneSizes: { value: [0, 0] },\n uImageSizes: { value: [0, 0] },\n uSpeed: { value: 0 },\n uTime: { value: 100 * Math.random() },\n uBorderRadius: { value: this.borderRadius }\n },\n transparent: true\n });\n const img = new Image();\n img.crossOrigin = 'anonymous';\n img.src = this.image;\n img.onload = () => {\n texture.image = img;\n this.program.uniforms.uImageSizes.value = [img.naturalWidth, img.naturalHeight];\n };\n }\n\n createMesh() {\n this.plane = new Mesh(this.gl, {\n geometry: this.geometry,\n program: this.program\n });\n this.plane.setParent(this.scene);\n }\n\n createTitle() {\n this.title = new Title({\n gl: this.gl,\n plane: this.plane,\n renderer: this.renderer,\n text: this.text,\n textColor: this.textColor,\n font: this.font\n });\n }\n\n update(scroll: { current: number; last: number }, direction: 'right' | 'left') {\n this.plane.position.x = this.x - scroll.current - this.extra;\n\n const x = this.plane.position.x;\n const H = this.viewport.width / 2;\n\n if (this.bend === 0) {\n this.plane.position.y = 0;\n this.plane.rotation.z = 0;\n } else {\n const B_abs = Math.abs(this.bend);\n const R = (H * H + B_abs * B_abs) / (2 * B_abs);\n const effectiveX = Math.min(Math.abs(x), H);\n\n const arc = R - Math.sqrt(R * R - effectiveX * effectiveX);\n if (this.bend > 0) {\n this.plane.position.y = -arc;\n this.plane.rotation.z = -Math.sign(x) * Math.asin(effectiveX / R);\n } else {\n this.plane.position.y = arc;\n this.plane.rotation.z = Math.sign(x) * Math.asin(effectiveX / R);\n }\n }\n\n this.speed = scroll.current - scroll.last;\n this.program.uniforms.uTime.value += 0.04;\n this.program.uniforms.uSpeed.value = this.speed;\n\n const planeOffset = this.plane.scale.x / 2;\n const viewportOffset = this.viewport.width / 2;\n this.isBefore = this.plane.position.x + planeOffset < -viewportOffset;\n this.isAfter = this.plane.position.x - planeOffset > viewportOffset;\n if (direction === 'right' && this.isBefore) {\n this.extra -= this.widthTotal;\n this.isBefore = this.isAfter = false;\n }\n if (direction === 'left' && this.isAfter) {\n this.extra += this.widthTotal;\n this.isBefore = this.isAfter = false;\n }\n }\n\n onResize({ screen, viewport }: { screen?: ScreenSize; viewport?: Viewport } = {}) {\n if (screen) this.screen = screen;\n if (viewport) {\n this.viewport = viewport;\n if (this.plane.program.uniforms.uViewportSizes) {\n this.plane.program.uniforms.uViewportSizes.value = [this.viewport.width, this.viewport.height];\n }\n }\n this.scale = this.screen.height / 1500;\n this.plane.scale.y = (this.viewport.height * (900 * this.scale)) / this.screen.height;\n this.plane.scale.x = (this.viewport.width * (700 * this.scale)) / this.screen.width;\n this.plane.program.uniforms.uPlaneSizes.value = [this.plane.scale.x, this.plane.scale.y];\n this.padding = 2;\n this.width = this.plane.scale.x + this.padding;\n this.widthTotal = this.width * this.length;\n this.x = this.width * this.index;\n }\n}\n\ninterface AppConfig {\n items?: { image: string; text: string }[];\n bend?: number;\n textColor?: string;\n borderRadius?: number;\n font?: string;\n scrollSpeed?: number;\n scrollEase?: number;\n}\n\nclass App {\n container: HTMLElement;\n scrollSpeed: number;\n scroll: {\n ease: number;\n current: number;\n target: number;\n last: number;\n position?: number;\n };\n onCheckDebounce: (...args: unknown[]) => void;\n renderer!: Renderer;\n gl!: GL;\n camera!: Camera;\n scene!: Transform;\n planeGeometry!: Plane;\n medias: Media[] = [];\n mediasImages: { image: string; text: string }[] = [];\n screen!: { width: number; height: number };\n viewport!: { width: number; height: number };\n raf: number = 0;\n\n boundOnResize!: () => void;\n boundOnWheel!: (e: Event) => void;\n boundOnTouchDown!: (e: MouseEvent | TouchEvent) => void;\n boundOnTouchMove!: (e: MouseEvent | TouchEvent) => void;\n boundOnTouchUp!: () => void;\n\n isDown: boolean = false;\n start: number = 0;\n\n constructor(\n container: HTMLElement,\n {\n items,\n bend = 1,\n textColor = '#ffffff',\n borderRadius = 0,\n font = 'bold 30px Figtree',\n scrollSpeed = 2,\n scrollEase = 0.05\n }: AppConfig\n ) {\n document.documentElement.classList.remove('no-js');\n this.container = container;\n this.scrollSpeed = scrollSpeed;\n this.scroll = { ease: scrollEase, current: 0, target: 0, last: 0 };\n this.onCheckDebounce = debounce(this.onCheck.bind(this), 200);\n this.createRenderer();\n this.createCamera();\n this.createScene();\n this.onResize();\n this.createGeometry();\n this.createMedias(items, bend, textColor, borderRadius, font);\n this.update();\n this.addEventListeners();\n }\n\n createRenderer() {\n this.renderer = new Renderer({ alpha: true });\n this.gl = this.renderer.gl;\n this.gl.clearColor(0, 0, 0, 0);\n this.container.appendChild(this.renderer.gl.canvas as HTMLCanvasElement);\n }\n\n createCamera() {\n this.camera = new Camera(this.gl);\n this.camera.fov = 45;\n this.camera.position.z = 20;\n }\n\n createScene() {\n this.scene = new Transform();\n }\n\n createGeometry() {\n this.planeGeometry = new Plane(this.gl, {\n heightSegments: 50,\n widthSegments: 100\n });\n }\n\n createMedias(\n items: { image: string; text: string }[] | undefined,\n bend: number = 1,\n textColor: string,\n borderRadius: number,\n font: string\n ) {\n const defaultItems = [\n {\n image: `https://picsum.photos/seed/1/800/600?grayscale`,\n text: 'Bridge'\n },\n {\n image: `https://picsum.photos/seed/2/800/600?grayscale`,\n text: 'Desk Setup'\n },\n {\n image: `https://picsum.photos/seed/3/800/600?grayscale`,\n text: 'Waterfall'\n },\n {\n image: `https://picsum.photos/seed/4/800/600?grayscale`,\n text: 'Strawberries'\n },\n {\n image: `https://picsum.photos/seed/5/800/600?grayscale`,\n text: 'Deep Diving'\n },\n {\n image: `https://picsum.photos/seed/16/800/600?grayscale`,\n text: 'Train Track'\n },\n {\n image: `https://picsum.photos/seed/17/800/600?grayscale`,\n text: 'Santorini'\n },\n {\n image: `https://picsum.photos/seed/8/800/600?grayscale`,\n text: 'Blurry Lights'\n },\n {\n image: `https://picsum.photos/seed/9/800/600?grayscale`,\n text: 'New York'\n },\n {\n image: `https://picsum.photos/seed/10/800/600?grayscale`,\n text: 'Good Boy'\n },\n {\n image: `https://picsum.photos/seed/21/800/600?grayscale`,\n text: 'Coastline'\n },\n {\n image: `https://picsum.photos/seed/12/800/600?grayscale`,\n text: 'Palm Trees'\n }\n ];\n const galleryItems = items && items.length ? items : defaultItems;\n this.mediasImages = galleryItems.concat(galleryItems);\n this.medias = this.mediasImages.map((data, index) => {\n return new Media({\n geometry: this.planeGeometry,\n gl: this.gl,\n image: data.image,\n index,\n length: this.mediasImages.length,\n renderer: this.renderer,\n scene: this.scene,\n screen: this.screen,\n text: data.text,\n viewport: this.viewport,\n bend,\n textColor,\n borderRadius,\n font\n });\n });\n }\n\n onTouchDown(e: MouseEvent | TouchEvent) {\n this.isDown = true;\n this.scroll.position = this.scroll.current;\n this.start = 'touches' in e ? e.touches[0].clientX : e.clientX;\n }\n\n onTouchMove(e: MouseEvent | TouchEvent) {\n if (!this.isDown) return;\n const x = 'touches' in e ? e.touches[0].clientX : e.clientX;\n const distance = (this.start - x) * (this.scrollSpeed * 0.025);\n this.scroll.target = (this.scroll.position ?? 0) + distance;\n }\n\n onTouchUp() {\n this.isDown = false;\n this.onCheck();\n }\n\n onWheel(e: Event) {\n const wheelEvent = e as WheelEvent;\n // Support legacy wheel events if present\n const legacy = wheelEvent as unknown as { wheelDelta?: number; detail?: number };\n const delta = wheelEvent.deltaY ?? legacy.wheelDelta ?? legacy.detail ?? 0;\n this.scroll.target += delta > 0 ? this.scrollSpeed : -this.scrollSpeed;\n this.onCheckDebounce();\n }\n\n onCheck() {\n if (!this.medias || !this.medias[0]) return;\n const width = this.medias[0].width;\n const itemIndex = Math.round(Math.abs(this.scroll.target) / width);\n const item = width * itemIndex;\n this.scroll.target = this.scroll.target < 0 ? -item : item;\n }\n\n onResize() {\n this.screen = {\n width: this.container.clientWidth,\n height: this.container.clientHeight\n };\n this.renderer.setSize(this.screen.width, this.screen.height);\n this.camera.perspective({\n aspect: this.screen.width / this.screen.height\n });\n const fov = (this.camera.fov * Math.PI) / 180;\n const height = 2 * Math.tan(fov / 2) * this.camera.position.z;\n const width = height * this.camera.aspect;\n this.viewport = { width, height };\n if (this.medias) {\n this.medias.forEach(media => media.onResize({ screen: this.screen, viewport: this.viewport }));\n }\n }\n\n update() {\n this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease);\n const direction = this.scroll.current > this.scroll.last ? 'right' : 'left';\n if (this.medias) {\n this.medias.forEach(media => media.update(this.scroll, direction));\n }\n this.renderer.render({ scene: this.scene, camera: this.camera });\n this.scroll.last = this.scroll.current;\n this.raf = window.requestAnimationFrame(this.update.bind(this));\n }\n\n addEventListeners() {\n this.boundOnResize = this.onResize.bind(this);\n this.boundOnWheel = this.onWheel.bind(this);\n this.boundOnTouchDown = this.onTouchDown.bind(this);\n this.boundOnTouchMove = this.onTouchMove.bind(this);\n this.boundOnTouchUp = this.onTouchUp.bind(this);\n\n window.addEventListener('resize', this.boundOnResize);\n\n this.container.addEventListener('wheel', this.boundOnWheel);\n this.container.addEventListener('mousedown', this.boundOnTouchDown);\n this.container.addEventListener('touchstart', this.boundOnTouchDown);\n\n window.addEventListener('mousemove', this.boundOnTouchMove);\n window.addEventListener('mouseup', this.boundOnTouchUp);\n window.addEventListener('touchmove', this.boundOnTouchMove);\n window.addEventListener('touchend', this.boundOnTouchUp);\n }\n\n destroy() {\n window.cancelAnimationFrame(this.raf);\n\n window.removeEventListener('resize', this.boundOnResize);\n window.removeEventListener('mousemove', this.boundOnTouchMove);\n window.removeEventListener('mouseup', this.boundOnTouchUp);\n window.removeEventListener('touchmove', this.boundOnTouchMove);\n window.removeEventListener('touchend', this.boundOnTouchUp);\n\n this.container.removeEventListener('wheel', this.boundOnWheel);\n this.container.removeEventListener('mousedown', this.boundOnTouchDown);\n this.container.removeEventListener('touchstart', this.boundOnTouchDown);\n\n if (this.renderer && this.renderer.gl && this.renderer.gl.canvas.parentNode) {\n this.renderer.gl.canvas.parentNode.removeChild(this.renderer.gl.canvas as HTMLCanvasElement);\n }\n }\n}\n\nonMounted(() => {\n if (!containerRef.value) return;\n\n app = new App(containerRef.value, {\n items: props.items,\n bend: props.bend,\n textColor: props.textColor,\n borderRadius: props.borderRadius,\n font: props.font,\n scrollSpeed: props.scrollSpeed,\n scrollEase: props.scrollEase\n });\n});\n\nonUnmounted(() => {\n if (app) {\n app.destroy();\n app = null;\n }\n});\n\nwatch(\n () => ({\n items: props.items,\n bend: props.bend,\n textColor: props.textColor,\n borderRadius: props.borderRadius,\n font: props.font,\n scrollSpeed: props.scrollSpeed,\n scrollEase: props.scrollEase\n }),\n newProps => {\n if (app) {\n app.destroy();\n }\n if (containerRef.value) {\n app = new App(containerRef.value, newProps);\n }\n },\n { deep: true }\n);\n</script>\n","path":"CircularGallery/CircularGallery.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"ogl","version":"^1.0.11"}],"devDependencies":[],"categories":["Components"]}