mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
401 lines
12 KiB
Vue
401 lines
12 KiB
Vue
<template>
|
|
<div ref="graphViewEl" class="graph-view" :class="{ 'graph-view--fullscreen': isFullscreen }">
|
|
<!-- Toolbar sub-component -->
|
|
<WikiGraphToolbar
|
|
:node-count="nodes.length"
|
|
:edge-count="edges.length"
|
|
:orphan-count="orphanCount"
|
|
v-model:show-orphans="showOrphans"
|
|
v-model:type-filter="typeFilter"
|
|
:available-types="availableTypes"
|
|
:is-fullscreen="isFullscreen"
|
|
@reset="resetChart"
|
|
@toggle-fullscreen="toggleFullscreen"
|
|
/>
|
|
|
|
<!-- ECharts canvas -->
|
|
<div ref="chartEl" class="graph-canvas" />
|
|
|
|
<!-- Node detail panel sub-component -->
|
|
<WikiGraphNodePanel
|
|
v-if="selectedNode"
|
|
:page="selectedNode"
|
|
:linked-pages="selectedNodeLinks"
|
|
@close="selectedNode = null"
|
|
@open-page="emit('open-page', $event)"
|
|
/>
|
|
|
|
<!-- Empty state -->
|
|
<div v-if="nodes.length === 0" class="graph-empty">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
|
<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/>
|
|
<line x1="5" y1="5" x2="19" y2="19" stroke-width="0.5"/>
|
|
</svg>
|
|
<p>{{ t('wiki.graph.empty') }}</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import * as echarts from 'echarts/core'
|
|
import { GraphChart } from 'echarts/charts'
|
|
import { TooltipComponent, LegendComponent } from 'echarts/components'
|
|
import { CanvasRenderer } from 'echarts/renderers'
|
|
import type { WikiPage } from '@/stores/useWikiStore'
|
|
import WikiGraphToolbar from './WikiGraphToolbar.vue'
|
|
import WikiGraphNodePanel from './WikiGraphNodePanel.vue'
|
|
|
|
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
|
|
|
|
const { t } = useI18n()
|
|
const props = defineProps<{ pages: WikiPage[] }>()
|
|
const emit = defineEmits<{ (e: 'open-page', slug: string): void }>()
|
|
|
|
const graphViewEl = ref<HTMLDivElement | null>(null)
|
|
const chartEl = ref<HTMLDivElement | null>(null)
|
|
let chart: echarts.ECharts | null = null
|
|
|
|
const isFullscreen = ref(false)
|
|
const showOrphans = ref(true)
|
|
const typeFilter = ref('')
|
|
const selectedNode = ref<WikiPage | null>(null)
|
|
|
|
// Type → color map
|
|
const TYPE_COLORS: Record<string, string> = {
|
|
concept: '#D96E46',
|
|
person: '#5B8DEF',
|
|
place: '#4CAF82',
|
|
event: '#F59E0B',
|
|
technology: '#8B5CF6',
|
|
organization: '#EC4899',
|
|
product: '#14B8A6',
|
|
term: '#6B7280',
|
|
process: '#F97316',
|
|
other: '#9CA3AF',
|
|
}
|
|
|
|
function typeColor(type: string | null | undefined): string {
|
|
return TYPE_COLORS[(type || 'other').toLowerCase()] || TYPE_COLORS.other
|
|
}
|
|
|
|
// Parse outgoing links JSON string → slug[]
|
|
function parseLinks(outgoingLinks: string | null | undefined): string[] {
|
|
if (!outgoingLinks) return []
|
|
try {
|
|
const arr = JSON.parse(outgoingLinks)
|
|
return Array.isArray(arr) ? arr : []
|
|
} catch { return [] }
|
|
}
|
|
|
|
// Canonical slug: strip hyphens + underscores, lowercase — mirrors Java WikiPageService.canonicalSlug
|
|
function canonicalSlug(s: string): string {
|
|
return s.toLowerCase().replace(/-/g, '').replace(/_/g, '')
|
|
}
|
|
|
|
// Map canonical slug → actual page slug (for edge resolution)
|
|
const canonicalToSlug = computed(() => {
|
|
const map = new Map<string, string>()
|
|
for (const p of props.pages) map.set(canonicalSlug(p.slug), p.slug)
|
|
return map
|
|
})
|
|
|
|
const slugToPage = computed(() => {
|
|
const map = new Map<string, WikiPage>()
|
|
for (const p of props.pages) map.set(p.slug, p)
|
|
return map
|
|
})
|
|
|
|
// Map page title (lowercased) → slug.
|
|
// The backend's extractLinksAsJson runs toSlug() on [[link text]], which keeps Chinese
|
|
// characters as-is (e.g. [[八维四十九因]] → "八维四十九因"). Page slugs, however, are
|
|
// pinyin (e.g. "bawei-sishijiu-yin"). This map bridges that gap.
|
|
const titleToSlug = computed(() => {
|
|
const map = new Map<string, string>()
|
|
for (const p of props.pages) {
|
|
map.set(p.title.toLowerCase(), p.slug)
|
|
// Also index the toSlug() equivalent: strip non-alphanum/non-CJK, lowercase
|
|
const titleSlug = p.title.toLowerCase().replace(/[^\p{Script=Han}a-z0-9\s-]/gu, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
|
if (titleSlug) map.set(titleSlug, p.slug)
|
|
}
|
|
return map
|
|
})
|
|
|
|
// Pages filtered by type
|
|
const filteredPages = computed(() => {
|
|
if (!typeFilter.value) return props.pages
|
|
return props.pages.filter(p => (p.pageType || 'other').toLowerCase() === typeFilter.value)
|
|
})
|
|
|
|
// Resolve a link token to a real page slug. Resolution order:
|
|
// 1. Direct slug match
|
|
// 2. Canonical slug match (handles pinyin segmentation differences)
|
|
// 3. Title match (for [[中文标题]] style links stored by extractLinksAsJson)
|
|
function resolveLink(link: string): string | null {
|
|
if (!link) return null
|
|
// 1. Direct slug match
|
|
if (slugToPage.value.has(link)) return link
|
|
// 2. Canonical slug match
|
|
const canon = canonicalSlug(link)
|
|
const bySlug = canonicalToSlug.value.get(canon)
|
|
if (bySlug) return bySlug
|
|
// 3. Title-based match (Chinese link text like "八维四十九因" → pinyin slug)
|
|
const byTitle = titleToSlug.value.get(link.toLowerCase())
|
|
if (byTitle) return byTitle
|
|
return null
|
|
}
|
|
|
|
// Build edges from outgoing links with canonical resolution
|
|
const edges = computed(() => {
|
|
const filteredSet = new Set(filteredPages.value.map(p => p.slug))
|
|
const result: { source: string; target: string }[] = []
|
|
for (const page of filteredPages.value) {
|
|
for (const rawLink of parseLinks(page.outgoingLinks)) {
|
|
const target = resolveLink(rawLink)
|
|
if (target && target !== page.slug && filteredSet.has(target)) {
|
|
result.push({ source: page.slug, target })
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
})
|
|
|
|
// Compute in-degree for each node
|
|
const inDegree = computed(() => {
|
|
const map = new Map<string, number>()
|
|
for (const e of edges.value) {
|
|
map.set(e.target, (map.get(e.target) || 0) + 1)
|
|
}
|
|
return map
|
|
})
|
|
|
|
const orphanCount = computed(() =>
|
|
filteredPages.value.filter(
|
|
p => (inDegree.value.get(p.slug) || 0) === 0 && parseLinks(p.outgoingLinks).length === 0
|
|
).length
|
|
)
|
|
|
|
const nodes = computed(() => {
|
|
let ps = filteredPages.value
|
|
if (!showOrphans.value) {
|
|
ps = ps.filter(p =>
|
|
(inDegree.value.get(p.slug) || 0) > 0 || parseLinks(p.outgoingLinks).length > 0
|
|
)
|
|
}
|
|
return ps
|
|
})
|
|
|
|
const availableTypes = computed(() => {
|
|
const types = new Set(props.pages.map(p => (p.pageType || 'other').toLowerCase()))
|
|
return [...types].sort()
|
|
})
|
|
|
|
const selectedNodeLinks = computed(() => {
|
|
if (!selectedNode.value) return []
|
|
return parseLinks(selectedNode.value.outgoingLinks)
|
|
.map(link => {
|
|
const slug = resolveLink(link)
|
|
return slug ? slugToPage.value.get(slug) : undefined
|
|
})
|
|
.filter(Boolean) as WikiPage[]
|
|
})
|
|
|
|
function buildOption() {
|
|
const nodeSet = new Set(nodes.value.map(p => p.slug))
|
|
const nodeList = nodes.value.map(p => {
|
|
const outDeg = parseLinks(p.outgoingLinks).filter(l => {
|
|
const resolved = resolveLink(l)
|
|
return resolved && resolved !== p.slug && nodeSet.has(resolved)
|
|
}).length
|
|
const deg = (inDegree.value.get(p.slug) || 0) + outDeg
|
|
const size = Math.max(10, Math.min(44, 10 + deg * 4))
|
|
return {
|
|
id: p.slug,
|
|
name: p.title,
|
|
symbolSize: size,
|
|
itemStyle: { color: typeColor(p.pageType) },
|
|
// Show label only for well-connected nodes; position to the right to avoid overlap
|
|
label: {
|
|
show: size > 22,
|
|
position: 'right' as const,
|
|
fontSize: 10,
|
|
color: 'var(--mc-text-secondary)',
|
|
distance: 4,
|
|
},
|
|
// Do NOT embed Vue reactive proxies here — ECharts normalizes data and strips them.
|
|
// Use slugToPage lookup in event handlers instead.
|
|
}
|
|
})
|
|
|
|
const edgeList = edges.value
|
|
.filter(e => nodeSet.has(e.source) && nodeSet.has(e.target))
|
|
.map(e => ({
|
|
source: e.source,
|
|
target: e.target,
|
|
lineStyle: { color: 'rgba(150,150,150,0.28)', width: 1 },
|
|
}))
|
|
|
|
return {
|
|
backgroundColor: 'transparent',
|
|
tooltip: {
|
|
trigger: 'item',
|
|
formatter: (params: any) => {
|
|
if (params.dataType !== 'node') return ''
|
|
// Look up from slugToPage instead of relying on _page in ECharts data
|
|
const page = slugToPage.value.get(params.data.id)
|
|
if (!page) return ''
|
|
const typeLabel = t(`wiki.pageTypes.${page.pageType || 'other'}`, page.pageType || 'other')
|
|
const summary = (page.summary || '').substring(0, 80)
|
|
const ellipsis = (page.summary || '').length > 80 ? '…' : ''
|
|
return [
|
|
`<div style="max-width:220px;word-break:break-all;white-space:normal">`,
|
|
`<strong style="display:block;margin-bottom:2px">${page.title}</strong>`,
|
|
`<small style="color:#999;display:block;margin-bottom:4px">${typeLabel}</small>`,
|
|
`<span style="font-size:11px;line-height:1.5;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden">${summary}${ellipsis}</span>`,
|
|
`</div>`,
|
|
].join('')
|
|
},
|
|
},
|
|
series: [{
|
|
type: 'graph',
|
|
layout: 'force',
|
|
data: nodeList,
|
|
links: edgeList,
|
|
roam: true,
|
|
force: {
|
|
repulsion: 220,
|
|
gravity: 0.06,
|
|
edgeLength: [60, 180],
|
|
friction: 0.55,
|
|
},
|
|
emphasis: {
|
|
focus: 'adjacency',
|
|
lineStyle: { width: 2 },
|
|
},
|
|
lineStyle: { color: 'rgba(150,150,150,0.28)', curveness: 0.08 },
|
|
edgeSymbol: ['none', 'arrow'],
|
|
edgeSymbolSize: 6,
|
|
}],
|
|
}
|
|
}
|
|
|
|
// Use notMerge:true only for the initial render or explicit reset.
|
|
// Incremental updates use notMerge:false so ECharts matches nodes by id,
|
|
// keeping existing positions stable and only animating new nodes in.
|
|
function renderChart(fullReset = false) {
|
|
if (!chartEl.value) return
|
|
if (!chart) {
|
|
chart = echarts.init(chartEl.value, undefined, { renderer: 'canvas' })
|
|
chart.on('click', (params: any) => {
|
|
if (params.dataType === 'node') {
|
|
// Look up page by slug (node id) — don't rely on _page in ECharts data
|
|
const page = slugToPage.value.get(params.data.id)
|
|
if (page) selectedNode.value = page
|
|
}
|
|
})
|
|
fullReset = true // always full-reset on first init
|
|
}
|
|
chart.setOption(buildOption(), { notMerge: fullReset, lazyUpdate: true })
|
|
}
|
|
|
|
function resetChart() {
|
|
selectedNode.value = null
|
|
renderChart(true)
|
|
}
|
|
|
|
async function toggleFullscreen() {
|
|
if (!document.fullscreenElement) {
|
|
await graphViewEl.value?.requestFullscreen()
|
|
} else {
|
|
await document.exitFullscreen()
|
|
}
|
|
}
|
|
|
|
function onFullscreenChange() {
|
|
isFullscreen.value = !!document.fullscreenElement
|
|
// Let the DOM settle after fullscreen resize, then notify ECharts
|
|
nextTick(() => {
|
|
chart?.resize()
|
|
})
|
|
}
|
|
|
|
// Debounce incremental re-renders to avoid jitter when pages stream in rapidly
|
|
let renderTimer: ReturnType<typeof setTimeout> | null = null
|
|
function scheduleRender() {
|
|
if (renderTimer) clearTimeout(renderTimer)
|
|
renderTimer = setTimeout(() => {
|
|
renderTimer = null
|
|
renderChart(false)
|
|
}, 300)
|
|
}
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
chart?.resize()
|
|
})
|
|
|
|
onMounted(async () => {
|
|
await nextTick()
|
|
renderChart(true)
|
|
if (chartEl.value) resizeObserver.observe(chartEl.value)
|
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (renderTimer) clearTimeout(renderTimer)
|
|
resizeObserver.disconnect()
|
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
|
// Exit fullscreen if component is unmounted while in fullscreen mode
|
|
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
|
chart?.dispose()
|
|
chart = null
|
|
})
|
|
|
|
// Single watcher on nodes+edges; the page-length watcher is redundant and removed.
|
|
watch([nodes, edges], () => {
|
|
scheduleRender()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.graph-view {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 0;
|
|
position: relative;
|
|
}
|
|
|
|
/* Native fullscreen: fill the entire screen with proper background */
|
|
.graph-view:fullscreen,
|
|
.graph-view:-webkit-full-screen {
|
|
background: var(--mc-bg-base, #fff);
|
|
width: 100vw;
|
|
height: 100vh;
|
|
}
|
|
|
|
.dark .graph-view:fullscreen,
|
|
.dark .graph-view:-webkit-full-screen {
|
|
background: var(--mc-bg-base, #1a1a1a);
|
|
}
|
|
|
|
.graph-canvas {
|
|
flex: 1;
|
|
min-height: 0;
|
|
width: 100%;
|
|
}
|
|
|
|
.graph-empty {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 12px;
|
|
color: var(--mc-text-tertiary);
|
|
pointer-events: none;
|
|
}
|
|
.graph-empty p { font-size: 14px; }
|
|
</style>
|