Browse Source

feat(component): render homepage tag cloud as word cloud

main
Mohan 1 month ago
parent
commit
c931f7c81c
  1. 156
      src/components/TagCloud.vue

156
src/components/TagCloud.vue

@ -9,10 +9,93 @@ const tags = ref<Tag[]>([])
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(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<PlacedTag[]>([])
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) { function goToTag(tag: Tag) {
@ -31,6 +114,7 @@ async function load() {
} finally { } finally {
loading.value = false loading.value = false
} }
buildLayout()
} }
onMounted(load) onMounted(load)
@ -42,17 +126,18 @@ onMounted(load)
<p v-if="error" class="error-text">{{ error }}</p> <p v-if="error" class="error-text">{{ error }}</p>
<p v-else-if="loading" class="hint-text">加载中...</p> <p v-else-if="loading" class="hint-text">加载中...</p>
<p v-else-if="tags.length === 0" class="hint-text">暂无标签</p> <p v-else-if="tags.length === 0" class="hint-text">暂无标签</p>
<div v-else class="tag-cloud-items">
<button
v-for="tag in tags"
:key="tag.id"
class="tag-item"
:class="sizeClass(tag.articleCount)"
@click="goToTag(tag)"
>
{{ tag.name }}
</button>
</div>
<svg v-else :viewBox="viewBox" class="word-cloud" role="img" aria-label="文章标签词云" preserveAspectRatio="xMidYMid meet">
<text
v-for="item in placedTags"
:key="item.tag.id"
class="cloud-tag"
:class="{ top: item.top }"
:x="item.x"
:y="item.y + item.fontSize"
:font-size="item.fontSize"
@click="goToTag(item.tag)"
>{{ item.tag.name }}</text>
</svg>
</div> </div>
</template> </template>
@ -69,43 +154,30 @@ onMounted(load)
font-size: 16px; 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; 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 { .error-text {
color: var(--ac-danger);
color: var(--ac-danger-ink);
} }
.hint-text { .hint-text {

Loading…
Cancel
Save