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

1 line
9.0 KiB
JSON

{"name":"DotGrid","title":"DotGrid","description":"Animated dot grid with cursor interactions.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<template>\n <section :class=\"`flex items-center justify-center h-full w-full relative ${className}`\" :style=\"style\">\n <div ref=\"wrapperRef\" class=\"w-full h-full relative\">\n <canvas ref=\"canvasRef\" class=\"absolute inset-0 w-full h-full pointer-events-none\" />\n </div>\n </section>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, onMounted, onUnmounted, computed, watch, nextTick, useTemplateRef } from 'vue';\nimport { gsap } from 'gsap';\nimport { InertiaPlugin } from 'gsap/InertiaPlugin';\n\ngsap.registerPlugin(InertiaPlugin);\n\nconst throttle = <T extends unknown[]>(func: (...args: T) => void, limit: number) => {\n let lastCall = 0;\n return function (this: unknown, ...args: T) {\n const now = performance.now();\n if (now - lastCall >= limit) {\n lastCall = now;\n func.apply(this, args);\n }\n };\n};\n\ninterface Dot {\n cx: number;\n cy: number;\n xOffset: number;\n yOffset: number;\n _inertiaApplied: boolean;\n}\n\nexport interface DotGridProps {\n dotSize?: number;\n gap?: number;\n baseColor?: string;\n activeColor?: string;\n proximity?: number;\n speedTrigger?: number;\n shockRadius?: number;\n shockStrength?: number;\n maxSpeed?: number;\n resistance?: number;\n returnDuration?: number;\n className?: string;\n style?: Record<string, string | number>;\n}\n\nconst props = withDefaults(defineProps<DotGridProps>(), {\n dotSize: 16,\n gap: 32,\n baseColor: '#27FF64',\n activeColor: '#27FF64',\n proximity: 150,\n speedTrigger: 100,\n shockRadius: 250,\n shockStrength: 5,\n maxSpeed: 5000,\n resistance: 750,\n returnDuration: 1.5,\n className: '',\n style: () => ({})\n});\n\nconst wrapperRef = useTemplateRef<HTMLDivElement>('wrapperRef');\nconst canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef');\nconst dots = ref<Dot[]>([]);\nconst pointer = ref({\n x: 0,\n y: 0,\n vx: 0,\n vy: 0,\n speed: 0,\n lastTime: 0,\n lastX: 0,\n lastY: 0\n});\n\nfunction hexToRgb(hex: string) {\n const m = hex.match(/^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i);\n if (!m) return { r: 0, g: 0, b: 0 };\n return {\n r: parseInt(m[1], 16),\n g: parseInt(m[2], 16),\n b: parseInt(m[3], 16)\n };\n}\n\nconst baseRgb = computed(() => hexToRgb(props.baseColor));\nconst activeRgb = computed(() => hexToRgb(props.activeColor));\n\nconst circlePath = computed(() => {\n if (typeof window === 'undefined' || !window.Path2D) return null;\n\n const p = new Path2D();\n p.arc(0, 0, props.dotSize / 2, 0, Math.PI * 2);\n return p;\n});\n\nconst buildGrid = () => {\n const wrap = wrapperRef.value;\n const canvas = canvasRef.value;\n if (!wrap || !canvas) return;\n\n const { width, height } = wrap.getBoundingClientRect();\n const dpr = window.devicePixelRatio || 1;\n\n canvas.width = width * dpr;\n canvas.height = height * dpr;\n canvas.style.width = `${width}px`;\n canvas.style.height = `${height}px`;\n const ctx = canvas.getContext('2d');\n if (ctx) ctx.scale(dpr, dpr);\n\n const cols = Math.floor((width + props.gap) / (props.dotSize + props.gap));\n const rows = Math.floor((height + props.gap) / (props.dotSize + props.gap));\n const cell = props.dotSize + props.gap;\n\n const gridW = cell * cols - props.gap;\n const gridH = cell * rows - props.gap;\n\n const extraX = width - gridW;\n const extraY = height - gridH;\n\n const startX = extraX / 2 + props.dotSize / 2;\n const startY = extraY / 2 + props.dotSize / 2;\n\n const newDots: Dot[] = [];\n for (let y = 0; y < rows; y++) {\n for (let x = 0; x < cols; x++) {\n const cx = startX + x * cell;\n const cy = startY + y * cell;\n newDots.push({ cx, cy, xOffset: 0, yOffset: 0, _inertiaApplied: false });\n }\n }\n dots.value = newDots;\n};\n\nlet rafId: number;\nlet resizeObserver: ResizeObserver | null = null;\n\nconst draw = () => {\n const canvas = canvasRef.value;\n if (!canvas) return;\n const ctx = canvas.getContext('2d');\n if (!ctx) return;\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n\n const { x: px, y: py } = pointer.value;\n const proxSq = props.proximity * props.proximity;\n\n for (const dot of dots.value) {\n const ox = dot.cx + dot.xOffset;\n const oy = dot.cy + dot.yOffset;\n const dx = dot.cx - px;\n const dy = dot.cy - py;\n const dsq = dx * dx + dy * dy;\n\n let style = props.baseColor;\n if (dsq <= proxSq) {\n const dist = Math.sqrt(dsq);\n const t = 1 - dist / props.proximity;\n const r = Math.round(baseRgb.value.r + (activeRgb.value.r - baseRgb.value.r) * t);\n const g = Math.round(baseRgb.value.g + (activeRgb.value.g - baseRgb.value.g) * t);\n const b = Math.round(baseRgb.value.b + (activeRgb.value.b - baseRgb.value.b) * t);\n style = `rgb(${r},${g},${b})`;\n }\n\n if (circlePath.value) {\n ctx.save();\n ctx.translate(ox, oy);\n ctx.fillStyle = style;\n ctx.fill(circlePath.value);\n ctx.restore();\n }\n }\n\n rafId = requestAnimationFrame(draw);\n};\n\nconst onMove = (e: MouseEvent) => {\n const now = performance.now();\n const pr = pointer.value;\n const dt = pr.lastTime ? now - pr.lastTime : 16;\n const dx = e.clientX - pr.lastX;\n const dy = e.clientY - pr.lastY;\n let vx = (dx / dt) * 1000;\n let vy = (dy / dt) * 1000;\n let speed = Math.hypot(vx, vy);\n if (speed > props.maxSpeed) {\n const scale = props.maxSpeed / speed;\n vx *= scale;\n vy *= scale;\n speed = props.maxSpeed;\n }\n pr.lastTime = now;\n pr.lastX = e.clientX;\n pr.lastY = e.clientY;\n pr.vx = vx;\n pr.vy = vy;\n pr.speed = speed;\n\n const canvas = canvasRef.value;\n if (!canvas) return;\n const rect = canvas.getBoundingClientRect();\n pr.x = e.clientX - rect.left;\n pr.y = e.clientY - rect.top;\n\n for (const dot of dots.value) {\n const dist = Math.hypot(dot.cx - pr.x, dot.cy - pr.y);\n if (speed > props.speedTrigger && dist < props.proximity && !dot._inertiaApplied) {\n dot._inertiaApplied = true;\n gsap.killTweensOf(dot);\n const pushX = dot.cx - pr.x + vx * 0.005;\n const pushY = dot.cy - pr.y + vy * 0.005;\n gsap.to(dot, {\n inertia: { xOffset: pushX, yOffset: pushY, resistance: props.resistance },\n onComplete: () => {\n gsap.to(dot, {\n xOffset: 0,\n yOffset: 0,\n duration: props.returnDuration,\n ease: 'elastic.out(1,0.75)'\n });\n dot._inertiaApplied = false;\n }\n });\n }\n }\n};\n\nconst onClick = (e: MouseEvent) => {\n const canvas = canvasRef.value;\n if (!canvas) return;\n const rect = canvas.getBoundingClientRect();\n const cx = e.clientX - rect.left;\n const cy = e.clientY - rect.top;\n for (const dot of dots.value) {\n const dist = Math.hypot(dot.cx - cx, dot.cy - cy);\n if (dist < props.shockRadius && !dot._inertiaApplied) {\n dot._inertiaApplied = true;\n gsap.killTweensOf(dot);\n const falloff = Math.max(0, 1 - dist / props.shockRadius);\n const pushX = (dot.cx - cx) * props.shockStrength * falloff;\n const pushY = (dot.cy - cy) * props.shockStrength * falloff;\n gsap.to(dot, {\n inertia: { xOffset: pushX, yOffset: pushY, resistance: props.resistance },\n onComplete: () => {\n gsap.to(dot, {\n xOffset: 0,\n yOffset: 0,\n duration: props.returnDuration,\n ease: 'elastic.out(1,0.75)'\n });\n dot._inertiaApplied = false;\n }\n });\n }\n }\n};\n\nconst throttledMove = throttle(onMove, 50);\n\nonMounted(async () => {\n await nextTick();\n\n buildGrid();\n\n if (circlePath.value) {\n draw();\n }\n\n if ('ResizeObserver' in window) {\n resizeObserver = new ResizeObserver(buildGrid);\n if (wrapperRef.value) {\n resizeObserver.observe(wrapperRef.value);\n }\n } else {\n (window as Window).addEventListener('resize', buildGrid);\n }\n\n window.addEventListener('mousemove', throttledMove, { passive: true });\n window.addEventListener('click', onClick);\n});\n\nonUnmounted(() => {\n if (rafId) {\n cancelAnimationFrame(rafId);\n }\n\n if (resizeObserver) {\n resizeObserver.disconnect();\n } else {\n window.removeEventListener('resize', buildGrid);\n }\n\n window.removeEventListener('mousemove', throttledMove);\n window.removeEventListener('click', onClick);\n});\n\nwatch([() => props.dotSize, () => props.gap], () => {\n buildGrid();\n});\n\nwatch([() => props.proximity, () => props.baseColor, activeRgb, baseRgb, circlePath], () => {\n if (rafId) {\n cancelAnimationFrame(rafId);\n }\n if (circlePath.value) {\n draw();\n }\n});\n</script>\n","path":"DotGrid/DotGrid.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"gsap","version":"^3.13.0"}],"devDependencies":[],"categories":["Backgrounds"]}