mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
improvement(wrapped-ui): 重构常用语词云卡交互与视觉表现
- Card06 文案改为年度常用语,展示候选句数、独特短句数与 topKeyword。 - 移除隐私开关、跳过按钮和粒子爆炸层,简化动效流程与交互路径。 - 词云布局改为百分比坐标 + 自适应缩放,点击词后的例句面板改为 Teleport 固定定位。 - 优化空态文案与再看一遍入口,提升移动端可读性与操作一致性。
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user