'use client';
import { useEffect, useRef } from 'react';
const SHAPES = ['square', 'tile', 'hexagon', 'triangle', 'diamond', 'circle', 'cross', 'dot'];
const DIRECTIONS = {
right: [1, 0],
left: [-1, 0],
up: [0, -1],
down: [0, 1],
diagonal: [Math.SQRT1_2, -Math.SQRT1_2],
none: [0, 0]
};
const SQRT3 = Math.sqrt(3);
const OFFSET = 32768;
const FAR = 2.5;
const MAX_PIXELS = 8e6;
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const smoothstep = (edge0, edge1, value) => {
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
};
const smootherstep = t => t * t * t * (t * (t * 6 - 15) + 10);
const hash = (i, j) => {
const s = Math.sin(i * 127.1 + j * 311.7) * 43758.5453;
return s - Math.floor(s);
};
const keyOf = (i, j) => (i + OFFSET) * 65536 + (j + OFFSET);
const fadeMask = amount => {
if (amount <= 0) return 'none';
const edge = amount * 26;
const stops = [];
for (let k = 0; k <= 8; k++) {
const t = k / 8;
stops.push(`rgba(0, 0, 0, ${smootherstep(t).toFixed(3)}) ${(t * edge).toFixed(2)}%`);
}
const inner = stops.join(', ');
const outer = [...stops]
.reverse()
.map(stop => stop.replace(/ ([\d.]+)%$/, (_, value) => ` ${(100 - parseFloat(value)).toFixed(2)}%`))
.join(', ');
const axis = direction => `linear-gradient(${direction}, ${inner}, ${outer})`;
return `${axis('to right')}, ${axis('to bottom')}`;
};
const lightnessOf = color => {
let hex = String(color).trim().replace(/^#/, '');
if (hex.length === 3 || hex.length === 4) hex = hex.replace(/./g, '$&$&');
hex = hex.slice(0, 6);
if (!/^[0-9a-f]{6}$/i.test(hex)) return 0.6;
const value = parseInt(hex, 16);
const channels = [value >> 16, (value >> 8) & 255, value & 255];
return (Math.max(...channels) + Math.min(...channels)) / 510;
};
const tint = (hue, lightness) =>
`hsl(${(((hue % 360) + 360) % 360).toFixed(1)}, 90%, ${(lightness * 100).toFixed(1)}%)`;
const multiply = (a, b) =>
[0, 1, 2, 3, 4, 5, 6, 7, 8].map(k => {
const r = k - (k % 3);
const c = k % 3;
return a[r] * b[c] + a[r + 1] * b[c + 3] + a[r + 2] * b[c + 6];
});
const invert = m => {
const [a, b, c, d, e, f, g, h, i] = m;
const A = e * i - f * h;
const B = f * g - d * i;
const C = d * h - e * g;
const det = a * A + b * B + c * C;
return [A, c * h - b * i, b * f - c * e, B, a * i - c * g, c * d - a * f, C, b * g - a * h, a * e - b * d].map(
value => value / det
);
};
const project = (m, x, y) => {
const w = m[6] * x + m[7] * y + m[8];
return [(m[0] * x + m[1] * y + m[2]) / w, (m[3] * x + m[4] * y + m[5]) / w];
};
const flatView = (width, height) => ({
x0: 0,
y0: 0,
width,
height,
inverse: null,
transform: 'none',
mist: null,
far: FAR,
reach: Math.hypot(width, height) / 2
});
const viewFor = (tiltX, tiltY, width, height, cellSize) => {
if (!tiltX && !tiltY) return flatView(width, height);
const ax = (tiltX * Math.PI) / 180;
const ay = (tiltY * Math.PI) / 180;
const depth = Math.max(width, height) * 1.25;
const cx = width / 2;
const cy = height / 2;
const tilt = [
Math.cos(ay),
0,
0,
Math.sin(ax) * Math.sin(ay),
Math.cos(ax),
0,
(Math.sin(ay) * Math.cos(ax)) / depth,
-Math.sin(ax) / depth,
1
];
const forward = multiply(multiply([1, 0, cx, 0, 1, cy, 0, 0, 1], tilt), [1, 0, -cx, 0, 1, -cy, 0, 0, 1]);
const inverse = invert(forward);
const facing = Math.cos(ax) * Math.cos(ay);
const far = clamp(Math.sqrt((facing * cellSize) / 5), 1.15, FAR);
const near = Math.min(Math.sqrt((facing * cellSize) / 14), far - 0.35);
const nearness = (x, y) => inverse[6] * x + inverse[7] * y + inverse[8] - 1 / far;
const corners = [
[0, 0],
[width, 0],
[width, height],
[0, height]
];
let x0 = Infinity;
let y0 = Infinity;
let x1 = -Infinity;
let y1 = -Infinity;
const include = (x, y) => {
const [px, py] = project(inverse, x, y);
x0 = Math.min(x0, px);
y0 = Math.min(y0, py);
x1 = Math.max(x1, px);
y1 = Math.max(y1, py);
};
corners.forEach((a, k) => {
const b = corners[(k + 1) % 4];
const na = nearness(a[0], a[1]);
const nb = nearness(b[0], b[1]);
if (na >= 0) include(a[0], a[1]);
if (na >= 0 !== nb >= 0) {
const t = na / (na - nb);
include(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t);
}
});
if (!(x1 > x0 && y1 > y0)) return flatView(width, height);
x0 = Math.floor(x0 / 16) * 16;
y0 = Math.floor(y0 / 16) * 16;
x1 = Math.ceil(x1 / 16) * 16;
y1 = Math.ceil(y1 / 16) * 16;
const m = multiply(forward, [1, 0, x0, 0, 1, y0, 0, 0, 1]);
const transform = `matrix3d(${[m[0], m[3], 0, m[6], m[1], m[4], 0, m[7], 0, 0, 1, 0, m[2], m[5], 0, m[8]].join(', ')})`;
const gx = forward[6];
const gy = forward[7];
const slope = Math.hypot(gx, gy);
const mist =
slope > 1e-9
? [
cx + (gx / slope) * ((near - 1) / slope),
cy + (gy / slope) * ((near - 1) / slope),
cx + (gx / slope) * ((far - 1) / slope),
cy + (gy / slope) * ((far - 1) / slope)
]
: null;
const reach = Math.max(
Math.hypot(x0 - cx, y0 - cy),
Math.hypot(x1 - cx, y0 - cy),
Math.hypot(x1 - cx, y1 - cy),
Math.hypot(x0 - cx, y1 - cy)
);
return { x0, y0, width: x1 - x0, height: y1 - y0, inverse, transform, mist, far, reach };
};
const layoutFor = (shape, size) => {
if (shape === 'hexagon') {
const radius = size / SQRT3;
return { stepX: radius * 1.5, stepY: size, radius };
}
if (shape === 'triangle') {
const side = (size * 2) / SQRT3;
return { stepX: side / 2, stepY: size, side };
}
return { stepX: size, stepY: size };
};
const centerOf = (shape, layout, i, j) => {
const { stepX, stepY } = layout;
if (shape === 'hexagon') return [i * stepX, j * stepY + (Math.abs(i) % 2 === 1 ? stepY / 2 : 0)];
if (shape === 'triangle') {
const up = (((i + j) % 2) + 2) % 2 === 0;
return [i * stepX, j * stepY + (up ? stepY * (2 / 3) : stepY / 3)];
}
if (shape === 'cross' || shape === 'dot') return [i * stepX, j * stepY];
return [(i + 0.5) * stepX, (j + 0.5) * stepY];
};
const traceCell = (ctx, shape, layout, size, i, j, x, y, inset) => {
if (shape === 'hexagon') {
const r = layout.radius - inset;
for (let k = 0; k < 6; k++) {
const angle = (Math.PI / 3) * k;
const px = x + r * Math.cos(angle);
const py = y + r * Math.sin(angle);
if (k === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
} else if (shape === 'triangle') {
const up = (((i + j) % 2) + 2) % 2 === 0;
const half = layout.side / 2 - inset * 1.7;
const height = size - inset * 2.6;
const top = up ? y - height * (2 / 3) : y + height * (2 / 3);
const base = up ? y + height / 3 : y - height / 3;
ctx.moveTo(x, top);
ctx.lineTo(x + half, base);
ctx.lineTo(x - half, base);
ctx.closePath();
} else if (shape === 'diamond') {
const r = size / 2 - inset;
ctx.moveTo(x, y - r);
ctx.lineTo(x + r, y);
ctx.lineTo(x, y + r);
ctx.lineTo(x - r, y);
ctx.closePath();
} else if (shape === 'circle') {
ctx.moveTo(x + size / 2 - inset, y);
ctx.arc(x, y, Math.max(0.5, size / 2 - inset), 0, Math.PI * 2);
} else if (shape === 'tile') {
const half = size / 2 - Math.max(inset, size * 0.06);
const r = Math.min(half, size * 0.22);
ctx.roundRect(x - half, y - half, half * 2, half * 2, r);
} else if (shape === 'cross') {
const arm = size * 0.16 + inset;
ctx.moveTo(x - arm, y);
ctx.lineTo(x + arm, y);
ctx.moveTo(x, y - arm);
ctx.lineTo(x, y + arm);
} else if (shape === 'dot') {
const r = size * 0.05 + inset;
ctx.moveTo(x + r, y);
ctx.arc(x, y, r, 0, Math.PI * 2);
} else {
const half = size / 2 - inset;
ctx.rect(x - half, y - half, half * 2, half * 2);
}
};
export default function ShapeGrid({
shape = 'square',
cellSize = 40,
lineWidth = 1,
direction = 'diagonal',
speed = 0.5,
lineColor = '#2a2734',
hoverColor = '#ffffff',
hoverFillColor,
fillOpacity = 0.15,
rainbow = false,
tiltX = 0,
tiltY = 0,
radius = 1.5,
trail = 0.6,
glow = 0.35,
twinkle = 0,
fade = 1,
clickCascade = true,
intro = true,
opacity = 1,
backgroundColor = 'transparent',
paused = false,
dpr,
className = '',
style
}) {
const containerRef = useRef(null);
const canvasRef = useRef(null);
const engineRef = useRef(null);
const settingsRef = useRef(null);
settingsRef.current = {
shape: SHAPES.includes(shape) ? shape : 'square',
cellSize: Math.max(8, cellSize),
lineWidth: Math.max(0.25, lineWidth),
direction: DIRECTIONS[direction] ? direction : 'diagonal',
speed: Math.max(0, speed),
lineColor,
hoverColor,
hoverFillColor: hoverFillColor ?? hoverColor,
fillOpacity: clamp(fillOpacity, 0, 1),
rainbow,
tone: clamp(lightnessOf(hoverColor), 0.45, 0.68),
tiltX: clamp(tiltX, -70, 70),
tiltY: clamp(tiltY, -70, 70),
radius: Math.max(0, radius),
trail: Math.max(0.05, trail),
glow: clamp(glow, 0, 1),
twinkle: clamp(twinkle, 0, 1),
clickCascade,
intro,
paused,
dpr
};
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return undefined;
const ctx = canvas.getContext('2d');
if (!ctx) return undefined;
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const state = {
alive: true,
raf: 0,
last: 0,
time: 0,
visible: true,
width: 1,
height: 1,
ratio: 1,
view: flatView(1, 1),
offsetX: 0,
offsetY: 0,
started: false,
startedAt: 0,
inside: false,
pointerX: 0,
pointerY: 0,
spotX: 0,
spotY: 0,
spotVX: 0,
spotVY: 0,
spotPower: 0,
lit: new Map(),
hues: new Map(),
cascades: [],
cascadeHue: 0
};
const resize = () => {
const s = settingsRef.current;
const width = Math.max(1, container.clientWidth);
const height = Math.max(1, container.clientHeight);
const view = viewFor(s.tiltX, s.tiltY, width, height, s.cellSize);
let ratio = s.dpr ?? Math.min(window.devicePixelRatio || 1, 2);
const pixels = view.width * view.height * ratio * ratio;
if (pixels > MAX_PIXELS) ratio *= Math.sqrt(MAX_PIXELS / pixels);
state.width = width;
state.height = height;
state.ratio = ratio;
state.view = view;
const pixelWidth = Math.max(1, Math.round(view.width * ratio));
const pixelHeight = Math.max(1, Math.round(view.height * ratio));
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
const cssWidth = `${view.width}px`;
const cssHeight = `${view.height}px`;
if (canvas.style.width !== cssWidth) canvas.style.width = cssWidth;
if (canvas.style.height !== cssHeight) canvas.style.height = cssHeight;
if (canvas.style.transform !== view.transform) canvas.style.transform = view.transform;
};
const toPlane = (x, y) => {
const { inverse, far } = state.view;
if (!inverse) return [x, y];
if (inverse[6] * x + inverse[7] * y + inverse[8] < 1 / far) return null;
return project(inverse, x, y);
};
const range = (layout, margin) => {
const { stepX, stepY } = layout;
const { view } = state;
return [
Math.floor((state.offsetX + view.x0 - margin) / stepX) - 1,
Math.ceil((state.offsetX + view.x0 + view.width + margin) / stepX) + 1,
Math.floor((state.offsetY + view.y0 - margin) / stepY) - 1,
Math.ceil((state.offsetY + view.y0 + view.height + margin) / stepY) + 1
];
};
const nearestCell = (s, layout, wx, wy) => {
const { stepX, stepY } = layout;
const ci = Math.round(wx / stepX);
const cj = Math.round(wy / stepY);
let best = Infinity;
let cell = [ci, cj];
for (let i = ci - 2; i <= ci + 2; i++) {
for (let j = cj - 2; j <= cj + 2; j++) {
const [x, y] = centerOf(s.shape, layout, i, j);
const d = (x - wx) ** 2 + (y - wy) ** 2;
if (d < best) {
best = d;
cell = [i, j];
}
}
}
return cell;
};
const illuminate = (s, layout, dt) => {
const decay = Math.exp(-dt / s.trail);
for (const [key, value] of state.lit) {
const next = value * decay;
if (next < 0.004) {
state.lit.delete(key);
state.hues.delete(key);
} else state.lit.set(key, next);
}
if (state.spotPower < 0.01) return;
const wx = state.spotX + state.offsetX;
const wy = state.spotY + state.offsetY;
const reach = Math.max(s.radius, 0.5) * s.cellSize;
const [hi, hj] = nearestCell(s, layout, wx, wy);
const spanI = Math.ceil(reach / layout.stepX) + 2;
const spanJ = Math.ceil(reach / layout.stepY) + 2;
const hue = state.time * 120;
for (let i = hi - spanI; i <= hi + spanI; i++) {
for (let j = hj - spanJ; j <= hj + spanJ; j++) {
const [x, y] = centerOf(s.shape, layout, i, j);
const distance = Math.hypot(x - wx, y - wy);
let value = s.radius > 0 ? smoothstep(reach, reach * 0.15, distance) : 0;
if (i === hi && j === hj) value = Math.max(value, 1);
value *= state.spotPower;
if (value <= 0.004) continue;
const key = keyOf(i, j);
if (value > (state.lit.get(key) ?? 0)) {
state.lit.set(key, value);
state.hues.set(key, hue + ((x + y) / s.cellSize) * 12);
}
}
}
};
const cascadeAt = (s, x, y) => {
let value = 0;
for (const cascade of state.cascades) {
const ring = Math.round(Math.hypot(x - cascade.x, y - cascade.y) / s.cellSize);
const local = state.time - cascade.time - ring * 0.055;
if (local < 0) continue;
const pulse = Math.exp(-((local / 0.085) ** 2)) * cascade.power * Math.max(0, 1 - ring / cascade.reach);
if (pulse > value) {
value = pulse;
state.cascadeHue = cascade.hue + ring * 16;
}
}
return value;
};
const draw = () => {
const s = settingsRef.current;
const { view, ratio } = state;
const layout = layoutFor(s.shape, s.cellSize);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(ratio, 0, 0, ratio, -view.x0 * ratio, -view.y0 * ratio);
const left = view.x0;
const top = view.y0;
const right = view.x0 + view.width;
const bottom = view.y0 + view.height;
const size = s.cellSize;
const [i0, i1, j0, j1] = range(layout, size);
const markers = s.shape === 'cross' || s.shape === 'dot';
const elapsed = state.time - state.startedAt;
const introRadius = s.intro && !reduce ? elapsed * view.reach * 1.8 : Infinity;
const originX = state.width / 2;
const originY = state.height / 2;
const centerX = originX + state.offsetX;
const centerY = originY + state.offsetY;
ctx.lineWidth = s.lineWidth;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.strokeStyle = s.lineColor;
ctx.fillStyle = s.lineColor;
ctx.beginPath();
if (s.shape === 'square') {
for (let i = i0; i <= i1; i++) {
const x = i * layout.stepX - state.offsetX;
ctx.moveTo(x, top - size);
ctx.lineTo(x, bottom + size);
}
for (let j = j0; j <= j1; j++) {
const y = j * layout.stepY - state.offsetY;
ctx.moveTo(left - size, y);
ctx.lineTo(right + size, y);
}
} else {
for (let i = i0; i <= i1; i++) {
for (let j = j0; j <= j1; j++) {
const [cx, cy] = centerOf(s.shape, layout, i, j);
traceCell(ctx, s.shape, layout, size, i, j, cx - state.offsetX, cy - state.offsetY, 0);
}
}
}
if (s.shape === 'dot') ctx.fill();
else ctx.stroke();
if (introRadius !== Infinity) {
const gradient = ctx.createRadialGradient(originX, originY, 0, originX, originY, Math.max(1, introRadius));
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)');
gradient.addColorStop(0.82, 'rgba(0, 0, 0, 1)');
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.globalCompositeOperation = 'destination-in';
ctx.fillStyle = gradient;
ctx.fillRect(left, top, view.width, view.height);
ctx.globalCompositeOperation = 'source-over';
}
const glowing = s.glow > 0;
for (let i = i0; i <= i1; i++) {
for (let j = j0; j <= j1; j++) {
const [cx, cy] = centerOf(s.shape, layout, i, j);
const key = keyOf(i, j);
let value = state.lit.get(key) ?? 0;
let hue = state.hues.get(key) ?? 0;
if (state.cascades.length) {
const pulse = cascadeAt(s, cx, cy);
if (pulse > value) {
value = pulse;
hue = state.cascadeHue;
}
}
if (s.twinkle > 0 && !reduce) {
const seed = hash(i, j);
const wave = Math.sin(state.time * (0.35 + seed * 0.5) + seed * 40);
const sparkle = s.twinkle * 0.7 * Math.max(0, wave) ** 18;
if (sparkle > value) {
value = sparkle;
hue = seed * 360;
}
}
if (introRadius !== Infinity) {
const front = introRadius - Math.hypot(cx - centerX, cy - centerY);
if (front < 0) continue;
const wave = Math.exp(-((front / (size * 1.4)) ** 2)) * 0.85;
if (wave > value) {
value = wave;
hue = (Math.atan2(cy - centerY, cx - centerX) * 180) / Math.PI + state.time * 120;
}
}
if (value <= 0.01) continue;
const color = s.rainbow ? tint(hue, s.tone) : s.hoverColor;
const x = cx - state.offsetX;
const y = cy - state.offsetY;
ctx.beginPath();
traceCell(ctx, s.shape, layout, size, i, j, x, y, markers ? value * size * 0.08 : s.lineWidth * 0.5);
if (markers) {
ctx.globalAlpha = Math.min(1, value);
if (glowing) {
ctx.shadowColor = color;
ctx.shadowBlur = s.glow * 14 * value;
}
if (s.shape === 'dot') {
ctx.fillStyle = color;
ctx.fill();
} else {
ctx.strokeStyle = color;
ctx.lineWidth = s.lineWidth * (1 + value * 0.6);
ctx.stroke();
}
ctx.shadowBlur = 0;
continue;
}
if (s.fillOpacity > 0) {
ctx.globalAlpha = Math.min(1, value) * s.fillOpacity;
ctx.fillStyle = s.rainbow ? color : s.hoverFillColor;
ctx.fill();
}
ctx.globalAlpha = Math.min(1, value) * 0.85;
if (glowing) {
ctx.shadowColor = color;
ctx.shadowBlur = s.glow * 16 * value;
}
ctx.strokeStyle = color;
ctx.lineWidth = s.lineWidth;
ctx.stroke();
ctx.shadowBlur = 0;
}
}
ctx.globalAlpha = 1;
if (view.mist) {
const [mx0, my0, mx1, my1] = view.mist;
const gradient = ctx.createLinearGradient(mx0, my0, mx1, my1);
for (let k = 0; k <= 8; k++) {
const t = k / 8;
gradient.addColorStop(t, `rgba(0, 0, 0, ${(1 - smootherstep(t)).toFixed(3)})`);
}
ctx.globalCompositeOperation = 'destination-in';
ctx.fillStyle = gradient;
ctx.fillRect(left, top, view.width, view.height);
ctx.globalCompositeOperation = 'source-over';
}
};
const frame = now => {
state.raf = 0;
if (!state.alive) return;
const s = settingsRef.current;
const dt = state.last ? Math.min(0.05, (now - state.last) / 1000) : 1 / 60;
state.last = now;
resize();
if (!state.started) {
state.started = true;
state.startedAt = state.time;
}
if (!s.paused) {
state.time += dt;
if (!reduce) {
const [dx, dy] = DIRECTIONS[s.direction];
state.offsetX -= dx * s.speed * 60 * dt;
state.offsetY -= dy * s.speed * 60 * dt;
}
const want = state.inside ? 1 : 0;
state.spotPower += (want - state.spotPower) * (1 - Math.exp(-(want ? 10 : 5) * dt));
const stiffness = 220;
const damping = 2 * Math.sqrt(stiffness) * 0.85;
state.spotVX += (stiffness * (state.pointerX - state.spotX) - damping * state.spotVX) * dt;
state.spotVY += (stiffness * (state.pointerY - state.spotY) - damping * state.spotVY) * dt;
state.spotX += state.spotVX * dt;
state.spotY += state.spotVY * dt;
illuminate(s, layoutFor(s.shape, s.cellSize), dt);
state.cascades = state.cascades.filter(cascade => state.time - cascade.time < cascade.reach * 0.055 + 0.4);
}
draw();
const settled =
s.paused ||
(s.speed === 0 || s.direction === 'none' || reduce
? state.lit.size === 0 &&
state.cascades.length === 0 &&
s.twinkle === 0 &&
state.spotPower < 0.005 &&
state.time - state.startedAt > 3
: false);
if (!settled && state.visible) state.raf = requestAnimationFrame(frame);
else state.last = 0;
};
const wake = () => {
if (!state.raf && state.alive && state.visible) state.raf = requestAnimationFrame(frame);
};
const pointFrom = event => {
const rect = container.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
if (x < 0 || y < 0 || x > rect.width || y > rect.height) return null;
return toPlane(x, y);
};
const onMove = event => {
const point = pointFrom(event);
if (point && !state.inside && state.spotPower < 0.02) {
state.spotX = point[0];
state.spotY = point[1];
state.spotVX = 0;
state.spotVY = 0;
}
state.inside = point !== null;
if (point) {
state.pointerX = point[0];
state.pointerY = point[1];
wake();
}
};
const onLeave = () => {
state.inside = false;
};
const onDown = event => {
const s = settingsRef.current;
if (!s.clickCascade) return;
const point = pointFrom(event);
if (!point) return;
state.cascades.push({
x: point[0] + state.offsetX,
y: point[1] + state.offsetY,
time: state.time,
power: 1,
reach: Math.hypot(state.view.width, state.view.height) / s.cellSize,
hue: state.time * 120
});
if (state.cascades.length > 6) state.cascades.shift();
wake();
};
const resizeObserver = new ResizeObserver(() => {
resize();
draw();
wake();
});
resizeObserver.observe(container);
const visibility = new IntersectionObserver(entries => {
state.visible = entries.some(entry => entry.isIntersecting);
if (state.visible) wake();
});
visibility.observe(container);
window.addEventListener('pointermove', onMove, { passive: true });
window.addEventListener('pointerdown', onDown, { passive: true });
document.documentElement.addEventListener('pointerleave', onLeave);
window.addEventListener('blur', onLeave);
engineRef.current = {
redraw: () => {
resize();
draw();
wake();
}
};
resize();
wake();
return () => {
state.alive = false;
cancelAnimationFrame(state.raf);
resizeObserver.disconnect();
visibility.disconnect();
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerdown', onDown);
document.documentElement.removeEventListener('pointerleave', onLeave);
window.removeEventListener('blur', onLeave);
engineRef.current = null;
};
}, []);
useEffect(() => {
engineRef.current?.redraw();
});
const mask = fadeMask(clamp(fade, 0, 1));
return (
<div
ref={containerRef}
className={`relative h-full w-full overflow-hidden ${className}`.trim()}
style={{
opacity: clamp(opacity, 0, 1),
backgroundColor,
...(mask === 'none'
? null
: { maskImage: mask, WebkitMaskImage: mask, maskComposite: 'intersect', WebkitMaskComposite: 'source-in' }),
...style
}}
>
<canvas ref={canvasRef} className="absolute left-0 top-0 block origin-top-left" />
</div>
);
}