/** * Hexagonal Magnetism Background * Rigid hexagon grid with mouse-based magnetism effect * Hexagons move as whole units - no vertex deformation */ (function() { 'use strict'; // Configuration const CONFIG = { hexSize: 30, lineWidth: 1, maxLineWidth: 2.5, baseOpacity: 0.15, maxOpacity: 0.6, magnetRadius: 200, maxDisplacement: 12, returnSpeed: 0.25, // Faster return to prevent color lag // Base color (neutral gray) baseR: 119, baseG: 119, baseB: 100, // Colors for proximity effect (teal to orange) tealR: 38, tealG: 166, tealB: 154, orangeR: 245, orangeG: 124, orangeB: 0 }; // State let canvas, ctx; let hexagons = []; let mouse = { x: -1000, y: -1000 }; let animationId; let isInitialized = false; // Hexagon class - rigid shape, no vertex warping class Hexagon { constructor(originX, originY, size) { this.originX = originX; this.originY = originY; this.currentX = originX; this.currentY = originY; this.size = size; this.opacity = CONFIG.baseOpacity; this.lineWidth = CONFIG.lineWidth; this.colorInfluence = 0; } // Calculate vertices relative to current center position getVertices() { const vertices = []; for (let i = 0; i < 6; i++) { // Pointy-top hexagon: first vertex at top const angle = (Math.PI / 3) * i - Math.PI / 2; vertices.push({ x: this.currentX + this.size * Math.cos(angle), y: this.currentY + this.size * Math.sin(angle) }); } return vertices; } update(mouseX, mouseY) { const dx = mouseX - this.originX; const dy = mouseY - this.originY; const distance = Math.sqrt(dx * dx + dy * dy); if (distance < CONFIG.magnetRadius && distance > 0) { // Calculate influence (stronger when closer) const influence = 1 - (distance / CONFIG.magnetRadius); const easedInfluence = easeOutCubic(influence); // Calculate displacement toward mouse (limited) const angle = Math.atan2(dy, dx); const displacement = CONFIG.maxDisplacement * easedInfluence; // Target position (pulled toward mouse) const targetX = this.originX + Math.cos(angle) * displacement; const targetY = this.originY + Math.sin(angle) * displacement; // Smooth interpolation to target this.currentX += (targetX - this.currentX) * 0.15; this.currentY += (targetY - this.currentY) * 0.15; // Update visual properties this.opacity = CONFIG.baseOpacity + (CONFIG.maxOpacity - CONFIG.baseOpacity) * easedInfluence; this.lineWidth = CONFIG.lineWidth + (CONFIG.maxLineWidth - CONFIG.lineWidth) * easedInfluence; this.colorInfluence = easedInfluence; } else { // Return to origin with spring effect this.currentX += (this.originX - this.currentX) * CONFIG.returnSpeed; this.currentY += (this.originY - this.currentY) * CONFIG.returnSpeed; // Fade back to default this.opacity += (CONFIG.baseOpacity - this.opacity) * CONFIG.returnSpeed; this.lineWidth += (CONFIG.lineWidth - this.lineWidth) * CONFIG.returnSpeed; this.colorInfluence += (0 - this.colorInfluence) * CONFIG.returnSpeed; } } draw(ctx) { const vertices = this.getVertices(); ctx.beginPath(); ctx.moveTo(vertices[0].x, vertices[0].y); for (let i = 1; i < vertices.length; i++) { ctx.lineTo(vertices[i].x, vertices[i].y); } ctx.closePath(); // Smooth color interpolation: gray -> teal -> orange based on proximity let r, g, b; if (this.colorInfluence < 0.01) { // Base gray color r = CONFIG.baseR; g = CONFIG.baseG; b = CONFIG.baseB; } else if (this.colorInfluence < 0.6) { // Gray to teal (smooth transition) const t = this.colorInfluence / 0.6; r = Math.round(CONFIG.baseR + (CONFIG.tealR - CONFIG.baseR) * t); g = Math.round(CONFIG.baseG + (CONFIG.tealG - CONFIG.baseG) * t); b = Math.round(CONFIG.baseB + (CONFIG.tealB - CONFIG.baseB) * t); } else { // Teal to orange (only very close to cursor) const t = (this.colorInfluence - 0.6) / 0.4; r = Math.round(CONFIG.tealR + (CONFIG.orangeR - CONFIG.tealR) * t); g = Math.round(CONFIG.tealG + (CONFIG.orangeG - CONFIG.tealG) * t); b = Math.round(CONFIG.tealB + (CONFIG.orangeB - CONFIG.tealB) * t); } ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${this.opacity})`; ctx.lineWidth = this.lineWidth; ctx.stroke(); } } // Easing function for smooth animations function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } // Initialize canvas function init() { canvas = document.getElementById('hexCanvas'); if (!canvas) { console.warn('hexCanvas element not found'); return; } ctx = canvas.getContext('2d'); resize(); generateHexagons(); // Event listeners window.addEventListener('resize', debounce(handleResize, 150)); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseleave', handleMouseLeave); isInitialized = true; animate(); } // Generate hexagon grid with proper pointy-top tessellation function generateHexagons() { hexagons = []; // Pointy-top hexagon math: // horizontal spacing = size * sqrt(3) // vertical spacing = size * 1.5 // every odd row offset by horizontal_spacing / 2 const horizSpacing = CONFIG.hexSize * Math.sqrt(3); const vertSpacing = CONFIG.hexSize * 1.5; // Use full screen dimensions const width = Math.max(window.innerWidth, document.documentElement.clientWidth); const height = Math.max(window.innerHeight, document.documentElement.clientHeight); const cols = Math.ceil(width / horizSpacing) + 4; const rows = Math.ceil(height / vertSpacing) + 4; for (let row = -1; row < rows; row++) { for (let col = -1; col < cols; col++) { // Offset every odd row by half horizontal spacing const xOffset = (row % 2 === 1) ? horizSpacing / 2 : 0; const x = col * horizSpacing + xOffset; const y = row * vertSpacing; hexagons.push(new Hexagon(x, y, CONFIG.hexSize)); } } } // Resize handler function handleResize() { resize(); generateHexagons(); } function resize() { const dpr = window.devicePixelRatio || 1; // Use full document dimensions to cover entire scrollable page const width = Math.max(window.innerWidth, document.documentElement.clientWidth); const height = Math.max(window.innerHeight, document.documentElement.clientHeight); canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; // Reset transform and apply new scale ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); } // Mouse handlers function handleMouseMove(e) { mouse.x = e.clientX; mouse.y = e.clientY; } function handleMouseLeave() { mouse.x = -1000; mouse.y = -1000; } // Animation loop function animate() { if (!isInitialized) return; ctx.clearRect(0, 0, canvas.width, canvas.height); const updateRadius = CONFIG.magnetRadius + CONFIG.hexSize * 2; for (let i = 0; i < hexagons.length; i++) { const hex = hexagons[i]; // Check if hexagon needs updating (near mouse or returning to origin) const dx = mouse.x - hex.originX; const dy = mouse.y - hex.originY; const distance = Math.sqrt(dx * dx + dy * dy); // Check if position OR color needs to return to default const isReturning = Math.abs(hex.currentX - hex.originX) > 0.1 || Math.abs(hex.currentY - hex.originY) > 0.1 || hex.colorInfluence > 0.01 || hex.opacity > CONFIG.baseOpacity + 0.01; if (distance < updateRadius || isReturning) { hex.update(mouse.x, mouse.y); } hex.draw(ctx); } animationId = requestAnimationFrame(animate); } // Debounce utility function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } // Cleanup function destroy() { if (animationId) { cancelAnimationFrame(animationId); } window.removeEventListener('resize', handleResize); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseleave', handleMouseLeave); isInitialized = false; } // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // Expose for potential external control window.HexBackground = { init, destroy, config: CONFIG }; })();