@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.
270 lines (243 loc) • 6.9 kB
text/typescript
/**
* 词云隧道 — 经典 3D 星空隧道布局
*
* xy:环外随机 + 最小间距拒绝采样
* z:只沿深度飞,perspective 汇向灭点
* 词数:1440→80、1920→120、2560→160
*/
export type TunnelWord = {
label: string
x: string
y: string
z0: number
z1: number
opacity: number
duration: number
delay: number
}
/** 默认搞笑词条(可被 props.words 覆盖) */
export const DEFAULT_WORDS = [
'我本地是好的',
'先上线再说',
'这不是 bug',
'是 feature',
'需求又改了',
'估计半小时',
'今天不加班',
'已读不回',
'在吗急',
'线上炸了',
'回滚回滚',
'能跑就行',
'LGTM',
'先 merge',
'文档?不存在',
'临时方案永久用',
'谁写的破代码',
'哦是我写的',
'只有上帝懂了',
'神秘空指针',
'依赖地狱',
'周五上线快乐',
'产品说随便改改',
'后端说前端的锅',
'前端说后端的锅',
'测过了吗',
'没测但感觉没问题',
'复制粘贴工程师',
'Ctrl+C Ctrl+V',
'热更新一下',
'没事没事',
'绝对没问题',
'大概也许可能',
'重构?下次一定',
'注释写给鬼看的',
'npm install 三小时',
'冲突自己解决',
'下班了吗',
'有空帮看下',
'又是一个小需求',
] as const
export const DEFAULTS = {
seed: 20250913,
perspective: 900,
/** 中心空洞半径(越小词越靠近灭点) */
innerRadius: 8,
/** 外圈采样半径 */
outerRadius: 52,
/** z 轴最短飞行距离 */
travelMin: 480,
/** z 轴额外随机飞行距离 */
travelRange: 560,
/** 淡入完成进度 0–100 */
fadeIn: 10,
/** 开始淡出进度 0–100(越大越晚消失、越贴中心) */
fadeOut: 88,
} as const
export type WordTunnelLayout = {
innerRadius: number
outerRadius: number
travelMin: number
travelRange: number
}
function mulberry32(seed: number) {
return () => {
let t = (seed += 0x6d2b79f5)
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
type Placed = { px: number; py: number; nearness: number; weight: number }
/** 1440→80、1920→120、2560→160;每约 480px 一档 +40 */
export function wordCountForWidth(width: number): number {
const steps = Math.round((width - 1440) / 480)
return Math.min(280, Math.max(40, 80 + steps * 40))
}
function baseMinDistance(
count: number,
innerRadius: number,
outerRadius: number,
): number {
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: Placed, b: Placed, baseMin: number): boolean {
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: () => number,
innerRadius: number,
outerRadius: number,
): { px: number; py: number } {
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: string): number {
const len = [...label].length
return Math.min(1.35, Math.max(0.9, 0.85 + len * 0.035))
}
function clampPct(n: number): number {
return Math.min(99, Math.max(1, Math.round(n)))
}
/** 生成可配置淡入淡出的 keyframes CSS */
export function buildConvergeKeyframes(
name: string,
fadeIn: number = DEFAULTS.fadeIn,
fadeOut: number = DEFAULTS.fadeOut,
): string {
let inn = clampPct(fadeIn)
let out = clampPct(fadeOut)
if (out <= inn) out = Math.min(99, inn + 1)
return ` ${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)
);
}
}`
}
export type BuildWordsOptions = {
count?: number
seed?: number
words?: readonly string[]
innerRadius?: number
outerRadius?: number
travelMin?: number
travelRange?: number
}
export function buildWords(options: BuildWordsOptions = {}): TunnelWord[] {
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: TunnelWord[] = []
const placed: 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: Placed = { 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
}