# V2 Gap Fix Design — 7 Gaps (P0+P1) Date: 2026-03-24 Branch: `feat/v2-fix-gaps` Status: Design ## 1. Problem Statement The current graph visualization has three critical issues: 1. All 63 nodes share a single `forceCenter`, producing a "hairball" layout with no group separation 2. All node statuses are hardcoded to `"unknown"` — node colors are meaningless grey 3. DesignDocument upstream/downstream edges are dead code (file paths vs node IDs mismatch) Additionally, the detail panel is nearly empty, there's no way to return from drill-down, no edit shortcut, and no legend. ## 2. Design Decisions ### DD-1: Architecture Layer Layout (方案 A) Y-axis represents abstraction level, matching TOGAF/ArchiMate conventions: ``` group_id targetX(ratio) targetY(ratio) business 0.50 0.15 application 0.50 0.38 data 0.30 0.65 technology 0.70 0.65 cross-layer 0.50 0.85 ``` ### DD-2: Compound Graph for Document View - Document nodes are containers; entity nodes nest inside their parent document - Toggle between default (group-partitioned) and document view modes - Both doc→doc edges and entity→entity edges coexist ### DD-3: Status Mapping via source_file - Entity type → source CSV file → FileStatus from ScanResult - Same-CSV entities share that file's status (one CSV = one entity type's completeness) ## 3. Gap Specifications --- ### 3.1 GAP-B1: Node Status Mapping **Layer:** Backend **Priority:** P0 **File:** `backend/app/modules/graph/application/services.py` **Current behavior:** `GraphService.build_panorama()` hardcodes `status="unknown"` for all nodes. **Target behavior:** Each node gets its status from the Scanner's `FileStatus` for the source CSV file that contains that entity type. **Implementation:** 1. Build `file_status_map: dict[str, str]` from `scan_result.file_statuses`: ```python file_status_map = {fs.path: fs.status.value for fs in scan_result.file_statuses} ``` 2. Define entity-type-to-source-file mapping (from `design/data-architecture/01-entities.csv`): ```python _SOURCE_FILES = { "capability": "business-architecture/02-capability-map.csv", "module": "application-architecture/02-modules.csv", "entity": "data-architecture/01-entities.csv", "runtime_component": "technology-architecture/01-runtime-components.csv", } ``` 3. When creating each node: ```python status = file_status_map.get(_SOURCE_FILES.get(node_type, ""), "unknown") ``` **Acceptance criteria:** - Nodes have real status values (ok/sparse/missing/template-residue/placeholder-heavy) instead of "unknown" - Nodes without a known source file retain "unknown" --- ### 3.2 GAP-B3: DesignDocument Edges — Compound Graph **Layer:** Backend **Priority:** P0 **File:** `backend/app/modules/graph/domain/entities.py`, `backend/app/modules/graph/application/services.py` **Current behavior:** Step 9 in `build_panorama()` tries to match `doc.upstream`/`doc.downstream` (file paths like `./02-capability-map.csv`) against node IDs. Always fails because (a) paths ≠ IDs and (b) DesignDocuments are never added as nodes. **Target behavior:** 1. DesignDocument objects become graph nodes (type="document", group="cross-layer") 2. Entity nodes get a `parent` field pointing to their containing document's `doc_id` 3. Two edge types: doc→doc (relation="documents") and entity→entity (existing relations unchanged) **Domain model change — GraphNode:** ```python @dataclass class GraphNode: id: str type: str # capability | module | entity | runtime_component | document label: str status: str group_id: str parent: str | None = None # doc_id of containing document, if any ``` **Implementation in build_panorama():** Step 5.5 — Create document nodes: ```python file_to_doc: dict[str, str] = {} for doc in scan_result.design_documents: file_to_doc[doc.source_file] = doc.doc_id nodes.append(GraphNode( id=doc.doc_id, type="document", label=doc.title or doc.doc_id, status=file_status_map.get(doc.source_file, "unknown"), group_id="cross-layer", )) node_ids.add(doc.doc_id) ``` Entity nodes get parent: ```python parent_doc_id = file_to_doc.get(_SOURCE_FILES.get(node_type)) node = GraphNode(..., parent=parent_doc_id) ``` Step 9 — Fix doc→doc edges using file path mapping: ```python path_to_doc = {doc.source_file: doc.doc_id for doc in scan_result.design_documents} for doc in scan_result.design_documents: for up_path in doc.upstream: normalized = up_path.lstrip("./") up_doc_id = path_to_doc.get(normalized) if up_doc_id and up_doc_id in node_ids: edges.append(GraphEdge(source=up_doc_id, target=doc.doc_id, relation="documents")) for down_path in doc.downstream: normalized = down_path.lstrip("./") down_doc_id = path_to_doc.get(normalized) if down_doc_id and down_doc_id in node_ids: edges.append(GraphEdge(source=doc.doc_id, target=down_doc_id, relation="documents")) ``` **Acceptance criteria:** - Document nodes appear in the graph (type="document", group="cross-layer") - Entity nodes have correct `parent` references - doc→doc edges are created based on upstream/downstream file path resolution - API response includes `parent` field (null for nodes without a parent) --- ### 3.3 GAP-F1: Group-Partitioned Layout + Compound Layout **Layer:** Frontend **Priority:** P0 **File:** `frontend/src/modules/graph/components/GraphPanorama.vue` **Current behavior:** Single `d3.forceCenter()` for all nodes — all groups overlap. **Target behavior:** Two layout modes controlled by a toggle. #### Default Mode (Document View OFF) - Filter out nodes where `type === "document"` and edges where `relation === "documents"` - Use `d3.forceX` / `d3.forceY` per group with strength ~0.3-0.5: ```js const groupPositions = { 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 }, } simulation .force('x', d3.forceX(d => groupPositions[d.group_id].x * width).strength(0.4)) .force('y', d3.forceY(d => groupPositions[d.group_id].y * height).strength(0.4)) .force('collide', d3.forceCollide(30)) .force('link', d3.forceLink(edges).id(d => d.id).distance(60)) ``` - Remove the single `forceCenter` — forceX/forceY provide the centering per group #### Document View Mode (Toggle ON) - Show all nodes including documents - Document nodes rendered as large `` containers, sized by child count: ```js const childCount = nodes.filter(n => n.parent === doc.id).length doc.width = Math.max(150, childCount * 60) doc.height = Math.max(100, childCount * 40) ``` - Document containers participate in force simulation (with high mass to resist movement) - Entity nodes with `parent` are constrained inside their parent's bounds on each tick: ```js simulation.on('tick', () => { nodes.forEach(n => { if (n.parent) { const p = nodeMap[n.parent] const pad = 20 n.x = clamp(n.x, p.x - p.width/2 + pad, p.x + p.width/2 - pad) n.y = clamp(n.y, p.y - p.height/2 + pad, p.y + p.height/2 - pad) } }) }) ``` - doc→doc edges rendered between container borders - entity→entity edges rendered normally (may cross container boundaries) #### Toggle - State: `showDocumentView: ref(false)` - UI: Toggle button in toolbar area - Switching destroys current simulation and reinitializes with the appropriate mode #### Node Rendering Shapes (both modes): | type | shape | size | |------|-------|------| | capability | circle | r=18 | | module | rect | 28×28 | | entity | diamond (rotated rect) | 24×24 | | runtime_component | circle | r=14 | | document | large rect container | dynamic | Colors (by status): | status | color | |--------|-------| | ok | #4CAF50 | | sparse | #FFC107 | | missing | #F44336 | | template-residue | #FF9800 | | placeholder-heavy | #9C27B0 | | unknown | #9E9E9E | Edge styles: | relation | style | |----------|-------| | traces_to | solid, #666 | | depends_on | dashed, #999 | | documents | solid, #42A5F5 (blue) | | integrates_with | dotted, #AB47BC | **Acceptance criteria:** - Default mode: nodes cluster by group in 5 distinct regions, no "hairball" - Document view: documents render as containers with entities nested inside - Toggle smoothly switches between modes - Nodes have correct shapes and colors --- ### 3.4 GAP-F2: Rich GraphDetail Panel **Layer:** Frontend **Priority:** P1 **File:** `frontend/src/modules/graph/components/GraphDetail.vue` **Current behavior:** Shows only `id, type, status, group_id`. **Target behavior:** Full attribute display + related entity list, fetched from detail APIs. **API calls by node type:** | node.type | endpoint | response type | |-----------|----------|---------------| | capability | `GET /entities/capabilities/{id}` | CapabilityDetail | | module | `GET /entities/modules/{id}` | ModuleDetail | | entity | `GET /entities/entities/{id}` | EntityDetail | | document | no API call, use node properties | — | | runtime_component | no API call, use node properties | — | **Panel layout:** ``` ┌─ GraphDetail ──────────────────────────┐ │ ✕ Close │ │ │ │ ● CAP-001 [Edit] │ │ capability · business │ │ │ │ ── Attributes ── │ │ name: 项目管理 │ │ description: 管理项目生命周期... │ │ owner: ... │ │ │ │ ── Related Entities ── │ │ → MOD-001 项目管理模块 (traces_to) │ │ → MOD-003 扫描服务 (traces_to) │ │ ← DOC-005 scope文档 (documents) │ │ │ └────────────────────────────────────────┘ ``` **Behavior:** - On node selection: if type has detail API, fetch it (show spinner while loading) - Error fallback: show basic 4 fields - Clicking a related entity → updates selected node → panel refreshes - Related entities are derived from `graphView.edges` where source or target matches the selected node **Acceptance criteria:** - Detail API is called for capability/module/entity nodes - All attributes from the API response are displayed - Related entities list is clickable and navigates the graph - Graceful fallback on API error --- ### 3.5 GAP-F5: Edit Button in GraphDetail **Layer:** Frontend **Priority:** P1 **File:** `frontend/src/modules/graph/components/GraphDetail.vue` **Current behavior:** No way to jump from graph node to editor. **Target behavior:** "Edit" button in the detail panel header, navigating to the editor page. **Implementation:** - Button visible only when `source_file` is available: - From detail API response (CapabilityDetail, ModuleDetail, EntityDetail have this field) - For document nodes: use node properties - For runtime_component: use static `_SOURCE_FILES` mapping - Click action: ```js router.push({ path: `/projects/${projectId}/editor`, query: { file: sourceFilePath } }) ``` **Acceptance criteria:** - Edit button appears for nodes with known source files - Clicking navigates to editor with correct file pre-selected - Button hidden for nodes without source file info --- ### 3.6 GAP-F4: Back to Panorama Button **Layer:** Frontend **Priority:** P1 **File:** `frontend/src/modules/graph/components/GraphPanorama.vue` **Current behavior:** Double-click drills down to neighbor subgraph; no way to return. **Target behavior:** Floating "back" button in drill-down mode. **Implementation:** - State: `isDrillDown: ref(false)`, `drillDownNodeLabel: ref('')` - On double-click drill-down: set `isDrillDown = true`, store the node label - Render (conditionally): ```html
当前: {{ drillDownNodeLabel }}
``` - `returnToPanorama()`: calls existing `loadGraph()` method, sets `isDrillDown = false` **Acceptance criteria:** - Button appears only in drill-down mode - Shows which node was drilled into - Clicking restores the full panorama graph --- ### 3.7 GAP-F3: Graph Legend **Layer:** Frontend **Priority:** P1 **File:** New `frontend/src/modules/graph/components/GraphLegend.vue`, imported in `GraphPanorama.vue` **Design:** ``` ┌─ Legend ────────────────────┐ │ Shapes Status │ │ ○ Capability ● OK │ │ ■ Module ● Sparse │ │ ◇ Entity ● Missing │ │ ○ Runtime Comp ● Template│ │ ▭ Document ● Placeholder │ │ ● Unknown │ │ Edges │ │ ── traces_to │ │ -- depends_on │ │ ━━ documents │ │ ·· integrates_with │ └────────────────────────────┘ ``` **Implementation:** - Positioned bottom-right of the graph canvas, `position: absolute` - Semi-transparent background (`rgba(255,255,255,0.9)`) - Collapsible: click title to toggle expand/collapse - Default: expanded - Uses actual SVG shapes/colors matching the graph rendering **Acceptance criteria:** - Legend renders in bottom-right corner - Shows all node shapes, status colors, and edge styles - Collapsible - Does not block graph interaction (pointer-events on legend only) --- ## 4. Files Changed | File | Change Type | Gaps | |------|-------------|------| | `backend/app/modules/graph/domain/entities.py` | Modify | B3 | | `backend/app/modules/graph/application/services.py` | Modify | B1, B3 | | `frontend/src/modules/graph/components/GraphPanorama.vue` | Modify | F1, F4 | | `frontend/src/modules/graph/components/GraphDetail.vue` | Modify | F2, F5 | | `frontend/src/modules/graph/components/GraphLegend.vue` | New | F3 | ## 5. Out of Scope The following gaps from the full analysis are NOT addressed in this spec: - GAP-X1 (layered drill-down) — Phase 2 - GAP-X2 (implementation progress on nodes) — Phase 2 - GAP-B2 (more entity types as nodes) — separate effort - GAP-B4 (recursive impact traversal) — separate effort - GAP-B5 (LLM-based impl tracking) — separate effort - GAP-F6 (route path alignment) — cosmetic - GAP-F7 (panel position conflict) — cosmetic ## 6. Constraints 1. Do not modify files under `design/` — those are the source of truth 2. All changes must pass `vue-tsc` (frontend) and Python type checking (backend) 3. Implementation order follows DDD layers: Domain → Infrastructure → Application → Interfaces