323 lines
11 KiB
JavaScript
323 lines
11 KiB
JavaScript
/**
|
|
* Hexagonal Magnetism Background - OPTIMIZED
|
|
* Only renders visible hexagons, uses viewport culling
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Configuration
|
|
const CONFIG = {
|
|
hexSize: 30,
|
|
lineWidth: 1,
|
|
maxLineWidth: 2.5,
|
|
baseOpacity: 0.15,
|
|
maxOpacity: 0.6,
|
|
magnetRadius: 280,
|
|
maxDisplacement: 12,
|
|
returnSpeed: 0.4,
|
|
// Pre-calculated colors
|
|
baseR: 119, baseG: 119, baseB: 100,
|
|
tealR: 38, tealG: 166, tealB: 154,
|
|
orangeR: 245, orangeG: 124, orangeB: 0
|
|
};
|
|
|
|
// Pre-calculate hex geometry
|
|
const SQRT3 = Math.sqrt(3);
|
|
const horizSpacing = CONFIG.hexSize * SQRT3;
|
|
const vertSpacing = CONFIG.hexSize * 1.5;
|
|
|
|
// Pre-calculate hex vertices angles (pointy-top)
|
|
const HEX_ANGLES = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
const angle = (Math.PI / 3) * i - Math.PI / 2;
|
|
HEX_ANGLES.push({ cos: Math.cos(angle), sin: Math.sin(angle) });
|
|
}
|
|
|
|
// State
|
|
let canvas, ctx;
|
|
let mouse = { x: -1000, y: -1000 };
|
|
let scrollY = 0;
|
|
let animationId;
|
|
let isInitialized = false;
|
|
let canvasWidth = 0;
|
|
let canvasHeight = 0;
|
|
|
|
// Active hexagons (only those being animated)
|
|
let activeHexagons = new Map();
|
|
|
|
// Easing function
|
|
function easeOutCubic(t) {
|
|
return 1 - (1 - t) * (1 - t) * (1 - t);
|
|
}
|
|
|
|
// Get hex key from grid position
|
|
function getHexKey(col, row) {
|
|
return `${col},${row}`;
|
|
}
|
|
|
|
// Get hex origin from grid position
|
|
function getHexOrigin(col, row) {
|
|
const xOffset = (row % 2 === 1) ? horizSpacing / 2 : 0;
|
|
return {
|
|
x: col * horizSpacing + xOffset,
|
|
y: row * vertSpacing
|
|
};
|
|
}
|
|
|
|
// Draw a single hexagon
|
|
function drawHex(x, y, opacity, lineWidth, colorInfluence) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(
|
|
x + CONFIG.hexSize * HEX_ANGLES[0].cos,
|
|
y + CONFIG.hexSize * HEX_ANGLES[0].sin
|
|
);
|
|
for (let i = 1; i < 6; i++) {
|
|
ctx.lineTo(
|
|
x + CONFIG.hexSize * HEX_ANGLES[i].cos,
|
|
y + CONFIG.hexSize * HEX_ANGLES[i].sin
|
|
);
|
|
}
|
|
ctx.closePath();
|
|
|
|
// Color calculation
|
|
let r, g, b;
|
|
if (colorInfluence < 0.01) {
|
|
r = CONFIG.baseR; g = CONFIG.baseG; b = CONFIG.baseB;
|
|
} else if (colorInfluence < 0.6) {
|
|
const t = colorInfluence / 0.6;
|
|
r = CONFIG.baseR + (CONFIG.tealR - CONFIG.baseR) * t | 0;
|
|
g = CONFIG.baseG + (CONFIG.tealG - CONFIG.baseG) * t | 0;
|
|
b = CONFIG.baseB + (CONFIG.tealB - CONFIG.baseB) * t | 0;
|
|
} else {
|
|
const t = (colorInfluence - 0.6) / 0.4;
|
|
r = CONFIG.tealR + (CONFIG.orangeR - CONFIG.tealR) * t | 0;
|
|
g = CONFIG.tealG + (CONFIG.orangeG - CONFIG.tealG) * t | 0;
|
|
b = CONFIG.tealB + (CONFIG.orangeB - CONFIG.tealB) * t | 0;
|
|
}
|
|
|
|
ctx.strokeStyle = `rgba(${r},${g},${b},${opacity})`;
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Draw static hex grid for mobile (no animation)
|
|
function initMobileStatic() {
|
|
const c = document.getElementById('hexCanvas');
|
|
if (!c) return;
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
const w = window.innerWidth;
|
|
const h = window.innerHeight;
|
|
c.width = w * dpr;
|
|
c.height = h * dpr;
|
|
c.style.width = w + 'px';
|
|
c.style.height = h + 'px';
|
|
const cx = c.getContext('2d');
|
|
cx.scale(dpr, dpr);
|
|
cx.strokeStyle = 'rgba(119,119,100,0.15)';
|
|
cx.lineWidth = 1;
|
|
const size = 30;
|
|
const hs = size * Math.sqrt(3);
|
|
const vs = size * 1.5;
|
|
const cols = Math.ceil(w / hs) + 2;
|
|
const rows = Math.ceil(h / vs) + 2;
|
|
for (let row = -1; row < rows; row++) {
|
|
for (let col = -1; col < cols; col++) {
|
|
const xOff = (row % 2 === 1) ? hs / 2 : 0;
|
|
const x = col * hs + xOff;
|
|
const y = row * vs;
|
|
cx.beginPath();
|
|
for (let i = 0; i < 6; i++) {
|
|
const angle = (Math.PI / 3) * i - Math.PI / 2;
|
|
const px = x + size * Math.cos(angle);
|
|
const py = y + size * Math.sin(angle);
|
|
i === 0 ? cx.moveTo(px, py) : cx.lineTo(px, py);
|
|
}
|
|
cx.closePath();
|
|
cx.stroke();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize
|
|
function init() {
|
|
// On mobile/touch: draw static hex pattern only, no animation
|
|
if (window.innerWidth <= 768 || ('ontouchstart' in window)) {
|
|
initMobileStatic();
|
|
return;
|
|
}
|
|
|
|
canvas = document.getElementById('hexCanvas');
|
|
if (!canvas) return;
|
|
|
|
ctx = canvas.getContext('2d', { alpha: true });
|
|
resize();
|
|
|
|
window.addEventListener('resize', debounce(resize, 200));
|
|
document.addEventListener('mousemove', handleMouseMove, { passive: true });
|
|
document.addEventListener('mouseleave', handleMouseLeave);
|
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
|
|
isInitialized = true;
|
|
animate();
|
|
}
|
|
|
|
function resize() {
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 2); // Cap DPR at 2
|
|
canvasWidth = window.innerWidth;
|
|
canvasHeight = window.innerHeight;
|
|
|
|
canvas.width = canvasWidth * dpr;
|
|
canvas.height = canvasHeight * dpr;
|
|
canvas.style.width = canvasWidth + 'px';
|
|
canvas.style.height = canvasHeight + 'px';
|
|
canvas.style.position = 'fixed';
|
|
canvas.style.top = '0';
|
|
canvas.style.left = '0';
|
|
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
ctx.scale(dpr, dpr);
|
|
}
|
|
|
|
function handleMouseMove(e) {
|
|
mouse.x = e.clientX;
|
|
mouse.y = e.clientY;
|
|
}
|
|
|
|
function handleMouseLeave() {
|
|
mouse.x = -1000;
|
|
mouse.y = -1000;
|
|
}
|
|
|
|
function handleScroll() {
|
|
scrollY = window.scrollY;
|
|
}
|
|
|
|
// Main animation loop - optimized
|
|
function animate() {
|
|
if (!isInitialized) return;
|
|
|
|
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
|
|
|
// Calculate visible grid range (viewport only)
|
|
const viewTop = scrollY - CONFIG.hexSize;
|
|
const viewBottom = scrollY + canvasHeight + CONFIG.hexSize;
|
|
const viewLeft = -CONFIG.hexSize;
|
|
const viewRight = canvasWidth + CONFIG.hexSize;
|
|
|
|
const startRow = Math.floor(viewTop / vertSpacing) - 1;
|
|
const endRow = Math.ceil(viewBottom / vertSpacing) + 1;
|
|
const startCol = Math.floor(viewLeft / horizSpacing) - 1;
|
|
const endCol = Math.ceil(viewRight / horizSpacing) + 1;
|
|
|
|
// Mouse position in world coordinates
|
|
const mouseWorldY = mouse.y + scrollY;
|
|
const magnetRadiusSq = CONFIG.magnetRadius * CONFIG.magnetRadius;
|
|
|
|
// Update active hexagons
|
|
for (const [key, hex] of activeHexagons) {
|
|
const dx = mouse.x - hex.originX;
|
|
const dy = mouseWorldY - hex.originY;
|
|
const distSq = dx * dx + dy * dy;
|
|
|
|
if (distSq < magnetRadiusSq && mouse.x > -500) {
|
|
const distance = Math.sqrt(distSq);
|
|
const influence = 1 - (distance / CONFIG.magnetRadius);
|
|
const easedInfluence = easeOutCubic(influence);
|
|
const angle = Math.atan2(dy, dx);
|
|
const displacement = CONFIG.maxDisplacement * easedInfluence;
|
|
|
|
hex.currentX += (hex.originX + Math.cos(angle) * displacement - hex.currentX) * 0.15;
|
|
hex.currentY += (hex.originY + Math.sin(angle) * displacement - hex.currentY) * 0.15;
|
|
hex.opacity = CONFIG.baseOpacity + (CONFIG.maxOpacity - CONFIG.baseOpacity) * easedInfluence;
|
|
hex.lineWidth = CONFIG.lineWidth + (CONFIG.maxLineWidth - CONFIG.lineWidth) * easedInfluence;
|
|
hex.colorInfluence = easedInfluence;
|
|
} else {
|
|
// Return to origin
|
|
hex.currentX += (hex.originX - hex.currentX) * CONFIG.returnSpeed;
|
|
hex.currentY += (hex.originY - hex.currentY) * CONFIG.returnSpeed;
|
|
hex.opacity += (CONFIG.baseOpacity - hex.opacity) * CONFIG.returnSpeed;
|
|
hex.lineWidth += (CONFIG.lineWidth - hex.lineWidth) * CONFIG.returnSpeed;
|
|
hex.colorInfluence *= (1 - CONFIG.returnSpeed);
|
|
|
|
// Remove if fully reset
|
|
if (hex.colorInfluence < 0.001 &&
|
|
Math.abs(hex.currentX - hex.originX) < 0.1 &&
|
|
Math.abs(hex.currentY - hex.originY) < 0.1) {
|
|
activeHexagons.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw visible hexagons
|
|
for (let row = startRow; row <= endRow; row++) {
|
|
for (let col = startCol; col <= endCol; col++) {
|
|
const origin = getHexOrigin(col, row);
|
|
const screenY = origin.y - scrollY;
|
|
|
|
// Skip if outside viewport
|
|
if (screenY < -CONFIG.hexSize || screenY > canvasHeight + CONFIG.hexSize) continue;
|
|
|
|
const key = getHexKey(col, row);
|
|
const activeHex = activeHexagons.get(key);
|
|
|
|
if (activeHex) {
|
|
// Draw animated hex
|
|
drawHex(
|
|
activeHex.currentX,
|
|
activeHex.currentY - scrollY,
|
|
activeHex.opacity,
|
|
activeHex.lineWidth,
|
|
activeHex.colorInfluence
|
|
);
|
|
} else {
|
|
// Check if should become active
|
|
const dx = mouse.x - origin.x;
|
|
const dy = mouseWorldY - origin.y;
|
|
const distSq = dx * dx + dy * dy;
|
|
|
|
if (distSq < magnetRadiusSq && mouse.x > -500) {
|
|
// Add to active hexagons
|
|
activeHexagons.set(key, {
|
|
originX: origin.x,
|
|
originY: origin.y,
|
|
currentX: origin.x,
|
|
currentY: origin.y,
|
|
opacity: CONFIG.baseOpacity,
|
|
lineWidth: CONFIG.lineWidth,
|
|
colorInfluence: 0
|
|
});
|
|
}
|
|
|
|
// Draw static hex
|
|
drawHex(origin.x, screenY, CONFIG.baseOpacity, CONFIG.lineWidth, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
animationId = requestAnimationFrame(animate);
|
|
}
|
|
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function(...args) {
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => func(...args), wait);
|
|
};
|
|
}
|
|
|
|
function destroy() {
|
|
if (animationId) cancelAnimationFrame(animationId);
|
|
isInitialized = false;
|
|
activeHexagons.clear();
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
window.HexBackground = { init, destroy, config: CONFIG };
|
|
})();
|