|
|
@ -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 { |
|
|
|