Files
suanfa/src/components/KnowledgeGraph.vue
T
2026-06-16 21:09:21 +08:00

331 lines
7.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="kg-wrapper">
<div
class="knowledge-graph"
ref="containerRef"
:style="{ width: svgWidth + 'px', height: svgHeight + 'px' }"
>
<svg class="graph-svg" :width="svgWidth" :height="svgHeight">
<!-- 连线 -->
<path
v-for="(edge, i) in layoutEdges"
:key="'e' + i"
:d="edge.path"
class="graph-edge"
:class="{ highlighted: highlightedNode && (edge.source === highlightedNode || edge.target === highlightedNode) }"
/>
<!-- 连线标签 -->
<text
v-for="(edge, i) in layoutEdges"
:key="'el' + i"
:x="edge.labelX"
:y="edge.labelY"
class="edge-label"
:class="{ highlighted: highlightedNode && (edge.source === highlightedNode || edge.target === highlightedNode) }"
>{{ edge.label }}</text>
</svg>
<!-- 结点 -->
<div
v-for="node in layoutNodes"
:key="node.id"
class="graph-node"
:class="{ highlighted: highlightedNode === node.id }"
:style="{
left: node.x + 'px',
top: node.y + 'px',
'--node-hue': node.hue
}"
@mouseenter="highlightedNode = node.id"
@mouseleave="highlightedNode = null"
>
<div class="node-label">{{ node.label }}</div>
<div class="node-desc">{{ node.desc }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
const props = defineProps({
nodes: { type: Array, default: () => [] },
edges: { type: Array, default: () => [] }
})
const containerRef = ref(null)
const highlightedNode = ref(null)
// ---- 布局计算 ----
const NODE_W = 160
const NODE_H = 56
const LEVEL_GAP = 160
const SIBLING_GAP = 24
const PADDING = 20
const layoutNodes = computed(() => {
if (!props.nodes.length) return []
// 建立邻接表
const children = {}
const parent = {}
const indeg = {}
for (const n of props.nodes) { indeg[n.id] = 0; children[n.id] = [] }
for (const e of props.edges) {
if (children[e.source]) children[e.source].push(e.target)
parent[e.target] = e.source
indeg[e.target] = (indeg[e.target] || 0) + 1
}
// 找根结点(入度为0
const roots = props.nodes.filter(n => (indeg[n.id] || 0) === 0).map(n => n.id)
const rootId = roots[0] || props.nodes[0].id
// 计算层级(BFS
const level = {}
const queue = [rootId]
level[rootId] = 0
let maxLevel = 0
const seen = new Set([rootId])
while (queue.length) {
const cur = queue.shift()
for (const ch of children[cur] || []) {
if (!seen.has(ch)) {
seen.add(ch)
level[ch] = level[cur] + 1
if (level[ch] > maxLevel) maxLevel = level[ch]
queue.push(ch)
}
}
}
// 按层级分组
const byLevel = {}
for (const n of props.nodes) {
const lv = level[n.id] !== undefined ? level[n.id] : maxLevel
if (!byLevel[lv]) byLevel[lv] = []
byLevel[lv].push(n.id)
}
// 计算每个结点 X 位置(同层均匀分布)
const xPos = {}
for (const lv of Object.keys(byLevel).map(Number).sort((a, b) => a - b)) {
const ids = byLevel[lv]
const totalW = ids.length * NODE_W + (ids.length - 1) * SIBLING_GAP
ids.forEach((id, i) => {
xPos[id] = PADDING + i * (NODE_W + SIBLING_GAP) + NODE_W / 2
})
}
// 计算 Y 位置
const yPos = {}
for (const n of props.nodes) {
const lv = level[n.id] !== undefined ? level[n.id] : maxLevel
yPos[n.id] = PADDING + lv * (NODE_H + LEVEL_GAP) + NODE_H / 2
}
// 分配颜色
const hues = [210, 260, 180, 30, 330, 160]
const nodeColors = {}
for (const n of props.nodes) {
const lv = level[n.id] !== undefined ? level[n.id] : 0
nodeColors[n.id] = hues[lv % hues.length]
}
return props.nodes.map(n => ({
...n,
x: xPos[n.id] - NODE_W / 2,
y: yPos[n.id] - NODE_H / 2,
cx: xPos[n.id],
cy: yPos[n.id],
hue: nodeColors[n.id]
}))
})
const layoutEdges = computed(() => {
if (!props.edges.length || !layoutNodes.value.length) return []
const nodeMap = {}
for (const n of layoutNodes.value) nodeMap[n.id] = n
return props.edges.map(e => {
const src = nodeMap[e.source]
const tgt = nodeMap[e.target]
if (!src || !tgt) return null
const x1 = src.cx, y1 = src.cy + NODE_H / 2
const x2 = tgt.cx, y2 = tgt.cy - NODE_H / 2
const cy = (y1 + y2) / 2
// 三次贝塞尔曲线
const path = `M ${x1} ${y1} C ${x1} ${cy}, ${x2} ${cy}, ${x2} ${y2}`
// 标签位置(曲线中点偏左)
const labelX = (x1 + x2) / 2
const labelY = (y1 + y2) / 2 - 6
return { source: e.source, target: e.target, label: e.label, path, labelX, labelY }
}).filter(Boolean)
})
// SVG 尺寸
const svgWidth = computed(() => {
if (!layoutNodes.value.length) return 400
const maxX = Math.max(...layoutNodes.value.map(n => n.x + NODE_W))
return maxX + PADDING
})
const svgHeight = computed(() => {
if (!layoutNodes.value.length) return 300
const maxY = Math.max(...layoutNodes.value.map(n => n.y + NODE_H))
return maxY + PADDING
})
</script>
<style scoped>
.kg-wrapper {
overflow-x: auto;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
border: 1px solid var(--border-color);
border-radius: 16px;
}
.knowledge-graph {
position: relative;
background:
radial-gradient(circle at 20% 30%, rgba(59, 130, 246, 0.03), transparent 60%),
radial-gradient(circle at 80% 70%, rgba(139, 92, 246, 0.03), transparent 60%);
overflow: visible;
}
.graph-svg {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
overflow: visible;
}
.graph-edge {
fill: none;
stroke: var(--border-color);
stroke-width: 1.5;
stroke-linecap: round;
transition: stroke 0.3s, stroke-width 0.3s;
}
.graph-edge.highlighted {
stroke: var(--primary-color);
stroke-width: 2.5;
filter: drop-shadow(0 0 4px rgba(59, 130, 246, 0.3));
}
.edge-label {
fill: var(--text-tertiary);
font-size: 11px;
text-anchor: middle;
font-family: var(--sans);
transition: fill 0.3s;
}
.edge-label.highlighted {
fill: var(--primary-color);
font-weight: 600;
}
/* ---- 结点 ---- */
.graph-node {
position: absolute;
width: 160px;
min-height: 48px;
background: var(--card-bg);
border: 1.5px solid var(--border-color);
border-radius: 12px;
padding: 8px 12px;
cursor: default;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
z-index: 1;
}
.graph-node::before {
content: '';
position: absolute;
inset: -2px;
border-radius: 14px;
background: linear-gradient(135deg,
hsla(var(--node-hue, 210), 70%, 55%, 0.2),
hsla(calc(var(--node-hue, 210) + 30), 70%, 55%, 0.05));
opacity: 0;
transition: opacity 0.3s;
z-index: -1;
}
.graph-node:hover,
.graph-node.highlighted {
transform: translateY(-3px) scale(1.03);
border-color: hsl(var(--node-hue, 210), 70%, 55%);
box-shadow:
0 8px 24px rgba(0,0,0,0.1),
0 0 0 1px hsla(var(--node-hue, 210), 70%, 55%, 0.15);
}
.graph-node:hover::before,
.graph-node.highlighted::before {
opacity: 1;
}
.node-label {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
white-space: nowrap;
}
.node-desc {
font-size: 11px;
color: var(--text-tertiary);
margin-top: 2px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 深色模式适配 */
@media (prefers-color-scheme: dark) {
.graph-node {
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.graph-node:hover,
.graph-node.highlighted {
box-shadow:
0 8px 24px rgba(0,0,0,0.3),
0 0 0 1px hsla(var(--node-hue, 210), 70%, 55%, 0.15);
}
}
/* 响应式 */
@media (max-width: 768px) {
.graph-node {
width: 120px;
min-height: 40px;
padding: 6px 8px;
}
.node-label {
font-size: 11px;
}
.node-desc {
display: none;
}
}
</style>