1import{r as g,j as l,af as T,ak as S,ai as w,aj as x,$ as F,B as R}from"./index-CQKK0Imd.js";import{u as E,C,T as O,P as z,a as U,b as L,c as D}from"./useComponentProps-CEvMaZsz.js";import{D as k}from"./Dependencies-Bm-Pofg-.js";import{C as P}from"./Customize-D9LDBJtX.js";import{P as p}from"./PreviewSlider-Ch78y-a-.js";import{P as B}from"./PreviewSelect-B9UsRYnF.js";import{u as I}from"./useForceRerender-DWXB3a6h.js";import{C as A}from"./Camera-Bx-W63sX.js";import{P as b}from"./Plane-NeOUlyJG.js";import{T as y}from"./Texture-BkQWYNP2.js";import"./index-2zoGWYkB.js";import"./backgroundThemeProps-DVgeaD0m.js";import"./ShikiCode-C19L5K1F.js";import"./aiExport-CBeFnU7D.js";import"./IconSelect-D5LzCXat.js";import"./sparkles-Df4Xp1Rb.js";import"./file-code-2-CTN1MbwE.js";import"./copy-DhagMDuz.js";import"./preview-slider-zh8UBlwx.js";import"./useScrubGesture-Db-j4kh3.js";const W=`.circular-gallery { 2 width: 100%; 3 height: 100%; 4 overflow: hidden; 5 cursor: grab; 6} 7 8.circular-gallery:active { 9 cursor: grabbing; 10} 11 12.circular-gallery:focus-visible { 13 outline: 2px solid #fff; 14 outline-offset: 4px; 15} 16`,G=`import { Camera, Mesh, Plane, Program, Renderer, Texture, Transform } from 'ogl'; 17import { useEffect, useRef } from 'react'; 18 19import './CircularGallery.css'; 20 21function debounce(func, wait) { 22 let timeout; 23 return function (...args) { 24 clearTimeout(timeout); 25 timeout = setTimeout(() => func.apply(this, args), wait); 26 }; 27} 28 29function lerp(p1, p2, t) { 30 return p1 + (p2 - p1) * t; 31} 32 33function autoBind(instance) { 34 const proto = Object.getPrototypeOf(instance); 35 Object.getOwnPropertyNames(proto).forEach(key => { 36 if (key !== 'constructor' && typeof instance[key] === 'function') { 37 instance[key] = instance[key].bind(instance); 38 } 39 }); 40} 41 42const DEFAULT_FONT = 'bold 30px Figtree'; 43// Figtree is not guaranteed to be available on the host page, so the component 44// loads it on demand whenever the default font is used. 45const DEFAULT_FONT_URL = 'https://fonts.googleapis.com/css2?family=Figtree:wght@400;700&display=swap'; 46 47function deriveFontFamilyFromUrl(url) { 48 const fileName = (url.split('/').pop() || 'custom-font').split('?')[0]; 49 const base = fileName.replace(/\\.(woff2?|ttf|otf|eot)$/i, ''); 50 return base.replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'CircularGalleryFont'; 51} 52 53async function loadFontFromStylesheet(url) { 54 const response = await fetch(url); 55 if (!response.ok) throw new Error(\`Failed to fetch font stylesheet (\${response.status})\`); 56 const cssText = await response.text(); 57 const faceBlocks = cssText.match(/@font-face\\s*{[^}]*}/g) || []; 58 let family = null; 59 const fontFaces = []; 60 for (const block of faceBlocks) { 61 const familyMatch = block.match(/font-family:\\s*['"]?([^;'"]+)['"]?/); 62 const urlMatch = block.match(/url\\(\\s*['"]?([^'")]+)['"]?\\s*\\)/); 63 if (!familyMatch || !urlMatch) continue; 64 family = familyMatch[1].trim(); 65 const descriptors = {}; 66 const weightMatch = block.match(/font-weight:\\s*([^;]+);/); 67 const styleMatch = block.match(/font-style:\\s*([^;]+);/); 68 const rangeMatch = block.match(/unicode-range:\\s*([^;]+);/); 69 if (weightMatch) descriptors.weight = weightMatch[1].trim(); 70 if (styleMatch) descriptors.style = styleMatch[1].trim(); 71 if (rangeMatch) descriptors.unicodeRange = rangeMatch[1].trim(); 72 fontFaces.push(new FontFace(family, \`url(\${urlMatch[1]})\`, descriptors)); 73 } 74 if (!family) throw new Error('No @font-face rule found in the stylesheet'); 75 await Promise.allSettled( 76 fontFaces.map(async face => { 77 await face.load(); 78 document.fonts.add(face); 79 }) 80 ); 81 return family; 82} 83 84async function loadFontFromFile(url) { 85 const family = deriveFontFamilyFromUrl(url); 86 const fontFace = new FontFace(family, \`url(\${url})\`); 87 await fontFace.load(); 88 document.fonts.add(fontFace); 89 return family; 90} 91 92async function loadCustomFont(fontUrl) { 93 const isStylesheet = fontUrl.includes('fonts.googleapis.com') || /\\.css(\\?.*)?$/i.test(fontUrl); 94 return isStylesheet ? loadFontFromStylesheet(fontUrl) : loadFontFromFile(fontUrl); 95} 96 97// Loads \`fontUrl\` (a stylesheet such as a Google Fonts URL, or a direct font 98// file) and returns a canvas-ready font string that keeps the size/weight from 99// \`font\` but swaps in the freshly loaded family. Falls back to \`font\` on error. 100async function resolveFont(font, fontUrl) { 101 // Use the bundled Figtree stylesheet when the caller relies on the default 102 // font, otherwise honor the explicit \`fontUrl\`. 103 const effectiveUrl = fontUrl || (font === DEFAULT_FONT ? DEFAULT_FONT_URL : null); 104 if (!effectiveUrl) { 105 // A custom family was supplied without a URL â make sure it is ready (in 106 // case the host page declares it) before we draw it to the canvas, 107 // otherwise the first paint silently falls back to a system font. 108 if (document.fonts && document.fonts.load) {
109 try { 110 await document.fonts.load(font); 111 await document.fonts.ready; 112 } catch { 113 // Ignore â fall back to whatever the browser provides. 114 } 115 } 116 return font; 117 } 118 try { 119 const family = await loadCustomFont(effectiveUrl); 120 const sizeMatch = font.match(/^\\s*(.*?\\d+px)/); 121 const prefix = sizeMatch ? sizeMatch[1].trim() : 'bold 30px'; 122 const resolved = \`\${prefix} "\${family}"\`; 123 if (document.fonts && document.fonts.load) { 124 try { 125 await document.fonts.load(resolved); 126 } catch { 127 // Ignore â we still attempt to render with the requested font. 128 } 129 } 130 return resolved; 131 } catch (error) { 132 console.error('CircularGallery: unable to load font from', fontUrl, error); 133 return font; 134 } 135} 136 137function getFontSize(font) { 138 const match = font.match(/(\\d+)px/); 139 return match ? parseInt(match[1], 10) : 30; 140} 141 142function createTextTexture(gl, text, font = 'bold 30px monospace', color = 'black') { 143 const canvas = document.createElement('canvas'); 144 const context = canvas.getContext('2d'); 145 context.font = font; 146 const metrics = context.measureText(text); 147 const textWidth = Math.ceil(metrics.width); 148 const textHeight = Math.ceil(getFontSize(font) * 1.2); 149 canvas.width = textWidth + 20; 150 canvas.height = textHeight + 20; 151 context.font = font; 152 context.fillStyle = color; 153 context.textBaseline = 'middle'; 154 context.textAlign = 'center'; 155 context.clearRect(0, 0, canvas.width, canvas.height); 156 context.fillText(text, canvas.width / 2, canvas.height / 2); 157 const texture = new Texture(gl, { generateMipmaps: false }); 158 texture.image = canvas; 159 return { texture, width: canvas.width, height: canvas.height }; 160} 161 162class Title { 163 constructor({ gl, plane, renderer, text, textColor = '#545050', font = '30px sans-serif' }) { 164 autoBind(this); 165 this.gl = gl; 166 this.plane = plane; 167 this.renderer = renderer; 168 this.text = text; 169 this.textColor = textColor; 170 this.font = font; 171 this.createMesh(); 172 } 173 createMesh() { 174 const { texture, width, height } = createTextTexture(this.gl, this.text, this.font, this.textColor); 175 const geometry = new Plane(this.gl); 176 const program = new Program(this.gl, { 177 vertex: \` 178 attribute vec3 position; 179 attribute vec2 uv; 180 uniform mat4 modelViewMatrix; 181 uniform mat4 projectionMatrix; 182 varying vec2 vUv; 183 void main() { 184 vUv = uv; 185 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 186 } 187 \`, 188 fragment: \` 189 precision highp float; 190 uniform sampler2D tMap; 191 varying vec2 vUv; 192 void main() { 193 vec4 color = texture2D(tMap, vUv); 194 if (color.a < 0.1) discard; 195 gl_FragColor = color; 196 } 197 \`, 198 uniforms: { tMap: { value: texture } }, 199 transparent: true 200 }); 201 this.mesh = new Mesh(this.gl, { geometry, program }); 202 const aspect = width / height; 203 const textHeight = this.plane.scale.y * 0.15; 204 const textWidth = textHeight * aspect; 205 this.mesh.scale.set(textWidth, textHeight, 1); 206 this.mesh.position.y = -this.plane.scale.y * 0.5 - textHeight * 0.5 - 0.05; 207 this.mesh.setParent(this.plane); 208 } 209} 210 211class Media { 212 constructor({ 213 geometry, 214 gl, 215 image, 216 index, 217 length, 218 renderer, 219 scene, 220 screen, 221 text, 222 viewport, 223 bend, 224 textColor, 225 borderRadius = 0, 226 font 227 }) { 228 this.extra = 0; 229 this.geometry = geometry; 230 this.gl = gl; 231 this.image = image; 232 this.index = index; 233 this.length = length; 234 this.renderer = renderer; 235 this.scene = scene; 236 this.screen = screen; 237 this.text = text; 238 this.viewport = viewport; 239 this.bend = bend; 240 this.textColor = textColor; 241 this.borderRadius = borderRadius; 242 this.font = font; 243 this.createShader(); 244 this.createMesh(); 245 this.createTitle(); 246 this.onResize(); 247 } 248 createShader() { 249 const texture = new Texture(this.gl, { 250 generateMipmaps: true 251 }); 252 this.program = new Program(this.gl, { 253 depthTest: false, 254 depthWrite: false, 255 vertex: \` 256 precision highp float; 257 attribute vec3 position; 258 attribute vec2 uv; 259 uniform mat4 modelViewMatrix; 260 uniform mat4 projectionMatrix; 261 uniform float uTime; 262 uniform float uSpeed; 263 varying vec2 vUv; 264 void main() { 265 vUv = uv; 266 vec3 p = position; 267 p.z = (sin(p.x * 4.0 + uTime) * 1.5 + cos(p.y * 2.0 + uTime) * 1.5) * (0.1 + uSpeed * 0.5); 268 gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); 269 } 270 \`, 271 fragment: \` 272 precision highp float; 273 uniform vec2 uImageSizes; 274 uniform vec2 uPlaneSizes; 275 uniform sampler2D tMap; 276 uniform float uBorderRadius; 277 varying vec2 vUv; 278
279 float roundedBoxSDF(vec2 p, vec2 b, float r) { 280 vec2 d = abs(p) - b; 281 return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r; 282 } 283 284 void main() { 285 vec2 ratio = vec2( 286 min((uPlaneSizes.x / uPlaneSizes.y) / (uImageSizes.x / uImageSizes.y), 1.0), 287 min((uPlaneSizes.y / uPlaneSizes.x) / (uImageSizes.y / uImageSizes.x), 1.0) 288 ); 289 vec2 uv = vec2( 290 vUv.x * ratio.x + (1.0 - ratio.x) * 0.5, 291 vUv.y * ratio.y + (1.0 - ratio.y) * 0.5 292 ); 293 vec4 color = texture2D(tMap, uv); 294 295 float d = roundedBoxSDF(vUv - 0.5, vec2(0.5 - uBorderRadius), uBorderRadius); 296 297 // Smooth antialiasing for edges 298 float edgeSmooth = 0.002; 299 float alpha = 1.0 - smoothstep(-edgeSmooth, edgeSmooth, d); 300 301 gl_FragColor = vec4(color.rgb, alpha); 302 } 303 \`, 304 uniforms: { 305 tMap: { value: texture }, 306 uPlaneSizes: { value: [0, 0] }, 307 uImageSizes: { value: [0, 0] }, 308 uSpeed: { value: 0 }, 309 uTime: { value: 100 * Math.random() }, 310 uBorderRadius: { value: this.borderRadius } 311 }, 312 transparent: true 313 }); 314 const img = new Image(); 315 img.crossOrigin = 'anonymous'; 316 img.src = this.image; 317 img.onload = () => { 318 texture.image = img; 319 this.program.uniforms.uImageSizes.value = [img.naturalWidth, img.naturalHeight]; 320 }; 321 } 322 createMesh() { 323 this.plane = new Mesh(this.gl, { 324 geometry: this.geometry, 325 program: this.program 326 }); 327 this.plane.setParent(this.scene); 328 } 329 createTitle() { 330 this.title = new Title({ 331 gl: this.gl, 332 plane: this.plane, 333 renderer: this.renderer, 334 text: this.text, 335 textColor: this.textColor, 336 font: this.font 337 }); 338 } 339 update(scroll, direction) { 340 this.plane.position.x = this.x - scroll.current - this.extra; 341 342 const x = this.plane.position.x; 343 const H = this.viewport.width / 2; 344 345 if (this.bend === 0) { 346 this.plane.position.y = 0; 347 this.plane.rotation.z = 0; 348 } else { 349 const B_abs = Math.abs(this.bend); 350 const R = (H * H + B_abs * B_abs) / (2 * B_abs); 351 const effectiveX = Math.min(Math.abs(x), H); 352 353 const arc = R - Math.sqrt(R * R - effectiveX * effectiveX); 354 if (this.bend > 0) { 355 this.plane.position.y = -arc; 356 this.plane.rotation.z = -Math.sign(x) * Math.asin(effectiveX / R); 357 } else { 358 this.plane.position.y = arc; 359 this.plane.rotation.z = Math.sign(x) * Math.asin(effectiveX / R); 360 } 361 } 362 363 this.speed = scroll.current - scroll.last; 364 this.program.uniforms.uTime.value += 0.04; 365 this.program.uniforms.uSpeed.value = this.speed; 366 367 const planeOffset = this.plane.scale.x / 2; 368 const viewportOffset = this.viewport.width / 2; 369 this.isBefore = this.plane.position.x + planeOffset < -viewportOffset; 370 this.isAfter = this.plane.position.x - planeOffset > viewportOffset; 371 if (direction === 'right' && this.isBefore) { 372 this.extra -= this.widthTotal; 373 this.isBefore = this.isAfter = false; 374 } 375 if (direction === 'left' && this.isAfter) { 376 this.extra += this.widthTotal; 377 this.isBefore = this.isAfter = false; 378 } 379 } 380 onResize({ screen, viewport } = {}) { 381 if (screen) this.screen = screen; 382 if (viewport) { 383 this.viewport = viewport; 384 if (this.plane.program.uniforms.uViewportSizes) { 385 this.plane.program.uniforms.uViewportSizes.value = [this.viewport.width, this.viewport.height]; 386 } 387 } 388 this.scale = this.screen.height / 1500; 389 this.plane.scale.y = (this.viewport.height * (900 * this.scale)) / this.screen.height; 390 this.plane.scale.x = (this.viewport.width * (700 * this.scale)) / this.screen.width; 391 this.plane.program.uniforms.uPlaneSizes.value = [this.plane.scale.x, this.plane.scale.y]; 392 this.padding = 2; 393 this.width = this.plane.scale.x + this.padding; 394 this.widthTotal = this.width * this.length; 395 this.x = this.width * this.index; 396 } 397} 398 399class App { 400 constructor( 401 container, 402 { 403 items, 404 bend, 405 textColor = '#ffffff', 406 borderRadius = 0, 407 font = 'bold 30px Figtree', 408 scrollSpeed = 2, 409 scrollEase = 0.05 410 } = {} 411 ) { 412 document.documentElement.classList.remove('no-js'); 413 this.container = container; 414 this.scrollSpeed = scrollSpeed; 415 this.scroll = { ease: scrollEase, current: 0, target: 0, last: 0 }; 416 this.onCheckDebounce = debounce(this.onCheck, 200); 417 this.createRenderer(); 418 this.createCamera(); 419 this.createScene(); 420 this.onResize(); 421 this.createGeometry(); 422 this.createMedias(items, bend, textColor, borderRadius, font); 423 this.update(); 424 this.addEventListeners(); 425 } 426 createRenderer() { 427 this.renderer = new Renderer({ 428 alpha: true, 429 antialias: true, 430 dpr: Math.min(window.devicePixelRatio || 1, 2) 431 }); 432 this.gl = this.renderer.gl; 433 this.gl.clearColor(0, 0, 0, 0); 434 this.container.appendChild(this.gl.canvas); 435 } 436 createCamera() { 437 this.camera = new Camera(this.gl); 438 this.camera.fov = 45; 439 this.camera.position.z = 20; 440 } 441 createScene() { 442 this.scene = new Transform(); 443 } 444 createGeometry() { 445 this.planeGeometry = new Plane(this.gl, { 446 heightSegments: 50, 447 widthSegments: 100 448 }); 449 } 450 createMedias(items, bend = 1, textColor, borderRadius, font) { 451 const defaultItems = [ 452 { image: \`https://picsum.photos/seed/1/800/600?grayscale\`, text: 'Bridge' }, 453 { image: \`https://picsum.photos/seed/2/800/600?grayscale\`, text: 'Desk Setup' }, 454 { image: \`https://picsum.photos/seed/3/800/600?grayscale\`, text: 'Waterfall' }, 455 { image: \`https://picsum.photos/seed/4/800/600?grayscale\`, text: 'Strawberries' }, 456 { image: \`https://picsum.photos/seed/5/800/600?grayscale\`, text: 'Deep Diving' }, 457 { image: \`https://picsum.photos/seed/16/800/600?grayscale\`, text: 'Train Track' }, 458 { image: \`https://picsum.photos/seed/17/800/600?grayscale\`, text: 'Santorini' }, 459 { image: \`https://picsum.photos/seed/8/800/600?grayscale\`, text: 'Blurry Lights' }, 460 { image: \`https://picsum.photos/seed/9/800/600?grayscale\`, text: 'New York' }, 461 { image: \`https://picsum.photos/seed/10/800/600?grayscale\`, text: 'Good Boy' }, 462 { image: \`https://picsum.photos/seed/21/800/600?grayscale\`, text: 'Coastline' }, 463 { image: \`https://picsum.photos/seed/12/800/600?grayscale\`, text: 'Palm Trees' } 464 ]; 465 const galleryItems = items && items.length ? items : defaultItems; 466 this.mediasImages = galleryItems.concat(galleryItems); 467 this.medias = this.mediasImages.map((data, index) => { 468 return new Media({ 469 geometry: this.planeGeometry, 470 gl: this.gl, 471 image: data.image, 472 index, 473 length: this.mediasImages.length, 474 renderer: this.renderer, 475 scene: this.scene, 476 screen: this.screen, 477 text: data.text, 478 viewport: this.viewport, 479 bend, 480 textColor, 481 borderRadius, 482 font 483 }); 484 }); 485 } 486 onTouchDown(e) { 487 this.isDown = true; 488 this.scroll.position = this.scroll.current; 489 this.start = e.touches ? e.touches[0].clientX : e.clientX; 490 } 491 onTouchMove(e) { 492 if (!this.isDown) return; 493 const x = e.touches ? e.touches[0].clientX : e.clientX; 494 const distance = (this.start - x) * (this.scrollSpeed * 0.025); 495 this.scroll.target = this.scroll.position + distance; 496 } 497 onTouchUp() { 498 this.isDown = false;
499 this.onCheck(); 500 } 501 onWheel(e) { 502 const delta = e.deltaY || e.wheelDelta || e.detail; 503 this.scroll.target += (delta > 0 ? this.scrollSpeed : -this.scrollSpeed) * 0.2; 504 this.onCheckDebounce(); 505 } 506 onKeyDown(e) { 507 switch (e.key) { 508 case 'ArrowRight': 509 e.preventDefault(); 510 this.scroll.target += this.scrollSpeed * 5; 511 this.onCheckDebounce(); 512 break; 513 514 case 'ArrowLeft': 515 e.preventDefault(); 516 this.scroll.target -= this.scrollSpeed * 5; 517 this.onCheckDebounce(); 518 break; 519 520 case 'Home': 521 e.preventDefault(); 522 this.scroll.target = 0; 523 this.onCheckDebounce(); 524 break; 525 526 default: 527 break; 528 } 529 } 530 531 onCheck() { 532 if (!this.medias || !this.medias[0]) return; 533 const width = this.medias[0].width; 534 const itemIndex = Math.round(Math.abs(this.scroll.target) / width); 535 const item = width * itemIndex; 536 this.scroll.target = this.scroll.target < 0 ? -item : item; 537 } 538 onResize() { 539 this.screen = { 540 width: this.container.clientWidth, 541 height: this.container.clientHeight 542 }; 543 this.renderer.setSize(this.screen.width, this.screen.height); 544 this.camera.perspective({ 545 aspect: this.screen.width / this.screen.height 546 }); 547 const fov = (this.camera.fov * Math.PI) / 180; 548 const height = 2 * Math.tan(fov / 2) * this.camera.position.z; 549 const width = height * this.camera.aspect; 550 this.viewport = { width, height }; 551 if (this.medias) { 552 this.medias.forEach(media => media.onResize({ screen: this.screen, viewport: this.viewport })); 553 } 554 } 555 update() { 556 this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease); 557 const direction = this.scroll.current > this.scroll.last ? 'right' : 'left'; 558 if (this.medias) { 559 this.medias.forEach(media => media.update(this.scroll, direction)); 560 } 561 this.renderer.render({ scene: this.scene, camera: this.camera }); 562 this.scroll.last = this.scroll.current; 563 this.raf = window.requestAnimationFrame(this.update.bind(this)); 564 } 565 addEventListeners() { 566 this.boundOnResize = this.onResize.bind(this); 567 this.boundOnWheel = this.onWheel.bind(this); 568 this.boundOnTouchDown = this.onTouchDown.bind(this); 569 this.boundOnTouchMove = this.onTouchMove.bind(this); 570 this.boundOnTouchUp = this.onTouchUp.bind(this); 571 this.boundOnKeyDown = this.onKeyDown.bind(this); 572 573 window.addEventListener('resize', this.boundOnResize); 574 window.addEventListener('mousewheel', this.boundOnWheel); 575 window.addEventListener('wheel', this.boundOnWheel); 576 window.addEventListener('mousedown', this.boundOnTouchDown); 577 window.addEventListener('mousemove', this.boundOnTouchMove); 578 window.addEventListener('mouseup', this.boundOnTouchUp); 579 window.addEventListener('touchstart', this.boundOnTouchDown); 580 window.addEventListener('touchmove', this.boundOnTouchMove); 581 window.addEventListener('touchend', this.boundOnTouchUp); 582 583 this.container?.addEventListener('keydown', this.boundOnKeyDown); 584 } 585 destroy() { 586 window.cancelAnimationFrame(this.raf); 587 window.removeEventListener('resize', this.boundOnResize); 588 window.removeEventListener('mousewheel', this.boundOnWheel); 589 window.removeEventListener('wheel', this.boundOnWheel); 590 window.removeEventListener('mousedown', this.boundOnTouchDown); 591 window.removeEventListener('mousemove', this.boundOnTouchMove); 592 window.removeEventListener('mouseup', this.boundOnTouchUp); 593 window.removeEventListener('touchstart', this.boundOnTouchDown); 594 window.removeEventListener('touchmove', this.boundOnTouchMove); 595 window.removeEventListener('touchend', this.boundOnTouchUp); 596 if (this.renderer && this.renderer.gl && this.renderer.gl.canvas.parentNode) { 597 this.renderer.gl.canvas.parentNode.removeChild(this.renderer.gl.canvas); 598 } 599 600 if (this.container) { 601 this.container.removeEventListener('keydown', this.boundOnKeyDown); 602 } 603 } 604} 605 606export default function CircularGallery({ 607 items, 608 bend = 3, 609 textColor = '#ffffff', 610 borderRadius = 0.05, 611 font = 'bold 30px Figtree', 612 fontUrl, 613 scrollSpeed = 2, 614 scrollEase = 0.05 615}) { 616 const containerRef = useRef(null); 617 useEffect(() => { 618 if (!containerRef.current) return; 619 let app; 620 let isMounted = true; 621 resolveFont(font, fontUrl).then(resolvedFont => { 622 if (!isMounted || !containerRef.current) return; 623 app = new App(containerRef.current, { 624 items, 625 bend, 626 textColor, 627 borderRadius, 628 font: resolvedFont, 629 scrollSpeed, 630 scrollEase 631 }); 632 }); 633 634 return () => { 635 isMounted = false;
636 if (app) app.destroy(); 637 }; 638 }, [items, bend, textColor, borderRadius, font, fontUrl, scrollSpeed, scrollEase]); 639 return ( 640 <div 641 className="circular-gallery" 642 ref={containerRef} 643 tabIndex={0} 644 role="region" 645 aria-label="Circular image gallery. Use left and right arrow keys to navigate." 646 /> 647 ); 648} 649`,_=`import { Camera, Mesh, Plane, Program, Renderer, Texture, Transform } from 'ogl'; 650import { useEffect, useRef } from 'react'; 651 652function debounce(func, wait) { 653 let timeout; 654 return function (...args) { 655 clearTimeout(timeout); 656 timeout = setTimeout(() => func.apply(this, args), wait); 657 }; 658} 659 660function lerp(p1, p2, t) { 661 return p1 + (p2 - p1) * t; 662} 663 664function autoBind(instance) { 665 const proto = Object.getPrototypeOf(instance); 666 Object.getOwnPropertyNames(proto).forEach(key => { 667 if (key !== 'constructor' && typeof instance[key] === 'function') { 668 instance[key] = instance[key].bind(instance); 669 } 670 }); 671} 672 673const DEFAULT_FONT = 'bold 30px Figtree'; 674// Figtree is not guaranteed to be available on the host page, so the component 675// loads it on demand whenever the default font is used. 676const DEFAULT_FONT_URL = 'https://fonts.googleapis.com/css2?family=Figtree:wght@400;700&display=swap'; 677 678function deriveFontFamilyFromUrl(url) { 679 const fileName = (url.split('/').pop() || 'custom-font').split('?')[0]; 680 const base = fileName.replace(/\\.(woff2?|ttf|otf|eot)$/i, ''); 681 return base.replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'CircularGalleryFont'; 682} 683 684async function loadFontFromStylesheet(url) { 685 const response = await fetch(url); 686 if (!response.ok) throw new Error(\`Failed to fetch font stylesheet (\${response.status})\`); 687 const cssText = await response.text(); 688 const faceBlocks = cssText.match(/@font-face\\s*{[^}]*}/g) || []; 689 let family = null; 690 const fontFaces = []; 691 for (const block of faceBlocks) { 692 const familyMatch = block.match(/font-family:\\s*['"]?([^;'"]+)['"]?/); 693 const urlMatch = block.match(/url\\(\\s*['"]?([^'")]+)['"]?\\s*\\)/); 694 if (!familyMatch || !urlMatch) continue; 695 family = familyMatch[1].trim(); 696 const descriptors = {}; 697 const weightMatch = block.match(/font-weight:\\s*([^;]+);/); 698 const styleMatch = block.match(/font-style:\\s*([^;]+);/); 699 const rangeMatch = block.match(/unicode-range:\\s*([^;]+);/); 700 if (weightMatch) descriptors.weight = weightMatch[1].trim(); 701 if (styleMatch) descriptors.style = styleMatch[1].trim(); 702 if (rangeMatch) descriptors.unicodeRange = rangeMatch[1].trim(); 703 fontFaces.push(new FontFace(family, \`url(\${urlMatch[1]})\`, descriptors)); 704 } 705 if (!family) throw new Error('No @font-face rule found in the stylesheet'); 706 await Promise.allSettled( 707 fontFaces.map(async face => { 708 await face.load(); 709 document.fonts.add(face); 710 }) 711 ); 712 return family; 713} 714 715async function loadFontFromFile(url) { 716 const family = deriveFontFamilyFromUrl(url); 717 const fontFace = new FontFace(family, \`url(\${url})\`); 718 await fontFace.load(); 719 document.fonts.add(fontFace); 720 return family; 721} 722 723async function loadCustomFont(fontUrl) { 724 const isStylesheet = fontUrl.includes('fonts.googleapis.com') || /\\.css(\\?.*)?$/i.test(fontUrl); 725 return isStylesheet ? loadFontFromStylesheet(fontUrl) : loadFontFromFile(fontUrl); 726} 727 728// Loads \`fontUrl\` (a stylesheet such as a Google Fonts URL, or a direct font 729// file) and returns a canvas-ready font string that keeps the size/weight from 730// \`font\` but swaps in the freshly loaded family. Falls back to \`font\` on error. 731async function resolveFont(font, fontUrl) { 732 // Use the bundled Figtree stylesheet when the caller relies on the default 733 // font, otherwise honor the explicit \`fontUrl\`. 734 const effectiveUrl = fontUrl || (font === DEFAULT_FONT ? DEFAULT_FONT_URL : null); 735 if (!effectiveUrl) { 736 // A custom family was supplied without a URL â make sure it is ready (in 737 // case the host page declares it) before we draw it to the canvas, 738 // otherwise the first paint silently falls back to a system font. 739 if (document.fonts && document.fonts.load) {
740 try { 741 await document.fonts.load(font); 742 await document.fonts.ready; 743 } catch { 744 // Ignore â fall back to whatever the browser provides. 745 } 746 } 747 return font; 748 } 749 try { 750 const family = await loadCustomFont(effectiveUrl); 751 const sizeMatch = font.match(/^\\s*(.*?\\d+px)/); 752 const prefix = sizeMatch ? sizeMatch[1].trim() : 'bold 30px'; 753 const resolved = \`\${prefix} "\${family}"\`; 754 if (document.fonts && document.fonts.load) { 755 try { 756 await document.fonts.load(resolved); 757 } catch { 758 // Ignore â we still attempt to render with the requested font. 759 } 760 } 761 return resolved; 762 } catch (error) { 763 console.error('CircularGallery: unable to load font from', fontUrl, error); 764 return font; 765 } 766} 767 768function getFontSize(font) { 769 const match = font.match(/(\\d+)px/); 770 return match ? parseInt(match[1], 10) : 30; 771} 772 773function createTextTexture(gl, text, font = 'bold 30px monospace', color = 'black') { 774 const canvas = document.createElement('canvas'); 775 const context = canvas.getContext('2d'); 776 context.font = font; 777 const metrics = context.measureText(text); 778 const textWidth = Math.ceil(metrics.width); 779 const textHeight = Math.ceil(getFontSize(font) * 1.2); 780 canvas.width = textWidth + 20; 781 canvas.height = textHeight + 20; 782 context.font = font; 783 context.fillStyle = color; 784 context.textBaseline = 'middle'; 785 context.textAlign = 'center'; 786 context.clearRect(0, 0, canvas.width, canvas.height); 787 context.fillText(text, canvas.width / 2, canvas.height / 2); 788 const texture = new Texture(gl, { generateMipmaps: false }); 789 texture.image = canvas; 790 return { texture, width: canvas.width, height: canvas.height }; 791} 792 793class Title { 794 constructor({ gl, plane, renderer, text, textColor = '#545050', font = '30px sans-serif' }) { 795 autoBind(this); 796 this.gl = gl; 797 this.plane = plane; 798 this.renderer = renderer; 799 this.text = text; 800 this.textColor = textColor; 801 this.font = font; 802 this.createMesh(); 803 } 804 createMesh() { 805 const { texture, width, height } = createTextTexture(this.gl, this.text, this.font, this.textColor); 806 const geometry = new Plane(this.gl); 807 const program = new Program(this.gl, { 808 vertex: \` 809 attribute vec3 position; 810 attribute vec2 uv; 811 uniform mat4 modelViewMatrix; 812 uniform mat4 projectionMatrix; 813 varying vec2 vUv; 814 void main() { 815 vUv = uv; 816 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 817 } 818 \`, 819 fragment: \` 820 precision highp float; 821 uniform sampler2D tMap; 822 varying vec2 vUv; 823 void main() { 824 vec4 color = texture2D(tMap, vUv); 825 if (color.a < 0.1) discard; 826 gl_FragColor = color; 827 } 828 \`, 829 uniforms: { tMap: { value: texture } }, 830 transparent: true 831 }); 832 this.mesh = new Mesh(this.gl, { geometry, program }); 833 const aspect = width / height; 834 const textHeight = this.plane.scale.y * 0.15; 835 const textWidth = textHeight * aspect; 836 this.mesh.scale.set(textWidth, textHeight, 1); 837 this.mesh.position.y = -this.plane.scale.y * 0.5 - textHeight * 0.5 - 0.05; 838 this.mesh.setParent(this.plane); 839 } 840} 841 842class Media { 843 constructor({ 844 geometry, 845 gl, 846 image, 847 index, 848 length, 849 renderer, 850 scene, 851 screen, 852 text, 853 viewport, 854 bend, 855 textColor, 856 borderRadius = 0, 857 font 858 }) { 859 this.extra = 0; 860 this.geometry = geometry; 861 this.gl = gl; 862 this.image = image; 863 this.index = index; 864 this.length = length; 865 this.renderer = renderer; 866 this.scene = scene; 867 this.screen = screen; 868 this.text = text; 869 this.viewport = viewport; 870 this.bend = bend; 871 this.textColor = textColor; 872 this.borderRadius = borderRadius; 873 this.font = font; 874 this.createShader(); 875 this.createMesh(); 876 this.createTitle(); 877 this.onResize(); 878 } 879 createShader() { 880 const texture = new Texture(this.gl, { 881 generateMipmaps: true 882 }); 883 this.program = new Program(this.gl, { 884 depthTest: false, 885 depthWrite: false, 886 vertex: \` 887 precision highp float; 888 attribute vec3 position; 889 attribute vec2 uv; 890 uniform mat4 modelViewMatrix; 891 uniform mat4 projectionMatrix; 892 uniform float uTime; 893 uniform float uSpeed; 894 varying vec2 vUv; 895 void main() { 896 vUv = uv; 897 vec3 p = position; 898 p.z = (sin(p.x * 4.0 + uTime) * 1.5 + cos(p.y * 2.0 + uTime) * 1.5) * (0.1 + uSpeed * 0.5); 899 gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); 900 } 901 \`, 902 fragment: \` 903 precision highp float; 904 uniform vec2 uImageSizes; 905 uniform vec2 uPlaneSizes; 906 uniform sampler2D tMap; 907 uniform float uBorderRadius; 908 varying vec2 vUv; 909
910 float roundedBoxSDF(vec2 p, vec2 b, float r) { 911 vec2 d = abs(p) - b; 912 return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r; 913 } 914 915 void main() { 916 vec2 ratio = vec2( 917 min((uPlaneSizes.x / uPlaneSizes.y) / (uImageSizes.x / uImageSizes.y), 1.0), 918 min((uPlaneSizes.y / uPlaneSizes.x) / (uImageSizes.y / uImageSizes.x), 1.0) 919 ); 920 vec2 uv = vec2( 921 vUv.x * ratio.x + (1.0 - ratio.x) * 0.5, 922 vUv.y * ratio.y + (1.0 - ratio.y) * 0.5 923 ); 924 vec4 color = texture2D(tMap, uv); 925 926 float d = roundedBoxSDF(vUv - 0.5, vec2(0.5 - uBorderRadius), uBorderRadius); 927 928 // Smooth antialiasing for edges 929 float edgeSmooth = 0.002; 930 float alpha = 1.0 - smoothstep(-edgeSmooth, edgeSmooth, d); 931 932 gl_FragColor = vec4(color.rgb, alpha); 933 } 934 \`, 935 uniforms: { 936 tMap: { value: texture }, 937 uPlaneSizes: { value: [0, 0] }, 938 uImageSizes: { value: [0, 0] }, 939 uSpeed: { value: 0 }, 940 uTime: { value: 100 * Math.random() }, 941 uBorderRadius: { value: this.borderRadius } 942 }, 943 transparent: true 944 }); 945 const img = new Image(); 946 img.crossOrigin = 'anonymous'; 947 img.src = this.image; 948 img.onload = () => { 949 texture.image = img; 950 this.program.uniforms.uImageSizes.value = [img.naturalWidth, img.naturalHeight]; 951 }; 952 } 953 createMesh() { 954 this.plane = new Mesh(this.gl, { 955 geometry: this.geometry, 956 program: this.program 957 }); 958 this.plane.setParent(this.scene); 959 } 960 createTitle() { 961 this.title = new Title({ 962 gl: this.gl, 963 plane: this.plane, 964 renderer: this.renderer, 965 text: this.text, 966 textColor: this.textColor, 967 font: this.font 968 }); 969 } 970 update(scroll, direction) { 971 this.plane.position.x = this.x - scroll.current - this.extra; 972 973 const x = this.plane.position.x; 974 const H = this.viewport.width / 2; 975 976 if (this.bend === 0) { 977 this.plane.position.y = 0; 978 this.plane.rotation.z = 0; 979 } else { 980 const B_abs = Math.abs(this.bend); 981 const R = (H * H + B_abs * B_abs) / (2 * B_abs); 982 const effectiveX = Math.min(Math.abs(x), H); 983 984 const arc = R - Math.sqrt(R * R - effectiveX * effectiveX); 985 if (this.bend > 0) { 986 this.plane.position.y = -arc; 987 this.plane.rotation.z = -Math.sign(x) * Math.asin(effectiveX / R); 988 } else { 989 this.plane.position.y = arc; 990 this.plane.rotation.z = Math.sign(x) * Math.asin(effectiveX / R); 991 } 992 } 993 994 this.speed = scroll.current - scroll.last; 995 this.program.uniforms.uTime.value += 0.04; 996 this.program.uniforms.uSpeed.value = this.speed; 997 998 const planeOffset = this.plane.scale.x / 2; 999 const viewportOffset = this.viewport.width / 2; 1000 this.isBefore = this.plane.position.x + planeOffset < -viewportOffset; 1001 this.isAfter = this.plane.position.x - planeOffset > viewportOffset; 1002 if (direction === 'right' && this.isBefore) { 1003 this.extra -= this.widthTotal; 1004 this.isBefore = this.isAfter = false; 1005 } 1006 if (direction === 'left' && this.isAfter) { 1007 this.extra += this.widthTotal; 1008 this.isBefore = this.isAfter = false; 1009 } 1010 } 1011 onResize({ screen, viewport } = {}) { 1012 if (screen) this.screen = screen; 1013 if (viewport) { 1014 this.viewport = viewport; 1015 if (this.plane.program.uniforms.uViewportSizes) { 1016 this.plane.program.uniforms.uViewportSizes.value = [this.viewport.width, this.viewport.height]; 1017 } 1018 } 1019 this.scale = this.screen.height / 1500; 1020 this.plane.scale.y = (this.viewport.height * (900 * this.scale)) / this.screen.height; 1021 this.plane.scale.x = (this.viewport.width * (700 * this.scale)) / this.screen.width; 1022 this.plane.program.uniforms.uPlaneSizes.value = [this.plane.scale.x, this.plane.scale.y]; 1023 this.padding = 2; 1024 this.width = this.plane.scale.x + this.padding; 1025 this.widthTotal = this.width * this.length; 1026 this.x = this.width * this.index; 1027 } 1028} 1029 1030class App { 1031 constructor( 1032 container, 1033 { 1034 items, 1035 bend, 1036 textColor = '#ffffff', 1037 borderRadius = 0, 1038 font = 'bold 30px Figtree', 1039 scrollSpeed = 2, 1040 scrollEase = 0.05 1041 } = {} 1042 ) { 1043 document.documentElement.classList.remove('no-js'); 1044 this.container = container; 1045 this.scrollSpeed = scrollSpeed; 1046 this.scroll = { ease: scrollEase, current: 0, target: 0, last: 0 }; 1047 this.onCheckDebounce = debounce(this.onCheck, 200); 1048 this.createRenderer(); 1049 this.createCamera(); 1050 this.createScene(); 1051 this.onResize(); 1052 this.createGeometry(); 1053 this.createMedias(items, bend, textColor, borderRadius, font); 1054 this.update(); 1055 this.addEventListeners(); 1056 } 1057 createRenderer() { 1058 this.renderer = new Renderer({ 1059 alpha: true, 1060 antialias: true, 1061 dpr: Math.min(window.devicePixelRatio || 1, 2) 1062 }); 1063 this.gl = this.renderer.gl; 1064 this.gl.clearColor(0, 0, 0, 0); 1065 this.container.appendChild(this.gl.canvas); 1066 } 1067 createCamera() { 1068 this.camera = new Camera(this.gl); 1069 this.camera.fov = 45; 1070 this.camera.position.z = 20; 1071 } 1072 createScene() { 1073 this.scene = new Transform(); 1074 } 1075 createGeometry() { 1076 this.planeGeometry = new Plane(this.gl, { 1077 heightSegments: 50, 1078 widthSegments: 100 1079 }); 1080 } 1081 createMedias(items, bend = 1, textColor, borderRadius, font) { 1082 const defaultItems = [ 1083 { image: \`https://picsum.photos/seed/1/800/600?grayscale\`, text: 'Bridge' }, 1084 { image: \`https://picsum.photos/seed/2/800/600?grayscale\`, text: 'Desk Setup' }, 1085 { image: \`https://picsum.photos/seed/3/800/600?grayscale\`, text: 'Waterfall' }, 1086 { image: \`https://picsum.photos/seed/4/800/600?grayscale\`, text: 'Strawberries' }, 1087 { image: \`https://picsum.photos/seed/5/800/600?grayscale\`, text: 'Deep Diving' }, 1088 { image: \`https://picsum.photos/seed/16/800/600?grayscale\`, text: 'Train Track' }, 1089 { image: \`https://picsum.photos/seed/17/800/600?grayscale\`, text: 'Santorini' }, 1090 { image: \`https://picsum.photos/seed/8/800/600?grayscale\`, text: 'Blurry Lights' }, 1091 { image: \`https://picsum.photos/seed/9/800/600?grayscale\`, text: 'New York' }, 1092 { image: \`https://picsum.photos/seed/10/800/600?grayscale\`, text: 'Good Boy' }, 1093 { image: \`https://picsum.photos/seed/21/800/600?grayscale\`, text: 'Coastline' }, 1094 { image: \`https://picsum.photos/seed/12/800/600?grayscale\`, text: 'Palm Trees' } 1095 ]; 1096 const galleryItems = items && items.length ? items : defaultItems; 1097 this.mediasImages = galleryItems.concat(galleryItems); 1098 this.medias = this.mediasImages.map((data, index) => { 1099 return new Media({ 1100 geometry: this.planeGeometry, 1101 gl: this.gl, 1102 image: data.image, 1103 index, 1104 length: this.mediasImages.length, 1105 renderer: this.renderer, 1106 scene: this.scene, 1107 screen: this.screen, 1108 text: data.text, 1109 viewport: this.viewport, 1110 bend, 1111 textColor, 1112 borderRadius, 1113 font 1114 }); 1115 }); 1116 } 1117 onTouchDown(e) { 1118 this.isDown = true; 1119 this.scroll.position = this.scroll.current; 1120 this.start = e.touches ? e.touches[0].clientX : e.clientX; 1121 } 1122 onTouchMove(e) { 1123 if (!this.isDown) return; 1124 const x = e.touches ? e.touches[0].clientX : e.clientX; 1125 const distance = (this.start - x) * (this.scrollSpeed * 0.025); 1126 this.scroll.target = this.scroll.position + distance; 1127 } 1128 onTouchUp() { 1129 this.isDown = false;
1130 this.onCheck(); 1131 } 1132 onWheel(e) { 1133 const delta = e.deltaY || e.wheelDelta || e.detail; 1134 this.scroll.target += (delta > 0 ? this.scrollSpeed : -this.scrollSpeed) * 0.2; 1135 this.onCheckDebounce(); 1136 } 1137 onKeyDown(e) { 1138 switch (e.key) { 1139 case 'ArrowRight': 1140 e.preventDefault(); 1141 this.scroll.target += this.scrollSpeed * 5; 1142 this.onCheckDebounce(); 1143 break; 1144 case 'ArrowLeft': 1145 e.preventDefault(); 1146 this.scroll.target -= this.scrollSpeed * 5; 1147 this.onCheckDebounce(); 1148 break; 1149 default: 1150 break; 1151 } 1152 } 1153 onCheck() { 1154 if (!this.medias || !this.medias[0]) return; 1155 const width = this.medias[0].width; 1156 const itemIndex = Math.round(Math.abs(this.scroll.target) / width); 1157 const item = width * itemIndex; 1158 this.scroll.target = this.scroll.target < 0 ? -item : item; 1159 } 1160 onResize() { 1161 this.screen = { 1162 width: this.container.clientWidth, 1163 height: this.container.clientHeight 1164 }; 1165 this.renderer.setSize(this.screen.width, this.screen.height); 1166 this.camera.perspective({ 1167 aspect: this.screen.width / this.screen.height 1168 }); 1169 const fov = (this.camera.fov * Math.PI) / 180; 1170 const height = 2 * Math.tan(fov / 2) * this.camera.position.z; 1171 const width = height * this.camera.aspect; 1172 this.viewport = { width, height }; 1173 if (this.medias) { 1174 this.medias.forEach(media => media.onResize({ screen: this.screen, viewport: this.viewport })); 1175 } 1176 } 1177 update() { 1178 this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease); 1179 const direction = this.scroll.current > this.scroll.last ? 'right' : 'left'; 1180 if (this.medias) { 1181 this.medias.forEach(media => media.update(this.scroll, direction)); 1182 } 1183 this.renderer.render({ scene: this.scene, camera: this.camera }); 1184 this.scroll.last = this.scroll.current; 1185 this.raf = window.requestAnimationFrame(this.update.bind(this)); 1186 } 1187 addEventListeners() { 1188 this.boundOnResize = this.onResize.bind(this); 1189 this.boundOnWheel = this.onWheel.bind(this); 1190 this.boundOnTouchDown = this.onTouchDown.bind(this); 1191 this.boundOnTouchMove = this.onTouchMove.bind(this); 1192 this.boundOnTouchUp = this.onTouchUp.bind(this); 1193 this.boundOnKeyDown = this.onKeyDown.bind(this); 1194 window.addEventListener('resize', this.boundOnResize); 1195 window.addEventListener('mousewheel', this.boundOnWheel); 1196 window.addEventListener('wheel', this.boundOnWheel); 1197 window.addEventListener('mousedown', this.boundOnTouchDown); 1198 window.addEventListener('mousemove', this.boundOnTouchMove); 1199 window.addEventListener('mouseup', this.boundOnTouchUp); 1200 window.addEventListener('touchstart', this.boundOnTouchDown); 1201 window.addEventListener('touchmove', this.boundOnTouchMove); 1202 window.addEventListener('touchend', this.boundOnTouchUp); 1203 1204 this.container?.addEventListener('keydown', this.boundOnKeyDown); 1205 } 1206 destroy() { 1207 window.cancelAnimationFrame(this.raf); 1208 window.removeEventListener('resize', this.boundOnResize); 1209 window.removeEventListener('mousewheel', this.boundOnWheel); 1210 window.removeEventListener('wheel', this.boundOnWheel); 1211 window.removeEventListener('mousedown', this.boundOnTouchDown); 1212 window.removeEventListener('mousemove', this.boundOnTouchMove); 1213 window.removeEventListener('mouseup', this.boundOnTouchUp); 1214 window.removeEventListener('touchstart', this.boundOnTouchDown); 1215 window.removeEventListener('touchmove', this.boundOnTouchMove); 1216 window.removeEventListener('touchend', this.boundOnTouchUp); 1217 if (this.renderer && this.renderer.gl && this.renderer.gl.canvas.parentNode) { 1218 this.renderer.gl.canvas.parentNode.removeChild(this.renderer.gl.canvas); 1219 } 1220 1221 if (this.container) { 1222 this.container.removeEventListener('keydown', this.boundOnKeyDown); 1223 } 1224 } 1225} 1226 1227export default function CircularGallery({ 1228 items, 1229 bend = 3, 1230 textColor = '#ffffff', 1231 borderRadius = 0.05, 1232 font = 'bold 30px Figtree', 1233 fontUrl, 1234 scrollSpeed = 2, 1235 scrollEase = 0.05 1236}) { 1237 const containerRef = useRef(null); 1238 useEffect(() => { 1239 if (!containerRef.current) return; 1240 let app; 1241 let isMounted = true; 1242 resolveFont(font, fontUrl).then(resolvedFont => { 1243 if (!isMounted || !containerRef.current) return; 1244 app = new App(containerRef.current, { 1245 items, 1246 bend, 1247 textColor, 1248 borderRadius, 1249 font: resolvedFont, 1250 scrollSpeed, 1251 scrollEase 1252 }); 1253 }); 1254 return () => { 1255 isMounted = false;
1256 if (app) app.destroy(); 1257 }; 1258 }, [items, bend, textColor, borderRadius, font, fontUrl, scrollSpeed, scrollEase]); 1259 return ( 1260 <div 1261 className="w-full h-full overflow-hidden cursor-grab active:cursor-grabbing focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4" 1262 tabIndex={0} 1263 role="region" 1264 aria-label="Circular image gallery. Use Left and Right Arrow keys to navigate." 1265 ref={containerRef} 1266 /> 1267 ); 1268} 1269`,H=`import { Camera, Mesh, Plane, Program, Renderer, Texture, Transform } from 'ogl'; 1270import { useEffect, useRef } from 'react'; 1271 1272import './CircularGallery.css'; 1273 1274type GL = Renderer['gl']; 1275 1276function debounce<T extends (...args: any[]) => void>(func: T, wait: number) { 1277 let timeout: number; 1278 return function (this: any, ...args: Parameters<T>) { 1279 window.clearTimeout(timeout); 1280 timeout = window.setTimeout(() => func.apply(this, args), wait); 1281 }; 1282} 1283 1284function lerp(p1: number, p2: number, t: number): number { 1285 return p1 + (p2 - p1) * t; 1286} 1287 1288function autoBind(instance: any): void { 1289 const proto = Object.getPrototypeOf(instance); 1290 Object.getOwnPropertyNames(proto).forEach(key => { 1291 if (key !== 'constructor' && typeof instance[key] === 'function') { 1292 instance[key] = instance[key].bind(instance); 1293 } 1294 }); 1295} 1296 1297const DEFAULT_FONT = 'bold 30px Figtree'; 1298// Figtree is not guaranteed to be available on the host page, so the component 1299// loads it on demand whenever the default font is used. 1300const DEFAULT_FONT_URL = 'https://fonts.googleapis.com/css2?family=Figtree:wght@400;700&display=swap'; 1301 1302function deriveFontFamilyFromUrl(url: string): string { 1303 const fileName = (url.split('/').pop() || 'custom-font').split('?')[0]; 1304 const base = fileName.replace(/\\.(woff2?|ttf|otf|eot)$/i, ''); 1305 return base.replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'CircularGalleryFont'; 1306} 1307 1308async function loadFontFromStylesheet(url: string): Promise<string> { 1309 const response = await fetch(url); 1310 if (!response.ok) throw new Error(\`Failed to fetch font stylesheet (\${response.status})\`); 1311 const cssText = await response.text(); 1312 const faceBlocks = cssText.match(/@font-face\\s*{[^}]*}/g) || []; 1313 let family: string | null = null; 1314 const fontFaces: FontFace[] = []; 1315 for (const block of faceBlocks) { 1316 const familyMatch = block.match(/font-family:\\s*['"]?([^;'"]+)['"]?/); 1317 const urlMatch = block.match(/url\\(\\s*['"]?([^'")]+)['"]?\\s*\\)/); 1318 if (!familyMatch || !urlMatch) continue; 1319 family = familyMatch[1].trim(); 1320 const descriptors: FontFaceDescriptors = {}; 1321 const weightMatch = block.match(/font-weight:\\s*([^;]+);/); 1322 const styleMatch = block.match(/font-style:\\s*([^;]+);/); 1323 const rangeMatch = block.match(/unicode-range:\\s*([^;]+);/); 1324 if (weightMatch) descriptors.weight = weightMatch[1].trim(); 1325 if (styleMatch) descriptors.style = styleMatch[1].trim(); 1326 if (rangeMatch) descriptors.unicodeRange = rangeMatch[1].trim(); 1327 fontFaces.push(new FontFace(family, \`url(\${urlMatch[1]})\`, descriptors)); 1328 } 1329 if (!family) throw new Error('No @font-face rule found in the stylesheet'); 1330 await Promise.allSettled( 1331 fontFaces.map(async face => { 1332 await face.load(); 1333 document.fonts.add(face); 1334 }) 1335 ); 1336 return family; 1337} 1338 1339async function loadFontFromFile(url: string): Promise<string> { 1340 const family = deriveFontFamilyFromUrl(url); 1341 const fontFace = new FontFace(family, \`url(\${url})\`); 1342 await fontFace.load(); 1343 document.fonts.add(fontFace); 1344 return family; 1345} 1346 1347async function loadCustomFont(fontUrl: string): Promise<string> { 1348 const isStylesheet = fontUrl.includes('fonts.googleapis.com') || /\\.css(\\?.*)?$/i.test(fontUrl); 1349 return isStylesheet ? loadFontFromStylesheet(fontUrl) : loadFontFromFile(fontUrl); 1350} 1351 1352// Loads \`fontUrl\` (a stylesheet such as a Google Fonts URL, or a direct font 1353// file) and returns a canvas-ready font string that keeps the size/weight from 1354// \`font\` but swaps in the freshly loaded family. Falls back to \`font\` on error. 1355async function resolveFont(font: string, fontUrl?: string): Promise<string> { 1356 // Use the bundled Figtree stylesheet when the caller relies on the default 1357 // font, otherwise honor the explicit \`fontUrl\`. 1358 const effectiveUrl = fontUrl || (font === DEFAULT_FONT ? DEFAULT_FONT_URL : null); 1359 if (!effectiveUrl) { 1360 // A custom family was supplied without a URL â make sure it is ready (in 1361 // case the host page declares it) before we draw it to the canvas, 1362 // otherwise the first paint silently falls back to a system font. 1363 if (document.fonts && document.fonts.load) {
1364 try { 1365 await document.fonts.load(font); 1366 await document.fonts.ready; 1367 } catch { 1368 // Ignore â fall back to whatever the browser provides. 1369 } 1370 } 1371 return font; 1372 } 1373 try { 1374 const family = await loadCustomFont(effectiveUrl); 1375 const sizeMatch = font.match(/^\\s*(.*?\\d+px)/); 1376 const prefix = sizeMatch ? sizeMatch[1].trim() : 'bold 30px'; 1377 const resolved = \`\${prefix} "\${family}"\`; 1378 if (document.fonts && document.fonts.load) { 1379 try { 1380 await document.fonts.load(resolved); 1381 } catch { 1382 // Ignore â we still attempt to render with the requested font. 1383 } 1384 } 1385 return resolved; 1386 } catch (error) { 1387 console.error('CircularGallery: unable to load font from', fontUrl, error); 1388 return font; 1389 } 1390} 1391 1392function getFontSize(font: string): number { 1393 const match = font.match(/(\\d+)px/); 1394 return match ? parseInt(match[1], 10) : 30; 1395} 1396 1397function createTextTexture( 1398 gl: GL, 1399 text: string, 1400 font: string = 'bold 30px monospace', 1401 color: string = 'black' 1402): { texture: Texture; width: number; height: number } { 1403 const canvas = document.createElement('canvas'); 1404 const context = canvas.getContext('2d'); 1405 if (!context) throw new Error('Could not get 2d context'); 1406 1407 context.font = font; 1408 const metrics = context.measureText(text); 1409 const textWidth = Math.ceil(metrics.width); 1410 const fontSize = getFontSize(font); 1411 const textHeight = Math.ceil(fontSize * 1.2); 1412 1413 canvas.width = textWidth + 20; 1414 canvas.height = textHeight + 20; 1415 1416 context.font = font; 1417 context.fillStyle = color; 1418 context.textBaseline = 'middle'; 1419 context.textAlign = 'center'; 1420 context.clearRect(0, 0, canvas.width, canvas.height); 1421 context.fillText(text, canvas.width / 2, canvas.height / 2); 1422 1423 const texture = new Texture(gl, { generateMipmaps: false }); 1424 texture.image = canvas; 1425 return { texture, width: canvas.width, height: canvas.height }; 1426} 1427 1428interface TitleProps { 1429 gl: GL; 1430 plane: Mesh; 1431 renderer: Renderer; 1432 text: string; 1433 textColor?: string; 1434 font?: string; 1435} 1436 1437class Title { 1438 gl: GL; 1439 plane: Mesh; 1440 renderer: Renderer; 1441 text: string; 1442 textColor: string; 1443 font: string; 1444 mesh!: Mesh; 1445 1446 constructor({ gl, plane, renderer, text, textColor = '#545050', font = '30px sans-serif' }: TitleProps) { 1447 autoBind(this); 1448 this.gl = gl; 1449 this.plane = plane; 1450 this.renderer = renderer; 1451 this.text = text; 1452 this.textColor = textColor; 1453 this.font = font; 1454 this.createMesh(); 1455 } 1456 1457 createMesh() { 1458 const { texture, width, height } = createTextTexture(this.gl, this.text, this.font, this.textColor); 1459 const geometry = new Plane(this.gl); 1460 const program = new Program(this.gl, { 1461 vertex: \` 1462 attribute vec3 position; 1463 attribute vec2 uv; 1464 uniform mat4 modelViewMatrix; 1465 uniform mat4 projectionMatrix; 1466 varying vec2 vUv; 1467 void main() { 1468 vUv = uv; 1469 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 1470 } 1471 \`, 1472 fragment: \` 1473 precision highp float; 1474 uniform sampler2D tMap; 1475 varying vec2 vUv; 1476 void main() { 1477 vec4 color = texture2D(tMap, vUv); 1478 if (color.a < 0.1) discard; 1479 gl_FragColor = color; 1480 } 1481 \`, 1482 uniforms: { tMap: { value: texture } }, 1483 transparent: true 1484 }); 1485 this.mesh = new Mesh(this.gl, { geometry, program }); 1486 const aspect = width / height; 1487 const textHeightScaled = this.plane.scale.y * 0.15; 1488 const textWidthScaled = textHeightScaled * aspect; 1489 this.mesh.scale.set(textWidthScaled, textHeightScaled, 1); 1490 this.mesh.position.y = -this.plane.scale.y * 0.5 - textHeightScaled * 0.5 - 0.05; 1491 this.mesh.setParent(this.plane); 1492 } 1493} 1494 1495interface ScreenSize { 1496 width: number; 1497 height: number; 1498} 1499 1500interface Viewport { 1501 width: number; 1502 height: number; 1503} 1504 1505interface MediaProps { 1506 geometry: Plane; 1507 gl: GL; 1508 image: string; 1509 index: number; 1510 length: number; 1511 renderer: Renderer; 1512 scene: Transform; 1513 screen: ScreenSize; 1514 text: string; 1515 viewport: Viewport; 1516 bend: number; 1517 textColor: string; 1518 borderRadius?: number; 1519 font?: string; 1520} 1521 1522class Media {
1523 extra: number = 0; 1524 geometry: Plane; 1525 gl: GL; 1526 image: string; 1527 index: number; 1528 length: number; 1529 renderer: Renderer; 1530 scene: Transform; 1531 screen: ScreenSize; 1532 text: string; 1533 viewport: Viewport; 1534 bend: number; 1535 textColor: string; 1536 borderRadius: number; 1537 font?: string; 1538 program!: Program; 1539 plane!: Mesh; 1540 title!: Title; 1541 scale!: number; 1542 padding!: number; 1543 width!: number; 1544 widthTotal!: number; 1545 x!: number; 1546 speed: number = 0; 1547 isBefore: boolean = false; 1548 isAfter: boolean = false; 1549 1550 constructor({ 1551 geometry, 1552 gl, 1553 image, 1554 index, 1555 length, 1556 renderer, 1557 scene, 1558 screen, 1559 text, 1560 viewport, 1561 bend, 1562 textColor, 1563 borderRadius = 0, 1564 font 1565 }: MediaProps) { 1566 this.geometry = geometry; 1567 this.gl = gl; 1568 this.image = image; 1569 this.index = index; 1570 this.length = length; 1571 this.renderer = renderer; 1572 this.scene = scene; 1573 this.screen = screen; 1574 this.text = text; 1575 this.viewport = viewport; 1576 this.bend = bend; 1577 this.textColor = textColor; 1578 this.borderRadius = borderRadius; 1579 this.font = font; 1580 this.createShader(); 1581 this.createMesh(); 1582 this.createTitle(); 1583 this.onResize(); 1584 } 1585 1586 createShader() { 1587 const texture = new Texture(this.gl, { 1588 generateMipmaps: true 1589 }); 1590 this.program = new Program(this.gl, { 1591 depthTest: false, 1592 depthWrite: false, 1593 vertex: \` 1594 precision highp float; 1595 attribute vec3 position; 1596 attribute vec2 uv; 1597 uniform mat4 modelViewMatrix; 1598 uniform mat4 projectionMatrix; 1599 uniform float uTime; 1600 uniform float uSpeed; 1601 varying vec2 vUv; 1602 void main() { 1603 vUv = uv; 1604 vec3 p = position; 1605 p.z = (sin(p.x * 4.0 + uTime) * 1.5 + cos(p.y * 2.0 + uTime) * 1.5) * (0.1 + uSpeed * 0.5); 1606 gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); 1607 } 1608 \`, 1609 fragment: \` 1610 precision highp float; 1611 uniform vec2 uImageSizes; 1612 uniform vec2 uPlaneSizes; 1613 uniform sampler2D tMap; 1614 uniform float uBorderRadius; 1615 varying vec2 vUv; 1616 1617 float roundedBoxSDF(vec2 p, vec2 b, float r) { 1618 vec2 d = abs(p) - b; 1619 return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r; 1620 } 1621 1622 void main() { 1623 vec2 ratio = vec2( 1624 min((uPlaneSizes.x / uPlaneSizes.y) / (uImageSizes.x / uImageSizes.y), 1.0), 1625 min((uPlaneSizes.y / uPlaneSizes.x) / (uImageSizes.y / uImageSizes.x), 1.0) 1626 ); 1627 vec2 uv = vec2( 1628 vUv.x * ratio.x + (1.0 - ratio.x) * 0.5, 1629 vUv.y * ratio.y + (1.0 - ratio.y) * 0.5 1630 ); 1631 vec4 color = texture2D(tMap, uv); 1632 1633 float d = roundedBoxSDF(vUv - 0.5, vec2(0.5 - uBorderRadius), uBorderRadius); 1634 1635 float edgeSmooth = 0.002; 1636 float alpha = 1.0 - smoothstep(-edgeSmooth, edgeSmooth, d); 1637 1638 gl_FragColor = vec4(color.rgb, alpha); 1639 } 1640 \`, 1641 uniforms: { 1642 tMap: { value: texture }, 1643 uPlaneSizes: { value: [0, 0] }, 1644 uImageSizes: { value: [0, 0] }, 1645 uSpeed: { value: 0 }, 1646 uTime: { value: 100 * Math.random() }, 1647 uBorderRadius: { value: this.borderRadius } 1648 }, 1649 transparent: true 1650 }); 1651 const img = new Image(); 1652 img.crossOrigin = 'anonymous'; 1653 img.src = this.image; 1654 img.onload = () => { 1655 texture.image = img; 1656 this.program.uniforms.uImageSizes.value = [img.naturalWidth, img.naturalHeight]; 1657 }; 1658 } 1659 1660 createMesh() { 1661 this.plane = new Mesh(this.gl, { 1662 geometry: this.geometry, 1663 program: this.program 1664 }); 1665 this.plane.setParent(this.scene); 1666 } 1667 1668 createTitle() { 1669 this.title = new Title({ 1670 gl: this.gl, 1671 plane: this.plane, 1672 renderer: this.renderer, 1673 text: this.text, 1674 textColor: this.textColor, 1675 font: this.font 1676 }); 1677 } 1678 1679 update(scroll: { current: number; last: number }, direction: 'right' | 'left') { 1680 this.plane.position.x = this.x - scroll.current - this.extra; 1681 1682 const x = this.plane.position.x; 1683 const H = this.viewport.width / 2; 1684 1685 if (this.bend === 0) { 1686 this.plane.position.y = 0; 1687 this.plane.rotation.z = 0; 1688 } else { 1689 const B_abs = Math.abs(this.bend); 1690 const R = (H * H + B_abs * B_abs) / (2 * B_abs); 1691 const effectiveX = Math.min(Math.abs(x), H); 1692 1693 const arc = R - Math.sqrt(R * R - effectiveX * effectiveX); 1694 if (this.bend > 0) { 1695 this.plane.position.y = -arc; 1696 this.plane.rotation.z = -Math.sign(x) * Math.asin(effectiveX / R); 1697 } else { 1698 this.plane.position.y = arc; 1699 this.plane.rotation.z = Math.sign(x) * Math.asin(effectiveX / R); 1700 } 1701 } 1702 1703 this.speed = scroll.current - scroll.last; 1704 this.program.uniforms.uTime.value += 0.04; 1705 this.program.uniforms.uSpeed.value = this.speed; 1706 1707 const planeOffset = this.plane.scale.x / 2; 1708 const viewportOffset = this.viewport.width / 2; 1709 this.isBefore = this.plane.position.x + planeOffset < -viewportOffset; 1710 this.isAfter = this.plane.position.x - planeOffset > viewportOffset; 1711 if (direction === 'right' && this.isBefore) { 1712 this.extra -= this.widthTotal; 1713 this.isBefore = this.isAfter = false;
1714 } 1715 if (direction === 'left' && this.isAfter) { 1716 this.extra += this.widthTotal; 1717 this.isBefore = this.isAfter = false; 1718 } 1719 } 1720 1721 onResize({ screen, viewport }: { screen?: ScreenSize; viewport?: Viewport } = {}) { 1722 if (screen) this.screen = screen; 1723 if (viewport) { 1724 this.viewport = viewport; 1725 if (this.plane.program.uniforms.uViewportSizes) { 1726 this.plane.program.uniforms.uViewportSizes.value = [this.viewport.width, this.viewport.height]; 1727 } 1728 } 1729 this.scale = this.screen.height / 1500; 1730 this.plane.scale.y = (this.viewport.height * (900 * this.scale)) / this.screen.height; 1731 this.plane.scale.x = (this.viewport.width * (700 * this.scale)) / this.screen.width; 1732 this.plane.program.uniforms.uPlaneSizes.value = [this.plane.scale.x, this.plane.scale.y]; 1733 this.padding = 2; 1734 this.width = this.plane.scale.x + this.padding; 1735 this.widthTotal = this.width * this.length; 1736 this.x = this.width * this.index; 1737 } 1738} 1739 1740interface AppConfig { 1741 items?: { image: string; text: string }[]; 1742 bend?: number; 1743 textColor?: string; 1744 borderRadius?: number; 1745 font?: string; 1746 scrollSpeed?: number; 1747 scrollEase?: number; 1748} 1749 1750class App { 1751 container: HTMLElement; 1752 scrollSpeed: number; 1753 scroll: { 1754 ease: number; 1755 current: number; 1756 target: number; 1757 last: number; 1758 position?: number; 1759 }; 1760 onCheckDebounce: (...args: any[]) => void; 1761 renderer!: Renderer; 1762 gl!: GL; 1763 camera!: Camera; 1764 scene!: Transform; 1765 planeGeometry!: Plane; 1766 medias: Media[] = []; 1767 mediasImages: { image: string; text: string }[] = []; 1768 screen!: { width: number; height: number }; 1769 viewport!: { width: number; height: number }; 1770 raf: number = 0; 1771 1772 boundOnResize!: () => void; 1773 boundOnWheel!: (e: Event) => void; 1774 boundOnTouchDown!: (e: MouseEvent | TouchEvent) => void; 1775 boundOnTouchMove!: (e: MouseEvent | TouchEvent) => void; 1776 boundOnTouchUp!: () => void; 1777 boundOnKeyDown!: (e: KeyboardEvent) => void; 1778 1779 isDown: boolean = false; 1780 start: number = 0; 1781 1782 constructor( 1783 container: HTMLElement, 1784 { 1785 items, 1786 bend = 1, 1787 textColor = '#ffffff', 1788 borderRadius = 0, 1789 font = 'bold 30px Figtree', 1790 scrollSpeed = 2, 1791 scrollEase = 0.05 1792 }: AppConfig 1793 ) { 1794 document.documentElement.classList.remove('no-js'); 1795 this.container = container; 1796 this.scrollSpeed = scrollSpeed; 1797 this.scroll = { ease: scrollEase, current: 0, target: 0, last: 0 }; 1798 this.onCheckDebounce = debounce(this.onCheck.bind(this), 200); 1799 this.createRenderer(); 1800 this.createCamera(); 1801 this.createScene(); 1802 this.onResize(); 1803 this.createGeometry(); 1804 this.createMedias(items, bend, textColor, borderRadius, font); 1805 this.update(); 1806 this.addEventListeners(); 1807 } 1808 1809 createRenderer() { 1810 this.renderer = new Renderer({ 1811 alpha: true, 1812 antialias: true, 1813 dpr: Math.min(window.devicePixelRatio || 1, 2) 1814 }); 1815 this.gl = this.renderer.gl; 1816 this.gl.clearColor(0, 0, 0, 0); 1817 this.container.appendChild(this.renderer.gl.canvas as HTMLCanvasElement); 1818 } 1819 1820 createCamera() { 1821 this.camera = new Camera(this.gl); 1822 this.camera.fov = 45; 1823 this.camera.position.z = 20; 1824 } 1825 1826 createScene() { 1827 this.scene = new Transform(); 1828 } 1829 1830 createGeometry() { 1831 this.planeGeometry = new Plane(this.gl, { 1832 heightSegments: 50, 1833 widthSegments: 100 1834 }); 1835 } 1836 1837 createMedias( 1838 items: { image: string; text: string }[] | undefined, 1839 bend: number = 1, 1840 textColor: string, 1841 borderRadius: number, 1842 font: string 1843 ) { 1844 const defaultItems = [ 1845 { 1846 image: \`https://picsum.photos/seed/1/800/600?grayscale\`, 1847 text: 'Bridge' 1848 }, 1849 { 1850 image: \`https://picsum.photos/seed/2/800/600?grayscale\`, 1851 text: 'Desk Setup' 1852 }, 1853 { 1854 image: \`https://picsum.photos/seed/3/800/600?grayscale\`, 1855 text: 'Waterfall' 1856 }, 1857 { 1858 image: \`https://picsum.photos/seed/4/800/600?grayscale\`, 1859 text: 'Strawberries' 1860 }, 1861 { 1862 image: \`https://picsum.photos/seed/5/800/600?grayscale\`, 1863 text: 'Deep Diving' 1864 }, 1865 { 1866 image: \`https://picsum.photos/seed/16/800/600?grayscale\`, 1867 text: 'Train Track' 1868 }, 1869 { 1870 image: \`https://picsum.photos/seed/17/800/600?grayscale\`, 1871 text: 'Santorini' 1872 }, 1873 { 1874 image: \`https://picsum.photos/seed/8/800/600?grayscale\`, 1875 text: 'Blurry Lights' 1876 }, 1877 { 1878 image: \`https://picsum.photos/seed/9/800/600?grayscale\`, 1879 text: 'New York' 1880 }, 1881 { 1882 image: \`https://picsum.photos/seed/10/800/600?grayscale\`, 1883 text: 'Good Boy' 1884 }, 1885 { 1886 image: \`https://picsum.photos/seed/21/800/600?grayscale\`, 1887 text: 'Coastline' 1888 }, 1889 { 1890 image: \`https://picsum.photos/seed/12/800/600?grayscale\`, 1891 text: 'Palm Trees' 1892 } 1893 ]; 1894 const galleryItems = items && items.length ? items : defaultItems; 1895 this.mediasImages = galleryItems.concat(galleryItems); 1896 this.medias = this.mediasImages.map((data, index) => { 1897 return new Media({ 1898 geometry: this.planeGeometry, 1899 gl: this.gl, 1900 image: data.image, 1901 index, 1902 length: this.mediasImages.length, 1903 renderer: this.renderer, 1904 scene: this.scene, 1905 screen: this.screen, 1906 text: data.text, 1907 viewport: this.viewport, 1908 bend, 1909 textColor, 1910 borderRadius, 1911 font 1912 }); 1913 }); 1914 } 1915 1916 onTouchDown(e: MouseEvent | TouchEvent) { 1917 this.isDown = true; 1918 this.scroll.position = this.scroll.current; 1919 this.start = 'touches' in e ? e.touches[0].clientX : e.clientX; 1920 } 1921 1922 onTouchMove(e: MouseEvent | TouchEvent) { 1923 if (!this.isDown) return; 1924 const x = 'touches' in e ? e.touches[0].clientX : e.clientX; 1925 const distance = (this.start - x) * (this.scrollSpeed * 0.025); 1926 this.scroll.target = (this.scroll.position ?? 0) + distance; 1927 } 1928 1929 onTouchUp() { 1930 this.isDown = false;
1931 this.onCheck(); 1932 } 1933 1934 onWheel(e: Event) { 1935 const wheelEvent = e as WheelEvent; 1936 const delta = wheelEvent.deltaY || (wheelEvent as any).wheelDelta || (wheelEvent as any).detail; 1937 this.scroll.target += (delta > 0 ? this.scrollSpeed : -this.scrollSpeed) * 0.2; 1938 this.onCheckDebounce(); 1939 } 1940 1941 onKeyDown(e: KeyboardEvent) { 1942 switch (e.key) { 1943 case 'ArrowRight': 1944 e.preventDefault(); 1945 this.scroll.target += this.scrollSpeed * 5; 1946 this.onCheckDebounce(); 1947 break; 1948 1949 case 'ArrowLeft': 1950 e.preventDefault(); 1951 this.scroll.target -= this.scrollSpeed * 5; 1952 this.onCheckDebounce(); 1953 break; 1954 1955 default: 1956 break; 1957 } 1958 } 1959 1960 onCheck() { 1961 if (!this.medias || !this.medias[0]) return; 1962 const width = this.medias[0].width; 1963 const itemIndex = Math.round(Math.abs(this.scroll.target) / width); 1964 const item = width * itemIndex; 1965 this.scroll.target = this.scroll.target < 0 ? -item : item; 1966 } 1967 1968 onResize() { 1969 this.screen = { 1970 width: this.container.clientWidth, 1971 height: this.container.clientHeight 1972 }; 1973 this.renderer.setSize(this.screen.width, this.screen.height); 1974 this.camera.perspective({ 1975 aspect: this.screen.width / this.screen.height 1976 }); 1977 const fov = (this.camera.fov * Math.PI) / 180; 1978 const height = 2 * Math.tan(fov / 2) * this.camera.position.z; 1979 const width = height * this.camera.aspect; 1980 this.viewport = { width, height }; 1981 if (this.medias) { 1982 this.medias.forEach(media => media.onResize({ screen: this.screen, viewport: this.viewport })); 1983 } 1984 } 1985 1986 update() { 1987 this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease); 1988 const direction = this.scroll.current > this.scroll.last ? 'right' : 'left'; 1989 if (this.medias) { 1990 this.medias.forEach(media => media.update(this.scroll, direction)); 1991 } 1992 this.renderer.render({ scene: this.scene, camera: this.camera }); 1993 this.scroll.last = this.scroll.current; 1994 this.raf = window.requestAnimationFrame(this.update.bind(this)); 1995 } 1996 1997 addEventListeners() { 1998 this.boundOnResize = this.onResize.bind(this); 1999 this.boundOnWheel = this.onWheel.bind(this); 2000 this.boundOnTouchDown = this.onTouchDown.bind(this); 2001 this.boundOnTouchMove = this.onTouchMove.bind(this); 2002 this.boundOnTouchUp = this.onTouchUp.bind(this); 2003 this.boundOnKeyDown = this.onKeyDown.bind(this); 2004 2005 window.addEventListener('resize', this.boundOnResize); 2006 window.addEventListener('mousewheel', this.boundOnWheel); 2007 window.addEventListener('wheel', this.boundOnWheel); 2008 window.addEventListener('mousedown', this.boundOnTouchDown); 2009 window.addEventListener('mousemove', this.boundOnTouchMove); 2010 window.addEventListener('mouseup', this.boundOnTouchUp); 2011 window.addEventListener('touchstart', this.boundOnTouchDown); 2012 window.addEventListener('touchmove', this.boundOnTouchMove); 2013 window.addEventListener('touchend', this.boundOnTouchUp); 2014 2015 this.container?.addEventListener('keydown', this.boundOnKeyDown); 2016 } 2017 2018 destroy() { 2019 window.cancelAnimationFrame(this.raf); 2020 window.removeEventListener('resize', this.boundOnResize); 2021 window.removeEventListener('mousewheel', this.boundOnWheel); 2022 window.removeEventListener('wheel', this.boundOnWheel); 2023 window.removeEventListener('mousedown', this.boundOnTouchDown); 2024 window.removeEventListener('mousemove', this.boundOnTouchMove); 2025 window.removeEventListener('mouseup', this.boundOnTouchUp); 2026 window.removeEventListener('touchstart', this.boundOnTouchDown); 2027 window.removeEventListener('touchmove', this.boundOnTouchMove); 2028 window.removeEventListener('touchend', this.boundOnTouchUp); 2029 if (this.renderer && this.renderer.gl && this.renderer.gl.canvas.parentNode) { 2030 this.renderer.gl.canvas.parentNode.removeChild(this.renderer.gl.canvas as HTMLCanvasElement); 2031 } 2032 if (this.container) { 2033 this.container.removeEventListener('keydown', this.boundOnKeyDown); 2034 } 2035 } 2036} 2037 2038interface CircularGalleryProps { 2039 items?: { image: string; text: string }[]; 2040 bend?: number; 2041 textColor?: string; 2042 borderRadius?: number; 2043 font?: string; 2044 fontUrl?: string; 2045 scrollSpeed?: number; 2046 scrollEase?: number; 2047} 2048 2049export default function CircularGallery({ 2050 items, 2051 bend = 3, 2052 textColor = '#ffffff', 2053 borderRadius = 0.05, 2054 font = 'bold 30px Figtree', 2055 fontUrl, 2056 scrollSpeed = 2, 2057 scrollEase = 0.05 2058}: CircularGalleryProps) { 2059 const containerRef = useRef<HTMLDivElement>(null); 2060 useEffect(() => { 2061 if (!containerRef.current) return; 2062 let app: App | undefined; 2063 let isMounted = true; 2064 resolveFont(font, fontUrl).then(resolvedFont => { 2065 if (!isMounted || !containerRef.current) return; 2066 app = new App(containerRef.current, { 2067 items, 2068 bend, 2069 textColor, 2070 borderRadius, 2071 font: resolvedFont, 2072 scrollSpeed, 2073 scrollEase 2074 }); 2075 }); 2076 return () => { 2077 isMounted = false;
2078 if (app) app.destroy(); 2079 }; 2080 }, [items, bend, textColor, borderRadius, font, fontUrl, scrollSpeed, scrollEase]); 2081 return ( 2082 <div 2083 className="circular-gallery" 2084 ref={containerRef} 2085 tabIndex={0} 2086 role="region" 2087 aria-label="Circular image gallery. Use Left and Right Arrow keys to navigate." 2088 /> 2089 ); 2090} 2091`,N=`import { Camera, Mesh, Plane, Program, Renderer, Texture, Transform } from 'ogl'; 2092import { useEffect, useRef } from 'react'; 2093 2094type GL = Renderer['gl']; 2095 2096function debounce<T extends (...args: any[]) => void>(func: T, wait: number) { 2097 let timeout: number; 2098 return function (this: any, ...args: Parameters<T>) { 2099 window.clearTimeout(timeout); 2100 timeout = window.setTimeout(() => func.apply(this, args), wait); 2101 }; 2102} 2103 2104function lerp(p1: number, p2: number, t: number): number { 2105 return p1 + (p2 - p1) * t; 2106} 2107 2108function autoBind(instance: any): void { 2109 const proto = Object.getPrototypeOf(instance); 2110 Object.getOwnPropertyNames(proto).forEach(key => { 2111 if (key !== 'constructor' && typeof instance[key] === 'function') { 2112 instance[key] = instance[key].bind(instance); 2113 } 2114 }); 2115} 2116 2117const DEFAULT_FONT = 'bold 30px Figtree'; 2118// Figtree is not guaranteed to be available on the host page, so the component 2119// loads it on demand whenever the default font is used. 2120const DEFAULT_FONT_URL = 'https://fonts.googleapis.com/css2?family=Figtree:wght@400;700&display=swap'; 2121 2122function deriveFontFamilyFromUrl(url: string): string { 2123 const fileName = (url.split('/').pop() || 'custom-font').split('?')[0]; 2124 const base = fileName.replace(/\\.(woff2?|ttf|otf|eot)$/i, ''); 2125 return base.replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'CircularGalleryFont'; 2126} 2127 2128async function loadFontFromStylesheet(url: string): Promise<string> { 2129 const response = await fetch(url); 2130 if (!response.ok) throw new Error(\`Failed to fetch font stylesheet (\${response.status})\`); 2131 const cssText = await response.text(); 2132 const faceBlocks = cssText.match(/@font-face\\s*{[^}]*}/g) || []; 2133 let family: string | null = null; 2134 const fontFaces: FontFace[] = []; 2135 for (const block of faceBlocks) { 2136 const familyMatch = block.match(/font-family:\\s*['"]?([^;'"]+)['"]?/); 2137 const urlMatch = block.match(/url\\(\\s*['"]?([^'")]+)['"]?\\s*\\)/); 2138 if (!familyMatch || !urlMatch) continue; 2139 family = familyMatch[1].trim(); 2140 const descriptors: FontFaceDescriptors = {}; 2141 const weightMatch = block.match(/font-weight:\\s*([^;]+);/); 2142 const styleMatch = block.match(/font-style:\\s*([^;]+);/); 2143 const rangeMatch = block.match(/unicode-range:\\s*([^;]+);/); 2144 if (weightMatch) descriptors.weight = weightMatch[1].trim(); 2145 if (styleMatch) descriptors.style = styleMatch[1].trim(); 2146 if (rangeMatch) descriptors.unicodeRange = rangeMatch[1].trim(); 2147 fontFaces.push(new FontFace(family, \`url(\${urlMatch[1]})\`, descriptors)); 2148 } 2149 if (!family) throw new Error('No @font-face rule found in the stylesheet'); 2150 await Promise.allSettled( 2151 fontFaces.map(async face => { 2152 await face.load(); 2153 document.fonts.add(face); 2154 }) 2155 ); 2156 return family; 2157} 2158 2159async function loadFontFromFile(url: string): Promise<string> { 2160 const family = deriveFontFamilyFromUrl(url); 2161 const fontFace = new FontFace(family, \`url(\${url})\`); 2162 await fontFace.load(); 2163 document.fonts.add(fontFace); 2164 return family; 2165} 2166 2167async function loadCustomFont(fontUrl: string): Promise<string> { 2168 const isStylesheet = fontUrl.includes('fonts.googleapis.com') || /\\.css(\\?.*)?$/i.test(fontUrl); 2169 return isStylesheet ? loadFontFromStylesheet(fontUrl) : loadFontFromFile(fontUrl); 2170} 2171 2172// Loads \`fontUrl\` (a stylesheet such as a Google Fonts URL, or a direct font 2173// file) and returns a canvas-ready font string that keeps the size/weight from 2174// \`font\` but swaps in the freshly loaded family. Falls back to \`font\` on error. 2175async function resolveFont(font: string, fontUrl?: string): Promise<string> { 2176 // Use the bundled Figtree stylesheet when the caller relies on the default 2177 // font, otherwise honor the explicit \`fontUrl\`. 2178 const effectiveUrl = fontUrl || (font === DEFAULT_FONT ? DEFAULT_FONT_URL : null); 2179 if (!effectiveUrl) { 2180 // A custom family was supplied without a URL â make sure it is ready (in 2181 // case the host page declares it) before we draw it to the canvas, 2182 // otherwise the first paint silently falls back to a system font. 2183 if (document.fonts && document.fonts.load) {
2184 try { 2185 await document.fonts.load(font); 2186 await document.fonts.ready; 2187 } catch { 2188 // Ignore â fall back to whatever the browser provides. 2189 } 2190 } 2191 return font; 2192 } 2193 try { 2194 const family = await loadCustomFont(effectiveUrl); 2195 const sizeMatch = font.match(/^\\s*(.*?\\d+px)/); 2196 const prefix = sizeMatch ? sizeMatch[1].trim() : 'bold 30px'; 2197 const resolved = \`\${prefix} "\${family}"\`; 2198 if (document.fonts && document.fonts.load) { 2199 try { 2200 await document.fonts.load(resolved); 2201 } catch { 2202 // Ignore â we still attempt to render with the requested font. 2203 } 2204 } 2205 return resolved; 2206 } catch (error) { 2207 console.error('CircularGallery: unable to load font from', fontUrl, error); 2208 return font; 2209 } 2210} 2211 2212function getFontSize(font: string): number { 2213 const match = font.match(/(\\d+)px/); 2214 return match ? parseInt(match[1], 10) : 30; 2215} 2216 2217function createTextTexture( 2218 gl: GL, 2219 text: string, 2220 font: string = 'bold 30px monospace', 2221 color: string = 'black' 2222): { texture: Texture; width: number; height: number } { 2223 const canvas = document.createElement('canvas'); 2224 const context = canvas.getContext('2d'); 2225 if (!context) throw new Error('Could not get 2d context'); 2226 2227 context.font = font; 2228 const metrics = context.measureText(text); 2229 const textWidth = Math.ceil(metrics.width); 2230 const fontSize = getFontSize(font); 2231 const textHeight = Math.ceil(fontSize * 1.2); 2232 2233 canvas.width = textWidth + 20; 2234 canvas.height = textHeight + 20; 2235 2236 context.font = font; 2237 context.fillStyle = color; 2238 context.textBaseline = 'middle'; 2239 context.textAlign = 'center'; 2240 context.clearRect(0, 0, canvas.width, canvas.height); 2241 context.fillText(text, canvas.width / 2, canvas.height / 2); 2242 2243 const texture = new Texture(gl, { generateMipmaps: false }); 2244 texture.image = canvas; 2245 return { texture, width: canvas.width, height: canvas.height }; 2246} 2247 2248interface TitleProps { 2249 gl: GL; 2250 plane: Mesh; 2251 renderer: Renderer; 2252 text: string; 2253 textColor?: string; 2254 font?: string; 2255} 2256 2257class Title { 2258 gl: GL; 2259 plane: Mesh; 2260 renderer: Renderer; 2261 text: string; 2262 textColor: string; 2263 font: string; 2264 mesh!: Mesh; 2265 2266 constructor({ gl, plane, renderer, text, textColor = '#545050', font = '30px sans-serif' }: TitleProps) { 2267 autoBind(this); 2268 this.gl = gl; 2269 this.plane = plane; 2270 this.renderer = renderer; 2271 this.text = text; 2272 this.textColor = textColor; 2273 this.font = font; 2274 this.createMesh(); 2275 } 2276 2277 createMesh() { 2278 const { texture, width, height } = createTextTexture(this.gl, this.text, this.font, this.textColor); 2279 const geometry = new Plane(this.gl); 2280 const program = new Program(this.gl, { 2281 vertex: \` 2282 attribute vec3 position; 2283 attribute vec2 uv; 2284 uniform mat4 modelViewMatrix; 2285 uniform mat4 projectionMatrix; 2286 varying vec2 vUv; 2287 void main() { 2288 vUv = uv; 2289 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 2290 } 2291 \`, 2292 fragment: \` 2293 precision highp float; 2294 uniform sampler2D tMap; 2295 varying vec2 vUv; 2296 void main() { 2297 vec4 color = texture2D(tMap, vUv); 2298 if (color.a < 0.1) discard; 2299 gl_FragColor = color; 2300 } 2301 \`, 2302 uniforms: { tMap: { value: texture } }, 2303 transparent: true 2304 }); 2305 this.mesh = new Mesh(this.gl, { geometry, program }); 2306 const aspect = width / height; 2307 const textHeightScaled = this.plane.scale.y * 0.15; 2308 const textWidthScaled = textHeightScaled * aspect; 2309 this.mesh.scale.set(textWidthScaled, textHeightScaled, 1); 2310 this.mesh.position.y = -this.plane.scale.y * 0.5 - textHeightScaled * 0.5 - 0.05; 2311 this.mesh.setParent(this.plane); 2312 } 2313} 2314 2315interface ScreenSize { 2316 width: number; 2317 height: number; 2318} 2319 2320interface Viewport { 2321 width: number; 2322 height: number; 2323} 2324 2325interface MediaProps { 2326 geometry: Plane; 2327 gl: GL; 2328 image: string; 2329 index: number; 2330 length: number; 2331 renderer: Renderer; 2332 scene: Transform; 2333 screen: ScreenSize; 2334 text: string; 2335 viewport: Viewport; 2336 bend: number; 2337 textColor: string; 2338 borderRadius?: number; 2339 font?: string; 2340} 2341 2342class Media {
2343 extra: number = 0; 2344 geometry: Plane; 2345 gl: GL; 2346 image: string; 2347 index: number; 2348 length: number; 2349 renderer: Renderer; 2350 scene: Transform; 2351 screen: ScreenSize; 2352 text: string; 2353 viewport: Viewport; 2354 bend: number; 2355 textColor: string; 2356 borderRadius: number; 2357 font?: string; 2358 program!: Program; 2359 plane!: Mesh; 2360 title!: Title; 2361 scale!: number; 2362 padding!: number; 2363 width!: number; 2364 widthTotal!: number; 2365 x!: number; 2366 speed: number = 0; 2367 isBefore: boolean = false; 2368 isAfter: boolean = false; 2369 2370 constructor({ 2371 geometry, 2372 gl, 2373 image, 2374 index, 2375 length, 2376 renderer, 2377 scene, 2378 screen, 2379 text, 2380 viewport, 2381 bend, 2382 textColor, 2383 borderRadius = 0, 2384 font 2385 }: MediaProps) { 2386 this.geometry = geometry; 2387 this.gl = gl; 2388 this.image = image; 2389 this.index = index; 2390 this.length = length; 2391 this.renderer = renderer; 2392 this.scene = scene; 2393 this.screen = screen; 2394 this.text = text; 2395 this.viewport = viewport; 2396 this.bend = bend; 2397 this.textColor = textColor; 2398 this.borderRadius = borderRadius; 2399 this.font = font; 2400 this.createShader(); 2401 this.createMesh(); 2402 this.createTitle(); 2403 this.onResize(); 2404 } 2405 2406 createShader() { 2407 const texture = new Texture(this.gl, { 2408 generateMipmaps: true 2409 }); 2410 this.program = new Program(this.gl, { 2411 depthTest: false, 2412 depthWrite: false, 2413 vertex: \` 2414 precision highp float; 2415 attribute vec3 position; 2416 attribute vec2 uv; 2417 uniform mat4 modelViewMatrix; 2418 uniform mat4 projectionMatrix; 2419 uniform float uTime; 2420 uniform float uSpeed; 2421 varying vec2 vUv; 2422 void main() { 2423 vUv = uv; 2424 vec3 p = position; 2425 p.z = (sin(p.x * 4.0 + uTime) * 1.5 + cos(p.y * 2.0 + uTime) * 1.5) * (0.1 + uSpeed * 0.5); 2426 gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); 2427 } 2428 \`, 2429 fragment: \` 2430 precision highp float; 2431 uniform vec2 uImageSizes; 2432 uniform vec2 uPlaneSizes; 2433 uniform sampler2D tMap; 2434 uniform float uBorderRadius; 2435 varying vec2 vUv; 2436 2437 float roundedBoxSDF(vec2 p, vec2 b, float r) { 2438 vec2 d = abs(p) - b; 2439 return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r; 2440 } 2441 2442 void main() { 2443 vec2 ratio = vec2( 2444 min((uPlaneSizes.x / uPlaneSizes.y) / (uImageSizes.x / uImageSizes.y), 1.0), 2445 min((uPlaneSizes.y / uPlaneSizes.x) / (uImageSizes.y / uImageSizes.x), 1.0) 2446 ); 2447 vec2 uv = vec2( 2448 vUv.x * ratio.x + (1.0 - ratio.x) * 0.5, 2449 vUv.y * ratio.y + (1.0 - ratio.y) * 0.5 2450 ); 2451 vec4 color = texture2D(tMap, uv); 2452 2453 float d = roundedBoxSDF(vUv - 0.5, vec2(0.5 - uBorderRadius), uBorderRadius); 2454 2455 // Smooth antialiasing for edges 2456 float edgeSmooth = 0.002; 2457 float alpha = 1.0 - smoothstep(-edgeSmooth, edgeSmooth, d); 2458 2459 gl_FragColor = vec4(color.rgb, alpha); 2460 } 2461 \`, 2462 uniforms: { 2463 tMap: { value: texture }, 2464 uPlaneSizes: { value: [0, 0] }, 2465 uImageSizes: { value: [0, 0] }, 2466 uSpeed: { value: 0 }, 2467 uTime: { value: 100 * Math.random() }, 2468 uBorderRadius: { value: this.borderRadius } 2469 }, 2470 transparent: true 2471 }); 2472 const img = new Image(); 2473 img.crossOrigin = 'anonymous'; 2474 img.src = this.image; 2475 img.onload = () => { 2476 texture.image = img; 2477 this.program.uniforms.uImageSizes.value = [img.naturalWidth, img.naturalHeight]; 2478 }; 2479 } 2480 2481 createMesh() { 2482 this.plane = new Mesh(this.gl, { 2483 geometry: this.geometry, 2484 program: this.program 2485 }); 2486 this.plane.setParent(this.scene); 2487 } 2488 2489 createTitle() { 2490 this.title = new Title({ 2491 gl: this.gl, 2492 plane: this.plane, 2493 renderer: this.renderer, 2494 text: this.text, 2495 textColor: this.textColor, 2496 font: this.font 2497 }); 2498 } 2499 2500 update(scroll: { current: number; last: number }, direction: 'right' | 'left') { 2501 this.plane.position.x = this.x - scroll.current - this.extra; 2502 2503 const x = this.plane.position.x; 2504 const H = this.viewport.width / 2; 2505 2506 if (this.bend === 0) { 2507 this.plane.position.y = 0; 2508 this.plane.rotation.z = 0; 2509 } else { 2510 const B_abs = Math.abs(this.bend); 2511 const R = (H * H + B_abs * B_abs) / (2 * B_abs); 2512 const effectiveX = Math.min(Math.abs(x), H); 2513 2514 const arc = R - Math.sqrt(R * R - effectiveX * effectiveX); 2515 if (this.bend > 0) { 2516 this.plane.position.y = -arc; 2517 this.plane.rotation.z = -Math.sign(x) * Math.asin(effectiveX / R); 2518 } else { 2519 this.plane.position.y = arc; 2520 this.plane.rotation.z = Math.sign(x) * Math.asin(effectiveX / R); 2521 } 2522 } 2523 2524 this.speed = scroll.current - scroll.last; 2525 this.program.uniforms.uTime.value += 0.04; 2526 this.program.uniforms.uSpeed.value = this.speed; 2527 2528 const planeOffset = this.plane.scale.x / 2; 2529 const viewportOffset = this.viewport.width / 2; 2530 this.isBefore = this.plane.position.x + planeOffset < -viewportOffset; 2531 this.isAfter = this.plane.position.x - planeOffset > viewportOffset; 2532 if (direction === 'right' && this.isBefore) { 2533 this.extra -= this.widthTotal; 2534 this.isBefore = this.isAfter = false;
2535 } 2536 if (direction === 'left' && this.isAfter) { 2537 this.extra += this.widthTotal; 2538 this.isBefore = this.isAfter = false; 2539 } 2540 } 2541 2542 onResize({ screen, viewport }: { screen?: ScreenSize; viewport?: Viewport } = {}) { 2543 if (screen) this.screen = screen; 2544 if (viewport) { 2545 this.viewport = viewport; 2546 if (this.plane.program.uniforms.uViewportSizes) { 2547 this.plane.program.uniforms.uViewportSizes.value = [this.viewport.width, this.viewport.height]; 2548 } 2549 } 2550 this.scale = this.screen.height / 1500; 2551 this.plane.scale.y = (this.viewport.height * (900 * this.scale)) / this.screen.height; 2552 this.plane.scale.x = (this.viewport.width * (700 * this.scale)) / this.screen.width; 2553 this.plane.program.uniforms.uPlaneSizes.value = [this.plane.scale.x, this.plane.scale.y]; 2554 this.padding = 2; 2555 this.width = this.plane.scale.x + this.padding; 2556 this.widthTotal = this.width * this.length; 2557 this.x = this.width * this.index; 2558 } 2559} 2560 2561interface AppConfig { 2562 items?: { image: string; text: string }[]; 2563 bend?: number; 2564 textColor?: string; 2565 borderRadius?: number; 2566 font?: string; 2567 scrollSpeed?: number; 2568 scrollEase?: number; 2569} 2570 2571class App { 2572 container: HTMLElement; 2573 scrollSpeed: number; 2574 scroll: { 2575 ease: number; 2576 current: number; 2577 target: number; 2578 last: number; 2579 position?: number; 2580 }; 2581 onCheckDebounce: (...args: any[]) => void; 2582 renderer!: Renderer; 2583 gl!: GL; 2584 camera!: Camera; 2585 scene!: Transform; 2586 planeGeometry!: Plane; 2587 medias: Media[] = []; 2588 mediasImages: { image: string; text: string }[] = []; 2589 screen!: { width: number; height: number }; 2590 viewport!: { width: number; height: number }; 2591 raf: number = 0; 2592 2593 boundOnResize!: () => void; 2594 boundOnWheel!: (e: Event) => void; 2595 boundOnTouchDown!: (e: MouseEvent | TouchEvent) => void; 2596 boundOnTouchMove!: (e: MouseEvent | TouchEvent) => void; 2597 boundOnTouchUp!: () => void; 2598 boundOnKeyDown!: (e: KeyboardEvent) => void; 2599 2600 isDown: boolean = false; 2601 start: number = 0; 2602 2603 constructor( 2604 container: HTMLElement, 2605 { 2606 items, 2607 bend = 1, 2608 textColor = '#ffffff', 2609 borderRadius = 0, 2610 font = 'bold 30px Figtree', 2611 scrollSpeed = 2, 2612 scrollEase = 0.05 2613 }: AppConfig 2614 ) { 2615 document.documentElement.classList.remove('no-js'); 2616 this.container = container; 2617 this.scrollSpeed = scrollSpeed; 2618 this.scroll = { ease: scrollEase, current: 0, target: 0, last: 0 }; 2619 this.onCheckDebounce = debounce(this.onCheck.bind(this), 200); 2620 this.createRenderer(); 2621 this.createCamera(); 2622 this.createScene(); 2623 this.onResize(); 2624 this.createGeometry(); 2625 this.createMedias(items, bend, textColor, borderRadius, font); 2626 this.update(); 2627 this.addEventListeners(); 2628 } 2629 2630 createRenderer() { 2631 this.renderer = new Renderer({ 2632 alpha: true, 2633 antialias: true, 2634 dpr: Math.min(window.devicePixelRatio || 1, 2) 2635 }); 2636 this.gl = this.renderer.gl; 2637 this.gl.clearColor(0, 0, 0, 0); 2638 this.container.appendChild(this.renderer.gl.canvas as HTMLCanvasElement); 2639 } 2640 2641 createCamera() { 2642 this.camera = new Camera(this.gl); 2643 this.camera.fov = 45; 2644 this.camera.position.z = 20; 2645 } 2646 2647 createScene() { 2648 this.scene = new Transform(); 2649 } 2650 2651 createGeometry() { 2652 this.planeGeometry = new Plane(this.gl, { 2653 heightSegments: 50, 2654 widthSegments: 100 2655 }); 2656 } 2657 2658 createMedias( 2659 items: { image: string; text: string }[] | undefined, 2660 bend: number = 1, 2661 textColor: string, 2662 borderRadius: number, 2663 font: string 2664 ) { 2665 const defaultItems = [ 2666 { 2667 image: \`https://picsum.photos/seed/1/800/600?grayscale\`, 2668 text: 'Bridge' 2669 }, 2670 { 2671 image: \`https://picsum.photos/seed/2/800/600?grayscale\`, 2672 text: 'Desk Setup' 2673 }, 2674 { 2675 image: \`https://picsum.photos/seed/3/800/600?grayscale\`, 2676 text: 'Waterfall' 2677 }, 2678 { 2679 image: \`https://picsum.photos/seed/4/800/600?grayscale\`, 2680 text: 'Strawberries' 2681 }, 2682 { 2683 image: \`https://picsum.photos/seed/5/800/600?grayscale\`, 2684 text: 'Deep Diving' 2685 }, 2686 { 2687 image: \`https://picsum.photos/seed/16/800/600?grayscale\`, 2688 text: 'Train Track' 2689 }, 2690 { 2691 image: \`https://picsum.photos/seed/17/800/600?grayscale\`, 2692 text: 'Santorini' 2693 }, 2694 { 2695 image: \`https://picsum.photos/seed/8/800/600?grayscale\`, 2696 text: 'Blurry Lights' 2697 }, 2698 { 2699 image: \`https://picsum.photos/seed/9/800/600?grayscale\`, 2700 text: 'New York' 2701 }, 2702 { 2703 image: \`https://picsum.photos/seed/10/800/600?grayscale\`, 2704 text: 'Good Boy' 2705 }, 2706 { 2707 image: \`https://picsum.photos/seed/21/800/600?grayscale\`, 2708 text: 'Coastline' 2709 }, 2710 { 2711 image: \`https://picsum.photos/seed/12/800/600?grayscale\`, 2712 text: 'Palm Trees' 2713 } 2714 ]; 2715 const galleryItems = items && items.length ? items : defaultItems; 2716 this.mediasImages = galleryItems.concat(galleryItems); 2717 this.medias = this.mediasImages.map((data, index) => { 2718 return new Media({ 2719 geometry: this.planeGeometry, 2720 gl: this.gl, 2721 image: data.image, 2722 index, 2723 length: this.mediasImages.length, 2724 renderer: this.renderer, 2725 scene: this.scene, 2726 screen: this.screen, 2727 text: data.text, 2728 viewport: this.viewport, 2729 bend, 2730 textColor, 2731 borderRadius, 2732 font 2733 }); 2734 }); 2735 } 2736 2737 onTouchDown(e: MouseEvent | TouchEvent) { 2738 this.isDown = true; 2739 this.scroll.position = this.scroll.current; 2740 this.start = 'touches' in e ? e.touches[0].clientX : e.clientX; 2741 } 2742 2743 onTouchMove(e: MouseEvent | TouchEvent) { 2744 if (!this.isDown) return; 2745 const x = 'touches' in e ? e.touches[0].clientX : e.clientX; 2746 const distance = (this.start - x) * (this.scrollSpeed * 0.025); 2747 this.scroll.target = (this.scroll.position ?? 0) + distance; 2748 } 2749 2750 onTouchUp() { 2751 this.isDown = false;
2752 this.onCheck(); 2753 } 2754 2755 onWheel(e: Event) { 2756 const wheelEvent = e as WheelEvent; 2757 const delta = wheelEvent.deltaY || (wheelEvent as any).wheelDelta || (wheelEvent as any).detail; 2758 this.scroll.target += (delta > 0 ? this.scrollSpeed : -this.scrollSpeed) * 0.2; 2759 this.onCheckDebounce(); 2760 } 2761 2762 onKeyDown(e: KeyboardEvent) { 2763 switch (e.key) { 2764 case 'ArrowRight': 2765 e.preventDefault(); 2766 this.scroll.target += this.scrollSpeed * 5; 2767 this.onCheckDebounce(); 2768 break; 2769 2770 case 'ArrowLeft': 2771 e.preventDefault(); 2772 this.scroll.target -= this.scrollSpeed * 5; 2773 this.onCheckDebounce(); 2774 break; 2775 } 2776 } 2777 2778 onCheck() { 2779 if (!this.medias || !this.medias[0]) return; 2780 const width = this.medias[0].width; 2781 const itemIndex = Math.round(Math.abs(this.scroll.target) / width); 2782 const item = width * itemIndex; 2783 this.scroll.target = this.scroll.target < 0 ? -item : item; 2784 } 2785 2786 onResize() { 2787 this.screen = { 2788 width: this.container.clientWidth, 2789 height: this.container.clientHeight 2790 }; 2791 this.renderer.setSize(this.screen.width, this.screen.height); 2792 this.camera.perspective({ 2793 aspect: this.screen.width / this.screen.height 2794 }); 2795 const fov = (this.camera.fov * Math.PI) / 180; 2796 const height = 2 * Math.tan(fov / 2) * this.camera.position.z; 2797 const width = height * this.camera.aspect; 2798 this.viewport = { width, height }; 2799 if (this.medias) { 2800 this.medias.forEach(media => media.onResize({ screen: this.screen, viewport: this.viewport })); 2801 } 2802 } 2803 2804 update() { 2805 this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease); 2806 const direction = this.scroll.current > this.scroll.last ? 'right' : 'left'; 2807 if (this.medias) { 2808 this.medias.forEach(media => media.update(this.scroll, direction)); 2809 } 2810 this.renderer.render({ scene: this.scene, camera: this.camera }); 2811 this.scroll.last = this.scroll.current; 2812 this.raf = window.requestAnimationFrame(this.update.bind(this)); 2813 } 2814 2815 addEventListeners() { 2816 this.boundOnResize = this.onResize.bind(this); 2817 this.boundOnWheel = this.onWheel.bind(this); 2818 this.boundOnTouchDown = this.onTouchDown.bind(this); 2819 this.boundOnTouchMove = this.onTouchMove.bind(this); 2820 this.boundOnTouchUp = this.onTouchUp.bind(this); 2821 this.boundOnKeyDown = this.onKeyDown.bind(this); 2822 2823 window.addEventListener('resize', this.boundOnResize); 2824 window.addEventListener('mousewheel', this.boundOnWheel); 2825 window.addEventListener('wheel', this.boundOnWheel); 2826 window.addEventListener('mousedown', this.boundOnTouchDown); 2827 window.addEventListener('mousemove', this.boundOnTouchMove); 2828 window.addEventListener('mouseup', this.boundOnTouchUp); 2829 window.addEventListener('touchstart', this.boundOnTouchDown); 2830 window.addEventListener('touchmove', this.boundOnTouchMove); 2831 window.addEventListener('touchend', this.boundOnTouchUp); 2832 2833 this.container?.addEventListener( 2834 'keydown', 2835 2836 this.boundOnKeyDown 2837 ); 2838 } 2839 2840 destroy() { 2841 window.cancelAnimationFrame(this.raf); 2842 window.removeEventListener('resize', this.boundOnResize); 2843 window.removeEventListener('mousewheel', this.boundOnWheel); 2844 window.removeEventListener('wheel', this.boundOnWheel); 2845 window.removeEventListener('mousedown', this.boundOnTouchDown); 2846 window.removeEventListener('mousemove', this.boundOnTouchMove); 2847 window.removeEventListener('mouseup', this.boundOnTouchUp); 2848 window.removeEventListener('touchstart', this.boundOnTouchDown); 2849 window.removeEventListener('touchmove', this.boundOnTouchMove); 2850 window.removeEventListener('touchend', this.boundOnTouchUp); 2851 if (this.renderer && this.renderer.gl && this.renderer.gl.canvas.parentNode) { 2852 this.renderer.gl.canvas.parentNode.removeChild(this.renderer.gl.canvas as HTMLCanvasElement); 2853 } 2854 if (this.container) { 2855 this.container.removeEventListener( 2856 'keydown', 2857 2858 this.boundOnKeyDown 2859 ); 2860 } 2861 } 2862} 2863 2864interface CircularGalleryProps { 2865 items?: { image: string; text: string }[]; 2866 bend?: number; 2867 textColor?: string; 2868 borderRadius?: number; 2869 font?: string; 2870 fontUrl?: string; 2871 scrollSpeed?: number; 2872 scrollEase?: number; 2873} 2874 2875export default function CircularGallery({ 2876 items, 2877 bend = 3, 2878 textColor = '#ffffff', 2879 borderRadius = 0.05, 2880 font = 'bold 30px Figtree', 2881 fontUrl, 2882 scrollSpeed = 2, 2883 scrollEase = 0.05 2884}: CircularGalleryProps) { 2885 const containerRef = useRef<HTMLDivElement>(null); 2886 useEffect(() => { 2887 if (!containerRef.current) return; 2888 let app: App | undefined; 2889 let isMounted = true; 2890 resolveFont(font, fontUrl).then(resolvedFont => { 2891 if (!isMounted || !containerRef.current) return; 2892 app = new App(containerRef.current, { 2893 items, 2894 bend, 2895 textColor, 2896 borderRadius, 2897 font: resolvedFont, 2898 scrollSpeed, 2899 scrollEase 2900 }); 2901 }); 2902 return () => { 2903 isMounted = false;
2904 if (app) app.destroy(); 2905 }; 2906 }, [items, bend, textColor, borderRadius, font, fontUrl, scrollSpeed, scrollEase]); 2907 return ( 2908 <div 2909 className="w-full h-full overflow-hidden cursor-grab active:cursor-grabbing" 2910 ref={containerRef} 2911 tabIndex={0} 2912 role="region" 2913 aria-label="Circular image gallery. Use Left and Right Arrow keys to navigate." 2914 /> 2915 ); 2916} 2917`,j={dependencies:"ogl",usage:`import CircularGallery from './CircularGallery' 2918 2919<div style={{ height: '600px', position: 'relative' }}> 2920 <CircularGallery 2921 bend={3} 2922 textColor="#ffffff" 2923 borderRadius={0.05} 2924 scrollEase={0.02} 2925 // Optionally load a custom font for the labels. 2926 // Accepts a stylesheet URL (e.g. Google Fonts) or a direct font file. 2927 fontUrl="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap" 2928 font="bold 30px Orbitron" 2929 /> 2930</div>`,code:G,css:W,tailwind:_,tsCode:H,tsTailwind:N};function X(t,e){let n;return function(...s){clearTimeout(n),n=setTimeout(()=>t.apply(this,s),e)}}function V(t,e,n){return t+(e-t)*n}function $(t){const e=Object.getPrototypeOf(t);Object.getOwnPropertyNames(e).forEach(n=>{n!=="constructor"&&typeof t[n]=="function"&&(t[n]=t[n].bind(t))})}const K="bold 30px Figtree",q="https://fonts.googleapis.com/css2?family=Figtree:wght@400;700&display=swap";function Y(t){return(t.split("/").pop()||"custom-font").split("?")[0].replace(/\.(woff2?|ttf|otf|eot)$/i,"").replace(/[^a-zA-Z0-9-_ ]/g,"").trim()||"CircularGalleryFont"}async function Z(t){const e=await fetch(t);if(!e.ok)throw new Error(`Failed to fetch font stylesheet (${e.status})`);const s=(await e.text()).match(/@font-face\s*{[^}]*}/g)||[];let i=null;const r=[];for(const o of s){const h=o.match(/font-family:\s*['"]?([^;'"]+)['"]?/),a=o.match(/url\(\s*['"]?([^'")]+)['"]?\s*\)/);if(!h||!a)continue;i=h[1].trim();const c={},d=o.match(/font-weight:\s*([^;]+);/),u=o.match(/font-style:\s*([^;]+);/),f=o.match(/unicode-range:\s*([^;]+);/);d&&(c.weight=d[1].trim()),u&&(c.style=u[1].trim()),f&&(c.unicodeRange=f[1].trim()),r.push(new FontFace(i,`url(${a[1]})`,c))}if(!i)throw new Error("No @font-face rule found in the stylesheet");return await Promise.allSettled(r.map(async o=>{await o.load(),document.fonts.add(o)})),i}async function J(t){const e=Y(t),n=new FontFace(e,`url(${t})`);return await n.load(),document.fonts.add(n),e}async function Q(t){return t.includes("fonts.googleapis.com")||/\.css(\?.*)?$/i.test(t)?Z(t):J(t)}async function ee(t,e){const n=e||(t===K?q:null);if(!n){if(document.fonts&&document.fonts.load)try{await document.fonts.load(t),await document.fonts.ready}catch{}return t}try{const s=await Q(n),i=t.match(/^\s*(.*?\d+px)/),o=`${i?i[1].trim():"bold 30px"} "${s}"`;if(document.fonts&&document.fonts.load)try{await document.fonts.load(o)}catch{}return o}catch(s){return console.error("CircularGallery: unable to load font from",e,s),t}}function ne(t){const e=t.match(/(\d+)px/);return e?parseInt(e[1],10):30}function te(t,e,n="bold 30px monospace",s="black"){const i=document.createElement("canvas"),r=i.getContext("2d");r.font=n;const o=r.measureText(e),h=Math.ceil(o.width),a=Math.ceil(ne(n)*1.2);i.width=h+20,i.height=a+20,r.font=n,r.fillStyle=s,r.textBaseline="middle",r.textAlign="center",r.clearRect(0,0,i.width,i.height),r.fillText(e,i.width/2,i.height/2);const c=new y(t,{generateMipmaps:!1});return c.image=i,{texture:c,width:i.width,height:i.height}}class ie{constructor({gl:e,plane:n,renderer:s,text:i,textColor:r="#545050",font:o="30px sans-serif"}){$(this),this.gl=e,this.plane=n,this.renderer=s,this.text=i,this.textColor=r,this.font=o,this.createMesh()}createMesh(){const{texture:e,width:n,height:s}=te(this.gl,this.text,this.font,this.textColor),i=new b(this.gl),r=new w(this.gl,{vertex:` 2931 attribute vec3 position; 2932 attribute vec2 uv; 2933 uniform mat4 modelViewMatrix; 2934 uniform mat4 projectionMatrix; 2935 varying vec2 vUv; 2936 void main() { 2937 vUv = uv; 2938 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 2939 } 2940 `,fragment:` 2941 precision highp float; 2942 uniform sampler2D tMap; 2943 varying vec2 vUv; 2944 void main() { 2945 vec4 color = texture2D(tMap, vUv); 2946 if (color.a < 0.1) discard; 2947 gl_FragColor = color; 2948 } 2949 `,uniforms:{tMap:{value:e}},transparent:!0});this.mesh=new x(this.gl,{geometry:i,program:r});const o=n/s,h=this.plane.scale.y*.15,a=h*o;this.mesh.scale.set(a,h,1),this.mesh.position.y=-this.plane.scale.y*.5-h*.5-.05,this.mesh.setParent(this.plane)}}class se{constructor({geometry:e,gl:n,image:s,index:i,length:r,renderer:o,scene:h,screen:a,text:c,viewport:d,bend:u,textColor:f,borderRadius:m=0,font:
2949M}){this.extra=0,this.geometry=e,this.gl=n,this.image=s,this.index=i,this.length=r,this.renderer=o,this.scene=h,this.screen=a,this.text=c,this.viewport=d,this.bend=u,this.textColor=f,this.borderRadius=m,this.font=M,this.createShader(),this.createMesh(),this.createTitle(),this.onResize()}createShader(){const e=new y(this.gl,{generateMipmaps:!0});this.program=new w(this.gl,{depthTest:!1,depthWrite:!1,vertex:` 2950 precision highp float; 2951 attribute vec3 position; 2952 attribute vec2 uv; 2953 uniform mat4 modelViewMatrix; 2954 uniform mat4 projectionMatrix; 2955 uniform float uTime; 2956 uniform float uSpeed; 2957 varying vec2 vUv; 2958 void main() { 2959 vUv = uv; 2960 vec3 p = position; 2961 p.z = (sin(p.x * 4.0 + uTime) * 1.5 + cos(p.y * 2.0 + uTime) * 1.5) * (0.1 + uSpeed * 0.5); 2962 gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); 2963 } 2964 `,fragment:` 2965 precision highp float; 2966 uniform vec2 uImageSizes; 2967 uniform vec2 uPlaneSizes; 2968 uniform sampler2D tMap; 2969 uniform float uBorderRadius; 2970 varying vec2 vUv; 2971 2972 float roundedBoxSDF(vec2 p, vec2 b, float r) { 2973 vec2 d = abs(p) - b; 2974 return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r; 2975 } 2976 2977 void main() { 2978 vec2 ratio = vec2( 2979 min((uPlaneSizes.x / uPlaneSizes.y) / (uImageSizes.x / uImageSizes.y), 1.0), 2980 min((uPlaneSizes.y / uPlaneSizes.x) / (uImageSizes.y / uImageSizes.x), 1.0) 2981 ); 2982 vec2 uv = vec2( 2983 vUv.x * ratio.x + (1.0 - ratio.x) * 0.5, 2984 vUv.y * ratio.y + (1.0 - ratio.y) * 0.5 2985 ); 2986 vec4 color = texture2D(tMap, uv); 2987 2988 float d = roundedBoxSDF(vUv - 0.5, vec2(0.5 - uBorderRadius), uBorderRadius); 2989 2990 // Smooth antialiasing for edges 2991 float edgeSmooth = 0.002; 2992 float alpha = 1.0 - smoothstep(-edgeSmooth, edgeSmooth, d); 2993 2994 gl_FragColor = vec4(color.rgb, alpha); 2995 } 2996 `,uniforms:{tMap:{value:e},uPlaneSizes:{value:[0,0]},uImageSizes:{value:[0,0]},uSpeed:{value:0},uTime:{value:100*Math.random()},uBorderRadius:{value:this.borderRadius}},transparent:!0});const n=new Image;n.crossOrigin="anonymous",n.src=this.image,n.onload=()=>{e.image=n,this.program.uniforms.uImageSizes.value=[n.naturalWidth,n.naturalHeight]}}createMesh(){this.plane=new x(this.gl,{geometry:this.geometry,program:this.program}),this.plane.setParent(this.scene)}createTitle(){this.title=new ie({gl:this.gl,plane:this.plane,renderer:this.renderer,text:this.text,textColor:this.textColor,font:this.font})}update(e,n){this.plane.position.x=this.x-e.current-this.extra;const s=this.plane.position.x,i=this.viewport.width/2;if(this.bend===0)this.plane.position.y=0,this.plane.rotation.z=0;else{const h=Math.abs(this.bend),a=(i*i+h*h)/(2*h),c=Math.min(Math.abs(s),i),d=a-Math.sqrt(a*a-c*c);this.bend>0?(this.plane.position.y=-d,this.plane.rotation.z=-Math.sign(s)*Math.asin(c/a)):(this.plane.position.y=d,this.plane.rotation.z=Math.sign(s)*Math.asin(c/a))}this.speed=e.current-e.last,this.program.uniforms.uTime.value+=.04,this.program.uniforms.uSpeed.value=this.speed;const r=this.plane.scale.x/2,o=this.viewport.width/2;this.isBefore=this.plane.position.x+r<-o,this.isAfter=this.plane.position.x-r>o,n==="right"&&this.isBefore&&(this.extra-=this.widthTotal,this.isBefore=this.isAfter=!1),n==="left"&&this.isAfter&&(this.extra+=this.widthTotal,this.isBefore=this.isAfter=!1)}onResize({screen:e,viewport:n}={}){e&&(this.screen=e),n&&(this.viewport=n,this.plane.program.uniforms.uViewportSizes&&(this.plane.program.uniforms.uViewportSizes.value=[this.viewport.width,this.viewport.height])),this.scale=this.screen.height/1500,this.plane.scale.y=this.viewport.height*(900*this.scale)/this.screen.height,this.plane.scale.x=this.viewport.width*(700*this.scale)/this.screen.width,this.plane.program.uniforms.uPlaneSizes.value=[this.plane.scale.x,this.plane.scale.y],this.padding=2,this.width=this.plane.scale.x+this.padding,this.widthTotal=this.width*this.length,this.x=this.width*this.index}}class oe{constructor(e,{items:n,bend:s,textColor:i="#ffffff",borderRadius:r=0,font:o="bold 30px Figtree",scrollSpeed:h=2,scrollEase:a=.05}={}){document.documentElement.classList.remove("no-js"),this.container=e,this.scrollSpeed=h,this.scroll={ease:a,current:0,target:0,last:0},this.onCheckDebounce=X(this.onCheck,200),this.createRenderer(),this.createCamera(),this.createScene(),this.onResize(),this.createGeometry(),this.createMedias(n,s,i,r,o),this.update(),this.addEventListeners()}createRenderer(){this.renderer=new T({alpha:!0,antialias:!0,dpr:Math.min(window.devicePixelRatio||1,2)}),this.gl=this.renderer.gl,this.gl.clearColor(0,0,0,0),this.container.appendChild(this.gl.canvas)}createCamera(){this.camera=new A(this.gl),this.camera.fov=45,this.camera.position.z=20}createScene(){this.scene=new S}createGeometry(){this.planeGeometry=new b(this.gl,{heightSegments:50,widthSegments:100})}createMedias(e,n=1,s,i,r){const o=[{image:"https://picsum.photos/seed/1/800/600?grayscale",text:"Bridge"},{image:"https://picsum.photos/seed/2/800/600?grayscale",text:"Desk Setup"},{image:"https://picsum.photos/seed/3/800/600?grayscale",text:"Waterfall"},{image:"https://picsum.photos/seed/4/800/600?grayscale",text:"Strawberries"},{image:"https://picsum.photos/seed/5/800/600?grayscale",text:"Deep Diving"},{image:"https://picsum.photos/seed/16/800/600?grayscale",text:"Train Track"},{image:"https://picsum.photos/seed/17/800/600?grayscale",text:"Santorini"},{image:"https://picsum.photos/seed/8/800/600?grayscale",text:"Blurry Lights"},{image:"https://picsum.photos/seed/9/800/600?grayscale",text:"New York"},{image:"https://picsum.photos/seed/10/800/600?grayscale",text:"Good Boy"},{image:"https://picsum.photos/seed/21/800/600?grayscale",text:"Coastline"},{image:"https://picsum.photos/seed/12/800/600?grayscale",text:"Palm Trees"}],h=e&&e.length?e:o;this.mediasImages=h.concat(h),this.medias=this.mediasImages.map((a,c)=>new se({geometry:this.planeGeometry,gl:this.gl,image:a.image,index:c,length:this.mediasImages.length,renderer:this.renderer,scene:this.scene,screen:this.screen,text:a.text,viewport:this.viewport,bend:n,textColor:s,borderRadius:i,font:r}))}onTouchDown(e){this.isDown=!0,this.scroll.position=this.scroll.current,this.start=e.touches?e.touches[0].clientX:e.clientX}onTouchMove(e){if(!this.isDown)return;const n=e.touches?e.touches[0].clientX:e.clientX,s=(this.start-n)
2996*(this.scrollSpeed*.025);this.scroll.target=this.scroll.position+s}onTouchUp(){this.isDown=!1,this.onCheck()}onWheel(e){const n=e.deltaY||e.wheelDelta||e.detail;this.scroll.target+=(n>0?this.scrollSpeed:-this.scrollSpeed)*.2,this.onCheckDebounce()}onKeyDown(e){switch(e.key){case"ArrowRight":e.preventDefault(),this.scroll.target+=this.scrollSpeed*5,this.onCheckDebounce();break;case"ArrowLeft":e.preventDefault(),this.scroll.target-=this.scrollSpeed*5,this.onCheckDebounce();break;case"Home":e.preventDefault(),this.scroll.target=0,this.onCheckDebounce();break}}onCheck(){if(!this.medias||!this.medias[0])return;const e=this.medias[0].width,n=Math.round(Math.abs(this.scroll.target)/e),s=e*n;this.scroll.target=this.scroll.target<0?-s:s}onResize(){this.screen={width:this.container.clientWidth,height:this.container.clientHeight},this.renderer.setSize(this.screen.width,this.screen.height),this.camera.perspective({aspect:this.screen.width/this.screen.height});const e=this.camera.fov*Math.PI/180,n=2*Math.tan(e/2)*this.camera.position.z,s=n*this.camera.aspect;this.viewport={width:s,height:n},this.medias&&this.medias.forEach(i=>i.onResize({screen:this.screen,viewport:this.viewport}))}update(){this.scroll.current=V(this.scroll.current,this.scroll.target,this.scroll.ease);const e=this.scroll.current>this.scroll.last?"right":"left";this.medias&&this.medias.forEach(n=>n.update(this.scroll,e)),this.renderer.render({scene:this.scene,camera:this.camera}),this.scroll.last=this.scroll.current,this.raf=window.requestAnimationFrame(this.update.bind(this))}addEventListeners(){var e;this.boundOnResize=this.onResize.bind(this),this.boundOnWheel=this.onWheel.bind(this),this.boundOnTouchDown=this.onTouchDown.bind(this),this.boundOnTouchMove=this.onTouchMove.bind(this),this.boundOnTouchUp=this.onTouchUp.bind(this),this.boundOnKeyDown=this.onKeyDown.bind(this),window.addEventListener("resize",this.boundOnResize),window.addEventListener("mousewheel",this.boundOnWheel),window.addEventListener("wheel",this.boundOnWheel),window.addEventListener("mousedown",this.boundOnTouchDown),window.addEventListener("mousemove",this.boundOnTouchMove),window.addEventListener("mouseup",this.boundOnTouchUp),window.addEventListener("touchstart",this.boundOnTouchDown),window.addEventListener("touchmove",this.boundOnTouchMove),window.addEventListener("touchend",this.boundOnTouchUp),(e=this.container)==null||e.addEventListener("keydown",this.boundOnKeyDown)}destroy(){window.cancelAnimationFrame(this.raf),window.removeEventListener("resize",this.boundOnResize),window.removeEventListener("mousewheel",this.boundOnWheel),window.removeEventListener("wheel",this.boundOnWheel),window.removeEventListener("mousedown",this.boundOnTouchDown),window.removeEventListener("mousemove",this.boundOnTouchMove),window.removeEventListener("mouseup",this.boundOnTouchUp),window.removeEventListener("touchstart",this.boundOnTouchDown),window.removeEventListener("touchmove",this.boundOnTouchMove),window.removeEventListener("touchend",this.boundOnTouchUp),this.renderer&&this.renderer.gl&&this.renderer.gl.canvas.parentNode&&this.renderer.gl.canvas.parentNode.removeChild(this.renderer.gl.canvas),this.container&&this.container.removeEventListener("keydown",this.boundOnKeyDown)}}function re({items:t,bend:e=3,textColor:n="#ffffff",borderRadius:s=.05,font:i="bold 30px Figtree",fontUrl:r,scrollSpeed:o=2,scrollEase:h=.05}){const a=g.useRef(null);return g.useEffect(()=>{if(!a.current)return;let c,d=!0;return ee(i,r).then(u=>{!d||!a.current||(c=new oe(a.current,{items:t,bend:e,textColor:n,borderRadius:s,font:u,scrollSpeed:o,scrollEase:h}))}),()=>{d=!1,c&&c.destroy()}},[t,e,n,s,i,r,o,h]),l.jsx("div",{className:"circular-gallery",ref:a,tabIndex:0,role:"region","aria-label":"Circular image gallery. Use left and right arrow keys to navigate."})}const ae=[{label:"Figtree (default)",value:""},{label:"Orbitron",value:"https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap"},{label:"Playfair Display",value:"https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&display=swap"},{label:"Pacifico",value:"https://fonts.googleapis.com/css2?family=Pacifico&display=swap"},{label:"Bungee",value:"https://fonts.googleapis.com/css2?family=Bungee&display=swap"},{label:"Roboto Mono",value:"https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@500&display=swap"}],v={bend:1,borderRadius:.05,scrollSpeed:2,scrollEase:.05,fontUrl:""},Ce=()=>{const{props:t,updateProp:e,resetProps:n,hasChanges:s}=E(v),{bend:i,borderRadius:r,scrollSpeed:o,scrollEase:h,fontUrl:a}=t,c=F("#71717a","#ffffff"),[d,u]=I(),f=g.useMemo(()=>[{name:"items",type:"Array<{ image: string; text: string }>",default:"undefined",description:"List of items to display in the gallery. Each item should have an image URL and a text label."},{name:"bend",type:"number",default:"3",description:"Determines the curvature of the gallery layout. A negative value bends in one direction, a positive value in the opposite."},{name:"textColor",type:"string",default:'"#ffffff"',description:"Specifies the color of the text labels."},{name:"borderRadius",type:"number",default:"0.05",description:"Sets the border radius for the media items to achieve rounded corners."},{name:"font",type:"string",default:'"bold 30px Figtree"',description:"CSS font shorthand (style, weight, size, family) used for the text labels below each card."},{name:"fontUrl",type:"string",default:"undefined",description:"URL of a font to load for the labels. Accepts a stylesheet URL (e.g. a Google Fonts link) or a direct font file (.woff2, .woff, .ttf, .otf). The loaded family overrides the family in `font`."},{name:"scrollSpeed",type:"number",default:"2",description:"Controls how much the gallery moves per scroll event. Lower values result in slower scrolling, higher values in faster scrolling."},{name:"scrollEase",type:"number",default:"0.05",description:"Controls the smoothness of scroll transitions. Lower values create smoother, more fluid motion, while higher values make it more responsive."}],[]);return l.jsx(C,{props:t,defaultProps:v,resetProps:n,hasChanges:s,children:l.jsxs(O,{children:[l.jsxs(z,{children:[l.jsx(R,{position:"relative",className:"demo-container",h:4
299600,p:0,overflow:"hidden",children:l.jsx(re,{bend:i,borderRadius:r,scrollSpeed:o,scrollEase:h,textColor:c,fontUrl:a||void 0},d)}),l.jsxs(P,{children:[l.jsx(B,{title:"Font",options:ae,value:a,onChange:m=>{e("fontUrl",m),u()}}),l.jsx(p,{title:"Bend Level",min:-10,max:10,step:1,value:i,onChange:m=>{e("bend",m),u()}}),l.jsx(p,{title:"Border Radius",min:0,max:.5,step:.01,value:r,onChange:m=>{e("borderRadius",m),u()}}),l.jsx(p,{title:"Scroll Speed",min:.5,max:5,step:.1,value:o,onChange:m=>{e("scrollSpeed",m),u()}}),l.jsx(p,{title:"Scroll Ease",min:.01,max:.15,step:.01,value:h,onChange:m=>{e("scrollEase",m),u()}})]}),l.jsx(U,{data:f}),l.jsx(k,{dependencyList:["ogl"]})]}),l.jsx(L,{children:l.jsx(D,{codeObject:j,componentName:"CircularGallery"})})]})})};export{Ce as default};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.