From c931f7c81c92c311a5e39ff8f70ba2878c46c71d Mon Sep 17 00:00:00 2001 From: Mohan Date: Fri, 14 Aug 2026 02:20:37 +0800 Subject: [PATCH] feat(component): render homepage tag cloud as word cloud --- src/components/TagCloud.vue | 156 ++++++++++++++++++++++++++---------- 1 file changed, 114 insertions(+), 42 deletions(-) diff --git a/src/components/TagCloud.vue b/src/components/TagCloud.vue index d31491e..d60aa06 100644 --- a/src/components/TagCloud.vue +++ b/src/components/TagCloud.vue @@ -9,10 +9,93 @@ const tags = ref([]) const loading = ref(false) const error = ref(null) -function sizeClass(count: number): string { - if (count >= 5) return 'tag-lg' - if (count >= 2) return 'tag-md' - return 'tag-sm' +const WIDTH = 260 +const MIN_FONT = 13 +const MAX_FONT = 30 + +interface PlacedTag { + tag: Tag + x: number + y: number + fontSize: number + top: boolean +} + +const placedTags = ref([]) +const viewBox = ref('0 0 260 40') + +function fontScale(count: number, max: number): number { + if (max <= 1) return 20 + return MIN_FONT + (count / max) * (MAX_FONT - MIN_FONT) +} + +function measure(text: string, fs: number): number { + let w = 0 + for (const ch of text) { + if (/[\u2e80-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]/.test(ch)) { + w += fs + } else if (/[A-Za-z0-9]/.test(ch)) { + w += fs * 0.6 + } else { + w += fs * 0.5 + } + } + return w +} + +function buildLayout() { + if (tags.value.length === 0) { + placedTags.value = [] + viewBox.value = '0 0 260 40' + return + } + const sorted = [...tags.value].sort((a, b) => b.articleCount - a.articleCount) + const maxCount = Math.max(...sorted.map((t) => t.articleCount), 1) + const sizes = sorted.map((t) => fontScale(t.articleCount, maxCount)) + + const placed: { x: number; y: number; w: number; h: number }[] = [] + let maxX = 0 + let maxY = 0 + + const collides = (x: number, y: number, w: number, h: number): boolean => { + if (x < 2 || x + w > WIDTH - 2 || y < 2) return true + return placed.some((r) => x < r.x + r.w && x + w > r.x && y < r.y + r.h && y + h > r.y) + } + + const findSpot = (w: number, h: number): [number, number] => { + const cx = WIDTH / 2 + const cy = maxY === 0 ? 34 : maxY / 2 + let x = cx - w / 2 + let y = cy - h / 2 + if (!collides(x, y, w, h)) return [x, y] + for (let t = 1; t < 900; t++) { + const angle = 0.75 * t + const rad = 3.4 * t + x = cx + rad * Math.cos(angle) - w / 2 + y = cy + rad * Math.sin(angle) - h / 2 + if (!collides(x, y, w, h)) return [x, y] + } + x = 2 + y = maxY + 8 + while (collides(x, y, w, h) && y < maxY + 400) { + y += 2 + } + return [x, y] + } + + const result: PlacedTag[] = [] + sorted.forEach((tag, idx) => { + const fs = sizes[idx] + const w = measure(tag.name, fs) + 6 + const h = fs * 1.45 + const [x, y] = findSpot(w, h) + placed.push({ x, y, w, h }) + maxX = Math.max(maxX, x + w) + maxY = Math.max(maxY, y + h) + result.push({ tag, x, y, fontSize: fs, top: idx === 0 }) + }) + placedTags.value = result + viewBox.value = `0 0 ${Math.ceil(maxX) + 4} ${Math.ceil(maxY) + 4}` } function goToTag(tag: Tag) { @@ -31,6 +114,7 @@ async function load() { } finally { loading.value = false } + buildLayout() } onMounted(load) @@ -42,17 +126,18 @@ onMounted(load)

{{ error }}

加载中...

暂无标签

-
- -
+ + {{ item.tag.name }} + @@ -69,43 +154,30 @@ onMounted(load) font-size: 16px; } -.tag-cloud-items { - display: flex; - flex-wrap: wrap; - gap: 8px; +.word-cloud { + display: block; + width: 100%; + height: auto; } -.tag-item { - border: none; - background: var(--ac-primary-bg); - color: var(--ac-primary-ink); - border-radius: var(--song-radius-m); +.cloud-tag { + font-family: 'Noto Serif SC', 'SimSun', 'STSong', serif; + fill: var(--ac-text); cursor: pointer; - font-size: 13px; - padding: 4px 12px; - transition: background var(--song-dur-1) var(--song-ease); -} - -.tag-item:hover { - background: var(--ac-active); -} - -.tag-lg { - font-size: 17px; - padding: 6px 16px; + transition: fill var(--song-dur-1) var(--song-ease); } -.tag-md { - font-size: 14px; - padding: 5px 14px; +.cloud-tag.top { + fill: var(--ac-primary-ink); + font-weight: 700; } -.tag-sm { - font-size: 12px; +.cloud-tag:hover { + fill: var(--ac-primary); } .error-text { - color: var(--ac-danger); + color: var(--ac-danger-ink); } .hint-text {