diff --git a/frontend/src/modules/graph/components/GraphPanorama.vue b/frontend/src/modules/graph/components/GraphPanorama.vue
index 53f4dfe..dad8c4b 100644
--- a/frontend/src/modules/graph/components/GraphPanorama.vue
+++ b/frontend/src/modules/graph/components/GraphPanorama.vue
@@ -9,6 +9,12 @@
Missing {{ scanResult.summary.missing }}
+
+
+
+
@@ -31,19 +37,48 @@ const { clearSelection } = store
const svgRef = ref(null)
+const showDocumentView = ref(false)
+const isDrillDown = ref(false)
+const drillDownNodeLabel = ref('')
+
const STATUS_COLORS: Record = {
ok: '#4CAF50', sparse: '#FFC107', missing: '#F44336',
'template-residue': '#FF9800', 'placeholder-heavy': '#9C27B0', unknown: '#9E9E9E',
}
+const GROUP_POSITIONS: Record = {
+ 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 = {
+ traces_to: '#666',
+ depends_on: '#999',
+ documents: '#42A5F5',
+ integrates_with: '#AB47BC',
+}
+
const EDGE_STYLES: Record = {
- 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 {
return STATUS_COLORS[status] || '#9E9E9E'
}
+interface SimNode extends GraphNode, d3.SimulationNodeDatum {
+ w?: number
+ h?: number
+}
+
+function toggleDocumentView() {
+ showDocumentView.value = !showDocumentView.value
+ drawGraph()
+}
+
function drawGraph() {
if (!svgRef.value || !graphView.value) return
@@ -63,34 +98,83 @@ function drawGraph() {
.on('zoom', (event) => { g.attr('transform', event.transform) })
svg.call(zoom)
- const nodes = graphView.value.nodes.map(n => ({ ...n } as GraphNode & d3.SimulationNodeDatum))
- const edges = graphView.value.edges.map(e => ({
+ // Filter nodes and edges based on view mode
+ 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 = {}
+ 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,
source: e.source,
target: e.target,
}))
- // Force simulation
- const simulation = d3.forceSimulation(nodes as any)
- .force('link', d3.forceLink(edges as any).id((d: any) => d.id).distance(100))
- .force('charge', d3.forceManyBody().strength(-200))
- .force('center', d3.forceCenter(width / 2, height / 2))
- .force('collision', d3.forceCollide().radius(30))
+ // Force simulation with group-partitioned layout
+ const simulation = d3.forceSimulation(simNodes as any)
+ .force('link', d3.forceLink(simEdges as any).id((d: any) => d.id).distance(60))
+ .force('charge', d3.forceManyBody().strength(-150))
+ .force('x', d3.forceX((d: any) =>
+ (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
const link = g.append('g')
.selectAll('line')
- .data(edges)
+ .data(simEdges)
.join('line')
- .attr('stroke', '#999')
+ .attr('stroke', (d: any) => EDGE_COLORS[d.relation] || '#999')
.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')
// Draw nodes
const node = g.append('g')
.selectAll('g')
- .data(nodes)
+ .data(simNodes)
.join('g')
.attr('cursor', 'pointer')
.call(d3.drag()
@@ -110,17 +194,48 @@ function drawGraph() {
const el = d3.select(this)
const color = getNodeColor(d.status)
- if (d.type === 'module') {
- el.append('rect').attr('width', 20).attr('height', 20).attr('x', -10).attr('y', -10)
+ if (d.type === 'document') {
+ // 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)
+ el.append('text').text(d.label).attr('x', 18).attr('y', 4)
+ .attr('font-size', '11px').attr('fill', '#333')
} 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 {
- 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
@@ -136,6 +251,20 @@ function drawGraph() {
})
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
.attr('x1', (d: any) => d.source.x)
.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;
}
.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;
+}