From b67780007c2e0ecfc1fb02d933c16488f675c442 Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 24 Mar 2026 07:24:09 +0000 Subject: [PATCH 01/11] 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 --- .../specs/2026-03-24-v2-fix-gaps-design.md | 430 ++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md diff --git a/docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md b/docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md new file mode 100644 index 0000000..e09f895 --- /dev/null +++ b/docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md @@ -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 `` 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 -- 2.45.1 From c97f201a9cef6d9eadf7f14c03bf2f570a691232 Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 24 Mar 2026 07:39:33 +0000 Subject: [PATCH 02/11] =?UTF-8?q?docs:=20fix=20spec=20issues=20from=20revi?= =?UTF-8?q?ew=20=E2=80=94=20path=20normalization,=20field=20names,=20dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../specs/2026-03-24-v2-fix-gaps-design.md | 69 +++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md b/docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md index e09f895..48c464e 100644 --- a/docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md +++ b/docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md @@ -52,6 +52,8 @@ cross-layer 0.50 0.85 **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. + 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} @@ -105,16 +107,45 @@ class GraphNode: **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: +```python +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: ```python file_to_doc: dict[str, str] = {} for doc in scan_result.design_documents: - file_to_doc[doc.source_file] = doc.doc_id + 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.source_file, "unknown"), + status=file_status_map.get(doc_rel, "unknown"), group_id="cross-layer", )) node_ids.add(doc.doc_id) @@ -126,20 +157,27 @@ 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: +Step 9 — Fix doc→doc edges using file path mapping (with deduplication): ```python -path_to_doc = {doc.source_file: doc.doc_id for doc in scan_result.design_documents} +path_to_doc = {} +doc_rel_paths = {} 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")) + 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: - normalized = down_path.lstrip("./") - down_doc_id = path_to_doc.get(normalized) + 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: - edges.append(GraphEdge(source=doc.doc_id, target=down_doc_id, relation="documents")) + 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:** @@ -174,8 +212,8 @@ for doc in scan_result.design_documents: } 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('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)) ``` @@ -269,6 +307,8 @@ Edge styles: | 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 ──────────────────────────┐ @@ -411,6 +451,7 @@ Edge styles: | `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 -- 2.45.1 From ac5b7bccc76ec687fb6ba37879bffea40e6fd30b Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 24 Mar 2026 08:01:59 +0000 Subject: [PATCH 03/11] docs: add v2 gap fix implementation plan (10 tasks) Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-24-v2-fix-gaps.md | 847 ++++++++++++++++++ 1 file changed, 847 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-24-v2-fix-gaps.md diff --git a/docs/superpowers/plans/2026-03-24-v2-fix-gaps.md b/docs/superpowers/plans/2026-03-24-v2-fix-gaps.md new file mode 100644 index 0000000..6fd2c5a --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-v2-fix-gaps.md @@ -0,0 +1,847 @@ +# V2 Gap Fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix 7 gaps (P0+P1) so the graph visualization shows grouped layout, real status colors, working document edges, rich detail panel, legend, back button, and edit shortcut. + +**Architecture:** Backend changes add `parent` field to `GraphNode`, status mapping from `FileStatusEntry`, and document nodes with proper edge resolution. Frontend changes replace the single-center D3 layout with per-group forceX/forceY, add compound document view toggle, enrich the detail panel with API calls, and add legend/back-button UI. + +**Tech Stack:** Python 3.12 / FastAPI / dataclasses (backend), Vue 3 / TypeScript / D3.js v7 / Pinia (frontend), pytest (backend tests) + +**Spec:** `docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md` + +--- + +## File Structure + +### Files to Modify +| File | Responsibility | Tasks | +|------|---------------|-------| +| `backend/app/modules/graph/domain/entities.py` | GraphNode dataclass — add `parent` field | 1 | +| `backend/app/modules/graph/application/services.py` | build_panorama — status mapping, document nodes, edge fix | 2, 3 | +| `backend/app/modules/graph/interfaces/http/router.py` | Pass `design_dir` to build_panorama | 4 | +| `backend/tests/test_graph_service.py` | Update existing tests + add new tests | 1, 2, 3, 4 | +| `frontend/src/shared/types/api.ts` | Add `parent` to GraphNode interface | 5 | +| `frontend/src/modules/graph/components/GraphPanorama.vue` | Group layout, compound layout, toggle, back button | 6, 8 | +| `frontend/src/modules/graph/components/GraphDetail.vue` | Rich detail panel, edit button | 7 | + +### Files to Create +| File | Responsibility | Tasks | +|------|---------------|-------| +| `frontend/src/modules/graph/components/GraphLegend.vue` | Legend component | 9 | + +--- + +### Task 1: Domain — Add `parent` field to GraphNode + +**Files:** +- Modify: `backend/app/modules/graph/domain/entities.py:4-10` +- Test: `backend/tests/test_graph_service.py` + +- [ ] **Step 1: Write the failing test** + +Add to `backend/tests/test_graph_service.py`: + +```python +def test_graph_node_has_parent_field(graph_service, scan_result): + view = graph_service.build_panorama(scan_result) + # All nodes should have a parent attribute (None for most, doc_id for some) + for node in view.nodes: + assert hasattr(node, 'parent') +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_graph_node_has_parent_field -v` +Expected: FAIL — `GraphNode` has no `parent` attribute + +- [ ] **Step 3: Add `parent` field to GraphNode** + +Edit `backend/app/modules/graph/domain/entities.py` — add to the GraphNode dataclass: + +```python +@dataclass +class GraphNode: + id: str + type: str # capability, module, entity, runtime_component, document + label: str + status: str # FileStatus value or "unknown" + group_id: str + parent: str | None = None # doc_id of containing document, if any +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_graph_node_has_parent_field -v` +Expected: PASS + +- [ ] **Step 5: Run all existing graph tests to verify no regression** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py -v` +Expected: All 9 tests PASS (existing 8 + new 1) + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/modules/graph/domain/entities.py backend/tests/test_graph_service.py +git commit -m "feat(graph): add parent field to GraphNode domain entity" +``` + +--- + +### Task 2: Application — Status mapping in build_panorama (GAP-B1) + +**Files:** +- Modify: `backend/app/modules/graph/application/services.py:22-77` +- Test: `backend/tests/test_graph_service.py` + +- [ ] **Step 1: Add design_dir fixture and write the failing tests** + +First, add a `design_dir` fixture to `backend/tests/test_graph_service.py` (will be used by all subsequent tests): + +```python +@pytest.fixture +def design_dir(): + return "/workspace/arch-design-agent-skill-dashboard/design" +``` + +Then add the new tests (note: all new tests from this point forward take `design_dir` and pass it to `build_panorama`): + +```python +def test_panorama_nodes_have_real_status(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) + statuses = {n.status for n in view.nodes} + # At least some nodes should NOT be "unknown" since we have real file statuses + assert statuses != {"unknown"}, "All nodes still have status='unknown' — status mapping not working" + + +def test_panorama_status_values_are_valid(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) + valid_statuses = {"ok", "sparse", "missing", "template-residue", "placeholder-heavy", "unknown"} + for node in view.nodes: + assert node.status in valid_statuses, f"Node {node.id} has invalid status '{node.status}'" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_panorama_nodes_have_real_status tests/test_graph_service.py::test_panorama_status_values_are_valid -v` +Expected: `test_panorama_nodes_have_real_status` FAILS (all statuses are "unknown") + +- [ ] **Step 3: Implement status mapping** + +Edit `backend/app/modules/graph/application/services.py`. Add the `_SOURCE_FILES` constant after `_GROUPS` and modify `build_panorama` to build the file status map: + +```python +_SOURCE_FILES: dict[str, str] = { + "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", +} +``` + +At the top of `build_panorama()`, add `design_dir` parameter with default so existing tests still work: +```python +def build_panorama(self, scan_result: ScanResult, design_dir: str = "") -> GraphView: + # Build file path -> status mapping + file_status_map: dict[str, str] = { + fs.path: fs.status.value for fs in scan_result.file_statuses + } +``` + +Replace each `status="unknown"` with: +```python +status=file_status_map.get(_SOURCE_FILES.get("capability", ""), "unknown"), # for caps +status=file_status_map.get(_SOURCE_FILES.get("module", ""), "unknown"), # for modules +status=file_status_map.get(_SOURCE_FILES.get("entity", ""), "unknown"), # for entities +status=file_status_map.get(_SOURCE_FILES.get("runtime_component", ""), "unknown"), # for runtime +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py -v` +Expected: All tests PASS including the two new status tests + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/modules/graph/application/services.py backend/tests/test_graph_service.py +git commit -m "feat(graph): map node status from FileStatus via source_file (GAP-B1)" +``` + +--- + +### Task 3: Application — Document nodes + doc→doc edges (GAP-B3) + +**Files:** +- Modify: `backend/app/modules/graph/application/services.py` +- Test: `backend/tests/test_graph_service.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `backend/tests/test_graph_service.py`: + +```python +def test_panorama_has_document_nodes(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) + doc_nodes = [n for n in view.nodes if n.type == "document"] + assert len(doc_nodes) > 0, "No document nodes found" + assert all(n.group_id == "cross-layer" for n in doc_nodes) + + +def test_panorama_document_edges(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) + doc_edges = [e for e in view.edges if e.relation == "documents"] + assert len(doc_edges) > 0, "No document edges found" + + +def test_panorama_capability_nodes_have_parent(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) + cap_nodes = [n for n in view.nodes if n.type == "capability"] + # Capability nodes should have parent pointing to a document + nodes_with_parent = [n for n in cap_nodes if n.parent is not None] + assert len(nodes_with_parent) > 0, "No capability nodes have a parent document" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_panorama_has_document_nodes tests/test_graph_service.py::test_panorama_document_edges tests/test_graph_service.py::test_panorama_capability_nodes_have_parent -v` +Expected: All 3 FAIL + +- [ ] **Step 3: Implement document nodes and edge resolution** + +Edit `backend/app/modules/graph/application/services.py`. + +Add path helper functions before the class: + +```python +from pathlib import PurePosixPath + + +def _to_rel_path(doc_file_path: str, design_dir: str) -> str: + """Convert absolute doc.file_path to design-dir-relative path.""" + 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.""" + doc_dir = str(PurePosixPath(doc_rel_path).parent) + resolved = str(PurePosixPath(doc_dir) / ref_path) + parts: list[str] = [] + for part in PurePosixPath(resolved).parts: + if part == '..': + if parts: + parts.pop() + else: + parts.append(part) + return str(PurePosixPath(*parts)) if parts else "" +``` + +**Important ordering:** Build `file_to_doc` mapping BEFORE Steps 2-5 so entity nodes can get their `parent`. Restructure `build_panorama` to: + +1. Build `file_status_map` (already done in Task 2) +2. Build `file_to_doc` from `scan_result.design_documents` + create document nodes +3. Then create entity nodes (Steps 2-5) with `parent` set via `file_to_doc` +4. Then create edges (Steps 6-9) with fixed Step 9 + +Step 5.5 (now moved before Steps 2-5): +```python + # Build document node mapping first (needed for parent refs) + 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) +``` + +In each entity creation (Steps 2-5), add parent: +```python + # e.g. for capability: + parent_doc_id = file_to_doc.get(_SOURCE_FILES.get("capability")) + nodes.append(GraphNode( + id=node_id, type="capability", label=cap.name, + status=file_status_map.get(_SOURCE_FILES.get("capability", ""), "unknown"), + group_id="business", + parent=parent_doc_id, + )) +``` + +Replace Step 9 with path resolution + deduplication: +```python + # Step 9: DesignDocument.downstream → doc-to-doc edges (deduplicated) + path_to_doc: dict[str, str] = {} + doc_rel_paths: dict[str, str] = {} + 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", + )) +``` + +- [ ] **Step 4: Run new tests to verify they pass** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_panorama_has_document_nodes tests/test_graph_service.py::test_panorama_document_edges tests/test_graph_service.py::test_panorama_capability_nodes_have_parent -v` +Expected: All 3 PASS + +- [ ] **Step 5: Run ALL graph tests to check no regression** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py tests/test_api_graph.py -v` +Expected: All tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/modules/graph/application/services.py backend/tests/test_graph_service.py +git commit -m "feat(graph): add document nodes, parent refs, and fixed doc edges (GAP-B3)" +``` + +--- + +### Task 4: Interfaces — Pass design_dir from router to build_panorama + +**Files:** +- Modify: `backend/app/modules/graph/interfaces/http/router.py:40-56` +- Test: `backend/tests/test_api_graph.py` + +- [ ] **Step 1: Update the router to pass design_dir** + +Edit `backend/app/modules/graph/interfaces/http/router.py`: + +In `get_graph()`: +```python +@router.get("") +def get_graph(project_id: str): + """Build and return the full panorama graph for a project.""" + project = _project_service.get_project(project_id) + scan_result = _get_or_trigger_scan(project_id) + view = _graph_service.build_panorama(scan_result, design_dir=project.design_dir) + return asdict(view) +``` + +In `get_neighbors()`: +```python +@router.get("/nodes/{node_id}/neighbors") +def get_neighbors(project_id: str, node_id: str): + """Return the subgraph of neighbors for a given node.""" + project = _project_service.get_project(project_id) + scan_result = _get_or_trigger_scan(project_id) + view = _graph_service.build_panorama(scan_result, design_dir=project.design_dir) + neighbors = _graph_service.get_neighbors(view, node_id) + return asdict(neighbors) +``` + +- [ ] **Step 2: Update test_graph_service.py to pass design_dir** + +Add a `design_dir` fixture and update all `build_panorama` calls: + +```python +@pytest.fixture +def design_dir(): + return "/workspace/arch-design-agent-skill-dashboard/design" +``` + +Update all test function signatures to include `design_dir` parameter. Update all calls from: +```python +view = graph_service.build_panorama(scan_result) +``` +to: +```python +view = graph_service.build_panorama(scan_result, design_dir=design_dir) +``` + +- [ ] **Step 3: Run ALL backend tests** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/ -v` +Expected: All tests PASS + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/modules/graph/interfaces/http/router.py backend/tests/test_graph_service.py +git commit -m "feat(graph): pass design_dir from router to build_panorama" +``` + +--- + +### Task 5: Frontend types — Add `parent` to GraphNode interface + +**Files:** +- Modify: `frontend/src/shared/types/api.ts:31-37` + +- [ ] **Step 1: Add parent field** + +Edit `frontend/src/shared/types/api.ts`: + +```typescript +export interface GraphNode { + id: string + type: string + label: string + status: string + group_id: string + parent: string | null +} +``` + +- [ ] **Step 2: Run type check** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/shared/types/api.ts +git commit -m "feat(graph): add parent field to GraphNode TypeScript interface" +``` + +--- + +### Task 6: Frontend — Group-partitioned layout + compound layout + toggle (GAP-F1) + +**Files:** +- Modify: `frontend/src/modules/graph/components/GraphPanorama.vue` + +This is the largest task. The entire `drawGraph()` function needs rewriting. + +- [ ] **Step 1: Add state variables and update constants** + +Add after existing refs in ` + + +``` + +- [ ] **Step 2: Import GraphLegend in GraphPanorama.vue** + +Add import: +```typescript +import GraphLegend from './GraphLegend.vue' +``` + +Add to template (inside `.graph-panorama` div, after ``): +```html + +``` + +- [ ] **Step 3: Run type check** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/modules/graph/components/GraphLegend.vue frontend/src/modules/graph/components/GraphPanorama.vue +git commit -m "feat(graph): add collapsible legend component (GAP-F3)" +``` + +--- + +### Task 10: Final verification + +- [ ] **Step 1: Run all backend tests** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/ -v` +Expected: All tests PASS + +- [ ] **Step 2: Run frontend type check** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Run frontend build** + +Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npm run build` +Expected: Build succeeds + +- [ ] **Step 4: Verify git status is clean** + +Run: `git status` +Expected: No uncommitted changes -- 2.45.1 From 4cf039e029d1bdcc91d40e8e5454eb4e56d680e1 Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 24 Mar 2026 08:09:29 +0000 Subject: [PATCH 04/11] feat(graph): add parent field to GraphNode domain entity Co-Authored-By: Claude Opus 4.6 --- backend/app/modules/graph/domain/entities.py | 1 + backend/tests/test_graph_service.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/backend/app/modules/graph/domain/entities.py b/backend/app/modules/graph/domain/entities.py index e1582f4..cf860d2 100644 --- a/backend/app/modules/graph/domain/entities.py +++ b/backend/app/modules/graph/domain/entities.py @@ -8,6 +8,7 @@ class GraphNode: label: str status: str # FileStatus or "unknown" group_id: str + parent: str | None = None @dataclass diff --git a/backend/tests/test_graph_service.py b/backend/tests/test_graph_service.py index 7a0a5ca..8ce5f1c 100644 --- a/backend/tests/test_graph_service.py +++ b/backend/tests/test_graph_service.py @@ -80,3 +80,9 @@ def test_neighbors_unknown_node(graph_service, scan_result): neighbors = graph_service.get_neighbors(view, "NONEXISTENT") assert len(neighbors.nodes) == 0 assert len(neighbors.edges) == 0 + + +def test_graph_node_has_parent_field(graph_service, scan_result): + view = graph_service.build_panorama(scan_result) + for node in view.nodes: + assert hasattr(node, 'parent') -- 2.45.1 From 4d70df76fc5601c8b556b1d75a749bc2e96e0f79 Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 24 Mar 2026 08:19:40 +0000 Subject: [PATCH 05/11] feat(graph): map node status from FileStatus via source_file (GAP-B1) Co-Authored-By: Claude Opus 4.6 --- .../app/modules/graph/application/services.py | 23 +++++++++++++++---- backend/tests/test_graph_service.py | 18 +++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/backend/app/modules/graph/application/services.py b/backend/app/modules/graph/application/services.py index 2a6a0cc..a0c9a35 100644 --- a/backend/app/modules/graph/application/services.py +++ b/backend/app/modules/graph/application/services.py @@ -16,15 +16,28 @@ _GROUPS = [ ] +_SOURCE_FILES: dict[str, str] = { + "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", +} + + class GraphService: """Constructs a panorama graph and supports neighbor queries.""" - def build_panorama(self, scan_result: ScanResult) -> GraphView: + def build_panorama(self, scan_result: ScanResult, *, design_dir: str = "") -> GraphView: """Build a full panorama GraphView from a ScanResult (9-step algorithm).""" nodes: list[GraphNode] = [] edges: list[GraphEdge] = [] node_ids: set[str] = set() + # Build file-status lookup from ScanResult + file_status_map: dict[str, str] = { + fs.path: fs.status.value for fs in scan_result.file_statuses + } + # Step 1: groups are always the fixed 5 groups = list(_GROUPS) @@ -35,7 +48,7 @@ class GraphService: id=node_id, type="capability", label=cap.name, - status="unknown", + status=file_status_map.get(_SOURCE_FILES["capability"], "unknown"), group_id="business", )) node_ids.add(node_id) @@ -47,7 +60,7 @@ class GraphService: id=node_id, type="module", label=mod.name, - status="unknown", + status=file_status_map.get(_SOURCE_FILES["module"], "unknown"), group_id="application", )) node_ids.add(node_id) @@ -59,7 +72,7 @@ class GraphService: id=node_id, type="entity", label=ent.name, - status="unknown", + status=file_status_map.get(_SOURCE_FILES["entity"], "unknown"), group_id="data", )) node_ids.add(node_id) @@ -71,7 +84,7 @@ class GraphService: id=node_id, type="runtime_component", label=rc.name, - status="unknown", + status=file_status_map.get(_SOURCE_FILES["runtime_component"], "unknown"), group_id="technology", )) node_ids.add(node_id) diff --git a/backend/tests/test_graph_service.py b/backend/tests/test_graph_service.py index 8ce5f1c..5954838 100644 --- a/backend/tests/test_graph_service.py +++ b/backend/tests/test_graph_service.py @@ -17,6 +17,11 @@ def scan_result(): return svc.scan(project) +@pytest.fixture +def design_dir(): + return "/workspace/arch-design-agent-skill-dashboard/design" + + @pytest.fixture def graph_service(): return GraphService() @@ -86,3 +91,16 @@ def test_graph_node_has_parent_field(graph_service, scan_result): view = graph_service.build_panorama(scan_result) for node in view.nodes: assert hasattr(node, 'parent') + + +def test_panorama_nodes_have_real_status(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) + statuses = {n.status for n in view.nodes} + assert statuses != {"unknown"}, "All nodes still have status='unknown' — status mapping not working" + + +def test_panorama_status_values_are_valid(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) + valid_statuses = {"ok", "sparse", "missing", "template-residue", "placeholder-heavy", "unknown"} + for node in view.nodes: + assert node.status in valid_statuses, f"Node {node.id} has invalid status '{node.status}'" -- 2.45.1 From 50e38c64a61407e37d252bc0f7d529f85daa77e6 Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 24 Mar 2026 08:27:06 +0000 Subject: [PATCH 06/11] feat(graph): add document nodes, parent refs, and fixed doc edges (GAP-B3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create document nodes from scan_result.design_documents with type="document" and group_id="cross-layer" - Set parent field on entity nodes (capability/module/entity/runtime_component) pointing to their containing document via directory-based lookup - Replace dead-code Step 9 with path-based resolution of downstream refs and edge deduplication - Add helper functions _to_rel_path and _resolve_ref_path for absolute→relative path conversion and ../ resolution Co-Authored-By: Claude Opus 4.6 --- .../app/modules/graph/application/services.py | 93 ++++++++++++++++--- backend/tests/test_graph_service.py | 20 ++++ 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/backend/app/modules/graph/application/services.py b/backend/app/modules/graph/application/services.py index a0c9a35..3bb051c 100644 --- a/backend/app/modules/graph/application/services.py +++ b/backend/app/modules/graph/application/services.py @@ -2,10 +2,34 @@ from __future__ import annotations +from pathlib import PurePosixPath + from app.modules.graph.domain.entities import GraphEdge, GraphGroup, GraphNode, GraphView from app.modules.scanner.domain.entities import ScanResult +def _to_rel_path(doc_file_path: str, design_dir: str) -> str: + """Convert absolute doc.file_path to design-dir-relative path.""" + 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.""" + doc_dir = str(PurePosixPath(doc_rel_path).parent) + resolved = str(PurePosixPath(doc_dir) / ref_path) + parts: list[str] = [] + for part in PurePosixPath(resolved).parts: + if part == '..': + if parts: + parts.pop() + else: + parts.append(part) + return str(PurePosixPath(*parts)) if parts else "" + + # Fixed set of groups _GROUPS = [ GraphGroup(id="business", label="Business", layer="business"), @@ -41,6 +65,34 @@ class GraphService: # Step 1: groups are always the fixed 5 groups = list(_GROUPS) + # Step 1.5: Build document nodes FIRST (needed for parent refs in Steps 2-5) + file_to_doc: dict[str, str] = {} + dir_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 + # Map directory to first doc found there (for parent lookups by CSV path) + doc_dir = str(PurePosixPath(doc_rel).parent) + if doc_dir not in dir_to_doc: + dir_to_doc[doc_dir] = 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) + + def _parent_for(entity_type: str) -> str | None: + """Find parent doc for an entity type via its source CSV directory.""" + csv_path = _SOURCE_FILES.get(entity_type) + if not csv_path: + return None + return file_to_doc.get(csv_path) or dir_to_doc.get( + str(PurePosixPath(csv_path).parent) + ) + # Step 2: Capability → node(type="capability", group="business") for cap in scan_result.capabilities: node_id = cap.capability_id @@ -50,6 +102,7 @@ class GraphService: label=cap.name, status=file_status_map.get(_SOURCE_FILES["capability"], "unknown"), group_id="business", + parent=_parent_for("capability"), )) node_ids.add(node_id) @@ -62,6 +115,7 @@ class GraphService: label=mod.name, status=file_status_map.get(_SOURCE_FILES["module"], "unknown"), group_id="application", + parent=_parent_for("module"), )) node_ids.add(node_id) @@ -74,6 +128,7 @@ class GraphService: label=ent.name, status=file_status_map.get(_SOURCE_FILES["entity"], "unknown"), group_id="data", + parent=_parent_for("entity"), )) node_ids.add(node_id) @@ -86,6 +141,7 @@ class GraphService: label=rc.name, status=file_status_map.get(_SOURCE_FILES["runtime_component"], "unknown"), group_id="technology", + parent=_parent_for("runtime_component"), )) node_ids.add(node_id) @@ -126,22 +182,29 @@ class GraphService: relation="depends_on", )) - # Step 9: DesignDocument.upstream/downstream → edges (if both are nodes) + # Step 9: DesignDocument.downstream → doc-to-doc edges (deduplicated) + path_to_doc: dict[str, str] = {} + doc_rel_paths: dict[str, str] = {} for doc in scan_result.design_documents: - for upstream_id in doc.upstream: - if doc.doc_id in node_ids and upstream_id in node_ids: - edges.append(GraphEdge( - source=doc.doc_id, - target=upstream_id, - relation="documents", - )) - for downstream_id in doc.downstream: - if doc.doc_id in node_ids and downstream_id in node_ids: - edges.append(GraphEdge( - source=doc.doc_id, - target=downstream_id, - relation="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", + )) return GraphView(nodes=nodes, edges=edges, groups=groups) diff --git a/backend/tests/test_graph_service.py b/backend/tests/test_graph_service.py index 5954838..f84c5ac 100644 --- a/backend/tests/test_graph_service.py +++ b/backend/tests/test_graph_service.py @@ -104,3 +104,23 @@ def test_panorama_status_values_are_valid(graph_service, scan_result, design_dir valid_statuses = {"ok", "sparse", "missing", "template-residue", "placeholder-heavy", "unknown"} for node in view.nodes: assert node.status in valid_statuses, f"Node {node.id} has invalid status '{node.status}'" + + +def test_panorama_has_document_nodes(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) + doc_nodes = [n for n in view.nodes if n.type == "document"] + assert len(doc_nodes) > 0, "No document nodes found" + assert all(n.group_id == "cross-layer" for n in doc_nodes) + + +def test_panorama_document_edges(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) + doc_edges = [e for e in view.edges if e.relation == "documents"] + assert len(doc_edges) > 0, "No document edges found" + + +def test_panorama_capability_nodes_have_parent(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) + cap_nodes = [n for n in view.nodes if n.type == "capability"] + nodes_with_parent = [n for n in cap_nodes if n.parent is not None] + assert len(nodes_with_parent) > 0, "No capability nodes have a parent document" -- 2.45.1 From e386a593361e6d7b0c8662cb865bd189ff6d08ea Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 24 Mar 2026 08:30:27 +0000 Subject: [PATCH 07/11] feat(graph): pass design_dir from router to build_panorama Co-Authored-By: Claude Opus 4.6 --- .../modules/graph/interfaces/http/router.py | 8 ++--- backend/tests/test_graph_service.py | 36 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/backend/app/modules/graph/interfaces/http/router.py b/backend/app/modules/graph/interfaces/http/router.py index 400e8bb..e6dd1de 100644 --- a/backend/app/modules/graph/interfaces/http/router.py +++ b/backend/app/modules/graph/interfaces/http/router.py @@ -40,17 +40,17 @@ def _get_or_trigger_scan(project_id: str): @router.get("") def get_graph(project_id: str): """Build and return the full panorama graph for a project.""" - _project_service.get_project(project_id) # Ensure project exists (raises 404) + project = _project_service.get_project(project_id) # Ensure project exists (raises 404) scan_result = _get_or_trigger_scan(project_id) - view = _graph_service.build_panorama(scan_result) + view = _graph_service.build_panorama(scan_result, design_dir=project.design_dir) return asdict(view) @router.get("/nodes/{node_id}/neighbors") def get_neighbors(project_id: str, node_id: str): """Return the subgraph of neighbors for a given node.""" - _project_service.get_project(project_id) # Ensure project exists (raises 404) + project = _project_service.get_project(project_id) # Ensure project exists (raises 404) scan_result = _get_or_trigger_scan(project_id) - view = _graph_service.build_panorama(scan_result) + view = _graph_service.build_panorama(scan_result, design_dir=project.design_dir) neighbors = _graph_service.get_neighbors(view, node_id) return asdict(neighbors) diff --git a/backend/tests/test_graph_service.py b/backend/tests/test_graph_service.py index f84c5ac..647eb2b 100644 --- a/backend/tests/test_graph_service.py +++ b/backend/tests/test_graph_service.py @@ -27,8 +27,8 @@ def graph_service(): return GraphService() -def test_panorama_has_groups(graph_service, scan_result): - view = graph_service.build_panorama(scan_result) +def test_panorama_has_groups(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) group_ids = {g.id for g in view.groups} assert "business" in group_ids assert "application" in group_ids @@ -37,42 +37,42 @@ def test_panorama_has_groups(graph_service, scan_result): assert "cross-layer" in group_ids -def test_panorama_has_capability_nodes(graph_service, scan_result): - view = graph_service.build_panorama(scan_result) +def test_panorama_has_capability_nodes(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) cap_nodes = [n for n in view.nodes if n.type == "capability"] assert len(cap_nodes) > 0 assert all(n.group_id == "business" for n in cap_nodes) -def test_panorama_has_module_nodes(graph_service, scan_result): - view = graph_service.build_panorama(scan_result) +def test_panorama_has_module_nodes(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) mod_nodes = [n for n in view.nodes if n.type == "module"] assert len(mod_nodes) > 0 assert all(n.group_id == "application" for n in mod_nodes) -def test_panorama_has_entity_nodes(graph_service, scan_result): - view = graph_service.build_panorama(scan_result) +def test_panorama_has_entity_nodes(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) ent_nodes = [n for n in view.nodes if n.type == "entity"] assert len(ent_nodes) > 0 assert all(n.group_id == "data" for n in ent_nodes) -def test_panorama_has_edges(graph_service, scan_result): - view = graph_service.build_panorama(scan_result) +def test_panorama_has_edges(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) assert len(view.edges) > 0 relations = {e.relation for e in view.edges} assert "traces_to" in relations -def test_panorama_depends_on_edges(graph_service, scan_result): - view = graph_service.build_panorama(scan_result) +def test_panorama_depends_on_edges(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) dep_edges = [e for e in view.edges if e.relation == "depends_on"] assert len(dep_edges) > 0 -def test_neighbors_returns_subgraph(graph_service, scan_result): - view = graph_service.build_panorama(scan_result) +def test_neighbors_returns_subgraph(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) # Use a known capability node neighbors = graph_service.get_neighbors(view, "CAP-PROJ-REG") assert len(neighbors.nodes) > 0 @@ -80,15 +80,15 @@ def test_neighbors_returns_subgraph(graph_service, scan_result): assert len(neighbors.edges) > 0 -def test_neighbors_unknown_node(graph_service, scan_result): - view = graph_service.build_panorama(scan_result) +def test_neighbors_unknown_node(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) neighbors = graph_service.get_neighbors(view, "NONEXISTENT") assert len(neighbors.nodes) == 0 assert len(neighbors.edges) == 0 -def test_graph_node_has_parent_field(graph_service, scan_result): - view = graph_service.build_panorama(scan_result) +def test_graph_node_has_parent_field(graph_service, scan_result, design_dir): + view = graph_service.build_panorama(scan_result, design_dir=design_dir) for node in view.nodes: assert hasattr(node, 'parent') -- 2.45.1 From 846242ae1af1efe29becdae78cce03690b8c3697 Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 24 Mar 2026 08:56:03 +0000 Subject: [PATCH 08/11] feat(graph): rich detail panel with API fetch and edit button (GAP-F2, GAP-F5) Co-Authored-By: Claude Opus 4.6 --- .../modules/graph/components/GraphDetail.vue | 375 ++++++++++++++++-- 1 file changed, 352 insertions(+), 23 deletions(-) diff --git a/frontend/src/modules/graph/components/GraphDetail.vue b/frontend/src/modules/graph/components/GraphDetail.vue index 9d4f7a8..dbb1d1c 100644 --- a/frontend/src/modules/graph/components/GraphDetail.vue +++ b/frontend/src/modules/graph/components/GraphDetail.vue @@ -1,47 +1,376 @@
-

