mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
Migrate <CircularGallery />
This commit is contained in:
@@ -47,6 +47,7 @@ export const CATEGORIES = [
|
|||||||
'Pixel Card',
|
'Pixel Card',
|
||||||
'Carousel',
|
'Carousel',
|
||||||
'Spotlight Card',
|
'Spotlight Card',
|
||||||
|
'Circular Gallery',
|
||||||
'Flying Posters',
|
'Flying Posters',
|
||||||
'Card Swap',
|
'Card Swap',
|
||||||
'Infinite Scroll',
|
'Infinite Scroll',
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const components = {
|
|||||||
'pixel-card': () => import("../demo/Components/PixelCardDemo.vue"),
|
'pixel-card': () => import("../demo/Components/PixelCardDemo.vue"),
|
||||||
'carousel': () => import("../demo/Components/CarouselDemo.vue"),
|
'carousel': () => import("../demo/Components/CarouselDemo.vue"),
|
||||||
'spotlight-card': () => import("../demo/Components/SpotlightCardDemo.vue"),
|
'spotlight-card': () => import("../demo/Components/SpotlightCardDemo.vue"),
|
||||||
|
'circular-gallery': () => import("../demo/Components/CircularGalleryDemo.vue"),
|
||||||
'flying-posters': () => import("../demo/Components/FlyingPostersDemo.vue"),
|
'flying-posters': () => import("../demo/Components/FlyingPostersDemo.vue"),
|
||||||
'card-swap': () => import("../demo/Components/CardSwapDemo.vue"),
|
'card-swap': () => import("../demo/Components/CardSwapDemo.vue"),
|
||||||
'infinite-scroll': () => import("../demo/Components/InfiniteScrollDemo.vue"),
|
'infinite-scroll': () => import("../demo/Components/InfiniteScrollDemo.vue"),
|
||||||
|
|||||||
27
src/constants/code/Components/circularGalleryCode.ts
Normal file
27
src/constants/code/Components/circularGalleryCode.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import code from '@content/Components/CircularGallery/CircularGallery.vue?raw'
|
||||||
|
import type { CodeObject } from '../../../types/code'
|
||||||
|
|
||||||
|
export const circularGallery: CodeObject = {
|
||||||
|
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/CircularGallery`,
|
||||||
|
installation: `npm install ogl`,
|
||||||
|
usage: `<template>
|
||||||
|
<CircularGallery
|
||||||
|
:items="[
|
||||||
|
{ image: 'https://picsum.photos/800/600?random=1', text: 'Beautiful Nature' },
|
||||||
|
{ image: 'https://picsum.photos/800/600?random=2', text: 'City Life' },
|
||||||
|
{ image: 'https://picsum.photos/800/600?random=3', text: 'Ocean Views' }
|
||||||
|
]"
|
||||||
|
:bend="3"
|
||||||
|
text-color="#ffffff"
|
||||||
|
:border-radius="0.05"
|
||||||
|
font="bold 30px Arial"
|
||||||
|
:scroll-speed="2"
|
||||||
|
:scroll-ease="0.05"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import CircularGallery from "./CircularGallery.vue";
|
||||||
|
</script>`,
|
||||||
|
code
|
||||||
|
}
|
||||||
711
src/content/Components/CircularGallery/CircularGallery.vue
Normal file
711
src/content/Components/CircularGallery/CircularGallery.vue
Normal file
@@ -0,0 +1,711 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="containerRef" class="w-full h-full overflow-hidden cursor-grab active:cursor-grabbing" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { Camera, Mesh, Plane, Program, Renderer, Texture, Transform } from 'ogl'
|
||||||
|
|
||||||
|
interface CircularGalleryProps {
|
||||||
|
items?: { image: string; text: string }[]
|
||||||
|
bend?: number
|
||||||
|
textColor?: string
|
||||||
|
borderRadius?: number
|
||||||
|
font?: string
|
||||||
|
scrollSpeed?: number
|
||||||
|
scrollEase?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<CircularGalleryProps>(), {
|
||||||
|
bend: 3,
|
||||||
|
textColor: '#ffffff',
|
||||||
|
borderRadius: 0.05,
|
||||||
|
font: 'bold 30px Figtree',
|
||||||
|
scrollSpeed: 2,
|
||||||
|
scrollEase: 0.05
|
||||||
|
})
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLDivElement>()
|
||||||
|
let app: App | null = null
|
||||||
|
|
||||||
|
type GL = Renderer['gl']
|
||||||
|
|
||||||
|
function debounce<T extends (...args: unknown[]) => void>(func: T, wait: number) {
|
||||||
|
let timeout: number
|
||||||
|
return function (this: unknown, ...args: Parameters<T>) {
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
timeout = window.setTimeout(() => func.apply(this, args), wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(p1: number, p2: number, t: number): number {
|
||||||
|
return p1 + (p2 - p1) * t
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoBind(instance: Record<string, unknown>): void {
|
||||||
|
const proto = Object.getPrototypeOf(instance)
|
||||||
|
Object.getOwnPropertyNames(proto).forEach((key) => {
|
||||||
|
if (key !== 'constructor' && typeof instance[key] === 'function') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
instance[key] = (instance[key] as any).bind(instance)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFontSize(font: string): number {
|
||||||
|
const match = font.match(/(\d+)px/)
|
||||||
|
return match ? parseInt(match[1], 10) : 30
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTextTexture(
|
||||||
|
gl: GL,
|
||||||
|
text: string,
|
||||||
|
font: string = 'bold 30px monospace',
|
||||||
|
color: string = 'black'
|
||||||
|
): { texture: Texture; width: number; height: number } {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) throw new Error('Could not get 2d context')
|
||||||
|
|
||||||
|
context.font = font
|
||||||
|
const metrics = context.measureText(text)
|
||||||
|
const textWidth = Math.ceil(metrics.width)
|
||||||
|
const fontSize = getFontSize(font)
|
||||||
|
const textHeight = Math.ceil(fontSize * 1.2)
|
||||||
|
|
||||||
|
canvas.width = textWidth + 20
|
||||||
|
canvas.height = textHeight + 20
|
||||||
|
|
||||||
|
context.font = font
|
||||||
|
context.fillStyle = color
|
||||||
|
context.textBaseline = 'middle'
|
||||||
|
context.textAlign = 'center'
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
context.fillText(text, canvas.width / 2, canvas.height / 2)
|
||||||
|
|
||||||
|
const texture = new Texture(gl, { generateMipmaps: false })
|
||||||
|
texture.image = canvas
|
||||||
|
return { texture, width: canvas.width, height: canvas.height }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TitleProps {
|
||||||
|
gl: GL
|
||||||
|
plane: Mesh
|
||||||
|
renderer: Renderer
|
||||||
|
text: string
|
||||||
|
textColor?: string
|
||||||
|
font?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class Title {
|
||||||
|
gl: GL
|
||||||
|
plane: Mesh
|
||||||
|
renderer: Renderer
|
||||||
|
text: string
|
||||||
|
textColor: string
|
||||||
|
font: string
|
||||||
|
mesh!: Mesh
|
||||||
|
|
||||||
|
constructor({ gl, plane, renderer, text, textColor = '#545050', font = '30px sans-serif' }: TitleProps) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
autoBind(this as any)
|
||||||
|
this.gl = gl
|
||||||
|
this.plane = plane
|
||||||
|
this.renderer = renderer
|
||||||
|
this.text = text
|
||||||
|
this.textColor = textColor
|
||||||
|
this.font = font
|
||||||
|
this.createMesh()
|
||||||
|
}
|
||||||
|
|
||||||
|
createMesh() {
|
||||||
|
const { texture, width, height } = createTextTexture(this.gl, this.text, this.font, this.textColor)
|
||||||
|
const geometry = new Plane(this.gl)
|
||||||
|
const program = new Program(this.gl, {
|
||||||
|
vertex: `
|
||||||
|
attribute vec3 position;
|
||||||
|
attribute vec2 uv;
|
||||||
|
uniform mat4 modelViewMatrix;
|
||||||
|
uniform mat4 projectionMatrix;
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragment: `
|
||||||
|
precision highp float;
|
||||||
|
uniform sampler2D tMap;
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vec4 color = texture2D(tMap, vUv);
|
||||||
|
if (color.a < 0.1) discard;
|
||||||
|
gl_FragColor = color;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
uniforms: { tMap: { value: texture } },
|
||||||
|
transparent: true,
|
||||||
|
})
|
||||||
|
this.mesh = new Mesh(this.gl, { geometry, program })
|
||||||
|
const aspect = width / height
|
||||||
|
const textHeightScaled = this.plane.scale.y * 0.15
|
||||||
|
const textWidthScaled = textHeightScaled * aspect
|
||||||
|
this.mesh.scale.set(textWidthScaled, textHeightScaled, 1)
|
||||||
|
this.mesh.position.y = -this.plane.scale.y * 0.5 - textHeightScaled * 0.5 - 0.05
|
||||||
|
this.mesh.setParent(this.plane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScreenSize {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Viewport {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaProps {
|
||||||
|
geometry: Plane
|
||||||
|
gl: GL
|
||||||
|
image: string
|
||||||
|
index: number
|
||||||
|
length: number
|
||||||
|
renderer: Renderer
|
||||||
|
scene: Transform
|
||||||
|
screen: ScreenSize
|
||||||
|
text: string
|
||||||
|
viewport: Viewport
|
||||||
|
bend: number
|
||||||
|
textColor: string
|
||||||
|
borderRadius?: number
|
||||||
|
font?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class Media {
|
||||||
|
extra: number = 0
|
||||||
|
geometry: Plane
|
||||||
|
gl: GL
|
||||||
|
image: string
|
||||||
|
index: number
|
||||||
|
length: number
|
||||||
|
renderer: Renderer
|
||||||
|
scene: Transform
|
||||||
|
screen: ScreenSize
|
||||||
|
text: string
|
||||||
|
viewport: Viewport
|
||||||
|
bend: number
|
||||||
|
textColor: string
|
||||||
|
borderRadius: number
|
||||||
|
font?: string
|
||||||
|
program!: Program
|
||||||
|
plane!: Mesh
|
||||||
|
title!: Title
|
||||||
|
scale!: number
|
||||||
|
padding!: number
|
||||||
|
width!: number
|
||||||
|
widthTotal!: number
|
||||||
|
x!: number
|
||||||
|
speed: number = 0
|
||||||
|
isBefore: boolean = false
|
||||||
|
isAfter: boolean = false
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
geometry,
|
||||||
|
gl,
|
||||||
|
image,
|
||||||
|
index,
|
||||||
|
length,
|
||||||
|
renderer,
|
||||||
|
scene,
|
||||||
|
screen,
|
||||||
|
text,
|
||||||
|
viewport,
|
||||||
|
bend,
|
||||||
|
textColor,
|
||||||
|
borderRadius = 0,
|
||||||
|
font,
|
||||||
|
}: MediaProps) {
|
||||||
|
this.geometry = geometry
|
||||||
|
this.gl = gl
|
||||||
|
this.image = image
|
||||||
|
this.index = index
|
||||||
|
this.length = length
|
||||||
|
this.renderer = renderer
|
||||||
|
this.scene = scene
|
||||||
|
this.screen = screen
|
||||||
|
this.text = text
|
||||||
|
this.viewport = viewport
|
||||||
|
this.bend = bend
|
||||||
|
this.textColor = textColor
|
||||||
|
this.borderRadius = borderRadius
|
||||||
|
this.font = font
|
||||||
|
this.createShader()
|
||||||
|
this.createMesh()
|
||||||
|
this.createTitle()
|
||||||
|
this.onResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
createShader() {
|
||||||
|
const texture = new Texture(this.gl, { generateMipmaps: false })
|
||||||
|
this.program = new Program(this.gl, {
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
vertex: `
|
||||||
|
precision highp float;
|
||||||
|
attribute vec3 position;
|
||||||
|
attribute vec2 uv;
|
||||||
|
uniform mat4 modelViewMatrix;
|
||||||
|
uniform mat4 projectionMatrix;
|
||||||
|
uniform float uTime;
|
||||||
|
uniform float uSpeed;
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
|
vec3 p = position;
|
||||||
|
p.z = (sin(p.x * 4.0 + uTime) * 1.5 + cos(p.y * 2.0 + uTime) * 1.5) * (0.1 + uSpeed * 0.5);
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragment: `
|
||||||
|
precision highp float;
|
||||||
|
uniform vec2 uImageSizes;
|
||||||
|
uniform vec2 uPlaneSizes;
|
||||||
|
uniform sampler2D tMap;
|
||||||
|
uniform float uBorderRadius;
|
||||||
|
varying vec2 vUv;
|
||||||
|
|
||||||
|
float roundedBoxSDF(vec2 p, vec2 b, float r) {
|
||||||
|
vec2 d = abs(p) - b;
|
||||||
|
return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 ratio = vec2(
|
||||||
|
min((uPlaneSizes.x / uPlaneSizes.y) / (uImageSizes.x / uImageSizes.y), 1.0),
|
||||||
|
min((uPlaneSizes.y / uPlaneSizes.x) / (uImageSizes.y / uImageSizes.x), 1.0)
|
||||||
|
);
|
||||||
|
vec2 uv = vec2(
|
||||||
|
vUv.x * ratio.x + (1.0 - ratio.x) * 0.5,
|
||||||
|
vUv.y * ratio.y + (1.0 - ratio.y) * 0.5
|
||||||
|
);
|
||||||
|
vec4 color = texture2D(tMap, uv);
|
||||||
|
|
||||||
|
float d = roundedBoxSDF(vUv - 0.5, vec2(0.5 - uBorderRadius), uBorderRadius);
|
||||||
|
if(d > 0.0) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_FragColor = vec4(color.rgb, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
uniforms: {
|
||||||
|
tMap: { value: texture },
|
||||||
|
uPlaneSizes: { value: [0, 0] },
|
||||||
|
uImageSizes: { value: [0, 0] },
|
||||||
|
uSpeed: { value: 0 },
|
||||||
|
uTime: { value: 100 * Math.random() },
|
||||||
|
uBorderRadius: { value: this.borderRadius },
|
||||||
|
},
|
||||||
|
transparent: true,
|
||||||
|
})
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
img.src = this.image
|
||||||
|
img.onload = () => {
|
||||||
|
texture.image = img
|
||||||
|
this.program.uniforms.uImageSizes.value = [img.naturalWidth, img.naturalHeight]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createMesh() {
|
||||||
|
this.plane = new Mesh(this.gl, {
|
||||||
|
geometry: this.geometry,
|
||||||
|
program: this.program,
|
||||||
|
})
|
||||||
|
this.plane.setParent(this.scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
createTitle() {
|
||||||
|
this.title = new Title({
|
||||||
|
gl: this.gl,
|
||||||
|
plane: this.plane,
|
||||||
|
renderer: this.renderer,
|
||||||
|
text: this.text,
|
||||||
|
textColor: this.textColor,
|
||||||
|
font: this.font,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
update(scroll: { current: number; last: number }, direction: 'right' | 'left') {
|
||||||
|
this.plane.position.x = this.x - scroll.current - this.extra
|
||||||
|
|
||||||
|
const x = this.plane.position.x
|
||||||
|
const H = this.viewport.width / 2
|
||||||
|
|
||||||
|
if (this.bend === 0) {
|
||||||
|
this.plane.position.y = 0
|
||||||
|
this.plane.rotation.z = 0
|
||||||
|
} else {
|
||||||
|
const B_abs = Math.abs(this.bend)
|
||||||
|
const R = (H * H + B_abs * B_abs) / (2 * B_abs)
|
||||||
|
const effectiveX = Math.min(Math.abs(x), H)
|
||||||
|
|
||||||
|
const arc = R - Math.sqrt(R * R - effectiveX * effectiveX)
|
||||||
|
if (this.bend > 0) {
|
||||||
|
this.plane.position.y = -arc
|
||||||
|
this.plane.rotation.z = -Math.sign(x) * Math.asin(effectiveX / R)
|
||||||
|
} else {
|
||||||
|
this.plane.position.y = arc
|
||||||
|
this.plane.rotation.z = Math.sign(x) * Math.asin(effectiveX / R)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.speed = scroll.current - scroll.last
|
||||||
|
this.program.uniforms.uTime.value += 0.04
|
||||||
|
this.program.uniforms.uSpeed.value = this.speed
|
||||||
|
|
||||||
|
const planeOffset = this.plane.scale.x / 2
|
||||||
|
const viewportOffset = this.viewport.width / 2
|
||||||
|
this.isBefore = this.plane.position.x + planeOffset < -viewportOffset
|
||||||
|
this.isAfter = this.plane.position.x - planeOffset > viewportOffset
|
||||||
|
if (direction === 'right' && this.isBefore) {
|
||||||
|
this.extra -= this.widthTotal
|
||||||
|
this.isBefore = this.isAfter = false
|
||||||
|
}
|
||||||
|
if (direction === 'left' && this.isAfter) {
|
||||||
|
this.extra += this.widthTotal
|
||||||
|
this.isBefore = this.isAfter = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onResize({ screen, viewport }: { screen?: ScreenSize; viewport?: Viewport } = {}) {
|
||||||
|
if (screen) this.screen = screen
|
||||||
|
if (viewport) {
|
||||||
|
this.viewport = viewport
|
||||||
|
if (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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppConfig {
|
||||||
|
items?: { image: string; text: string }[]
|
||||||
|
bend?: number
|
||||||
|
textColor?: string
|
||||||
|
borderRadius?: number
|
||||||
|
font?: string
|
||||||
|
scrollSpeed?: number
|
||||||
|
scrollEase?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class App {
|
||||||
|
container: HTMLElement
|
||||||
|
scrollSpeed: number
|
||||||
|
scroll: {
|
||||||
|
ease: number
|
||||||
|
current: number
|
||||||
|
target: number
|
||||||
|
last: number
|
||||||
|
position?: number
|
||||||
|
}
|
||||||
|
onCheckDebounce: (...args: unknown[]) => void
|
||||||
|
renderer!: Renderer
|
||||||
|
gl!: GL
|
||||||
|
camera!: Camera
|
||||||
|
scene!: Transform
|
||||||
|
planeGeometry!: Plane
|
||||||
|
medias: Media[] = []
|
||||||
|
mediasImages: { image: string; text: string }[] = []
|
||||||
|
screen!: { width: number; height: number }
|
||||||
|
viewport!: { width: number; height: number }
|
||||||
|
raf: number = 0
|
||||||
|
|
||||||
|
boundOnResize!: () => void
|
||||||
|
boundOnWheel!: (e: Event) => void
|
||||||
|
boundOnTouchDown!: (e: MouseEvent | TouchEvent) => void
|
||||||
|
boundOnTouchMove!: (e: MouseEvent | TouchEvent) => void
|
||||||
|
boundOnTouchUp!: () => void
|
||||||
|
|
||||||
|
isDown: boolean = false
|
||||||
|
start: number = 0
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
container: HTMLElement,
|
||||||
|
{
|
||||||
|
items,
|
||||||
|
bend = 1,
|
||||||
|
textColor = '#ffffff',
|
||||||
|
borderRadius = 0,
|
||||||
|
font = 'bold 30px Figtree',
|
||||||
|
scrollSpeed = 2,
|
||||||
|
scrollEase = 0.05,
|
||||||
|
}: AppConfig
|
||||||
|
) {
|
||||||
|
document.documentElement.classList.remove('no-js')
|
||||||
|
this.container = container
|
||||||
|
this.scrollSpeed = scrollSpeed
|
||||||
|
this.scroll = { ease: scrollEase, current: 0, target: 0, last: 0 }
|
||||||
|
this.onCheckDebounce = debounce(this.onCheck.bind(this), 200)
|
||||||
|
this.createRenderer()
|
||||||
|
this.createCamera()
|
||||||
|
this.createScene()
|
||||||
|
this.onResize()
|
||||||
|
this.createGeometry()
|
||||||
|
this.createMedias(items, bend, textColor, borderRadius, font)
|
||||||
|
this.update()
|
||||||
|
this.addEventListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
createRenderer() {
|
||||||
|
this.renderer = new Renderer({ alpha: true })
|
||||||
|
this.gl = this.renderer.gl
|
||||||
|
this.gl.clearColor(0, 0, 0, 0)
|
||||||
|
this.container.appendChild(this.renderer.gl.canvas as HTMLCanvasElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
createCamera() {
|
||||||
|
this.camera = new Camera(this.gl)
|
||||||
|
this.camera.fov = 45
|
||||||
|
this.camera.position.z = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
createScene() {
|
||||||
|
this.scene = new Transform()
|
||||||
|
}
|
||||||
|
|
||||||
|
createGeometry() {
|
||||||
|
this.planeGeometry = new Plane(this.gl, {
|
||||||
|
heightSegments: 50,
|
||||||
|
widthSegments: 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createMedias(
|
||||||
|
items: { image: string; text: string }[] | undefined,
|
||||||
|
bend: number = 1,
|
||||||
|
textColor: string,
|
||||||
|
borderRadius: number,
|
||||||
|
font: string
|
||||||
|
) {
|
||||||
|
const defaultItems = [
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const galleryItems = items && items.length ? items : defaultItems
|
||||||
|
this.mediasImages = galleryItems.concat(galleryItems)
|
||||||
|
this.medias = this.mediasImages.map((data, index) => {
|
||||||
|
return new Media({
|
||||||
|
geometry: this.planeGeometry,
|
||||||
|
gl: this.gl,
|
||||||
|
image: data.image,
|
||||||
|
index,
|
||||||
|
length: this.mediasImages.length,
|
||||||
|
renderer: this.renderer,
|
||||||
|
scene: this.scene,
|
||||||
|
screen: this.screen,
|
||||||
|
text: data.text,
|
||||||
|
viewport: this.viewport,
|
||||||
|
bend,
|
||||||
|
textColor,
|
||||||
|
borderRadius,
|
||||||
|
font,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchDown(e: MouseEvent | TouchEvent) {
|
||||||
|
this.isDown = true
|
||||||
|
this.scroll.position = this.scroll.current
|
||||||
|
this.start = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchMove(e: MouseEvent | TouchEvent) {
|
||||||
|
if (!this.isDown) return
|
||||||
|
const x = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||||
|
const distance = (this.start - x) * (this.scrollSpeed * 0.025)
|
||||||
|
this.scroll.target = (this.scroll.position ?? 0) + distance
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchUp() {
|
||||||
|
this.isDown = false
|
||||||
|
this.onCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
onWheel(e: Event) {
|
||||||
|
const wheelEvent = e as WheelEvent
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const delta = wheelEvent.deltaY || (wheelEvent as any).wheelDelta || (wheelEvent as any).detail
|
||||||
|
this.scroll.target += delta > 0 ? this.scrollSpeed : -this.scrollSpeed
|
||||||
|
this.onCheckDebounce()
|
||||||
|
}
|
||||||
|
|
||||||
|
onCheck() {
|
||||||
|
if (!this.medias || !this.medias[0]) return
|
||||||
|
const width = this.medias[0].width
|
||||||
|
const itemIndex = Math.round(Math.abs(this.scroll.target) / width)
|
||||||
|
const item = width * itemIndex
|
||||||
|
this.scroll.target = this.scroll.target < 0 ? -item : item
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fov = (this.camera.fov * Math.PI) / 180
|
||||||
|
const height = 2 * Math.tan(fov / 2) * this.camera.position.z
|
||||||
|
const width = height * this.camera.aspect
|
||||||
|
this.viewport = { width, height }
|
||||||
|
if (this.medias) {
|
||||||
|
this.medias.forEach((media) => media.onResize({ screen: this.screen, viewport: this.viewport }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease)
|
||||||
|
const direction = this.scroll.current > this.scroll.last ? 'right' : 'left'
|
||||||
|
if (this.medias) {
|
||||||
|
this.medias.forEach((media) => media.update(this.scroll, direction))
|
||||||
|
}
|
||||||
|
this.renderer.render({ scene: this.scene, camera: this.camera })
|
||||||
|
this.scroll.last = this.scroll.current
|
||||||
|
this.raf = window.requestAnimationFrame(this.update.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListeners() {
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if (this.renderer && this.renderer.gl && this.renderer.gl.canvas.parentNode) {
|
||||||
|
this.renderer.gl.canvas.parentNode.removeChild(this.renderer.gl.canvas as HTMLCanvasElement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!containerRef.value) return
|
||||||
|
|
||||||
|
app = new App(containerRef.value, {
|
||||||
|
items: props.items,
|
||||||
|
bend: props.bend,
|
||||||
|
textColor: props.textColor,
|
||||||
|
borderRadius: props.borderRadius,
|
||||||
|
font: props.font,
|
||||||
|
scrollSpeed: props.scrollSpeed,
|
||||||
|
scrollEase: props.scrollEase,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (app) {
|
||||||
|
app.destroy()
|
||||||
|
app = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => ({
|
||||||
|
items: props.items,
|
||||||
|
bend: props.bend,
|
||||||
|
textColor: props.textColor,
|
||||||
|
borderRadius: props.borderRadius,
|
||||||
|
font: props.font,
|
||||||
|
scrollSpeed: props.scrollSpeed,
|
||||||
|
scrollEase: props.scrollEase,
|
||||||
|
}),
|
||||||
|
(newProps) => {
|
||||||
|
if (app) {
|
||||||
|
app.destroy()
|
||||||
|
}
|
||||||
|
if (containerRef.value) {
|
||||||
|
app = new App(containerRef.value, newProps)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
160
src/demo/Components/CircularGalleryDemo.vue
Normal file
160
src/demo/Components/CircularGalleryDemo.vue
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<div class="circular-gallery-demo">
|
||||||
|
<TabbedLayout>
|
||||||
|
<template #preview>
|
||||||
|
<div class="demo-container h-[500px] p-0 overflow-hidden">
|
||||||
|
<CircularGallery
|
||||||
|
:key="rerenderKey"
|
||||||
|
:bend="bend"
|
||||||
|
:border-radius="borderRadius"
|
||||||
|
:scroll-speed="scrollSpeed"
|
||||||
|
:scroll-ease="scrollEase"
|
||||||
|
:text-color="textColor"
|
||||||
|
:font="font"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Customize>
|
||||||
|
<PreviewSlider
|
||||||
|
title="Bend Level"
|
||||||
|
v-model="bend"
|
||||||
|
:min="-10"
|
||||||
|
:max="10"
|
||||||
|
:step="1"
|
||||||
|
@update:model-value="forceRerender"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSlider
|
||||||
|
title="Border Radius"
|
||||||
|
v-model="borderRadius"
|
||||||
|
:min="0"
|
||||||
|
:max="0.5"
|
||||||
|
:step="0.01"
|
||||||
|
@update:model-value="forceRerender"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSlider
|
||||||
|
title="Scroll Speed"
|
||||||
|
v-model="scrollSpeed"
|
||||||
|
:min="0.5"
|
||||||
|
:max="5"
|
||||||
|
:step="0.1"
|
||||||
|
@update:model-value="forceRerender"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSlider
|
||||||
|
title="Scroll Ease"
|
||||||
|
v-model="scrollEase"
|
||||||
|
:min="0.01"
|
||||||
|
:max="0.15"
|
||||||
|
:step="0.01"
|
||||||
|
@update:model-value="forceRerender"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewColor
|
||||||
|
title="Text Color"
|
||||||
|
v-model="textColor"
|
||||||
|
@update:model-value="forceRerender"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewSelect
|
||||||
|
title="Font"
|
||||||
|
v-model="font"
|
||||||
|
:options="fontOptions"
|
||||||
|
@update:model-value="forceRerender"
|
||||||
|
/>
|
||||||
|
</Customize>
|
||||||
|
|
||||||
|
<PropTable :data="propData" />
|
||||||
|
<Dependencies :dependency-list="['ogl']" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #code>
|
||||||
|
<CodeExample :code-object="circularGallery" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cli>
|
||||||
|
<CliInstallation :command="circularGallery.cli" />
|
||||||
|
</template>
|
||||||
|
</TabbedLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import TabbedLayout from '../../components/common/TabbedLayout.vue'
|
||||||
|
import PropTable from '../../components/common/PropTable.vue'
|
||||||
|
import Dependencies from '../../components/code/Dependencies.vue'
|
||||||
|
import CliInstallation from '../../components/code/CliInstallation.vue'
|
||||||
|
import CodeExample from '../../components/code/CodeExample.vue'
|
||||||
|
import Customize from '../../components/common/Customize.vue'
|
||||||
|
import PreviewSlider from '../../components/common/PreviewSlider.vue'
|
||||||
|
import PreviewColor from '../../components/common/PreviewColor.vue'
|
||||||
|
import PreviewSelect from '../../components/common/PreviewSelect.vue'
|
||||||
|
import CircularGallery from '../../content/Components/CircularGallery/CircularGallery.vue'
|
||||||
|
import { circularGallery } from '@/constants/code/Components/circularGalleryCode'
|
||||||
|
import { useForceRerender } from '@/composables/useForceRerender'
|
||||||
|
|
||||||
|
const bend = ref(1)
|
||||||
|
const borderRadius = ref(0.05)
|
||||||
|
const scrollSpeed = ref(2)
|
||||||
|
const scrollEase = ref(0.05)
|
||||||
|
const textColor = ref('#ffffff')
|
||||||
|
const font = ref('bold 30px Figtree')
|
||||||
|
|
||||||
|
const { rerenderKey, forceRerender } = useForceRerender()
|
||||||
|
|
||||||
|
const fontOptions = [
|
||||||
|
{ label: 'Figtree Bold', value: 'bold 30px Figtree' },
|
||||||
|
{ label: 'Arial Bold', value: 'bold 30px Arial' },
|
||||||
|
{ label: 'Helvetica Bold', value: 'bold 30px Helvetica' },
|
||||||
|
{ label: 'Times Bold', value: 'bold 30px Times' },
|
||||||
|
{ label: 'Courier Bold', value: 'bold 30px Courier' },
|
||||||
|
{ label: 'Georgia Bold', value: 'bold 30px Georgia' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const propData = [
|
||||||
|
{
|
||||||
|
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: 'Font style for the text labels.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user