Files
Websites/Profice WebSite/scripts/hex-background.js
2026-02-06 13:37:22 +01:00

296 lines
10 KiB
JavaScript

/**
* 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
};
})();