PageSourceSearch

https://reactbits.dev/assets/CircularGalleryDemo-CQXUw4gZ.js

js reactbits.dev collected 2026-09-25 14:15:03 UTC 111,613 bytes, 2,996 lines download raw bytes

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.