429 lines
14 KiB
JavaScript
429 lines
14 KiB
JavaScript
class MatrixBackground {
|
|
constructor() {
|
|
this.canvas = document.getElementById('matrixCanvas');
|
|
if (!this.canvas) return;
|
|
|
|
this.ctx = this.canvas.getContext('2d');
|
|
if (!this.ctx) return;
|
|
|
|
this.dots = [];
|
|
this.connections = [];
|
|
this.mouse = { x: -9999, y: -9999, active: false };
|
|
this.rafId = null;
|
|
this.isRunning = false;
|
|
this.lastTimestamp = 0;
|
|
this.motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
this.prefersReducedMotion = this.motionQuery.matches;
|
|
this.dotSpriteCache = new Map();
|
|
this.onAnimationFrame = (timestamp) => this.animate(timestamp);
|
|
|
|
this.config = {
|
|
dotCount: this.getMobileDotCount(),
|
|
dotSpeed: 0.2,
|
|
connectionDuration: { min: 2000, max: 6000 },
|
|
connectionChance: 0.0002,
|
|
maxConnections: this.getMobileMaxConnections(),
|
|
dotSize: 3,
|
|
mouseRadius: 110,
|
|
mouseForce: 0.6,
|
|
colors: {
|
|
dotDefault: 'rgba(230, 240, 255, 0.78)',
|
|
dotDefaultGlow: 'rgba(180, 220, 255, 0.40)',
|
|
dotConnected: 'rgba(120, 190, 245, 0.85)',
|
|
dotConnectedGlow: 'rgba(79, 195, 247, 0.55)',
|
|
connection: {
|
|
start: 'rgba(102, 126, 234, 0.22)',
|
|
middle: 'rgba(118, 75, 162, 0.62)',
|
|
end: 'rgba(79, 195, 247, 0.22)'
|
|
}
|
|
}
|
|
};
|
|
|
|
this.init();
|
|
}
|
|
|
|
getMobileDotCount() {
|
|
if (window.innerWidth <= 480) return 35;
|
|
if (window.innerWidth <= 768) return 45;
|
|
if (window.innerWidth >= 2100) return 100;
|
|
return 50;
|
|
}
|
|
|
|
getMobileMaxConnections() {
|
|
if (window.innerWidth <= 480) return 4;
|
|
if (window.innerWidth <= 768) return 6;
|
|
return 8;
|
|
}
|
|
|
|
init() {
|
|
this.handleResize();
|
|
this.bindEvents();
|
|
|
|
if (this.prefersReducedMotion) {
|
|
this.renderFrame(0);
|
|
} else {
|
|
this.start();
|
|
}
|
|
}
|
|
|
|
bindEvents() {
|
|
window.addEventListener('pointermove', (event) => {
|
|
this.mouse.x = event.clientX;
|
|
this.mouse.y = event.clientY;
|
|
this.mouse.active = true;
|
|
}, { passive: true });
|
|
|
|
window.addEventListener('pointerout', () => {
|
|
this.mouse.active = false;
|
|
this.mouse.x = -9999;
|
|
this.mouse.y = -9999;
|
|
});
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) {
|
|
this.stop();
|
|
} else if (this.prefersReducedMotion) {
|
|
this.renderFrame(this.lastTimestamp);
|
|
} else {
|
|
this.start();
|
|
}
|
|
});
|
|
|
|
const updateMotionPreference = (event) => {
|
|
this.prefersReducedMotion = event.matches;
|
|
|
|
if (this.prefersReducedMotion) {
|
|
this.stop();
|
|
this.renderFrame(this.lastTimestamp);
|
|
} else if (!document.hidden) {
|
|
this.start();
|
|
}
|
|
};
|
|
|
|
if (this.motionQuery.addEventListener) {
|
|
this.motionQuery.addEventListener('change', updateMotionPreference);
|
|
} else {
|
|
this.motionQuery.addListener(updateMotionPreference);
|
|
}
|
|
|
|
if (window.innerWidth > 768) {
|
|
let resizeRaf = null;
|
|
window.addEventListener('resize', () => {
|
|
if (resizeRaf !== null) cancelAnimationFrame(resizeRaf);
|
|
resizeRaf = requestAnimationFrame(() => {
|
|
resizeRaf = null;
|
|
this.handleResize();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
start() {
|
|
if (this.rafId !== null || this.prefersReducedMotion || document.hidden) return;
|
|
|
|
this.isRunning = true;
|
|
this.rafId = requestAnimationFrame(this.onAnimationFrame);
|
|
}
|
|
|
|
stop() {
|
|
if (this.rafId !== null) {
|
|
cancelAnimationFrame(this.rafId);
|
|
}
|
|
|
|
this.rafId = null;
|
|
this.isRunning = false;
|
|
}
|
|
|
|
handleResize() {
|
|
this.canvas.width = window.innerWidth;
|
|
this.canvas.height = window.innerHeight;
|
|
this.config.dotCount = this.getMobileDotCount();
|
|
this.config.maxConnections = this.getMobileMaxConnections();
|
|
this.connections = [];
|
|
this.createDots();
|
|
|
|
if (this.prefersReducedMotion) {
|
|
this.renderFrame(this.lastTimestamp);
|
|
}
|
|
}
|
|
|
|
createDots() {
|
|
this.dots = [];
|
|
for (let index = 0; index < this.config.dotCount; index++) {
|
|
this.createDot();
|
|
}
|
|
}
|
|
|
|
createDot() {
|
|
const startX = Math.random() * (this.canvas.width - 20) + 10;
|
|
const startY = Math.random() * (this.canvas.height - 20) + 10;
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const speed = this.config.dotSpeed * (0.8 + Math.random() * 0.4);
|
|
const baseVelocityX = Math.cos(angle) * speed;
|
|
const baseVelocityY = Math.sin(angle) * speed;
|
|
|
|
this.dots.push({
|
|
x: startX,
|
|
y: startY,
|
|
vx: baseVelocityX,
|
|
vy: baseVelocityY,
|
|
baseVx: baseVelocityX,
|
|
baseVy: baseVelocityY,
|
|
wanderPhase: Math.random() * Math.PI * 2,
|
|
wanderSpeed: 0.01 + Math.random() * 0.01,
|
|
opacity: Math.random() * 0.3 + 0.7,
|
|
size: this.config.dotSize + Math.random() * 2,
|
|
connectionCount: 0
|
|
});
|
|
}
|
|
|
|
updateDots() {
|
|
const radius = this.config.mouseRadius;
|
|
const radiusSquared = radius * radius;
|
|
const force = this.config.mouseForce;
|
|
|
|
for (const dot of this.dots) {
|
|
if (this.mouse.active) {
|
|
const dx = dot.x - this.mouse.x;
|
|
const dy = dot.y - this.mouse.y;
|
|
const distanceSquared = dx * dx + dy * dy;
|
|
|
|
if (distanceSquared < radiusSquared && distanceSquared > 0.5) {
|
|
const distance = Math.sqrt(distanceSquared);
|
|
const strength = (1 - distance / radius) * force;
|
|
dot.vx += (dx / distance) * strength;
|
|
dot.vy += (dy / distance) * strength;
|
|
}
|
|
}
|
|
|
|
dot.x += dot.vx;
|
|
dot.y += dot.vy;
|
|
dot.wanderPhase += dot.wanderSpeed;
|
|
|
|
const wanderX = Math.cos(dot.wanderPhase) * 0.03;
|
|
const wanderY = Math.sin(dot.wanderPhase * 0.8) * 0.03;
|
|
dot.vx += (dot.baseVx + wanderX - dot.vx) * 0.02;
|
|
dot.vy += (dot.baseVy + wanderY - dot.vy) * 0.02;
|
|
|
|
const dotRadius = dot.size / 2;
|
|
const minX = dotRadius;
|
|
const minY = dotRadius;
|
|
const maxX = Math.max(minX, this.canvas.width - dotRadius);
|
|
const maxY = Math.max(minY, this.canvas.height - dotRadius);
|
|
|
|
if (dot.x <= minX && dot.vx < 0) {
|
|
dot.x = minX + (minX - dot.x);
|
|
dot.vx = Math.abs(dot.vx);
|
|
dot.baseVx = Math.abs(dot.baseVx);
|
|
} else if (dot.x >= maxX && dot.vx > 0) {
|
|
dot.x = maxX - (dot.x - maxX);
|
|
dot.vx = -Math.abs(dot.vx);
|
|
dot.baseVx = -Math.abs(dot.baseVx);
|
|
}
|
|
|
|
if (dot.y <= minY && dot.vy < 0) {
|
|
dot.y = minY + (minY - dot.y);
|
|
dot.vy = Math.abs(dot.vy);
|
|
dot.baseVy = Math.abs(dot.baseVy);
|
|
} else if (dot.y >= maxY && dot.vy > 0) {
|
|
dot.y = maxY - (dot.y - maxY);
|
|
dot.vy = -Math.abs(dot.vy);
|
|
dot.baseVy = -Math.abs(dot.baseVy);
|
|
}
|
|
|
|
dot.x = Math.max(minX, Math.min(maxX, dot.x));
|
|
dot.y = Math.max(minY, Math.min(maxY, dot.y));
|
|
}
|
|
}
|
|
|
|
getDotSprite(size, isConnected) {
|
|
const roundedSize = Math.round(size * 2) / 2;
|
|
const state = isConnected ? 'connected' : 'default';
|
|
const cacheKey = state + ':' + roundedSize;
|
|
const cachedSprite = this.dotSpriteCache.get(cacheKey);
|
|
|
|
if (cachedSprite) {
|
|
return cachedSprite;
|
|
}
|
|
|
|
const padding = 10;
|
|
const dimension = Math.ceil(roundedSize + padding * 2);
|
|
const sprite = document.createElement('canvas');
|
|
sprite.width = dimension;
|
|
sprite.height = dimension;
|
|
|
|
const spriteContext = sprite.getContext('2d');
|
|
spriteContext.shadowBlur = 6;
|
|
spriteContext.shadowColor = isConnected
|
|
? this.config.colors.dotConnectedGlow
|
|
: this.config.colors.dotDefaultGlow;
|
|
spriteContext.fillStyle = isConnected
|
|
? this.config.colors.dotConnected
|
|
: this.config.colors.dotDefault;
|
|
spriteContext.beginPath();
|
|
spriteContext.arc(dimension / 2, dimension / 2, roundedSize / 2, 0, Math.PI * 2);
|
|
spriteContext.fill();
|
|
|
|
this.dotSpriteCache.set(cacheKey, sprite);
|
|
return sprite;
|
|
}
|
|
|
|
drawDots() {
|
|
for (const dot of this.dots) {
|
|
const sprite = this.getDotSprite(dot.size, dot.connectionCount > 0);
|
|
const halfDimension = sprite.width / 2;
|
|
|
|
this.ctx.globalAlpha = dot.opacity;
|
|
this.ctx.drawImage(sprite, dot.x - halfDimension, dot.y - halfDimension);
|
|
}
|
|
|
|
this.ctx.globalAlpha = 1;
|
|
}
|
|
|
|
createConnection(dot1, dot2, timestamp) {
|
|
if (this.connections.length >= this.config.maxConnections) return;
|
|
|
|
dot1.connectionCount++;
|
|
dot2.connectionCount++;
|
|
|
|
this.connections.push({
|
|
dot1,
|
|
dot2,
|
|
startTime: timestamp,
|
|
duration: Math.random() * (this.config.connectionDuration.max - this.config.connectionDuration.min)
|
|
+ this.config.connectionDuration.min
|
|
});
|
|
}
|
|
|
|
updateConnections(timestamp) {
|
|
let writeIndex = 0;
|
|
|
|
for (let readIndex = 0; readIndex < this.connections.length; readIndex++) {
|
|
const connection = this.connections[readIndex];
|
|
|
|
if (timestamp - connection.startTime > connection.duration) {
|
|
connection.dot1.connectionCount--;
|
|
connection.dot2.connectionCount--;
|
|
continue;
|
|
}
|
|
|
|
this.connections[writeIndex] = connection;
|
|
writeIndex++;
|
|
}
|
|
|
|
this.connections.length = writeIndex;
|
|
}
|
|
|
|
samplePoisson(lambda) {
|
|
if (lambda <= 0) return 0;
|
|
|
|
const threshold = Math.exp(-lambda);
|
|
let product = 1;
|
|
let count = 0;
|
|
|
|
do {
|
|
count++;
|
|
product *= Math.random();
|
|
} while (product > threshold);
|
|
|
|
return count - 1;
|
|
}
|
|
|
|
tryCreateConnections(timestamp) {
|
|
const dotCount = this.dots.length;
|
|
if (dotCount < 2 || this.connections.length >= this.config.maxConnections) return;
|
|
|
|
const pairCount = dotCount * (dotCount - 1) / 2;
|
|
const attempts = this.samplePoisson(pairCount * this.config.connectionChance);
|
|
|
|
for (let attempt = 0;
|
|
attempt < attempts && this.connections.length < this.config.maxConnections;
|
|
attempt++) {
|
|
const firstIndex = Math.floor(Math.random() * dotCount);
|
|
let secondIndex = Math.floor(Math.random() * (dotCount - 1));
|
|
|
|
if (secondIndex >= firstIndex) {
|
|
secondIndex++;
|
|
}
|
|
|
|
const dot1 = this.dots[firstIndex];
|
|
const dot2 = this.dots[secondIndex];
|
|
const dx = dot2.x - dot1.x;
|
|
const dy = dot2.y - dot1.y;
|
|
const distanceSquared = dx * dx + dy * dy;
|
|
|
|
if (distanceSquared >= 22500 || distanceSquared <= 900) {
|
|
continue;
|
|
}
|
|
|
|
const alreadyConnected = this.connections.some((connection) =>
|
|
(connection.dot1 === dot1 && connection.dot2 === dot2)
|
|
|| (connection.dot1 === dot2 && connection.dot2 === dot1)
|
|
);
|
|
|
|
if (!alreadyConnected) {
|
|
this.createConnection(dot1, dot2, timestamp);
|
|
}
|
|
}
|
|
}
|
|
|
|
drawConnections(timestamp) {
|
|
for (const connection of this.connections) {
|
|
const elapsed = timestamp - connection.startTime;
|
|
const opacity = Math.min(1, elapsed / 500);
|
|
const gradient = this.ctx.createLinearGradient(
|
|
connection.dot1.x,
|
|
connection.dot1.y,
|
|
connection.dot2.x,
|
|
connection.dot2.y
|
|
);
|
|
|
|
gradient.addColorStop(0, this.config.colors.connection.start);
|
|
gradient.addColorStop(0.5, this.config.colors.connection.middle);
|
|
gradient.addColorStop(1, this.config.colors.connection.end);
|
|
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(connection.dot1.x, connection.dot1.y);
|
|
this.ctx.lineTo(connection.dot2.x, connection.dot2.y);
|
|
this.ctx.strokeStyle = gradient;
|
|
this.ctx.lineWidth = 2;
|
|
this.ctx.globalAlpha = opacity;
|
|
|
|
const pulse = (Math.sin((elapsed / 2000) * Math.PI * 2) + 1) / 2;
|
|
this.ctx.shadowBlur = 4 + pulse * 6;
|
|
this.ctx.shadowColor = 'rgba(118, 75, 162, 0.5)';
|
|
this.ctx.stroke();
|
|
}
|
|
|
|
this.ctx.globalAlpha = 1;
|
|
this.ctx.shadowBlur = 0;
|
|
}
|
|
|
|
renderFrame(timestamp) {
|
|
this.lastTimestamp = timestamp;
|
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
|
|
if (!this.prefersReducedMotion) {
|
|
this.updateDots();
|
|
this.updateConnections(timestamp);
|
|
this.tryCreateConnections(timestamp);
|
|
}
|
|
|
|
this.drawConnections(timestamp);
|
|
this.drawDots();
|
|
}
|
|
|
|
animate(timestamp) {
|
|
this.rafId = null;
|
|
this.renderFrame(timestamp);
|
|
|
|
if (this.isRunning && !this.prefersReducedMotion && !document.hidden) {
|
|
this.rafId = requestAnimationFrame(this.onAnimationFrame);
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
new MatrixBackground();
|
|
});
|