This commit is contained in:
2026-02-06 14:03:59 +01:00
parent 371c3da66c
commit 40c67c99f3
3 changed files with 284 additions and 225 deletions

View File

@@ -1,7 +1,6 @@
/**
* Hexagonal Magnetism Background
* Rigid hexagon grid with mouse-based magnetism effect
* Hexagons move as whole units - no vertex deformation
* Hexagonal Magnetism Background - OPTIMIZED
* Only renders visible hexagons, uses viewport culling
*/
(function() {
@@ -14,210 +13,128 @@
maxLineWidth: 2.5,
baseOpacity: 0.15,
maxOpacity: 0.6,
magnetRadius: 350, // Larger radius for 16:9/16:10 monitors
maxDisplacement: 15,
returnSpeed: 0.35, // Faster return to reset colors quickly
// Base color (neutral gray)
magnetRadius: 280,
maxDisplacement: 12,
returnSpeed: 0.4,
// Pre-calculated colors
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
};
// 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 hexagons = [];
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();
// 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 && mouseX > -500 && mouseY > -500) {
// 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 - use faster speed for reset
const resetSpeed = CONFIG.returnSpeed * 1.5;
this.currentX += (this.originX - this.currentX) * resetSpeed;
this.currentY += (this.originY - this.currentY) * resetSpeed;
// Fade back to default - faster color reset
this.opacity += (CONFIG.baseOpacity - this.opacity) * resetSpeed;
this.lineWidth += (CONFIG.lineWidth - this.lineWidth) * resetSpeed;
this.colorInfluence += (0 - this.colorInfluence) * resetSpeed;
// Force reset if very close to default
if (this.colorInfluence < 0.005) this.colorInfluence = 0;
if (Math.abs(this.opacity - CONFIG.baseOpacity) < 0.005) this.opacity = CONFIG.baseOpacity;
if (Math.abs(this.lineWidth - CONFIG.lineWidth) < 0.01) this.lineWidth = CONFIG.lineWidth;
}
}
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
// Easing function
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
return 1 - (1 - t) * (1 - t) * (1 - t);
}
// Initialize canvas
// 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();
}
// Initialize
function init() {
canvas = document.getElementById('hexCanvas');
if (!canvas) {
console.warn('hexCanvas element not found');
return;
}
if (!canvas) return;
ctx = canvas.getContext('2d');
ctx = canvas.getContext('2d', { alpha: true });
resize();
generateHexagons();
// Event listeners
window.addEventListener('resize', debounce(handleResize, 150));
document.addEventListener('mousemove', handleMouseMove);
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();
}
// 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);
const dpr = Math.min(window.devicePixelRatio || 1, 2); // Cap DPR at 2
canvasWidth = window.innerWidth;
canvasHeight = window.innerHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
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';
// 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;
@@ -228,74 +145,134 @@
mouse.y = -1000;
}
// Animation loop
function handleScroll() {
scrollY = window.scrollY;
}
// Main animation loop - optimized
function animate() {
if (!isInitialized) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
const updateRadius = CONFIG.magnetRadius + CONFIG.hexSize * 2;
// 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;
for (let i = 0; i < hexagons.length; i++) {
const hex = hexagons[i];
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;
// Check if hexagon needs updating (near mouse or returning to origin)
// 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 = 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.05 ||
Math.abs(hex.currentY - hex.originY) > 0.05 ||
hex.colorInfluence > 0.001 ||
hex.opacity > CONFIG.baseOpacity + 0.001;
const dy = mouseWorldY - hex.originY;
const distSq = dx * dx + dy * dy;
if (distance < updateRadius || isReturning) {
hex.update(mouse.x, mouse.y);
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);
}
}
}
hex.draw(ctx);
// 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);
}
// Debounce utility
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(later, wait);
timeout = setTimeout(() => func(...args), wait);
};
}
// Cleanup
function destroy() {
if (animationId) {
cancelAnimationFrame(animationId);
}
window.removeEventListener('resize', handleResize);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseleave', handleMouseLeave);
if (animationId) cancelAnimationFrame(animationId);
isInitialized = false;
activeHexagons.clear();
}
// 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
};
window.HexBackground = { init, destroy, config: CONFIG };
})();