improvement(wrapped-ui): 重构常用语词云卡交互与视觉表现

- Card06 文案改为年度常用语,展示候选句数、独特短句数与 topKeyword。

- 移除隐私开关、跳过按钮和粒子爆炸层,简化动效流程与交互路径。

- 词云布局改为百分比坐标 + 自适应缩放,点击词后的例句面板改为 Teleport 固定定位。

- 优化空态文案与再看一遍入口,提升移动端可读性与操作一致性。
This commit is contained in:
2977094657
2026-02-22 22:18:58 +08:00
Unverified
parent 236d0ae703
commit e721ad1e32
2 changed files with 257 additions and 284 deletions
@@ -9,27 +9,13 @@
:style="{ zIndex: 9999 }"
@pointerdown="onStagePointerDown"
>
<!-- 控制按钮 -->
<div class="absolute top-3 right-3 z-40 flex items-center gap-2" data-no-accel>
<button
type="button"
class="kw-chip"
:class="privacyMode ? 'kw-chip--on' : ''"
@click="privacyMode = !privacyMode"
>
{{ privacyMode ? '隐私:开' : '隐私:关' }}
</button>
<button type="button" class="kw-chip" @click="skipToCloud">跳过</button>
<button type="button" class="kw-chip" @click="replay">重播</button>
</div>
<!-- 提示accelerated 默认开启此提示基本不显示 -->
<div
v-if="showHint"
class="absolute bottom-3 right-3 z-30 wrapped-label text-[10px] text-[#00000055] bg-white/55 backdrop-blur rounded-lg px-2 py-1 border border-[#0000000a]"
data-no-accel
>
点击空白处加速 · 右上角可重播
点击空白处加速
</div>
<!-- 气泡层 -->
@@ -44,7 +30,6 @@
>
<div
class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-[#95EC69] text-black bubble-tail-r"
:class="privacyMode ? 'privacy-blur' : ''"
>
<span v-if="Array.isArray(b.segments) && b.segments.length > 0">
<span v-for="(seg, idx) in b.segments" :key="`${b.id}-${idx}`">
@@ -57,12 +42,6 @@
</div>
</div>
<!-- 粒子 (burst) -->
<canvas
v-show="showParticles"
ref="particleCanvas"
class="absolute inset-0 z-20 pointer-events-none"
/>
</div>
</Teleport>
@@ -75,7 +54,11 @@
你的话正在涌来
</template>
<template v-else>
每一句话都是一个气泡最终爆开成你的年度关键词词云点击关键词回看它出现的瞬间
这一年你一共发出了 <span class="font-medium text-[#07C160]">{{ card.data?.meta?.matchedCandidates || 0 }}</span> 句简短的表达其中 <span class="font-medium text-[#07C160]">{{ card.data?.meta?.uniquePhrases || 0 }}</span> 句话成了你的专属口头禅
<template v-if="card.data?.topKeyword">
<span class="font-medium text-[#07C160]">{{ card.data.topKeyword.word }}</span>是你最常说的话足足被你重复了 <span class="font-medium text-[#07C160]">{{ card.data.topKeyword.count }}</span>
</template>
点击气泡找回当时的心情
</template>
</p>
</div>
@@ -87,32 +70,22 @@
class="kw-stage relative w-full h-[56vh] min-h-[360px] max-h-[680px] rounded-[28px] overflow-hidden"
>
<!-- cloud 阶段的控制按钮 -->
<div v-if="phase === 'cloud'" class="absolute top-3 right-3 z-40 flex items-center gap-2">
<button
type="button"
class="kw-chip"
:class="privacyMode ? 'kw-chip--on' : ''"
@click="privacyMode = !privacyMode"
>
{{ privacyMode ? '隐私:开' : '隐私:关' }}
</button>
<button type="button" class="kw-chip" @click="replay">重播</button>
</div>
<!-- 词云 -->
<transition name="cloud-fade">
<div v-if="phase === 'cloud'" class="absolute inset-0 z-30 p-3 sm:p-5">
<KeywordWordCloud
:keywords="keywords"
:examples="examples"
:privacy-mode="privacyMode"
:animate="true"
:reduced-motion="reducedMotion"
/>
</div>
</transition>
</div>
<div v-if="phase === 'cloud'" class="mt-3 flex justify-center">
<button type="button" class="kw-chip" @click="replay">再看一遍</button>
</div>
</div>
</WrappedCardShell>
</div>
@@ -132,13 +105,10 @@ const props = defineProps({
const cardRoot = ref(null)
const stageEl = ref(null)
const overlayEl = ref(null)
const particleCanvas = ref(null)
const phase = ref('idle') // 'idle' | 'storm' | 'packed' | 'merge' | 'burst' | 'cloud'
const hasPlayed = ref(false)
const privacyMode = ref(false)
const accelerated = ref(true) // 默认加速
const showParticles = ref(false)
// 通知父级 deck 隐藏顶部 UI
const deckChromeHidden = inject('deckChromeHidden', ref(false))
@@ -271,7 +241,6 @@ const bubbleSizeForText = (text, compact = false) => {
let stormTimer = null
let packedTimer = null
let mainTl = null
let particleRaf = null
let hardStopTimer = null
let animationStartedAt = 0
let animationDeadlineAt = 0
@@ -299,18 +268,7 @@ const armHardStop = () => {
}, remain + 8)
}
const stopParticles = () => {
showParticles.value = false
if (particleRaf) cancelAnimationFrame(particleRaf)
particleRaf = null
const c = particleCanvas.value
if (c) {
try {
const ctx = c.getContext('2d')
ctx?.clearRect?.(0, 0, c.width, c.height)
} catch {}
}
}
const stopParticles = () => {}
const killTimeline = () => {
if (mainTl) {
@@ -362,71 +320,6 @@ const isVisible = ref(false)
let io = null
const updateVisibility = (v) => { isVisible.value = !!v }
const startParticles = (rng, centerX, centerY) => {
if (!import.meta.client) return
const canvas = particleCanvas.value
if (!canvas || !curViewW || !curViewH) return
const dpr = Math.max(1, Math.min(3, Number(window.devicePixelRatio || 1)))
canvas.width = Math.max(1, Math.round(curViewW * dpr))
canvas.height = Math.max(1, Math.round(curViewH * dpr))
canvas.style.width = `${curViewW}px`
canvas.style.height = `${curViewH}px`
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const N = 80
const particles = []
for (let i = 0; i < N; i += 1) {
const ang = rng() * Math.PI * 2
const sp = 120 + rng() * 320
particles.push({
x: centerX,
y: centerY,
vx: Math.cos(ang) * sp,
vy: Math.sin(ang) * sp,
size: 0.8 + rng() * 2.4,
life: 1,
color: rng() < 0.66 ? 'rgba(7,193,96,0.65)' : (rng() < 0.5 ? 'rgba(242,170,0,0.55)' : 'rgba(14,165,233,0.55)')
})
}
showParticles.value = true
const duration = 600
let last = 0
const started = performance.now()
const tick = (now) => {
const t = now - started
const dt = last > 0 ? Math.min((now - last) / 1000, 0.05) : 0.016
last = now
ctx.clearRect(0, 0, curViewW, curViewH)
const p = clamp(t / duration, 0, 1)
const alpha = 1 - p
for (const it of particles) {
it.x += it.vx * dt
it.y += it.vy * dt
it.vx *= 0.86
it.vy *= 0.86
const a = alpha * 0.9
ctx.fillStyle = it.color.replace(/[\d.]+\)$/g, `${a})`)
ctx.beginPath()
ctx.arc(it.x, it.y, it.size, 0, Math.PI * 2)
ctx.fill()
}
if (t < duration) {
particleRaf = requestAnimationFrame(tick)
} else {
stopParticles()
}
}
particleRaf = requestAnimationFrame(tick)
}
const maybeStart = () => {
if (!import.meta.client) return
detectReducedMotion()
@@ -849,10 +742,7 @@ const runMergeBurst = (rng, centerX, centerY) => {
opacity: 0,
scale: 0.92,
ease: 'power3.out',
stagger: staggerBurst,
onStart: () => {
startParticles(rng, centerX, centerY)
}
stagger: staggerBurst
})
const tlTotal = mainTl.totalDuration()
@@ -941,12 +831,6 @@ onBeforeUnmount(() => {
transform: translateY(-1px);
}
.kw-chip--on {
background: rgba(7, 193, 96, 0.12);
border-color: rgba(7, 193, 96, 0.22);
color: rgba(0, 0, 0, 0.75);
}
.kw-bubble {
will-change: transform, opacity;
transform: translate3d(0, 0, 0);
@@ -6,98 +6,103 @@
:class="shouldAnimate ? 'kw-animate' : ''"
@pointerdown="onBgPointerDown"
>
<button
v-for="(w, idx) in placedWords"
:key="w.word"
type="button"
class="kw-word"
:class="selectedWord === w.word ? 'kw-word--selected' : ''"
:style="wordStyle(w, idx)"
:title="`${w.word} · ${formatInt(w.count)} 次`"
@pointerdown.stop="selectWord(w.word)"
>
{{ w.word }}
</button>
</div>
<!-- Empty state -->
<div v-if="placedWords.length === 0" class="absolute inset-0 flex items-center justify-center">
<div class="rounded-2xl border border-[#EDEDED] bg-white/70 backdrop-blur px-5 py-4 text-center">
<div class="wrapped-title text-base text-[#000000e6]">暂无关键词</div>
<div class="mt-1 wrapped-body text-sm text-[#7F7F7F]">这一年你还没有足够的文字消息来生成词云</div>
</div>
</div>
<!-- Examples panel -->
<transition name="kw-panel">
<div
v-if="selectedInfo"
class="kw-panel absolute left-1/2 bottom-3 -translate-x-1/2 w-[min(92%,420px)] rounded-2xl border border-[#EDEDED] bg-white/80 backdrop-blur shadow-[0_16px_40px_rgba(0,0,0,0.14)] overflow-hidden"
data-no-accel
@pointerdown.stop
>
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-2 border-b border-[#F3F3F3]">
<div class="min-w-0">
<div class="wrapped-title text-base text-[#000000e6] truncate">
{{ selectedInfo.word }}
<span class="wrapped-number text-sm text-[#07C160] font-semibold">· {{ formatInt(selectedInfo.count) }} </span>
</div>
<div class="mt-0.5 wrapped-body text-xs text-[#7F7F7F]">
点击其它词看看它出现的瞬间
</div>
</div>
<div class="kw-cloud-core" :style="{ '--cloud-scale': String(cloudScale) }">
<div class="kw-cloud-halo" aria-hidden="true" />
<button
v-for="(w, idx) in placedWords"
:key="w.word"
type="button"
class="inline-flex items-center justify-center w-8 h-8 rounded-full text-[#00000066] hover:bg-[#00000008] transition"
aria-label="关闭"
@click="clearSelection"
class="kw-word"
:class="selectedWord === w.word ? 'kw-word--selected' : ''"
:style="wordStyle(w, idx)"
:title="`${w.word} · ${formatInt(w.count)} 次`"
@pointerdown.stop="selectWord(w.word, $event)"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6L6 18" />
<path d="M6 6l12 12" />
</svg>
{{ w.word }}
</button>
</div>
</div>
<div class="px-4 py-3 max-h-[220px] overflow-auto">
<div v-if="selectedInfo.messages.length === 0" class="wrapped-body text-sm text-[#7F7F7F]">
没找到可展示的例句
</div>
<!-- Empty state -->
<div v-if="placedWords.length === 0" class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="rounded-2xl border border-[#EDEDED] bg-white/70 backdrop-blur px-5 py-4 text-center">
<div class="wrapped-title text-base text-[#000000e6]">暂无常用语</div>
<div class="mt-1 wrapped-body text-sm text-[#7F7F7F]">这一年你还没有足够的重复短句来生成常用语词云</div>
</div>
</div>
<div v-else class="space-y-2">
<div
v-for="(m, i) in selectedInfo.messages"
:key="`${selectedInfo.word}-${i}-${m.raw}`"
class="flex justify-end"
>
<div class="relative bubble-tail-r bg-[#95EC69] msg-radius px-3 py-2 shadow-[0_6px_16px_rgba(0,0,0,0.12)] max-w-[92%]">
<div :class="privacyMode ? 'privacy-blur' : ''" class="wrapped-body text-sm text-[#000000e6] leading-snug whitespace-pre-wrap break-words">
<span v-if="Array.isArray(m.segments) && m.segments.length > 0">
<span v-for="(seg, sidx) in m.segments" :key="`${selectedInfo.word}-${i}-${sidx}`">
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" />
</span>
</span>
<span v-else>{{ m.raw }}</span>
<!-- Examples panel -->
<Teleport to="body">
<transition name="kw-panel">
<div
v-if="selectedInfo"
class="kw-panel fixed z-[100] w-[min(92%,420px)] rounded-2xl border border-[#EDEDED] bg-white/80 backdrop-blur shadow-[0_16px_40px_rgba(0,0,0,0.14)] overflow-hidden"
:style="panelStyle"
data-no-accel
@pointerdown.stop
>
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-2 border-b border-[#F3F3F3]">
<div class="min-w-0">
<div class="wrapped-title text-base text-[#000000e6] truncate">
{{ selectedInfo.word }}
<span class="wrapped-number text-sm text-[#07C160] font-semibold">· {{ formatInt(selectedInfo.count) }} </span>
</div>
<div class="mt-0.5 wrapped-body text-xs text-[#7F7F7F]">
点击其它词看看它出现的瞬间
</div>
</div>
<button
type="button"
class="inline-flex items-center justify-center w-8 h-8 rounded-full text-[#00000066] hover:bg-[#00000008] transition"
aria-label="关闭"
@click="clearSelection"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6L6 18" />
<path d="M6 6l12 12" />
</svg>
</button>
</div>
<div class="px-4 py-3 max-h-[220px] overflow-auto">
<div v-if="selectedInfo.messages.length === 0" class="wrapped-body text-sm text-[#7F7F7F]">
没找到可展示的例句
</div>
<div v-else class="space-y-2">
<div
v-for="(m, i) in selectedInfo.messages"
:key="`${selectedInfo.word}-${i}-${m.raw}`"
class="flex justify-end"
>
<div class="relative bubble-tail-r bg-[#95EC69] msg-radius px-3 py-2 shadow-[0_6px_16px_rgba(0,0,0,0.12)] max-w-[92%]">
<div class="wrapped-body text-sm text-[#000000e6] leading-snug whitespace-pre-wrap break-words">
<span v-if="Array.isArray(m.segments) && m.segments.length > 0">
<span v-for="(seg, sidx) in m.segments" :key="`${selectedInfo.word}-${i}-${sidx}`">
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" />
</span>
</span>
<span v-else>{{ m.raw }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
</transition>
</Teleport>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
const props = defineProps({
keywords: { type: Array, default: () => [] }, // [{word,count,weight}]
examples: { type: Array, default: () => [] }, // [{word,count,messages:[]}]
privacyMode: { type: Boolean, default: false },
animate: { type: Boolean, default: true },
reducedMotion: { type: Boolean, default: false }
})
@@ -105,8 +110,6 @@ const props = defineProps({
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
const privacyMode = computed(() => !!props.privacyMode)
const rootEl = ref(null)
const width = ref(0)
const height = ref(0)
@@ -140,6 +143,7 @@ const examplesMap = computed(() => {
})
const selectedWord = ref('')
const mousePos = ref({ x: 0, y: 0 })
const selectedInfo = computed(() => {
const w = String(selectedWord.value || '').trim()
if (!w) return null
@@ -153,7 +157,38 @@ const selectedInfo = computed(() => {
})
const clearSelection = () => { selectedWord.value = '' }
const selectWord = (w) => { selectedWord.value = String(w || '').trim() }
const selectWord = (w, e) => {
selectedWord.value = String(w || '').trim()
if (e && Number.isFinite(e.clientX) && Number.isFinite(e.clientY)) {
mousePos.value = { x: e.clientX, y: e.clientY }
}
}
const panelStyle = computed(() => {
if (!import.meta.client) return {}
const vw = window.innerWidth || 0
const vh = window.innerHeight || 0
const maxW = 420
const maxH = 280
const margin = 12
let left = mousePos.value.x + 20
let top = mousePos.value.y - (maxH / 2)
if ((left + maxW) > (vw - margin)) {
left = mousePos.value.x - maxW - 20
}
if (left < margin) left = margin
if (top < margin) top = margin
if ((top + maxH) > (vh - margin)) top = Math.max(margin, vh - maxH - margin)
return {
left: `${Math.round(left)}px`,
top: `${Math.round(top)}px`
}
})
const onBgPointerDown = (e) => {
if (!e) return
@@ -175,97 +210,127 @@ const ensureMeasureCtx = () => {
const placedWords = ref([])
const wordStyle = (w, idx) => ({
left: `${Number(w?.x || 0)}px`,
top: `${Number(w?.y || 0)}px`,
left: `${Number(w?.xPct ?? 50)}%`,
top: `${Number(w?.yPct ?? 50)}%`,
fontSize: `${Math.max(10, Math.round(Number(w?.fontSize || 14)))}px`,
fontWeight: Number(w?.fontWeight || 600),
color: String(w?.color || '#111827'),
'--d': `${Math.max(0, Number(idx || 0) * 15)}ms`
'--kw-color': String(w?.color || '#111827'),
'--d': `${Math.max(0, Number(idx || 0) * 40)}ms`
})
const clamp = (v, a, b) => Math.min(Math.max(v, a), b)
const palette = ['#07C160', '#0EA5E9', '#F2AA00', '#111827', '#16A34A', '#2563EB']
const clamp = (v, a, b) => Math.min(Math.max(v, a), b)
const cloudScale = computed(() => {
// 根据容器宽高自适应放大
const maxScaleByW = width.value ? clamp(width.value / 520, 0.8, 1.3) : 1
const maxScaleByH = height.value ? clamp(height.value / 520, 0.8, 1.3) : 1
return Math.min(maxScaleByW, maxScaleByH)
})
const seededRandom = (seed) => {
const x = Math.sin(seed) * 10000
return x - Math.floor(x)
}
const layoutWords = async () => {
if (!import.meta.client) return
if (!width.value || !height.value) return
const layoutWords = async () => {
if (!import.meta.client) return
if (!width.value || !height.value) return
const ctx = ensureMeasureCtx()
if (!ctx) return
const ctx = ensureMeasureCtx()
if (!ctx) return
const margin = 18
const w = width.value
const h = height.value
const cx = w / 2
const cy = h / 2
// 允许根据实际容器比例一定程度上拉伸碰撞边界的空间,但不改变布局基础数值逻辑
const baseW = 560
const baseH = 560
const src = (Array.isArray(props.keywords) ? props.keywords : [])
.map((x) => ({
const srcRaw = (Array.isArray(props.keywords) ? props.keywords : [])
.map((x) => ({
word: String(x?.word || '').trim(),
count: Number(x?.count || 0),
weight: Number(x?.weight || 0)
}))
.filter((x) => x.word && Number.isFinite(x.count) && x.count > 0)
src.sort((a, b) => {
srcRaw.sort((a, b) => {
if (b.weight !== a.weight) return b.weight - a.weight
if (b.count !== a.count) return b.count - a.count
return a.word.localeCompare(b.word)
})
const maxFont = clamp(Math.round(Math.min(54, Math.max(34, h * 0.095))), 32, 54)
const minFont = clamp(Math.round(Math.min(20, Math.max(14, h * 0.03))), 14, 20)
const topWords = srcRaw.slice(0, 32)
if (topWords.length === 0) {
placedWords.value = []
await nextTick()
return
}
const boxes = []
const maxCount = Math.max(1, Number(topWords[0]?.count || 1))
const placedItems = []
const placed = []
const intersects = (a, b) => !(
a.x2 <= b.x1 ||
a.x1 >= b.x2 ||
a.y2 <= b.y1 ||
a.y1 >= b.y2
)
const canPlace = (x, y, wPct, hPct) => {
const halfW = wPct / 2
const halfH = hPct / 2
const dx = x - 50
const dy = y - 50
const dist = Math.sqrt((dx * dx) + (dy * dy))
const maxR = 49 - Math.max(halfW, halfH)
if (dist > maxR) return false
for (let idx = 0; idx < src.length; idx += 1) {
const item = src[idx]
const wn = clamp((item.weight - 0.2) / 0.8, 0, 1)
const fontSize = Math.round(minFont + (maxFont - minFont) * wn)
const fontWeight = idx <= 2 ? 800 : (idx <= 10 ? 700 : 600)
const pad = 4.0
for (const p of placedItems) {
if (
(x - halfW - pad) < (p.x + (p.w / 2)) &&
(x + halfW + pad) > (p.x - (p.w / 2)) &&
(y - halfH - pad) < (p.y + (p.h / 2)) &&
(y + halfH + pad) > (p.y - (p.h / 2))
) {
return false
}
}
return true
}
for (let i = 0; i < topWords.length; i += 1) {
const item = topWords[i]
const ratio = clamp(item.count / maxCount, 0, 1)
const fontSize = Math.round(12 + (Math.pow(ratio, 0.65) * 20))
const fontWeight = i === 0 ? 800 : 600
ctx.font = `${fontWeight} ${fontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif`
const textW = Math.ceil(ctx.measureText(item.word).width || 0)
const textH = Math.ceil(fontSize * 1.18)
const bw = textW + 10
const bh = textH + 6
const textH = Math.ceil(fontSize * 1.1)
const widthPct = (textW / baseW) * 100
const heightPct = (textH / baseH) * 100
let x = 50
let y = 50
let placedOk = false
let best = null
const tries = i === 0 ? 1 : 420
const angleStep = 0.35
const radiusStep = 6
for (let t = 0; t < 250; t += 1) {
const ang = t * angleStep
const rad = t * radiusStep
const x = cx + Math.cos(ang) * rad
const y = cy + Math.sin(ang) * rad
const box = { x1: x - bw / 2, y1: y - bh / 2, x2: x + bw / 2, y2: y + bh / 2 }
if (box.x1 < margin || box.y1 < margin || box.x2 > (w - margin) || box.y2 > (h - margin)) continue
let ok = true
for (const b of boxes) {
if (intersects(box, b)) { ok = false; break }
for (let t = 0; t < tries; t += 1) {
if (i === 0) {
x = 50
y = 50
} else {
const spiralIndex = i + (t * 0.28)
const radius = (Math.sqrt(spiralIndex) * 7.6) + ((seededRandom((i * 1000) + t) * 1.2) - 0.6)
const angle = (spiralIndex * 2.399963) + (seededRandom((i * 2000) + t) * 0.35)
// Use an oval shape logic so it spreads wider horizontally over time
x = 50 + (radius * Math.cos(angle) * 1.3)
y = 50 + (radius * Math.sin(angle) * 0.7)
}
if (!ok) continue
best = { x, y }
placedOk = true
break
if (canPlace(x, y, widthPct, heightPct)) {
placedOk = true
break
}
}
if (!placedOk || !best) continue
if (!placedOk) continue
const color = idx === 0 ? '#07C160' : palette[idx % palette.length]
placedItems.push({ x, y, w: widthPct, h: heightPct })
const alpha = Math.min(1, Math.max(0.35, 0.35 + (ratio * 0.65)))
const color = i === 0 ? '#2f3338' : `rgba(45, 49, 54, ${alpha})`
placed.push({
word: item.word,
count: Math.round(Number(item.count) || 0),
@@ -273,10 +338,9 @@ const layoutWords = async () => {
fontSize,
fontWeight,
color,
x: best.x,
y: best.y
xPct: Number(x.toFixed(2)),
yPct: Number(y.toFixed(2))
})
boxes.push({ x1: best.x - bw / 2, y1: best.y - bh / 2, x2: best.x + bw / 2, y2: best.y + bh / 2 })
}
placedWords.value = placed
@@ -326,35 +390,60 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.kw-cloud-core {
position: absolute;
left: 50%;
top: 50%;
width: 100%;
max-width: 720px;
height: 480px;
transform: translate(-50%, -50%) scale(var(--cloud-scale, 1));
transform-origin: center;
}
.kw-cloud-halo {
position: absolute;
inset: -6% -15%;
background:
radial-gradient(ellipse at 35% 45%, rgba(7, 193, 96, 0.12), transparent 55%),
radial-gradient(ellipse at 65% 50%, rgba(242, 170, 0, 0.10), transparent 58%),
radial-gradient(ellipse at 50% 65%, rgba(0, 0, 0, 0.04), transparent 60%);
filter: blur(24px);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.kw-word {
position: absolute;
left: 0;
top: 0;
z-index: 1;
transform: translate(-50%, -50%);
line-height: 1.15;
line-height: 1.2;
letter-spacing: 0.02em;
padding: 2px 6px;
border-radius: 10px;
padding: 4px 8px;
border-radius: 8px;
cursor: pointer;
user-select: none;
background: rgba(255, 255, 255, 0.42);
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.08);
transition: transform 160ms ease, filter 160ms ease, background 160ms ease, box-shadow 160ms ease;
appearance: none;
-webkit-appearance: none;
background: transparent;
border: none;
box-shadow: none;
color: var(--kw-color);
text-shadow: none;
transition: transform 160ms ease, filter 160ms ease, color 160ms ease;
opacity: 1;
}
.kw-word:hover {
transform: translate(-50%, -50%) scale(1.04);
background: rgba(255, 255, 255, 0.62);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.12);
transform: translate(-50%, -50%) scale(1.08);
color: #2b2f35;
}
.kw-word--selected {
background: rgba(7, 193, 96, 0.12);
border-color: rgba(7, 193, 96, 0.28);
box-shadow: 0 18px 42px rgba(7, 193, 96, 0.18);
filter: drop-shadow(0 0 10px rgba(7, 193, 96, 0.28));
color: #202429;
transform: translate(-50%, -50%) scale(1.12);
font-weight: 800;
}
.kw-animate .kw-word {
@@ -383,6 +472,6 @@ onBeforeUnmount(() => {
.kw-panel-enter-from,
.kw-panel-leave-to {
opacity: 0;
transform: translate(-50%, 10px);
transform: translate(0, 10px);
}
</style>