Files
ivanch.me/js/matrix.js
Jose Henrique 72c7d6c9e0
Some checks failed
Homepage Build and Deploy (Test) / Build Homepage Image (push) Successful in 32s
Homepage Build and Deploy (Test) / Deploy Homepage (push) Failing after 3s
new pipeline and build improvements
2026-07-10 17:06:16 -03:00

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