331 lines
7.9 KiB
Vue
331 lines
7.9 KiB
Vue
<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>
|