feat: 修复 7 个 Gap — 图分组布局、status 映射、详情面板丰富等 #2
|
|
@ -9,6 +9,12 @@
|
||||||
<div class="summary-item" style="color: var(--color-missing)">Missing <strong>{{ scanResult.summary.missing }}</strong></div>
|
<div class="summary-item" style="color: var(--color-missing)">Missing <strong>{{ scanResult.summary.missing }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="toggle-btn" :class="{ active: showDocumentView }" @click="toggleDocumentView">
|
||||||
|
{{ showDocumentView ? '默认视图' : '文档视图' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<svg ref="svgRef" class="graph-svg"></svg>
|
<svg ref="svgRef" class="graph-svg"></svg>
|
||||||
|
|
||||||
<GraphDetail :node="selectedNode" @close="clearSelection" />
|
<GraphDetail :node="selectedNode" @close="clearSelection" />
|
||||||
|
|
@ -31,19 +37,48 @@ const { clearSelection } = store
|
||||||
|
|
||||||
const svgRef = ref<SVGSVGElement | null>(null)
|
const svgRef = ref<SVGSVGElement | null>(null)
|
||||||
|
|
||||||
|
const showDocumentView = ref(false)
|
||||||
|
const isDrillDown = ref(false)
|
||||||
|
const drillDownNodeLabel = ref('')
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
ok: '#4CAF50', sparse: '#FFC107', missing: '#F44336',
|
ok: '#4CAF50', sparse: '#FFC107', missing: '#F44336',
|
||||||
'template-residue': '#FF9800', 'placeholder-heavy': '#9C27B0', unknown: '#9E9E9E',
|
'template-residue': '#FF9800', 'placeholder-heavy': '#9C27B0', unknown: '#9E9E9E',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GROUP_POSITIONS: Record<string, { x: number; y: number }> = {
|
||||||
|
business: { x: 0.50, y: 0.15 },
|
||||||
|
application: { x: 0.50, y: 0.38 },
|
||||||
|
data: { x: 0.30, y: 0.65 },
|
||||||
|
technology: { x: 0.70, y: 0.65 },
|
||||||
|
'cross-layer': { x: 0.50, y: 0.85 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDGE_COLORS: Record<string, string> = {
|
||||||
|
traces_to: '#666',
|
||||||
|
depends_on: '#999',
|
||||||
|
documents: '#42A5F5',
|
||||||
|
integrates_with: '#AB47BC',
|
||||||
|
}
|
||||||
|
|
||||||
const EDGE_STYLES: Record<string, string> = {
|
const EDGE_STYLES: Record<string, string> = {
|
||||||
traces_to: '0', depends_on: '6,3', owns: '0', integrates_with: '4,2', documents: '2,2',
|
traces_to: '0', depends_on: '6,3', integrates_with: '4,2', documents: '0',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNodeColor(status: string): string {
|
function getNodeColor(status: string): string {
|
||||||
return STATUS_COLORS[status] || '#9E9E9E'
|
return STATUS_COLORS[status] || '#9E9E9E'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SimNode extends GraphNode, d3.SimulationNodeDatum {
|
||||||
|
w?: number
|
||||||
|
h?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDocumentView() {
|
||||||
|
showDocumentView.value = !showDocumentView.value
|
||||||
|
drawGraph()
|
||||||
|
}
|
||||||
|
|
||||||
function drawGraph() {
|
function drawGraph() {
|
||||||
if (!svgRef.value || !graphView.value) return
|
if (!svgRef.value || !graphView.value) return
|
||||||
|
|
||||||
|
|
@ -63,34 +98,83 @@ function drawGraph() {
|
||||||
.on('zoom', (event) => { g.attr('transform', event.transform) })
|
.on('zoom', (event) => { g.attr('transform', event.transform) })
|
||||||
svg.call(zoom)
|
svg.call(zoom)
|
||||||
|
|
||||||
const nodes = graphView.value.nodes.map(n => ({ ...n } as GraphNode & d3.SimulationNodeDatum))
|
// Filter nodes and edges based on view mode
|
||||||
const edges = graphView.value.edges.map(e => ({
|
let filteredNodes: GraphNode[]
|
||||||
|
let filteredEdges: GraphEdge[]
|
||||||
|
|
||||||
|
if (showDocumentView.value) {
|
||||||
|
filteredNodes = graphView.value.nodes.map(n => ({ ...n }))
|
||||||
|
filteredEdges = graphView.value.edges.map(e => ({ ...e }))
|
||||||
|
} else {
|
||||||
|
filteredNodes = graphView.value.nodes.filter(n => n.type !== 'document').map(n => ({ ...n }))
|
||||||
|
filteredEdges = graphView.value.edges.filter(e => e.relation !== 'documents').map(e => ({ ...e }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const simNodes: SimNode[] = filteredNodes.map(n => ({ ...n } as SimNode))
|
||||||
|
|
||||||
|
// Build node map for parent lookups
|
||||||
|
const nodeMap: Record<string, SimNode> = {}
|
||||||
|
for (const n of simNodes) {
|
||||||
|
nodeMap[n.id] = n
|
||||||
|
}
|
||||||
|
|
||||||
|
// For document view, compute document container sizes
|
||||||
|
if (showDocumentView.value) {
|
||||||
|
for (const n of simNodes) {
|
||||||
|
if (n.type === 'document') {
|
||||||
|
const childCount = simNodes.filter(c => c.parent === n.id).length
|
||||||
|
n.w = Math.max(150, childCount * 60)
|
||||||
|
n.h = Math.max(100, childCount * 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const simEdges = filteredEdges.map(e => ({
|
||||||
...e,
|
...e,
|
||||||
source: e.source,
|
source: e.source,
|
||||||
target: e.target,
|
target: e.target,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Force simulation
|
// Force simulation with group-partitioned layout
|
||||||
const simulation = d3.forceSimulation(nodes as any)
|
const simulation = d3.forceSimulation(simNodes as any)
|
||||||
.force('link', d3.forceLink(edges as any).id((d: any) => d.id).distance(100))
|
.force('link', d3.forceLink(simEdges as any).id((d: any) => d.id).distance(60))
|
||||||
.force('charge', d3.forceManyBody().strength(-200))
|
.force('charge', d3.forceManyBody().strength(-150))
|
||||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
.force('x', d3.forceX((d: any) =>
|
||||||
.force('collision', d3.forceCollide().radius(30))
|
(GROUP_POSITIONS[d.group_id] || GROUP_POSITIONS['cross-layer']).x * width
|
||||||
|
).strength(0.4))
|
||||||
|
.force('y', d3.forceY((d: any) =>
|
||||||
|
(GROUP_POSITIONS[d.group_id] || GROUP_POSITIONS['cross-layer']).y * height
|
||||||
|
).strength(0.4))
|
||||||
|
.force('collide', d3.forceCollide(30))
|
||||||
|
|
||||||
|
// Render group labels
|
||||||
|
const groupLabels = g.append('g').attr('class', 'group-labels')
|
||||||
|
for (const [groupId, pos] of Object.entries(GROUP_POSITIONS)) {
|
||||||
|
groupLabels.append('text')
|
||||||
|
.attr('x', pos.x * width)
|
||||||
|
.attr('y', pos.y * height - 40)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', '14px')
|
||||||
|
.attr('font-weight', 'bold')
|
||||||
|
.attr('fill', '#aaa')
|
||||||
|
.attr('pointer-events', 'none')
|
||||||
|
.text(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
// Draw edges
|
// Draw edges
|
||||||
const link = g.append('g')
|
const link = g.append('g')
|
||||||
.selectAll('line')
|
.selectAll('line')
|
||||||
.data(edges)
|
.data(simEdges)
|
||||||
.join('line')
|
.join('line')
|
||||||
.attr('stroke', '#999')
|
.attr('stroke', (d: any) => EDGE_COLORS[d.relation] || '#999')
|
||||||
.attr('stroke-opacity', 0.6)
|
.attr('stroke-opacity', 0.6)
|
||||||
.attr('stroke-width', (d: any) => d.relation === 'owns' ? 3 : 1.5)
|
.attr('stroke-width', 1.5)
|
||||||
.attr('stroke-dasharray', (d: any) => EDGE_STYLES[d.relation] || '0')
|
.attr('stroke-dasharray', (d: any) => EDGE_STYLES[d.relation] || '0')
|
||||||
|
|
||||||
// Draw nodes
|
// Draw nodes
|
||||||
const node = g.append('g')
|
const node = g.append('g')
|
||||||
.selectAll('g')
|
.selectAll('g')
|
||||||
.data(nodes)
|
.data(simNodes)
|
||||||
.join('g')
|
.join('g')
|
||||||
.attr('cursor', 'pointer')
|
.attr('cursor', 'pointer')
|
||||||
.call(d3.drag<any, any>()
|
.call(d3.drag<any, any>()
|
||||||
|
|
@ -110,17 +194,48 @@ function drawGraph() {
|
||||||
const el = d3.select(this)
|
const el = d3.select(this)
|
||||||
const color = getNodeColor(d.status)
|
const color = getNodeColor(d.status)
|
||||||
|
|
||||||
if (d.type === 'module') {
|
if (d.type === 'document') {
|
||||||
el.append('rect').attr('width', 20).attr('height', 20).attr('x', -10).attr('y', -10)
|
// Document container node (only in document view)
|
||||||
|
el.append('rect')
|
||||||
|
.attr('width', d.w || 150)
|
||||||
|
.attr('height', d.h || 100)
|
||||||
|
.attr('x', -(d.w || 150) / 2)
|
||||||
|
.attr('y', -(d.h || 100) / 2)
|
||||||
|
.attr('fill', 'rgba(66, 165, 245, 0.08)')
|
||||||
|
.attr('stroke', '#42A5F5')
|
||||||
|
.attr('stroke-width', 1.5)
|
||||||
|
.attr('stroke-dasharray', '6,3')
|
||||||
|
.attr('rx', 8)
|
||||||
|
el.append('text').text(d.label)
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('y', -(d.h || 100) / 2 + 16)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', '12px')
|
||||||
|
.attr('font-weight', 'bold')
|
||||||
|
.attr('fill', '#42A5F5')
|
||||||
|
} else if (d.type === 'capability') {
|
||||||
|
el.append('circle').attr('r', 18).attr('fill', color)
|
||||||
|
el.append('text').text(d.label).attr('x', 22).attr('y', 4)
|
||||||
|
.attr('font-size', '11px').attr('fill', '#333')
|
||||||
|
} else if (d.type === 'module') {
|
||||||
|
el.append('rect').attr('width', 28).attr('height', 28).attr('x', -14).attr('y', -14)
|
||||||
.attr('fill', color).attr('rx', 3)
|
.attr('fill', color).attr('rx', 3)
|
||||||
|
el.append('text').text(d.label).attr('x', 18).attr('y', 4)
|
||||||
|
.attr('font-size', '11px').attr('fill', '#333')
|
||||||
} else if (d.type === 'entity') {
|
} else if (d.type === 'entity') {
|
||||||
el.append('polygon').attr('points', '0,-12 12,0 0,12 -12,0').attr('fill', color)
|
el.append('polygon').attr('points', '0,-24 24,0 0,24 -24,0').attr('fill', color)
|
||||||
|
el.append('text').text(d.label).attr('x', 28).attr('y', 4)
|
||||||
|
.attr('font-size', '11px').attr('fill', '#333')
|
||||||
|
} else if (d.type === 'runtime_component') {
|
||||||
|
el.append('circle').attr('r', 14).attr('fill', color)
|
||||||
|
el.append('text').text(d.label).attr('x', 18).attr('y', 4)
|
||||||
|
.attr('font-size', '11px').attr('fill', '#333')
|
||||||
} else {
|
} else {
|
||||||
el.append('circle').attr('r', 10).attr('fill', color)
|
// fallback
|
||||||
|
el.append('circle').attr('r', 14).attr('fill', color)
|
||||||
|
el.append('text').text(d.label).attr('x', 18).attr('y', 4)
|
||||||
|
.attr('font-size', '11px').attr('fill', '#333')
|
||||||
}
|
}
|
||||||
|
|
||||||
el.append('text').text(d.label).attr('x', 14).attr('y', 4)
|
|
||||||
.attr('font-size', '11px').attr('fill', '#333')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Tooltip on hover
|
// Tooltip on hover
|
||||||
|
|
@ -136,6 +251,20 @@ function drawGraph() {
|
||||||
})
|
})
|
||||||
|
|
||||||
simulation.on('tick', () => {
|
simulation.on('tick', () => {
|
||||||
|
// In document view, clamp child nodes inside parent bounds
|
||||||
|
if (showDocumentView.value) {
|
||||||
|
for (const n of simNodes) {
|
||||||
|
if (n.parent) {
|
||||||
|
const p = nodeMap[n.parent]
|
||||||
|
if (p && p.w && p.h) {
|
||||||
|
const pad = 20
|
||||||
|
n.x = Math.max(p.x! - p.w / 2 + pad, Math.min(p.x! + p.w / 2 - pad, n.x!))
|
||||||
|
n.y = Math.max(p.y! - p.h / 2 + pad, Math.min(p.y! + p.h / 2 - pad, n.y!))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
link
|
link
|
||||||
.attr('x1', (d: any) => d.source.x)
|
.attr('x1', (d: any) => d.source.x)
|
||||||
.attr('y1', (d: any) => d.source.y)
|
.attr('y1', (d: any) => d.source.y)
|
||||||
|
|
@ -170,4 +299,26 @@ watch(graphView, () => { drawGraph() })
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10;
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10;
|
||||||
}
|
}
|
||||||
.summary-item { font-size: 13px; }
|
.summary-item { font-size: 13px; }
|
||||||
|
.toolbar {
|
||||||
|
position: absolute; top: 12px; left: 12px;
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.toggle-btn {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-btn:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.toggle-btn.active {
|
||||||
|
background: #42A5F5;
|
||||||
|
color: white;
|
||||||
|
border-color: #42A5F5;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user