You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
186 lines
4.4 KiB
186 lines
4.4 KiB
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { tagApi } from '@/api/client'
|
|
import type { Tag } from '@/generated/api'
|
|
|
|
const router = useRouter()
|
|
const tags = ref<Tag[]>([])
|
|
const loading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
|
|
const WIDTH = 260
|
|
const MIN_FONT = 11
|
|
const MAX_FONT = 22
|
|
|
|
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 16
|
|
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) {
|
|
router.push({ name: 'Home', query: { tag: tag.slug || tag.name } })
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
const resp = await tagApi.apiTagsGet()
|
|
tags.value = resp.data.data ?? []
|
|
} catch (e) {
|
|
console.error(e)
|
|
error.value = '标签加载失败'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
buildLayout()
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="tag-cloud">
|
|
<h3 class="tag-cloud-title">标签云</h3>
|
|
<p v-if="error" class="error-text">{{ error }}</p>
|
|
<p v-else-if="loading" class="hint-text">加载中...</p>
|
|
<p v-else-if="tags.length === 0" class="hint-text">暂无标签</p>
|
|
<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>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.tag-cloud {
|
|
background: var(--ac-bg-card);
|
|
border: 1px solid var(--ac-border);
|
|
border-radius: var(--song-radius-l);
|
|
padding: 16px;
|
|
}
|
|
|
|
.tag-cloud-title {
|
|
margin: 0 0 12px;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.word-cloud {
|
|
display: block;
|
|
width: 100%;
|
|
height: auto;
|
|
}
|
|
|
|
.cloud-tag {
|
|
font-family: 'Noto Serif SC', 'SimSun', 'STSong', serif;
|
|
fill: var(--ac-text);
|
|
cursor: pointer;
|
|
transition: fill var(--song-dur-1) var(--song-ease);
|
|
}
|
|
|
|
.cloud-tag.top {
|
|
fill: var(--ac-primary-ink);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.cloud-tag:hover {
|
|
fill: var(--ac-primary);
|
|
}
|
|
|
|
.error-text {
|
|
color: var(--ac-danger-ink);
|
|
}
|
|
|
|
.hint-text {
|
|
color: var(--ac-text-disabled);
|
|
}
|
|
</style>
|