From aa8d495a920eb303ec742b0cb6d974a109e2116e Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 24 Mar 2026 08:57:08 +0000 Subject: [PATCH] feat(graph): group-partitioned layout with document view toggle (GAP-F1) Co-Authored-By: Claude Opus 4.6 --- .../graph/components/GraphPanorama.vue | 191 ++++++++++++++++-- 1 file changed, 171 insertions(+), 20 deletions(-) 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; +}