Files
torbox-admin/src/lib/dot-field.ts
T
2026-10-05 08:13:35 -07:00

251 lines
7.4 KiB
TypeScript

// 点阵背景动画引擎
import {
animate,
cancelFrame,
frame,
type AnimationPlaybackControls,
type FrameData
} from "motion";
import type { RipplePreset, RippleTone } from "@/constants/motion";
export type DotFieldConfig = {
/** 点间距与首个点的偏移,需与 CSS 点阵背景一致 */
spacing: number;
offset: number;
hoverRadius: number;
decayMs: number;
baseRadius: number;
litRadius: number;
colors: Record<RippleTone, string>;
};
export type DotField = {
resize: (width: number, height: number, dpr: number) => void;
/** 光标位置(画布坐标),连续调用会沿轨迹点亮 */
setPointer: (x: number, y: number) => void;
clearPointer: () => void;
/** 从 origin 扩散一圈(或多圈)涟漪,全部播完后 resolve */
ripple: (
origin: { x: number; y: number },
preset: RipplePreset
) => Promise<void>;
destroy: () => void;
};
type Ring = {
x: number;
y: number;
radius: number;
maxRadius: number;
preset: RipplePreset;
};
const TONES: RippleTone[] = ["primary", "destructive"];
/** 透明度分档数:同档同色的点合并成一条路径绘制 */
const LEVELS = 6;
const THRESHOLD = 0.03;
/**
* 画布点阵场:只绘制被点亮的点,底层静态点由 CSS 背景负责。
* 光标与涟漪向能量场注入能量,能量按指数衰减形成余辉;无能量时停止帧循环。
*/
export const createDotField = (
canvas: HTMLCanvasElement,
config: DotFieldConfig
): DotField => {
const context = canvas.getContext("2d");
const { spacing, offset } = config;
let width = 0;
let height = 0;
let cols = 0;
let rows = 0;
let energy = new Float32Array(0);
let tone = new Uint8Array(0);
let running = false;
let last: { x: number; y: number } | null = null;
const trail: { x: number; y: number }[] = [];
const rings = new Set<Ring>();
const animations = new Set<AnimationPlaybackControls>();
/** 在 (cx, cy) 周围 reach 范围内按 falloff(距离) 注入能量 */
const inject = (
cx: number,
cy: number,
reach: number,
toneIndex: number,
falloff: (d: number) => number
) => {
const c0 = Math.max(0, Math.floor((cx - reach - offset) / spacing));
const c1 = Math.min(cols - 1, Math.ceil((cx + reach - offset) / spacing));
const r0 = Math.max(0, Math.floor((cy - reach - offset) / spacing));
const r1 = Math.min(rows - 1, Math.ceil((cy + reach - offset) / spacing));
for (let row = r0; row <= r1; row += 1) {
const dy = offset + row * spacing - cy;
for (let col = c0; col <= c1; col += 1) {
const value = falloff(Math.hypot(offset + col * spacing - cx, dy));
const index = row * cols + col;
if (value > energy[index]) {
energy[index] = value;
tone[index] = toneIndex;
}
}
}
};
const hoverFalloff = (d: number) =>
d < config.hoverRadius ? (1 - d / config.hoverRadius) ** 2 : 0;
/** 沿光标轨迹按半径一半的步长插值注入,快速划过也能连成一条 */
const injectTrail = () => {
const step = config.hoverRadius / 2;
for (const point of trail) {
const from = last ?? point;
const steps = Math.max(
1,
Math.ceil(Math.hypot(point.x - from.x, point.y - from.y) / step)
);
for (let s = 1; s <= steps; s += 1) {
const t = s / steps;
inject(
from.x + (point.x - from.x) * t,
from.y + (point.y - from.y) * t,
config.hoverRadius,
0,
hoverFalloff
);
}
last = point;
}
trail.length = 0;
};
const injectRing = (ring: Ring) => {
if (ring.radius < 1) return;
const half = ring.preset.width / 2;
const fade = 1 - ring.radius / ring.maxRadius;
const toneIndex = TONES.indexOf(ring.preset.tone);
inject(ring.x, ring.y, ring.radius + half, toneIndex, (d) => {
const gap = Math.abs(d - ring.radius);
return gap < half ? ring.preset.strength * (1 - gap / half) * fade : 0;
});
};
const draw = () => {
if (!context) return;
context.clearRect(0, 0, width, height);
const buckets: number[][] = Array.from(
{ length: TONES.length * LEVELS },
() => []
);
let active = false;
for (let index = 0; index < energy.length; index += 1) {
const value = energy[index];
if (value < THRESHOLD) continue;
active = true;
const level = Math.min(LEVELS, Math.ceil(value * LEVELS));
buckets[tone[index] * LEVELS + level - 1].push(index);
}
buckets.forEach((indices, bucket) => {
if (indices.length === 0) return;
const level = (bucket % LEVELS) + 1;
const radius =
config.baseRadius +
((config.litRadius - config.baseRadius) * level) / LEVELS;
context.globalAlpha = level / LEVELS;
context.fillStyle = config.colors[TONES[Math.floor(bucket / LEVELS)]];
context.beginPath();
for (const index of indices) {
const x = offset + (index % cols) * spacing;
const y = offset + Math.floor(index / cols) * spacing;
context.moveTo(x + radius, y);
context.arc(x, y, radius, 0, Math.PI * 2);
}
context.fill();
});
context.globalAlpha = 1;
return active;
};
const tick = ({ delta }: FrameData) => {
const decay = Math.exp(-delta / config.decayMs);
for (let index = 0; index < energy.length; index += 1)
energy[index] *= decay;
injectTrail();
rings.forEach(injectRing);
const active = draw();
if (!active && rings.size === 0 && trail.length === 0) stop();
};
const start = () => {
if (running || !context) return;
running = true;
frame.render(tick, true);
};
const stop = () => {
running = false;
cancelFrame(tick);
};
return {
resize: (nextWidth, nextHeight, dpr) => {
width = nextWidth;
height = nextHeight;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
context?.setTransform(dpr, 0, 0, dpr, 0, 0);
cols = Math.max(0, Math.ceil((width - offset) / spacing));
rows = Math.max(0, Math.ceil((height - offset) / spacing));
energy = new Float32Array(cols * rows);
tone = new Uint8Array(cols * rows);
},
setPointer: (x, y) => {
trail.push({ x, y });
start();
},
clearPointer: () => {
last = null;
},
ripple: (origin, preset) => {
const maxRadius =
preset.maxRadius ??
Math.hypot(
Math.max(origin.x, width - origin.x),
Math.max(origin.y, height - origin.y)
) + preset.width;
const count = preset.rings?.count ?? 1;
const gap = preset.rings?.gap ?? 0;
start();
const runs = Array.from({ length: count }, (_, index) => {
const ring: Ring = { ...origin, radius: 0, maxRadius, preset };
rings.add(ring);
const controls = animate(0, maxRadius, {
duration: preset.duration,
delay: index * gap,
ease: "easeOut",
onUpdate: (radius) => {
ring.radius = radius;
}
});
animations.add(controls);
return new Promise<void>((resolve) => {
controls.then(() => {
rings.delete(ring);
animations.delete(controls);
resolve();
});
});
});
return Promise.all(runs).then(() => undefined);
},
destroy: () => {
stop();
animations.forEach((controls) => controls.stop());
animations.clear();
rings.clear();
}
};
};