- Fix doc.source_file → doc.file_path (actual DesignDocument field name)
- Replace lstrip("./") with proper path resolution helpers
- Add deduplication for doc→doc edges (process only downstream)
- Add frontend/src/shared/types/api.ts to files changed
- Add fallback for unknown group_id in forceX/forceY
- Note build_panorama needs design_dir parameter
- Clarify runtime_component/document detail panel behavior
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
17 KiB
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:
- All 63 nodes share a single
forceCenter, producing a "hairball" layout with no group separation - All node statuses are hardcoded to
"unknown"— node colors are meaningless grey - 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:
Note: build_panorama() will need an additional design_dir: str parameter to convert DesignDocument.file_path (absolute) to design-dir-relative paths. This parameter is passed from the route handler which already has access to the project's design directory.
-
Build
file_status_map: dict[str, str]fromscan_result.file_statuses:file_status_map = {fs.path: fs.status.value for fs in scan_result.file_statuses} -
Define entity-type-to-source-file mapping (from
design/data-architecture/01-entities.csv):_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", } -
When creating each node:
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:
- DesignDocument objects become graph nodes (type="document", group="cross-layer")
- Entity nodes get a
parentfield pointing to their containing document'sdoc_id - Two edge types: doc→doc (relation="documents") and entity→entity (existing relations unchanged)
Domain model change — GraphNode:
@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():
Path normalization note: DesignDocument.file_path is an absolute path (e.g., /home/user/project/design/business-architecture/01-scope-and-goals.md), while FileStatusEntry.path is relative to the design directory (e.g., business-architecture/01-scope-and-goals.md). The upstream/downstream fields in frontmatter are relative paths (e.g., ../business-architecture/01-scope-and-goals.md). All paths must be normalized to design-dir-relative format before comparison.
Helper function:
def _to_rel_path(doc_file_path: str, design_dir: str) -> str:
"""Convert absolute doc.file_path to design-dir-relative path."""
from pathlib import PurePosixPath
try:
return str(PurePosixPath(doc_file_path).relative_to(design_dir))
except ValueError:
return doc_file_path
def _resolve_ref_path(ref_path: str, doc_rel_path: str) -> str:
"""Resolve a relative upstream/downstream ref against the doc's directory."""
from pathlib import PurePosixPath
doc_dir = str(PurePosixPath(doc_rel_path).parent)
resolved = str(PurePosixPath(doc_dir) / ref_path)
# Normalize away ../ segments
parts = []
for part in PurePosixPath(resolved).parts:
if part == '..':
if parts:
parts.pop()
else:
parts.append(part)
return str(PurePosixPath(*parts)) if parts else ""
Step 5.5 — Create document nodes:
file_to_doc: dict[str, str] = {}
for doc in scan_result.design_documents:
doc_rel = _to_rel_path(doc.file_path, design_dir)
file_to_doc[doc_rel] = 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_rel, "unknown"),
group_id="cross-layer",
))
node_ids.add(doc.doc_id)
Entity nodes get parent:
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 (with deduplication):
path_to_doc = {}
doc_rel_paths = {}
for doc in scan_result.design_documents:
doc_rel = _to_rel_path(doc.file_path, design_dir)
path_to_doc[doc_rel] = doc.doc_id
doc_rel_paths[doc.doc_id] = doc_rel
seen_edges: set[tuple[str, str]] = set()
for doc in scan_result.design_documents:
doc_rel = doc_rel_paths[doc.doc_id]
for down_path in doc.downstream:
resolved = _resolve_ref_path(down_path, doc_rel)
down_doc_id = path_to_doc.get(resolved)
if down_doc_id and down_doc_id in node_ids:
edge_key = (doc.doc_id, down_doc_id)
if edge_key not in seen_edges:
seen_edges.add(edge_key)
edges.append(GraphEdge(source=doc.doc_id, target=down_doc_id, relation="documents"))
# Only process downstream to avoid duplicates (A.downstream=B ↔ B.upstream=A)
Acceptance criteria:
- Document nodes appear in the graph (type="document", group="cross-layer")
- Entity nodes have correct
parentreferences - doc→doc edges are created based on upstream/downstream file path resolution
- API response includes
parentfield (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 whererelation === "documents" - Use
d3.forceX/d3.forceYper group with strength ~0.3-0.5: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] || groupPositions['cross-layer']).x * width).strength(0.4)) .force('y', d3.forceY(d => (groupPositions[d.group_id] || groupPositions['cross-layer']).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
<rect>containers, sized by child count: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
parentare constrained inside their parent's bounds on each tick: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 | — |
Note on non-API types: For document and runtime_component nodes that have no detail API, the panel displays the basic GraphNode fields (id, type, label, status, group_id, parent). This is acceptable — these types have limited attributes. A detail API for these types can be added later if needed.
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.edgeswhere 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_fileis available:- From detail API response (CapabilityDetail, ModuleDetail, EntityDetail have this field)
- For document nodes: use node properties
- For runtime_component: use static
_SOURCE_FILESmapping
- Click action:
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):
<div v-if="isDrillDown" class="drill-down-bar"> <button @click="returnToPanorama">← 返回全景图</button> <span>当前: {{ drillDownNodeLabel }}</span> </div> returnToPanorama(): calls existingloadGraph()method, setsisDrillDown = 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 |
frontend/src/shared/types/api.ts |
Modify | B3 (add parent field to GraphNode interface) |
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
- Do not modify files under
design/— those are the source of truth - All changes must pass
vue-tsc(frontend) and Python type checking (backend) - Implementation order follows DDD layers: Domain → Infrastructure → Application → Interfaces