docs: add v2 gap fix design spec for 7 gaps (P0+P1)

Covers GAP-B1 (status mapping), GAP-B3 (compound graph with document
nodes), GAP-F1 (group-partitioned layout), GAP-F2 (rich detail panel),
GAP-F3 (legend), GAP-F4 (back button), GAP-F5 (edit shortcut).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
openclaw 2026-03-24 07:24:09 +00:00
parent 1a92c91591
commit b67780007c

View File

@ -0,0 +1,430 @@
# 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 `<rect>` 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
<div v-if="isDrillDown" class="drill-down-bar">
<button @click="returnToPanorama">← 返回全景图</button>
<span>当前: {{ drillDownNodeLabel }}</span>
</div>
```
- `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