{{ node.label }}

- +
-
-
ID: {{ node.id }}
-
类型: {{ node.type }}
-
状态: {{ node.status }}
-
分组: {{ node.group_id }}
+ +
+
+ ● {{ node.id }} + +
+
+ {{ node.type }} · {{ node.status }} +
+
+ + +
+ Loading details... +
+ + +
+ {{ error }} +
+ + +
+
—— Attributes ——
+
+ {{ key }}: {{ formatValue(value) }} +
+
+ + +
+
—— Related Entities ——
+
-- 2.45.1 From aa8d495a920eb303ec742b0cb6d974a109e2116e Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 24 Mar 2026 08:57:08 +0000 Subject: [PATCH 09/11] 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; +} -- 2.45.1 From ca9f199d53f2f6ffb37fa86606acdddc827975d8 Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 24 Mar 2026 08:58:39 +0000 Subject: [PATCH 10/11] feat(graph): add parent field to GraphNode TypeScript interface Co-Authored-By: Claude Opus 4.6 --- frontend/src/shared/types/api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts index 43fa19d..2d9264f 100644 --- a/frontend/src/shared/types/api.ts +++ b/frontend/src/shared/types/api.ts @@ -34,6 +34,7 @@ export interface GraphNode { label: string status: string group_id: string + parent: string | null } export interface GraphEdge { -- 2.45.1 From f7f54a56cc6bd01289e7ce6589dbc2aecb510e2e Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 24 Mar 2026 09:03:48 +0000 Subject: [PATCH 11/11] feat(graph): add back-to-panorama button, legend component, and wire detail panel props (GAP-F3, GAP-F4) Co-Authored-By: Claude Opus 4.6 --- .../modules/graph/components/GraphLegend.vue | 91 +++++++++++++++++++ .../graph/components/GraphPanorama.vue | 39 +++++++- 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 frontend/src/modules/graph/components/GraphLegend.vue diff --git a/frontend/src/modules/graph/components/GraphLegend.vue b/frontend/src/modules/graph/components/GraphLegend.vue new file mode 100644 index 0000000..7085a89 --- /dev/null +++ b/frontend/src/modules/graph/components/GraphLegend.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/frontend/src/modules/graph/components/GraphPanorama.vue b/frontend/src/modules/graph/components/GraphPanorama.vue index dad8c4b..7fa58b7 100644 --- a/frontend/src/modules/graph/components/GraphPanorama.vue +++ b/frontend/src/modules/graph/components/GraphPanorama.vue @@ -9,7 +9,7 @@
Missing {{ scanResult.summary.missing }}
-
+
@@ -17,7 +17,20 @@ - + + +
+ + 当前: {{ drillDownNodeLabel }} +
+ +
@@ -28,6 +41,7 @@ import { storeToRefs } from 'pinia' import * as d3 from 'd3' import { useGraphStore } from '../composables/useGraph' import GraphDetail from './GraphDetail.vue' +import GraphLegend from './GraphLegend.vue' import type { GraphNode, GraphEdge } from '@/shared/types/api' const route = useRoute() @@ -79,6 +93,13 @@ function toggleDocumentView() { drawGraph() } +async function returnToPanorama() { + const projectId = route.params.id as string + isDrillDown.value = false + drillDownNodeLabel.value = '' + await store.loadGraph(projectId) +} + function drawGraph() { if (!svgRef.value || !graphView.value) return @@ -247,6 +268,8 @@ function drawGraph() { // Double-click -> drill down node.on('dblclick', (_event: any, d: any) => { const projectId = route.params.id as string + isDrillDown.value = true + drillDownNodeLabel.value = d.label store.loadNeighbors(projectId, d.id) }) @@ -321,4 +344,16 @@ watch(graphView, () => { drawGraph() }) color: white; border-color: #42A5F5; } +.drill-down-bar { + position: absolute; top: 12px; left: 12px; + display: flex; align-items: center; gap: 12px; + background: white; padding: 8px 16px; border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10; +} +.back-btn { + background: #1976D2; color: white; border: none; + padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; +} +.back-btn:hover { background: #1565C0; } +.drill-down-label { font-size: 13px; color: #666; } -- 2.45.1