new pipeline and build improvements
This commit is contained in:
310
js/matrix.js
310
js/matrix.js
@@ -1,13 +1,21 @@
|
||||
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 = true;
|
||||
this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
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(),
|
||||
@@ -18,7 +26,6 @@ class MatrixBackground {
|
||||
dotSize: 3,
|
||||
mouseRadius: 110,
|
||||
mouseForce: 0.6,
|
||||
// Palette synced to the UI accent tokens
|
||||
colors: {
|
||||
dotDefault: 'rgba(230, 240, 255, 0.78)',
|
||||
dotDefaultGlow: 'rgba(180, 220, 255, 0.40)',
|
||||
@@ -51,15 +58,18 @@ class MatrixBackground {
|
||||
init() {
|
||||
this.handleResize();
|
||||
this.bindEvents();
|
||||
this.animate();
|
||||
|
||||
if (this.prefersReducedMotion) {
|
||||
this.renderFrame(0);
|
||||
} else {
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Pointer reactivity — push dots away from cursor for a "matrix repel" feel.
|
||||
// Touch devices are included; the dot pushes happen only while touched.
|
||||
window.addEventListener('pointermove', (e) => {
|
||||
this.mouse.x = e.clientX;
|
||||
this.mouse.y = e.clientY;
|
||||
window.addEventListener('pointermove', (event) => {
|
||||
this.mouse.x = event.clientX;
|
||||
this.mouse.y = event.clientY;
|
||||
this.mouse.active = true;
|
||||
}, { passive: true });
|
||||
|
||||
@@ -69,35 +79,59 @@ class MatrixBackground {
|
||||
this.mouse.y = -9999;
|
||||
});
|
||||
|
||||
// Pause the rAF loop when the tab is hidden to save CPU / battery.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.stop();
|
||||
} else if (this.prefersReducedMotion) {
|
||||
this.renderFrame(this.lastTimestamp);
|
||||
} else {
|
||||
this.start();
|
||||
}
|
||||
});
|
||||
|
||||
// Resize is debounced via rAF; disabled on mobile for perf parity with original.
|
||||
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) cancelAnimationFrame(resizeRaf);
|
||||
resizeRaf = requestAnimationFrame(() => this.handleResize());
|
||||
if (resizeRaf !== null) cancelAnimationFrame(resizeRaf);
|
||||
resizeRaf = requestAnimationFrame(() => {
|
||||
resizeRaf = null;
|
||||
this.handleResize();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.rafId || !this.isRunning) return;
|
||||
this.animate();
|
||||
if (this.rafId !== null || this.prefersReducedMotion || document.hidden) return;
|
||||
|
||||
this.isRunning = true;
|
||||
this.rafId = requestAnimationFrame(this.onAnimationFrame);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.rafId) {
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
|
||||
this.rafId = null;
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
@@ -105,12 +139,17 @@ class MatrixBackground {
|
||||
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 i = 0; i < this.config.dotCount; i++) {
|
||||
for (let index = 0; index < this.config.dotCount; index++) {
|
||||
this.createDot();
|
||||
}
|
||||
}
|
||||
@@ -118,7 +157,6 @@ class MatrixBackground {
|
||||
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;
|
||||
@@ -141,36 +179,32 @@ class MatrixBackground {
|
||||
|
||||
updateDots() {
|
||||
const radius = this.config.mouseRadius;
|
||||
const radiusSq = radius * radius;
|
||||
const radiusSquared = radius * radius;
|
||||
const force = this.config.mouseForce;
|
||||
|
||||
this.dots.forEach(dot => {
|
||||
// Mouse repulsion
|
||||
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 distSq = dx * dx + dy * dy;
|
||||
if (distSq < radiusSq && distSq > 0.5) {
|
||||
const dist = Math.sqrt(distSq);
|
||||
const strength = (1 - dist / radius) * force;
|
||||
dot.vx += (dx / dist) * strength;
|
||||
dot.vy += (dy / dist) * strength;
|
||||
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;
|
||||
|
||||
// Ease back to a gentle autonomous drift after pointer interaction.
|
||||
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;
|
||||
|
||||
// Reflect at the canvas edge. Keep the base velocity in sync with
|
||||
// the bounce so the drift easing does not push the dot back into
|
||||
// the same wall on the next frames.
|
||||
const dotRadius = dot.size / 2;
|
||||
const minX = dotRadius;
|
||||
const minY = dotRadius;
|
||||
@@ -197,69 +231,160 @@ class MatrixBackground {
|
||||
dot.baseVy = -Math.abs(dot.baseVy);
|
||||
}
|
||||
|
||||
// A large pointer impulse can overshoot both edges by more than
|
||||
// one frame, so clamp after reflecting the position.
|
||||
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() {
|
||||
this.dots.forEach(dot => {
|
||||
const isConnected = dot.connectionCount > 0;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(dot.x, dot.y, dot.size / 2, 0, Math.PI * 2);
|
||||
this.ctx.fillStyle = isConnected ? this.config.colors.dotConnected : this.config.colors.dotDefault;
|
||||
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.shadowBlur = 6;
|
||||
this.ctx.shadowColor = isConnected ? this.config.colors.dotConnectedGlow : this.config.colors.dotDefaultGlow;
|
||||
this.ctx.fill();
|
||||
});
|
||||
this.ctx.drawImage(sprite, dot.x - halfDimension, dot.y - halfDimension);
|
||||
}
|
||||
|
||||
this.ctx.globalAlpha = 1;
|
||||
this.ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
createConnection(dot1, dot2) {
|
||||
createConnection(dot1, dot2, timestamp) {
|
||||
if (this.connections.length >= this.config.maxConnections) return;
|
||||
|
||||
dot1.connectionCount++;
|
||||
dot2.connectionCount++;
|
||||
|
||||
this.connections.push({
|
||||
dot1: dot1,
|
||||
dot2: dot2,
|
||||
startTime: Date.now(),
|
||||
duration: Math.random() * (this.config.connectionDuration.max - this.config.connectionDuration.min) + this.config.connectionDuration.min
|
||||
dot1,
|
||||
dot2,
|
||||
startTime: timestamp,
|
||||
duration: Math.random() * (this.config.connectionDuration.max - this.config.connectionDuration.min)
|
||||
+ this.config.connectionDuration.min
|
||||
});
|
||||
}
|
||||
|
||||
updateConnections() {
|
||||
this.connections = this.connections.filter(conn => {
|
||||
const elapsed = Date.now() - conn.startTime;
|
||||
if (elapsed > conn.duration) {
|
||||
conn.dot1.connectionCount--;
|
||||
conn.dot2.connectionCount--;
|
||||
return false;
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
this.connections[writeIndex] = connection;
|
||||
writeIndex++;
|
||||
}
|
||||
|
||||
this.connections.length = writeIndex;
|
||||
}
|
||||
|
||||
drawConnections() {
|
||||
this.connections.forEach(conn => {
|
||||
const { dot1, dot2, startTime, duration } = conn;
|
||||
const elapsed = Date.now() - startTime;
|
||||
const opacity = Math.min(1, elapsed / 500);
|
||||
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
|
||||
);
|
||||
|
||||
const gradient = this.ctx.createLinearGradient(dot1.x, dot1.y, dot2.x, 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(dot1.x, dot1.y);
|
||||
this.ctx.lineTo(dot2.x, dot2.y);
|
||||
|
||||
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;
|
||||
@@ -267,51 +392,34 @@ class MatrixBackground {
|
||||
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;
|
||||
}
|
||||
|
||||
tryCreateConnections() {
|
||||
for (let i = 0; i < this.dots.length; i++) {
|
||||
for (let j = i + 1; j < this.dots.length; j++) {
|
||||
if (Math.random() < this.config.connectionChance) {
|
||||
const dot1 = this.dots[i];
|
||||
const dot2 = this.dots[j];
|
||||
|
||||
const dx = dot2.x - dot1.x;
|
||||
const dy = dot2.y - dot1.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < 150 && distance > 30) {
|
||||
const alreadyConnected = this.connections.some(conn =>
|
||||
(conn.dot1 === dot1 && conn.dot2 === dot2) ||
|
||||
(conn.dot1 === dot2 && conn.dot2 === dot1)
|
||||
);
|
||||
if (!alreadyConnected) {
|
||||
this.createConnection(dot1, dot2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
animate() {
|
||||
renderFrame(timestamp) {
|
||||
this.lastTimestamp = timestamp;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
if (!this.prefersReducedMotion) {
|
||||
this.updateDots();
|
||||
this.updateConnections();
|
||||
this.tryCreateConnections();
|
||||
this.updateConnections(timestamp);
|
||||
this.tryCreateConnections(timestamp);
|
||||
}
|
||||
|
||||
this.drawConnections();
|
||||
this.drawConnections(timestamp);
|
||||
this.drawDots();
|
||||
}
|
||||
|
||||
this.rafId = requestAnimationFrame(() => this.animate());
|
||||
animate(timestamp) {
|
||||
this.rafId = null;
|
||||
this.renderFrame(timestamp);
|
||||
|
||||
if (this.isRunning && !this.prefersReducedMotion && !document.hidden) {
|
||||
this.rafId = requestAnimationFrame(this.onAnimationFrame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user