UNPKG

hover-effects-ts

Version:

A collection of beautiful hover effects for images using canvas

596 lines (489 loc) 19.5 kB
import { HoverEffect } from '../types'; export default class DotMatrixHover implements HoverEffect { private readonly id: string; private element: HTMLImageElement | null = null; private canvas: HTMLCanvasElement | null = null; private ctx: CanvasRenderingContext2D | null = null; private tempCanvas: HTMLCanvasElement | null = null; private tempCtx: CanvasRenderingContext2D | null = null; private isHovering = false; private mousePos = { x: 0, y: 0 }; private animationFrame: number | null = null; private imageData: ImageData | null = null; private lastRenderTime = 0; private readonly FPS = 30; private readonly frameInterval = 1000 / 30; // 30 FPS private frameCount = 0; // Add frame counter for animations // Pre-built circle path for performance private ledPath: Path2D | null = null; // Configuration options private radius: number; private softEdge: number; private ledSize: number; private ledSpacing: number; private scale: number; private glow: boolean; private fadeExp: number; private colorMode: 'mono' | 'rgb'; // Animation properties private animationType: 'none' | 'wave' | 'pulse' | 'drift' | 'ripple' | 'rotation'; private animationSpeed: number; private animationIntensity: number; private driftOffsets: Array<{x: number, y: number, vx: number, vy: number}> = []; constructor(options: { radius?: number; softEdge?: number; ledSize?: number; ledSpacing?: number; scale?: number; glow?: boolean; fadeExp?: number; colorMode?: 'mono' | 'rgb'; animationType?: 'none' | 'wave' | 'pulse' | 'drift' | 'ripple' | 'rotation'; animationSpeed?: number; animationIntensity?: number; } = {}) { this.id = Math.random().toString(36).substring(2, 9); this.radius = options.radius ?? 120; this.softEdge = options.softEdge ?? 20; this.ledSize = options.ledSize ?? 4; this.ledSpacing = options.ledSpacing ?? 5; this.scale = options.scale ?? 0.12; this.glow = options.glow ?? true; this.fadeExp = options.fadeExp ?? 2; this.colorMode = options.colorMode ?? 'mono'; this.animationType = options.animationType ?? 'wave'; this.animationSpeed = options.animationSpeed ?? 1.0; this.animationIntensity = options.animationIntensity ?? 3.0; this.buildLedPath(); } private buildLedPath(): void { this.ledPath = new Path2D(); this.ledPath.arc(0, 0, this.ledSize / 2, 0, Math.PI * 2); } private initDriftOffsets(gridWidth: number, gridHeight: number): void { this.driftOffsets = []; for (let gridY = 0; gridY < gridHeight; gridY++) { for (let gridX = 0; gridX < gridWidth; gridX++) { this.driftOffsets.push({ x: 0, y: 0, vx: (Math.random() - 0.5) * 0.5, vy: (Math.random() - 0.5) * 0.5 }); } } } private updateDriftOffsets(): void { this.driftOffsets.forEach(offset => { // Update position offset.x += offset.vx * this.animationSpeed; offset.y += offset.vy * this.animationSpeed; // Add some randomness to velocity if (Math.random() < 0.02) { offset.vx += (Math.random() - 0.5) * 0.1; offset.vy += (Math.random() - 0.5) * 0.1; } // Limit velocity const maxVel = 0.8; offset.vx = Math.max(-maxVel, Math.min(maxVel, offset.vx)); offset.vy = Math.max(-maxVel, Math.min(maxVel, offset.vy)); // Limit position offset const maxOffset = this.animationIntensity; offset.x = Math.max(-maxOffset, Math.min(maxOffset, offset.x)); offset.y = Math.max(-maxOffset, Math.min(maxOffset, offset.y)); }); } private getAnimationOffset(gridX: number, gridY: number, gridWidth: number, gridHeight: number): {x: number, y: number, scale: number, rotation: number} { const time = this.frameCount * 0.1 * this.animationSpeed; const intensity = this.animationIntensity; switch (this.animationType) { case 'wave': return { x: Math.sin(time + gridX * 0.3) * intensity, y: Math.cos(time + gridY * 0.3) * intensity, scale: 1 + Math.sin(time + (gridX + gridY) * 0.2) * 0.3, rotation: 0 }; case 'pulse': const pulsePhase = time + (gridX + gridY) * 0.1; const pulse = Math.sin(pulsePhase) * 0.5 + 0.5; return { x: 0, y: 0, scale: 1 + pulse * 0.8, rotation: 0 }; case 'drift': const index = gridY * gridWidth + gridX; const drift = this.driftOffsets[index] || { x: 0, y: 0, vx: 0, vy: 0 }; return { x: drift.x, y: drift.y, scale: 1, rotation: 0 }; case 'ripple': const centerX = this.mousePos.x / this.ledSpacing; const centerY = this.mousePos.y / this.ledSpacing; const dist = Math.hypot(gridX - centerX, gridY - centerY); const ripple = Math.sin(time * 2 - dist * 0.5) * intensity; return { x: 0, y: 0, scale: 1 + ripple * 0.3, rotation: 0 }; case 'rotation': const rotationSpeed = time * 0.5; const rotationRadius = intensity * 0.5; return { x: Math.cos(rotationSpeed + gridX * 0.1) * rotationRadius, y: Math.sin(rotationSpeed + gridY * 0.1) * rotationRadius, scale: 1, rotation: rotationSpeed + (gridX + gridY) * 0.1 }; default: return { x: 0, y: 0, scale: 1, rotation: 0 }; } } private onMouseEnter = (e: MouseEvent): void => { if (!this.element || !this.canvas) return; this.isHovering = true; // Get accurate cursor position relative to the image const rect = this.element.getBoundingClientRect(); this.mousePos = { x: e.clientX - rect.left, y: e.clientY - rect.top }; this.canvas.style.opacity = '1'; this.lastRenderTime = 0; // Reset last render time to force immediate render this.render(); }; private onMouseLeave = (): void => { if (!this.canvas || !this.ctx) return; this.isHovering = false; this.canvas.style.opacity = '0'; if (this.animationFrame) { cancelAnimationFrame(this.animationFrame); this.animationFrame = null; } setTimeout(() => { if (!this.isHovering && this.ctx && this.canvas) { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); } }, 300); }; private onMouseMove = (e: MouseEvent): void => { if (!this.element || !this.canvas) return; // Get accurate cursor position relative to the canvas const rect = this.canvas.getBoundingClientRect(); const scaleX = this.canvas.width / rect.width; const scaleY = this.canvas.height / rect.height; this.mousePos = { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY }; }; private updateImageData(): void { if (!this.element || !this.tempCanvas || !this.tempCtx || !this.canvas) return; const width = this.canvas.width; const height = this.canvas.height; // Validate canvas dimensions if (width <= 0 || height <= 0) { console.warn('Canvas dimensions are invalid, skipping image data update'); return; } // Calculate grid dimensions based on LED spacing const gridWidth = Math.floor(width / this.ledSpacing); const gridHeight = Math.floor(height / this.ledSpacing); // Ensure grid dimensions are valid if (gridWidth <= 0 || gridHeight <= 0) { console.warn('Grid dimensions are invalid, skipping image data update'); return; } // Set temp canvas size for sampling this.tempCanvas.width = gridWidth; this.tempCanvas.height = gridHeight; // Draw the image to the temporary canvas at grid resolution this.tempCtx.drawImage(this.element, 0, 0, gridWidth, gridHeight); // Get image data for processing this.imageData = this.tempCtx.getImageData(0, 0, gridWidth, gridHeight); } private render = (timestamp = 0): void => { if (!this.element || !this.canvas || !this.ctx || !this.imageData || !this.isHovering || !this.ledPath) return; // Additional safety check for imageData if (!this.imageData.data || this.imageData.width <= 0 || this.imageData.height <= 0) { console.warn('Invalid image data, skipping render'); return; } // Check if enough time has passed since last render const elapsed = timestamp - this.lastRenderTime; if (elapsed < this.frameInterval) { this.animationFrame = requestAnimationFrame(this.render); return; } this.lastRenderTime = timestamp; this.frameCount++; // Increment frame counter for animations // Use displayed dimensions const width = this.canvas.width; const height = this.canvas.height; // Calculate grid dimensions const gridWidth = Math.floor(width / this.ledSpacing); const gridHeight = Math.floor(height / this.ledSpacing); // Ensure grid dimensions match imageData if (gridWidth !== this.imageData.width || gridHeight !== this.imageData.height) { console.warn('Grid dimensions mismatch with imageData, updating...'); this.updateImageData(); return; } // Initialize drift offsets if needed if (this.animationType === 'drift' && this.driftOffsets.length !== gridWidth * gridHeight) { this.initDriftOffsets(gridWidth, gridHeight); } // Update drift offsets if using drift animation if (this.animationType === 'drift') { this.updateDriftOffsets(); } // Clear the canvas with black background (LED matrix background) this.ctx.fillStyle = '#000000'; this.ctx.fillRect(0, 0, width, height); // Set composite operation this.ctx.globalCompositeOperation = 'source-over'; const data = this.imageData.data; const effectiveRadius = this.radius; // Iterate through the LED grid for (let gridY = 0; gridY < gridHeight; gridY++) { for (let gridX = 0; gridX < gridWidth; gridX++) { // Calculate base LED position on canvas const baseLedX = (gridX + 0.5) * this.ledSpacing; const baseLedY = (gridY + 0.5) * this.ledSpacing; // Get animation offset const animOffset = this.getAnimationOffset(gridX, gridY, gridWidth, gridHeight); // Apply animation offset to LED position const ledX = baseLedX + animOffset.x; const ledY = baseLedY + animOffset.y; // Calculate distance from mouse (using base position for radius calculation) const dist = Math.hypot(baseLedX - this.mousePos.x, baseLedY - this.mousePos.y); // Only render LEDs within the hover radius if (dist < effectiveRadius) { const index = (gridY * gridWidth + gridX) * 4; const r = data[index]; const g = data[index + 1]; const b = data[index + 2]; // Calculate brightness (0-1) const brightness = (r + g + b) / (3 * 255); // Calculate radial falloff with soft edge let radialFalloff = 1; if (dist > effectiveRadius - this.softEdge) { const edgeDistance = dist - (effectiveRadius - this.softEdge); radialFalloff = 1 - Math.pow(edgeDistance / this.softEdge, this.fadeExp); radialFalloff = Math.max(0, radialFalloff); } // Calculate final alpha const alpha = brightness * radialFalloff; // Skip if alpha is too low if (alpha < 0.05) continue; // Determine LED color based on color mode let fillColor: string; if (this.colorMode === 'rgb') { fillColor = `rgba(${r}, ${g}, ${b}, ${alpha})`; } else { // Mono mode - cyan fillColor = `rgba(0, 255, 255, ${alpha})`; } // Configure glow effect if enabled if (this.glow) { this.ctx.shadowBlur = 4 * animOffset.scale; this.ctx.shadowColor = fillColor; } else { this.ctx.shadowBlur = 0; } // Set fill color this.ctx.fillStyle = fillColor; // Save context for transformation this.ctx.save(); // Translate to LED position this.ctx.translate(ledX, ledY); // Apply rotation if needed if (animOffset.rotation !== 0) { this.ctx.rotate(animOffset.rotation); } // Apply scale if needed if (animOffset.scale !== 1) { this.ctx.scale(animOffset.scale, animOffset.scale); } // Draw the LED circle using the pre-built path this.ctx.fill(this.ledPath); // Restore context this.ctx.restore(); } } } // Reset shadow settings this.ctx.shadowBlur = 0; if (this.isHovering) { this.animationFrame = requestAnimationFrame(this.render); } }; public attach(element: HTMLElement): void { if (!(element instanceof HTMLImageElement)) { console.error('DotMatrix effect can only be applied to img elements'); return; } this.element = element; const setupEffect = () => { // Create canvas without DPR scaling const canvas = document.createElement('canvas'); // Get the displayed dimensions of the image const rect = element.getBoundingClientRect(); let width = rect.width; let height = rect.height; // Fallback to natural dimensions if displayed dimensions are 0 if (width <= 0 || height <= 0) { width = element.naturalWidth || 300; // Default fallback height = element.naturalHeight || 200; // Default fallback console.warn('Using fallback dimensions for canvas:', width, height); } // Set canvas size to match the displayed image size exactly (no retina scaling) canvas.width = width; canvas.height = height; // Position canvas directly over the image canvas.style.position = 'absolute'; canvas.style.top = '0'; canvas.style.left = '0'; canvas.style.width = '100%'; canvas.style.height = '100%'; canvas.style.opacity = '0'; canvas.style.transition = 'opacity 0.3s ease'; canvas.style.pointerEvents = 'none'; canvas.style.backgroundColor = 'transparent'; canvas.dataset.dotMatrixId = this.id; this.canvas = canvas; // Get context without scaling this.ctx = canvas.getContext('2d', { alpha: true, willReadFrequently: false }); if (this.ctx) { this.ctx.globalCompositeOperation = 'source-over'; this.ctx.imageSmoothingEnabled = false; } // Create temp canvas for sampling this.tempCanvas = document.createElement('canvas'); this.tempCtx = this.tempCanvas.getContext('2d', { alpha: true }); // Only update image data if we have valid dimensions if (width > 0 && height > 0) { this.updateImageData(); } // Create wrapper let wrapper = this.element!.parentElement; if (!wrapper || !wrapper.classList.contains('dot-matrix-wrapper')) { wrapper = document.createElement('div'); wrapper.className = 'dot-matrix-wrapper'; wrapper.style.position = 'relative'; wrapper.style.display = 'inline-block'; this.element!.replaceWith(wrapper); wrapper.appendChild(this.element!); } wrapper.appendChild(canvas); // Add event listeners wrapper.addEventListener('mouseenter', this.onMouseEnter); wrapper.addEventListener('mouseleave', this.onMouseLeave); wrapper.addEventListener('mousemove', this.onMouseMove); // Handle resize to re-sample const resizeObserver = new ResizeObserver(() => { if (this.canvas && this.element) { const newRect = this.element.getBoundingClientRect(); let newWidth = newRect.width; let newHeight = newRect.height; // Fallback to natural dimensions if needed if (newWidth <= 0 || newHeight <= 0) { newWidth = this.element.naturalWidth || 300; newHeight = this.element.naturalHeight || 200; } this.canvas.width = newWidth; this.canvas.height = newHeight; // Only update if dimensions are valid if (newWidth > 0 && newHeight > 0) { this.updateImageData(); } } }); if (this.element) { resizeObserver.observe(this.element); } }; if (element.complete) { setupEffect(); } else { element.onload = setupEffect; } } public detach(): void { if (!this.element) return; const wrapper = this.element.parentElement; if (wrapper) { wrapper.removeEventListener('mouseenter', this.onMouseEnter); wrapper.removeEventListener('mouseleave', this.onMouseLeave); wrapper.removeEventListener('mousemove', this.onMouseMove); } if (this.animationFrame) { cancelAnimationFrame(this.animationFrame); this.animationFrame = null; } if (this.canvas) { this.canvas.remove(); this.canvas = null; this.ctx = null; } this.tempCanvas = null; this.tempCtx = null; this.imageData = null; this.element = null; } public destroy(): void { this.detach(); } // Public setters public setRadius(radius: number): void { this.radius = Math.max(10, Math.min(500, radius)); } public setLedSize(ledSize: number): void { this.ledSize = Math.max(2, Math.min(20, ledSize)); this.buildLedPath(); // Rebuild the path with new size } public setLedSpacing(ledSpacing: number): void { this.ledSpacing = Math.max(this.ledSize + 1, Math.min(50, ledSpacing)); this.updateImageData(); // Re-sample with new spacing } public setGlow(glow: boolean): void { this.glow = glow; } public setSoftEdge(softEdge: number): void { this.softEdge = Math.max(0, Math.min(100, softEdge)); } public setScale(scale: number): void { this.scale = Math.max(0.05, Math.min(0.3, scale)); this.updateImageData(); // Re-sample with new scale } public setFadeExp(fadeExp: number): void { this.fadeExp = Math.max(0.5, Math.min(5, fadeExp)); } public setColorMode(colorMode: 'mono' | 'rgb'): void { this.colorMode = colorMode; } // Animation methods public setAnimationType(animationType: 'none' | 'wave' | 'pulse' | 'drift' | 'ripple' | 'rotation'): void { this.animationType = animationType; } public setAnimationSpeed(animationSpeed: number): void { this.animationSpeed = Math.max(0.1, Math.min(5.0, animationSpeed)); } public setAnimationIntensity(animationIntensity: number): void { this.animationIntensity = Math.max(0.1, Math.min(10.0, animationIntensity)); } public addDriftOffset(x: number, y: number, vx: number, vy: number): void { this.driftOffsets.push({ x, y, vx, vy }); } }