@fxshelf/word-tunnel
Version:
3D keyword tunnel word-cloud for React and Vue — perspective fly-through text. Zero animation-lib deps. Copy-as-source friendly.
298 lines (294 loc) • 9.52 kB
JavaScript
"use client";
;
var react = require('react');
var jsxRuntime = require('react/jsx-runtime');
// src/react/WordTunnel.tsx
// src/core/keywords.ts
var DEFAULT_WORDS = [
"\u6211\u672C\u5730\u662F\u597D\u7684",
"\u5148\u4E0A\u7EBF\u518D\u8BF4",
"\u8FD9\u4E0D\u662F bug",
"\u662F feature",
"\u9700\u6C42\u53C8\u6539\u4E86",
"\u4F30\u8BA1\u534A\u5C0F\u65F6",
"\u4ECA\u5929\u4E0D\u52A0\u73ED",
"\u5DF2\u8BFB\u4E0D\u56DE",
"\u5728\u5417\u6025",
"\u7EBF\u4E0A\u70B8\u4E86",
"\u56DE\u6EDA\u56DE\u6EDA",
"\u80FD\u8DD1\u5C31\u884C",
"LGTM",
"\u5148 merge",
"\u6587\u6863\uFF1F\u4E0D\u5B58\u5728",
"\u4E34\u65F6\u65B9\u6848\u6C38\u4E45\u7528",
"\u8C01\u5199\u7684\u7834\u4EE3\u7801",
"\u54E6\u662F\u6211\u5199\u7684",
"\u53EA\u6709\u4E0A\u5E1D\u61C2\u4E86",
"\u795E\u79D8\u7A7A\u6307\u9488",
"\u4F9D\u8D56\u5730\u72F1",
"\u5468\u4E94\u4E0A\u7EBF\u5FEB\u4E50",
"\u4EA7\u54C1\u8BF4\u968F\u4FBF\u6539\u6539",
"\u540E\u7AEF\u8BF4\u524D\u7AEF\u7684\u9505",
"\u524D\u7AEF\u8BF4\u540E\u7AEF\u7684\u9505",
"\u6D4B\u8FC7\u4E86\u5417",
"\u6CA1\u6D4B\u4F46\u611F\u89C9\u6CA1\u95EE\u9898",
"\u590D\u5236\u7C98\u8D34\u5DE5\u7A0B\u5E08",
"Ctrl+C Ctrl+V",
"\u70ED\u66F4\u65B0\u4E00\u4E0B",
"\u6CA1\u4E8B\u6CA1\u4E8B",
"\u7EDD\u5BF9\u6CA1\u95EE\u9898",
"\u5927\u6982\u4E5F\u8BB8\u53EF\u80FD",
"\u91CD\u6784\uFF1F\u4E0B\u6B21\u4E00\u5B9A",
"\u6CE8\u91CA\u5199\u7ED9\u9B3C\u770B\u7684",
"npm install \u4E09\u5C0F\u65F6",
"\u51B2\u7A81\u81EA\u5DF1\u89E3\u51B3",
"\u4E0B\u73ED\u4E86\u5417",
"\u6709\u7A7A\u5E2E\u770B\u4E0B",
"\u53C8\u662F\u4E00\u4E2A\u5C0F\u9700\u6C42"
];
var DEFAULTS = {
seed: 20250913,
perspective: 900,
/** 中心空洞半径(越小词越靠近灭点) */
innerRadius: 8,
/** 外圈采样半径 */
outerRadius: 52,
/** z 轴最短飞行距离 */
travelMin: 480,
/** z 轴额外随机飞行距离 */
travelRange: 560,
/** 淡入完成进度 0–100 */
fadeIn: 10,
/** 开始淡出进度 0–100(越大越晚消失、越贴中心) */
fadeOut: 88
};
function mulberry32(seed) {
return () => {
let t = seed += 1831565813;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function wordCountForWidth(width) {
const steps = Math.round((width - 1440) / 480);
return Math.min(280, Math.max(40, 80 + steps * 40));
}
function baseMinDistance(count, innerRadius, outerRadius) {
const area = Math.PI * (outerRadius * 1.12) ** 2 - Math.PI * innerRadius ** 2;
return Math.min(13, Math.max(5.5, Math.sqrt(Math.max(area, 1) / count) * 0.92));
}
function tooClose(a, b, baseMin) {
const scale = 0.65 + 0.55 * ((a.nearness + b.nearness) / 2);
const weight = (a.weight + b.weight) / 2;
const min = baseMin * scale * weight;
const dx = a.px - b.px;
const dy = a.py - b.py;
return dx * dx + dy * dy < min * min;
}
function samplePoint(rnd, innerRadius, outerRadius) {
const span = Math.max(0.01, outerRadius - innerRadius);
for (let tryN = 0; tryN < 32; tryN++) {
const angle = rnd() * Math.PI * 2;
const r = innerRadius + Math.sqrt(rnd()) * span;
const px = Math.cos(angle) * r * 1.18;
const py = Math.sin(angle) * r * 0.9;
if (Math.hypot(px, py) >= innerRadius) return { px, py };
}
return { px: innerRadius * 1.2, py: 0 };
}
function labelWeight(label) {
const len = [...label].length;
return Math.min(1.35, Math.max(0.9, 0.85 + len * 0.035));
}
function clampPct(n) {
return Math.min(99, Math.max(1, Math.round(n)));
}
function buildConvergeKeyframes(name, fadeIn = DEFAULTS.fadeIn, fadeOut = DEFAULTS.fadeOut) {
let inn = clampPct(fadeIn);
let out = clampPct(fadeOut);
if (out <= inn) out = Math.min(99, inn + 1);
return `@keyframes ${name} {
0% {
opacity: 0;
transform: translate3d(
calc(-50% + var(--fx-wt-x)),
calc(-50% + var(--fx-wt-y)),
var(--fx-wt-z0)
);
}
${inn}% {
opacity: var(--fx-wt-op);
}
${out}% {
opacity: calc(var(--fx-wt-op) * 0.7);
}
100% {
opacity: 0;
transform: translate3d(
calc(-50% + var(--fx-wt-x)),
calc(-50% + var(--fx-wt-y)),
var(--fx-wt-z1)
);
}
}`;
}
function buildWords(options = {}) {
const words = options.words?.length ? options.words : DEFAULT_WORDS;
const count = options.count ?? 80;
const seed = options.seed ?? DEFAULTS.seed;
const innerRadius = options.innerRadius ?? DEFAULTS.innerRadius;
const outerRadius = Math.max(
innerRadius + 1,
options.outerRadius ?? DEFAULTS.outerRadius
);
const travelMin = options.travelMin ?? DEFAULTS.travelMin;
const travelRange = Math.max(0, options.travelRange ?? DEFAULTS.travelRange);
const rnd = mulberry32(seed + count * 997);
const list = [];
const placed = [];
const baseMin = baseMinDistance(count, innerRadius, outerRadius);
for (let i = 0; i < count; i++) {
const label = words[Math.floor(rnd() * words.length)];
const weight = labelWeight(label);
const z0 = Math.round(-80 + rnd() * 280);
const nearness = Math.min(1, Math.max(0, (z0 + 80) / 280));
let px = 0;
let py = 0;
let ok = false;
for (let relax = 0; relax < 6 && !ok; relax++) {
const minFactor = 1 - relax * 0.12;
const tries = 40 + relax * 20;
for (let t = 0; t < tries; t++) {
const p = samplePoint(rnd, innerRadius, outerRadius);
const candidate = { px: p.px, py: p.py, nearness, weight };
if (placed.every((o) => !tooClose(candidate, o, baseMin * minFactor))) {
px = p.px;
py = p.py;
ok = true;
break;
}
}
}
if (!ok) {
const p = samplePoint(rnd, innerRadius, outerRadius);
px = p.px;
py = p.py;
}
placed.push({ px, py, nearness, weight });
const travel = travelMin + rnd() * travelRange;
const z1 = Math.round(z0 - travel);
const opacity = Math.round((0.18 + nearness * 0.72 + rnd() * 0.08) * 100) / 100;
const duration = Math.round((5.5 + (1 - nearness) * 6 + rnd() * 5) * 100) / 100;
const delay = Math.round(-rnd() * duration * 100) / 100;
list.push({
label,
x: `${Math.round(px * 100) / 100}vw`,
y: `${Math.round(py * 100) / 100}vh`,
z0,
z1,
opacity,
duration,
delay
});
}
return list;
}
function WordTunnel({
words,
count,
seed = DEFAULTS.seed,
perspective = DEFAULTS.perspective,
innerRadius = DEFAULTS.innerRadius,
outerRadius = DEFAULTS.outerRadius,
travelMin = DEFAULTS.travelMin,
travelRange = DEFAULTS.travelRange,
fadeIn = DEFAULTS.fadeIn,
fadeOut = DEFAULTS.fadeOut,
color,
fontSize,
className,
style,
children,
ariaHidden = true
}) {
const uid = react.useId().replace(/:/g, "");
const animName = `fx-wt-converge-${uid}`;
const layout = react.useMemo(
() => ({
seed,
words,
innerRadius,
outerRadius,
travelMin,
travelRange
}),
[seed, words, innerRadius, outerRadius, travelMin, travelRange]
);
const [items, setItems] = react.useState(
() => buildWords({
count: count ?? 80,
...layout
})
);
react.useEffect(() => {
function rebuild() {
const nextCount = count ?? wordCountForWidth(window.innerWidth);
setItems(buildWords({ count: nextCount, ...layout }));
}
rebuild();
if (count != null) return;
let timer;
function onResize() {
if (timer) clearTimeout(timer);
timer = setTimeout(rebuild, 150);
}
window.addEventListener("resize", onResize);
return () => {
if (timer) clearTimeout(timer);
window.removeEventListener("resize", onResize);
};
}, [count, layout]);
const keyframesCss = react.useMemo(
() => buildConvergeKeyframes(animName, fadeIn, fadeOut),
[animName, fadeIn, fadeOut]
);
const rootStyle = {
...style,
["--fx-wt-perspective"]: `${perspective}px`,
["--fx-wt-anim"]: animName,
...color ? { ["--fx-wt-color"]: color } : null,
...fontSize != null ? {
["--fx-wt-font-size"]: typeof fontSize === "number" ? `${fontSize}px` : fontSize
} : null
};
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: ["fx-wt", className].filter(Boolean).join(" "), style: rootStyle, children: [
/* @__PURE__ */ jsxRuntime.jsx("style", { children: keyframesCss }),
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "fx-wt__stage", "aria-hidden": ariaHidden, children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fx-wt__space", children: items.map((item, i) => /* @__PURE__ */ jsxRuntime.jsx(
"span",
{
className: "fx-wt__word",
style: {
["--fx-wt-x"]: item.x,
["--fx-wt-y"]: item.y,
["--fx-wt-z0"]: `${item.z0}px`,
["--fx-wt-z1"]: `${item.z1}px`,
["--fx-wt-op"]: String(item.opacity),
animationDuration: `${item.duration}s`,
animationDelay: `${item.delay}s`
},
children: item.label
},
`${items.length}-${i}-${item.label}`
)) }) }),
children != null ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fx-wt__content", children }) : null
] });
}
exports.DEFAULTS = DEFAULTS;
exports.DEFAULT_WORDS = DEFAULT_WORDS;
exports.WordTunnel = WordTunnel;
exports.buildConvergeKeyframes = buildConvergeKeyframes;
exports.buildWords = buildWords;
exports.wordCountForWidth = wordCountForWidth;
//# sourceMappingURL=index.cjs.map
//# sourceMappingURL=index.cjs.map