Files
Montech_Landing/apps/landing/components/ParticleField.vue
T

163 lines
4.6 KiB
Vue

<template>
<canvas
ref="el"
class="pointer-events-none absolute inset-0 h-full w-full"
aria-hidden="true"
/>
</template>
<script setup lang="ts">
type Particle = { x: number; y: number; vx: number; vy: number; r: number; a: number };
const el = ref<HTMLCanvasElement | null>(null);
onMounted(() => {
const canvas = el.value;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let particles: Particle[] = [];
let raf = 0;
let w = 0;
let h = 0;
let dpr = 1;
const mouse = { x: -9999, y: -9999, tx: -9999, ty: -9999 };
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const resize = () => {
const parent = canvas.parentElement;
if (!parent) return;
dpr = Math.min(window.devicePixelRatio || 1, 2);
w = parent.clientWidth;
h = parent.clientHeight;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const count = Math.min(110, Math.max(40, Math.floor((w * h) / 12000)));
particles = Array.from({ length: count }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.45,
vy: (Math.random() - 0.5) * 0.45,
r: Math.random() * 1.8 + 0.5,
a: Math.random() * 0.5 + 0.18,
}));
};
const onMove = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.tx = e.clientX - rect.left;
mouse.ty = e.clientY - rect.top;
};
const onLeave = () => {
mouse.tx = -9999;
mouse.ty = -9999;
};
const draw = () => {
ctx.clearRect(0, 0, w, h);
// smooth mouse follow
if (mouse.tx > -9000) {
mouse.x += (mouse.tx - mouse.x) * 0.12;
mouse.y += (mouse.ty - mouse.y) * 0.12;
} else {
mouse.x += (-9999 - mouse.x) * 0.05;
mouse.y += (-9999 - mouse.y) * 0.05;
}
const hasMouse = mouse.x > -5000;
for (const p of particles) {
if (!reduced && hasMouse) {
const dx = mouse.x - p.x;
const dy = mouse.y - p.y;
const dist = Math.hypot(dx, dy) || 1;
if (dist < 220) {
const force = (1 - dist / 220) * 0.065;
p.vx += (dx / dist) * force;
p.vy += (dy / dist) * force;
}
// soft orbital swirl near cursor
if (dist < 160) {
p.vx += (-dy / dist) * 0.02;
p.vy += (dx / dist) * 0.02;
}
}
p.vx *= 0.98;
p.vy *= 0.98;
p.x += p.vx;
p.y += p.vy;
if (p.x < 0) p.x = w;
if (p.x > w) p.x = 0;
if (p.y < 0) p.y = h;
if (p.y > h) p.y = 0;
// keep a tiny base drift so field never freezes
if (Math.abs(p.vx) < 0.05) p.vx += (Math.random() - 0.5) * 0.04;
if (Math.abs(p.vy) < 0.05) p.vy += (Math.random() - 0.5) * 0.04;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(200, 255, 46, ${p.a})`;
ctx.fill();
}
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const a = particles[i];
const b = particles[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const dist = Math.hypot(dx, dy);
if (dist > 120) continue;
let alpha = (1 - dist / 120) * 0.2;
if (hasMouse) {
const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2;
const toMouse = Math.hypot(midX - mouse.x, midY - mouse.y);
if (toMouse < 180) alpha += (1 - toMouse / 180) * 0.25;
}
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = `rgba(200, 255, 46, ${Math.min(alpha, 0.55)})`;
ctx.lineWidth = 1;
ctx.stroke();
}
}
if (hasMouse) {
const g = ctx.createRadialGradient(mouse.x, mouse.y, 0, mouse.x, mouse.y, 140);
g.addColorStop(0, "rgba(200, 255, 46, 0.12)");
g.addColorStop(1, "rgba(200, 255, 46, 0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(mouse.x, mouse.y, 140, 0, Math.PI * 2);
ctx.fill();
}
raf = requestAnimationFrame(draw);
};
resize();
draw();
window.addEventListener("resize", resize);
window.addEventListener("pointermove", onMove, { passive: true });
document.addEventListener("pointerleave", onLeave);
onUnmounted(() => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
window.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerleave", onLeave);
});
});
</script>