UNPKG

@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.

291 lines (288 loc) • 9.3 kB
"use client"; import { useId, useMemo, useState, useEffect } from 'react'; import { jsxs, jsx } from '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 = useId().replace(/:/g, ""); const animName = `fx-wt-converge-${uid}`; const layout = useMemo( () => ({ seed, words, innerRadius, outerRadius, travelMin, travelRange }), [seed, words, innerRadius, outerRadius, travelMin, travelRange] ); const [items, setItems] = useState( () => buildWords({ count: count ?? 80, ...layout }) ); 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 = 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__ */ jsxs("div", { className: ["fx-wt", className].filter(Boolean).join(" "), style: rootStyle, children: [ /* @__PURE__ */ jsx("style", { children: keyframesCss }), /* @__PURE__ */ jsx("div", { className: "fx-wt__stage", "aria-hidden": ariaHidden, children: /* @__PURE__ */ jsx("div", { className: "fx-wt__space", children: items.map((item, i) => /* @__PURE__ */ 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__ */ jsx("div", { className: "fx-wt__content", children }) : null ] }); } export { DEFAULTS, DEFAULT_WORDS, WordTunnel, buildConvergeKeyframes, buildWords, wordCountForWidth }; //# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map