宽度调整
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div class="knowledge-graph" ref="containerRef">
|
||||
<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>
|
||||
</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>
|
||||
.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%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
min-height: 300px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user