Compare commits
No commits in common. "f7f54a56cc6bd01289e7ce6589dbc2aecb510e2e" and "1a92c91591c73442abb898f65c2b1f98d8c0eb8d" have entirely different histories.
f7f54a56cc
...
1a92c91591
|
|
@ -2,34 +2,10 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
|
|
||||||
from app.modules.graph.domain.entities import GraphEdge, GraphGroup, GraphNode, GraphView
|
from app.modules.graph.domain.entities import GraphEdge, GraphGroup, GraphNode, GraphView
|
||||||
from app.modules.scanner.domain.entities import ScanResult
|
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
|
# Fixed set of groups
|
||||||
_GROUPS = [
|
_GROUPS = [
|
||||||
GraphGroup(id="business", label="Business", layer="business"),
|
GraphGroup(id="business", label="Business", layer="business"),
|
||||||
|
|
@ -40,59 +16,18 @@ _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:
|
class GraphService:
|
||||||
"""Constructs a panorama graph and supports neighbor queries."""
|
"""Constructs a panorama graph and supports neighbor queries."""
|
||||||
|
|
||||||
def build_panorama(self, scan_result: ScanResult, *, design_dir: str = "") -> GraphView:
|
def build_panorama(self, scan_result: ScanResult) -> GraphView:
|
||||||
"""Build a full panorama GraphView from a ScanResult (9-step algorithm)."""
|
"""Build a full panorama GraphView from a ScanResult (9-step algorithm)."""
|
||||||
nodes: list[GraphNode] = []
|
nodes: list[GraphNode] = []
|
||||||
edges: list[GraphEdge] = []
|
edges: list[GraphEdge] = []
|
||||||
node_ids: set[str] = set()
|
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
|
# Step 1: groups are always the fixed 5
|
||||||
groups = list(_GROUPS)
|
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")
|
# Step 2: Capability → node(type="capability", group="business")
|
||||||
for cap in scan_result.capabilities:
|
for cap in scan_result.capabilities:
|
||||||
node_id = cap.capability_id
|
node_id = cap.capability_id
|
||||||
|
|
@ -100,9 +35,8 @@ class GraphService:
|
||||||
id=node_id,
|
id=node_id,
|
||||||
type="capability",
|
type="capability",
|
||||||
label=cap.name,
|
label=cap.name,
|
||||||
status=file_status_map.get(_SOURCE_FILES["capability"], "unknown"),
|
status="unknown",
|
||||||
group_id="business",
|
group_id="business",
|
||||||
parent=_parent_for("capability"),
|
|
||||||
))
|
))
|
||||||
node_ids.add(node_id)
|
node_ids.add(node_id)
|
||||||
|
|
||||||
|
|
@ -113,9 +47,8 @@ class GraphService:
|
||||||
id=node_id,
|
id=node_id,
|
||||||
type="module",
|
type="module",
|
||||||
label=mod.name,
|
label=mod.name,
|
||||||
status=file_status_map.get(_SOURCE_FILES["module"], "unknown"),
|
status="unknown",
|
||||||
group_id="application",
|
group_id="application",
|
||||||
parent=_parent_for("module"),
|
|
||||||
))
|
))
|
||||||
node_ids.add(node_id)
|
node_ids.add(node_id)
|
||||||
|
|
||||||
|
|
@ -126,9 +59,8 @@ class GraphService:
|
||||||
id=node_id,
|
id=node_id,
|
||||||
type="entity",
|
type="entity",
|
||||||
label=ent.name,
|
label=ent.name,
|
||||||
status=file_status_map.get(_SOURCE_FILES["entity"], "unknown"),
|
status="unknown",
|
||||||
group_id="data",
|
group_id="data",
|
||||||
parent=_parent_for("entity"),
|
|
||||||
))
|
))
|
||||||
node_ids.add(node_id)
|
node_ids.add(node_id)
|
||||||
|
|
||||||
|
|
@ -139,9 +71,8 @@ class GraphService:
|
||||||
id=node_id,
|
id=node_id,
|
||||||
type="runtime_component",
|
type="runtime_component",
|
||||||
label=rc.name,
|
label=rc.name,
|
||||||
status=file_status_map.get(_SOURCE_FILES["runtime_component"], "unknown"),
|
status="unknown",
|
||||||
group_id="technology",
|
group_id="technology",
|
||||||
parent=_parent_for("runtime_component"),
|
|
||||||
))
|
))
|
||||||
node_ids.add(node_id)
|
node_ids.add(node_id)
|
||||||
|
|
||||||
|
|
@ -182,27 +113,20 @@ class GraphService:
|
||||||
relation="depends_on",
|
relation="depends_on",
|
||||||
))
|
))
|
||||||
|
|
||||||
# Step 9: DesignDocument.downstream → doc-to-doc edges (deduplicated)
|
# Step 9: DesignDocument.upstream/downstream → edges (if both are nodes)
|
||||||
path_to_doc: dict[str, str] = {}
|
|
||||||
doc_rel_paths: dict[str, str] = {}
|
|
||||||
for doc in scan_result.design_documents:
|
for doc in scan_result.design_documents:
|
||||||
doc_rel = _to_rel_path(doc.file_path, design_dir)
|
for upstream_id in doc.upstream:
|
||||||
path_to_doc[doc_rel] = doc.doc_id
|
if doc.doc_id in node_ids and upstream_id in node_ids:
|
||||||
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(
|
edges.append(GraphEdge(
|
||||||
source=doc.doc_id,
|
source=doc.doc_id,
|
||||||
target=down_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",
|
relation="documents",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ class GraphNode:
|
||||||
label: str
|
label: str
|
||||||
status: str # FileStatus or "unknown"
|
status: str # FileStatus or "unknown"
|
||||||
group_id: str
|
group_id: str
|
||||||
parent: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -40,17 +40,17 @@ def _get_or_trigger_scan(project_id: str):
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def get_graph(project_id: str):
|
def get_graph(project_id: str):
|
||||||
"""Build and return the full panorama graph for a project."""
|
"""Build and return the full panorama graph for a project."""
|
||||||
project = _project_service.get_project(project_id) # Ensure project exists (raises 404)
|
_project_service.get_project(project_id) # Ensure project exists (raises 404)
|
||||||
scan_result = _get_or_trigger_scan(project_id)
|
scan_result = _get_or_trigger_scan(project_id)
|
||||||
view = _graph_service.build_panorama(scan_result, design_dir=project.design_dir)
|
view = _graph_service.build_panorama(scan_result)
|
||||||
return asdict(view)
|
return asdict(view)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/nodes/{node_id}/neighbors")
|
@router.get("/nodes/{node_id}/neighbors")
|
||||||
def get_neighbors(project_id: str, node_id: str):
|
def get_neighbors(project_id: str, node_id: str):
|
||||||
"""Return the subgraph of neighbors for a given node."""
|
"""Return the subgraph of neighbors for a given node."""
|
||||||
project = _project_service.get_project(project_id) # Ensure project exists (raises 404)
|
_project_service.get_project(project_id) # Ensure project exists (raises 404)
|
||||||
scan_result = _get_or_trigger_scan(project_id)
|
scan_result = _get_or_trigger_scan(project_id)
|
||||||
view = _graph_service.build_panorama(scan_result, design_dir=project.design_dir)
|
view = _graph_service.build_panorama(scan_result)
|
||||||
neighbors = _graph_service.get_neighbors(view, node_id)
|
neighbors = _graph_service.get_neighbors(view, node_id)
|
||||||
return asdict(neighbors)
|
return asdict(neighbors)
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,13 @@ def scan_result():
|
||||||
return svc.scan(project)
|
return svc.scan(project)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def design_dir():
|
|
||||||
return "/workspace/arch-design-agent-skill-dashboard/design"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def graph_service():
|
def graph_service():
|
||||||
return GraphService()
|
return GraphService()
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_has_groups(graph_service, scan_result, design_dir):
|
def test_panorama_has_groups(graph_service, scan_result):
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
view = graph_service.build_panorama(scan_result)
|
||||||
group_ids = {g.id for g in view.groups}
|
group_ids = {g.id for g in view.groups}
|
||||||
assert "business" in group_ids
|
assert "business" in group_ids
|
||||||
assert "application" in group_ids
|
assert "application" in group_ids
|
||||||
|
|
@ -37,42 +32,42 @@ def test_panorama_has_groups(graph_service, scan_result, design_dir):
|
||||||
assert "cross-layer" in group_ids
|
assert "cross-layer" in group_ids
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_has_capability_nodes(graph_service, scan_result, design_dir):
|
def test_panorama_has_capability_nodes(graph_service, scan_result):
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
view = graph_service.build_panorama(scan_result)
|
||||||
cap_nodes = [n for n in view.nodes if n.type == "capability"]
|
cap_nodes = [n for n in view.nodes if n.type == "capability"]
|
||||||
assert len(cap_nodes) > 0
|
assert len(cap_nodes) > 0
|
||||||
assert all(n.group_id == "business" for n in cap_nodes)
|
assert all(n.group_id == "business" for n in cap_nodes)
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_has_module_nodes(graph_service, scan_result, design_dir):
|
def test_panorama_has_module_nodes(graph_service, scan_result):
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
view = graph_service.build_panorama(scan_result)
|
||||||
mod_nodes = [n for n in view.nodes if n.type == "module"]
|
mod_nodes = [n for n in view.nodes if n.type == "module"]
|
||||||
assert len(mod_nodes) > 0
|
assert len(mod_nodes) > 0
|
||||||
assert all(n.group_id == "application" for n in mod_nodes)
|
assert all(n.group_id == "application" for n in mod_nodes)
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_has_entity_nodes(graph_service, scan_result, design_dir):
|
def test_panorama_has_entity_nodes(graph_service, scan_result):
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
view = graph_service.build_panorama(scan_result)
|
||||||
ent_nodes = [n for n in view.nodes if n.type == "entity"]
|
ent_nodes = [n for n in view.nodes if n.type == "entity"]
|
||||||
assert len(ent_nodes) > 0
|
assert len(ent_nodes) > 0
|
||||||
assert all(n.group_id == "data" for n in ent_nodes)
|
assert all(n.group_id == "data" for n in ent_nodes)
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_has_edges(graph_service, scan_result, design_dir):
|
def test_panorama_has_edges(graph_service, scan_result):
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
view = graph_service.build_panorama(scan_result)
|
||||||
assert len(view.edges) > 0
|
assert len(view.edges) > 0
|
||||||
relations = {e.relation for e in view.edges}
|
relations = {e.relation for e in view.edges}
|
||||||
assert "traces_to" in relations
|
assert "traces_to" in relations
|
||||||
|
|
||||||
|
|
||||||
def test_panorama_depends_on_edges(graph_service, scan_result, design_dir):
|
def test_panorama_depends_on_edges(graph_service, scan_result):
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
view = graph_service.build_panorama(scan_result)
|
||||||
dep_edges = [e for e in view.edges if e.relation == "depends_on"]
|
dep_edges = [e for e in view.edges if e.relation == "depends_on"]
|
||||||
assert len(dep_edges) > 0
|
assert len(dep_edges) > 0
|
||||||
|
|
||||||
|
|
||||||
def test_neighbors_returns_subgraph(graph_service, scan_result, design_dir):
|
def test_neighbors_returns_subgraph(graph_service, scan_result):
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
view = graph_service.build_panorama(scan_result)
|
||||||
# Use a known capability node
|
# Use a known capability node
|
||||||
neighbors = graph_service.get_neighbors(view, "CAP-PROJ-REG")
|
neighbors = graph_service.get_neighbors(view, "CAP-PROJ-REG")
|
||||||
assert len(neighbors.nodes) > 0
|
assert len(neighbors.nodes) > 0
|
||||||
|
|
@ -80,47 +75,8 @@ def test_neighbors_returns_subgraph(graph_service, scan_result, design_dir):
|
||||||
assert len(neighbors.edges) > 0
|
assert len(neighbors.edges) > 0
|
||||||
|
|
||||||
|
|
||||||
def test_neighbors_unknown_node(graph_service, scan_result, design_dir):
|
def test_neighbors_unknown_node(graph_service, scan_result):
|
||||||
view = graph_service.build_panorama(scan_result, design_dir=design_dir)
|
view = graph_service.build_panorama(scan_result)
|
||||||
neighbors = graph_service.get_neighbors(view, "NONEXISTENT")
|
neighbors = graph_service.get_neighbors(view, "NONEXISTENT")
|
||||||
assert len(neighbors.nodes) == 0
|
assert len(neighbors.nodes) == 0
|
||||||
assert len(neighbors.edges) == 0
|
assert len(neighbors.edges) == 0
|
||||||
|
|
||||||
|
|
||||||
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')
|
|
||||||
|
|
||||||
|
|
||||||
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}'"
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
|
||||||
|
|
@ -1,847 +0,0 @@
|
||||||
# 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 `<script setup>`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const showDocumentView = ref(false)
|
|
||||||
const isDrillDown = ref(false)
|
|
||||||
const drillDownNodeLabel = ref('')
|
|
||||||
|
|
||||||
const GROUP_POSITIONS: Record<string, { x: number; y: number }> = {
|
|
||||||
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<string, string> = {
|
|
||||||
traces_to: '#666',
|
|
||||||
depends_on: '#999',
|
|
||||||
documents: '#42A5F5',
|
|
||||||
integrates_with: '#AB47BC',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also update the existing `EDGE_STYLES` constant to fix `documents` from dashed to solid:
|
|
||||||
```typescript
|
|
||||||
const EDGE_STYLES: Record<string, string> = {
|
|
||||||
traces_to: '0', depends_on: '6,3', integrates_with: '4,2', documents: '0',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Rewrite drawGraph() for default mode**
|
|
||||||
|
|
||||||
Replace the `drawGraph()` function. Key changes:
|
|
||||||
- Filter out `type === "document"` nodes and `relation === "documents"` edges when `showDocumentView.value` is false
|
|
||||||
- Replace `d3.forceCenter` with `d3.forceX`/`d3.forceY` per group with strength 0.4
|
|
||||||
- Correct node shapes: circle r=18 for capability, rect 28x28 for module, diamond 24x24 for entity, circle r=14 for runtime_component
|
|
||||||
- Apply `STATUS_COLORS` for fill, `EDGE_STYLES` + `EDGE_COLORS` for edges
|
|
||||||
|
|
||||||
Core simulation setup:
|
|
||||||
```typescript
|
|
||||||
const simulation = d3.forceSimulation(simNodes)
|
|
||||||
.force('link', d3.forceLink(simEdges).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))
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add compound layout mode (document view)**
|
|
||||||
|
|
||||||
When `showDocumentView.value` is true:
|
|
||||||
- Include document nodes, render as large `<rect>` containers
|
|
||||||
- Size by child count: `width = Math.max(150, childCount * 60)`, `height = Math.max(100, childCount * 40)`
|
|
||||||
- On each tick, clamp entity nodes with `parent` inside parent's bounds (pad=20):
|
|
||||||
```typescript
|
|
||||||
if (n.parent) {
|
|
||||||
const p = nodeMap[n.parent]
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add toggle button and group labels in template**
|
|
||||||
|
|
||||||
Add after scan-summary div:
|
|
||||||
```html
|
|
||||||
<div class="toolbar">
|
|
||||||
<button class="toggle-btn" :class="{ active: showDocumentView }" @click="toggleDocumentView">
|
|
||||||
{{ showDocumentView ? '默认视图' : '文档视图' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Add toggle function:
|
|
||||||
```typescript
|
|
||||||
function toggleDocumentView() {
|
|
||||||
showDocumentView.value = !showDocumentView.value
|
|
||||||
drawGraph()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Render group labels at each group position as static text in the SVG.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run type check**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit`
|
|
||||||
Expected: No errors
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/modules/graph/components/GraphPanorama.vue
|
|
||||||
git commit -m "feat(graph): group-partitioned layout with document view toggle (GAP-F1)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: Frontend — Rich GraphDetail panel + edit button (GAP-F2 + GAP-F5)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/modules/graph/components/GraphDetail.vue`
|
|
||||||
- Modify: `frontend/src/modules/graph/components/GraphPanorama.vue` (update props)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Rewrite GraphDetail.vue**
|
|
||||||
|
|
||||||
Full rewrite to:
|
|
||||||
|
|
||||||
1. Accept additional props: `graphView: GraphView | null`, `projectId: string`
|
|
||||||
2. Fetch detail API on node selection based on `node.type`:
|
|
||||||
- `capability` → `getCapabilityDetail(projectId, node.id)`
|
|
||||||
- `module` → `getModuleDetail(projectId, node.id)`
|
|
||||||
- `entity` → `getEntityDetail(projectId, node.id)`
|
|
||||||
- others → show basic fields only
|
|
||||||
3. Display attributes section (iterate over response fields dynamically)
|
|
||||||
4. Display related entities section (from `graphView.edges`)
|
|
||||||
5. Edit button (navigates to `/projects/${projectId}/editor?file=${sourceFile}`)
|
|
||||||
|
|
||||||
Key imports:
|
|
||||||
```typescript
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { getCapabilityDetail, getModuleDetail, getEntityDetail } from '../api'
|
|
||||||
import type { GraphNode, GraphView } from '@/shared/types/api'
|
|
||||||
```
|
|
||||||
|
|
||||||
Define `SOURCE_FILES` constant:
|
|
||||||
```typescript
|
|
||||||
const SOURCE_FILES: Record<string, string> = {
|
|
||||||
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',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Emit a `selectNode` event when a related entity is clicked. The handler looks up the full `GraphNode` from `graphView.nodes` by ID before emitting:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const emit = defineEmits<{ close: []; selectNode: [node: GraphNode] }>()
|
|
||||||
|
|
||||||
function onRelatedEntityClick(nodeId: string) {
|
|
||||||
const found = props.graphView?.nodes.find(n => n.id === nodeId)
|
|
||||||
if (found) emit('selectNode', found)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit button click handler:
|
|
||||||
```typescript
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
function openEditor() {
|
|
||||||
if (!sourceFile.value) return
|
|
||||||
router.push({
|
|
||||||
path: `/projects/${props.projectId}/editor`,
|
|
||||||
query: { file: sourceFile.value }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update GraphPanorama.vue to pass new props**
|
|
||||||
|
|
||||||
```html
|
|
||||||
<GraphDetail
|
|
||||||
:node="selectedNode"
|
|
||||||
:graphView="graphView"
|
|
||||||
:projectId="route.params.id as string"
|
|
||||||
@close="clearSelection"
|
|
||||||
@selectNode="store.selectNode"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **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/GraphDetail.vue frontend/src/modules/graph/components/GraphPanorama.vue
|
|
||||||
git commit -m "feat(graph): rich detail panel with API fetch and edit button (GAP-F2, GAP-F5)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 8: Frontend — Back to panorama button (GAP-F4)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/modules/graph/components/GraphPanorama.vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Wire up drill-down state**
|
|
||||||
|
|
||||||
The `isDrillDown` and `drillDownNodeLabel` refs were added in Task 6. Now wire them:
|
|
||||||
|
|
||||||
Update double-click handler:
|
|
||||||
```typescript
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `returnToPanorama`:
|
|
||||||
```typescript
|
|
||||||
async function returnToPanorama() {
|
|
||||||
const projectId = route.params.id as string
|
|
||||||
isDrillDown.value = false
|
|
||||||
drillDownNodeLabel.value = ''
|
|
||||||
await store.loadGraph(projectId)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add back button in template**
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div v-if="isDrillDown" class="drill-down-bar">
|
|
||||||
<button @click="returnToPanorama" class="back-btn">← 返回全景图</button>
|
|
||||||
<span class="drill-down-label">当前: {{ drillDownNodeLabel }}</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add styles**
|
|
||||||
|
|
||||||
```css
|
|
||||||
.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; }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run type check**
|
|
||||||
|
|
||||||
Run: `cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit`
|
|
||||||
Expected: No errors
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/modules/graph/components/GraphPanorama.vue
|
|
||||||
git commit -m "feat(graph): add back-to-panorama button for drill-down mode (GAP-F4)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 9: Frontend — Graph Legend component (GAP-F3)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `frontend/src/modules/graph/components/GraphLegend.vue`
|
|
||||||
- Modify: `frontend/src/modules/graph/components/GraphPanorama.vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create GraphLegend.vue**
|
|
||||||
|
|
||||||
Create `frontend/src/modules/graph/components/GraphLegend.vue` with:
|
|
||||||
- SVG shapes matching the graph (circle, rect, diamond, small-circle, container-rect)
|
|
||||||
- Status color swatches with labels
|
|
||||||
- Edge style samples with labels
|
|
||||||
- Collapsible (expanded by default)
|
|
||||||
- Positioned bottom-right, semi-transparent background
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div class="graph-legend" :class="{ collapsed: !expanded }">
|
|
||||||
<div class="legend-header" @click="expanded = !expanded">
|
|
||||||
<span>图例</span>
|
|
||||||
<span class="toggle">{{ expanded ? '▼' : '▶' }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="expanded" class="legend-body">
|
|
||||||
<div class="legend-section">
|
|
||||||
<div class="legend-title">形状</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><circle cx="10" cy="10" r="8" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Capability</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><rect x="2" y="2" width="16" height="16" rx="2" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Module</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><polygon points="10,2 18,10 10,18 2,10" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Entity</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><circle cx="10" cy="10" r="6" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Runtime</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><rect x="1" y="4" width="18" height="12" rx="2" fill="none" stroke="#9E9E9E" stroke-width="2"/></svg>
|
|
||||||
<span>Document</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="legend-section">
|
|
||||||
<div class="legend-title">状态</div>
|
|
||||||
<div class="legend-item" v-for="s in statuses" :key="s.label">
|
|
||||||
<svg width="20" height="20"><circle cx="10" cy="10" r="6" :fill="s.color"/></svg>
|
|
||||||
<span>{{ s.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="legend-section">
|
|
||||||
<div class="legend-title">边线</div>
|
|
||||||
<div class="legend-item" v-for="e in edgeTypes" :key="e.label">
|
|
||||||
<svg width="40" height="12">
|
|
||||||
<line x1="0" y1="6" x2="40" y2="6" :stroke="e.color" stroke-width="2" :stroke-dasharray="e.dash" />
|
|
||||||
</svg>
|
|
||||||
<span>{{ e.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const expanded = ref(true)
|
|
||||||
|
|
||||||
const statuses = [
|
|
||||||
{ label: 'OK', color: '#4CAF50' },
|
|
||||||
{ label: 'Sparse', color: '#FFC107' },
|
|
||||||
{ label: 'Missing', color: '#F44336' },
|
|
||||||
{ label: 'Template Residue', color: '#FF9800' },
|
|
||||||
{ label: 'Placeholder Heavy', color: '#9C27B0' },
|
|
||||||
{ label: 'Unknown', color: '#9E9E9E' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const edgeTypes = [
|
|
||||||
{ label: 'traces_to', color: '#666', dash: '0' },
|
|
||||||
{ label: 'depends_on', color: '#999', dash: '6,3' },
|
|
||||||
{ label: 'documents', color: '#42A5F5', dash: '0' },
|
|
||||||
{ label: 'integrates_with', color: '#AB47BC', dash: '4,2' },
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.graph-legend {
|
|
||||||
position: absolute; bottom: 16px; right: 16px;
|
|
||||||
background: rgba(255,255,255,0.92); border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
|
||||||
padding: 8px 12px; z-index: 10; min-width: 180px;
|
|
||||||
font-size: 12px; pointer-events: auto;
|
|
||||||
}
|
|
||||||
.legend-header {
|
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
|
||||||
cursor: pointer; font-weight: 600; font-size: 13px; padding-bottom: 4px;
|
|
||||||
}
|
|
||||||
.toggle { font-size: 10px; color: #999; }
|
|
||||||
.legend-body { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
|
|
||||||
.legend-section { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.legend-title { font-weight: 600; color: #666; margin-bottom: 2px; }
|
|
||||||
.legend-item { display: flex; align-items: center; gap: 6px; }
|
|
||||||
.legend-item span { color: #333; }
|
|
||||||
</style>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Import GraphLegend in GraphPanorama.vue**
|
|
||||||
|
|
||||||
Add import:
|
|
||||||
```typescript
|
|
||||||
import GraphLegend from './GraphLegend.vue'
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to template (inside `.graph-panorama` div, after `<svg>`):
|
|
||||||
```html
|
|
||||||
<GraphLegend />
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **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
|
|
||||||
|
|
@ -1,471 +0,0 @@
|
||||||
# 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:**
|
|
||||||
|
|
||||||
**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}
|
|
||||||
```
|
|
||||||
|
|
||||||
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():**
|
|
||||||
|
|
||||||
**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:
|
|
||||||
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:
|
|
||||||
```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 (with deduplication):
|
|
||||||
```python
|
|
||||||
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 `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] || 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:
|
|
||||||
```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 | — |
|
|
||||||
|
|
||||||
**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.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 |
|
|
||||||
| `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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
@ -1,376 +1,47 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="graph-detail" v-if="node">
|
<div class="graph-detail" v-if="node">
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<button class="close-btn" @click="emit('close')">✕ Close</button>
|
<h3>{{ node.label }}</h3>
|
||||||
</div>
|
<button @click="$emit('close')">✕</button>
|
||||||
|
|
||||||
<div class="detail-identity">
|
|
||||||
<div class="identity-row">
|
|
||||||
<span class="node-id">● {{ node.id }}</span>
|
|
||||||
<button v-if="sourceFile" class="edit-btn" @click="openEditor">[Edit]</button>
|
|
||||||
</div>
|
|
||||||
<div class="node-meta">
|
|
||||||
{{ node.type }} · <span :style="{ color: statusColor }">{{ node.status }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading spinner -->
|
|
||||||
<div v-if="loading" class="loading-section">
|
|
||||||
<span class="spinner"></span> Loading details...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error message -->
|
|
||||||
<div v-if="error" class="error-section">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Attributes section -->
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-title">—— Attributes ——</div>
|
|
||||||
<div v-for="(value, key) in displayAttributes" :key="key" class="field">
|
|
||||||
<span class="label">{{ key }}:</span> {{ formatValue(value) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Related Entities section -->
|
|
||||||
<div v-if="relatedEntities.length > 0" class="section">
|
|
||||||
<div class="section-title">—— Related Entities ——</div>
|
|
||||||
<div
|
|
||||||
v-for="rel in relatedEntities"
|
|
||||||
:key="rel.nodeId + rel.relation + rel.direction"
|
|
||||||
class="related-entity"
|
|
||||||
@click="onRelatedEntityClick(rel.nodeId)"
|
|
||||||
>
|
|
||||||
<span class="direction">{{ rel.direction === 'outgoing' ? '→' : '←' }}</span>
|
|
||||||
{{ rel.label }}
|
|
||||||
<span class="relation-tag">({{ rel.relation }})</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="detail-body">
|
||||||
|
<div class="field"><span class="label">ID:</span> {{ node.id }}</div>
|
||||||
|
<div class="field"><span class="label">类型:</span> {{ node.type }}</div>
|
||||||
|
<div class="field"><span class="label">状态:</span> <span :style="{ color: statusColor }">{{ node.status }}</span></div>
|
||||||
|
<div class="field"><span class="label">分组:</span> {{ node.group_id }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import type { GraphNode } from '@/shared/types/api'
|
||||||
import { getCapabilityDetail, getModuleDetail, getEntityDetail } from '../api'
|
|
||||||
import type { GraphNode, GraphView } from '@/shared/types/api'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{ node: GraphNode | null }>()
|
||||||
node: GraphNode | null
|
defineEmits<{ close: [] }>()
|
||||||
graphView: GraphView | null
|
|
||||||
projectId: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{ close: []; selectNode: [node: GraphNode] }>()
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const SOURCE_FILES: Record<string, string> = {
|
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
ok: '#4CAF50',
|
ok: '#4CAF50', sparse: '#FFC107', missing: '#F44336',
|
||||||
sparse: '#FFC107',
|
'template-residue': '#FF9800', 'placeholder-heavy': '#9C27B0', unknown: '#9E9E9E',
|
||||||
missing: '#F44336',
|
|
||||||
'template-residue': '#FF9800',
|
|
||||||
'placeholder-heavy': '#9C27B0',
|
|
||||||
unknown: '#9E9E9E',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = ref(false)
|
const statusColor = computed(() => STATUS_COLORS[props.node?.status || 'unknown'] || '#9E9E9E')
|
||||||
const error = ref<string | null>(null)
|
|
||||||
const detailData = ref<Record<string, unknown> | null>(null)
|
|
||||||
|
|
||||||
const statusColor = computed(() =>
|
|
||||||
STATUS_COLORS[props.node?.status || 'unknown'] || '#9E9E9E',
|
|
||||||
)
|
|
||||||
|
|
||||||
const sourceFile = computed(() => {
|
|
||||||
if (!props.node) return null
|
|
||||||
return SOURCE_FILES[props.node.type] || null
|
|
||||||
})
|
|
||||||
|
|
||||||
const displayAttributes = computed<Record<string, unknown>>(() => {
|
|
||||||
if (detailData.value) {
|
|
||||||
return detailData.value
|
|
||||||
}
|
|
||||||
// Fallback: basic fields from node
|
|
||||||
if (!props.node) return {}
|
|
||||||
return {
|
|
||||||
id: props.node.id,
|
|
||||||
type: props.node.type,
|
|
||||||
status: props.node.status,
|
|
||||||
group_id: props.node.group_id,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
interface RelatedEntity {
|
|
||||||
nodeId: string
|
|
||||||
label: string
|
|
||||||
relation: string
|
|
||||||
direction: 'outgoing' | 'incoming'
|
|
||||||
}
|
|
||||||
|
|
||||||
const relatedEntities = computed<RelatedEntity[]>(() => {
|
|
||||||
if (!props.node || !props.graphView) return []
|
|
||||||
|
|
||||||
const nodeId = props.node.id
|
|
||||||
const result: RelatedEntity[] = []
|
|
||||||
|
|
||||||
for (const edge of props.graphView.edges) {
|
|
||||||
if (edge.source === nodeId) {
|
|
||||||
const targetNode = props.graphView.nodes.find(n => n.id === edge.target)
|
|
||||||
result.push({
|
|
||||||
nodeId: edge.target,
|
|
||||||
label: targetNode ? `${targetNode.id} ${targetNode.label}` : edge.target,
|
|
||||||
relation: edge.relation,
|
|
||||||
direction: 'outgoing',
|
|
||||||
})
|
|
||||||
} else if (edge.target === nodeId) {
|
|
||||||
const sourceNode = props.graphView.nodes.find(n => n.id === edge.source)
|
|
||||||
result.push({
|
|
||||||
nodeId: edge.source,
|
|
||||||
label: sourceNode ? `${sourceNode.id} ${sourceNode.label}` : edge.source,
|
|
||||||
relation: edge.relation,
|
|
||||||
direction: 'incoming',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatValue(value: unknown): string {
|
|
||||||
if (value === null || value === undefined) return '-'
|
|
||||||
if (Array.isArray(value)) return value.join(', ')
|
|
||||||
if (typeof value === 'object') return JSON.stringify(value)
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractAttributes(data: Record<string, unknown>): Record<string, unknown> {
|
|
||||||
// The detail API returns a wrapper like { capability: {...}, modules: [...] }
|
|
||||||
// We want to extract the primary entity's fields as attributes
|
|
||||||
const attrs: Record<string, unknown> = {}
|
|
||||||
for (const [key, val] of Object.entries(data)) {
|
|
||||||
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
|
|
||||||
// This is the primary entity object — flatten its fields
|
|
||||||
for (const [innerKey, innerVal] of Object.entries(val as Record<string, unknown>)) {
|
|
||||||
attrs[innerKey] = innerVal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Skip arrays (they are related collections, shown in related entities)
|
|
||||||
}
|
|
||||||
// If no nested objects found, just use all fields
|
|
||||||
if (Object.keys(attrs).length === 0) {
|
|
||||||
Object.assign(attrs, data)
|
|
||||||
}
|
|
||||||
return attrs
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchDetail() {
|
|
||||||
if (!props.node || !props.projectId) return
|
|
||||||
|
|
||||||
const nodeType = props.node.type
|
|
||||||
const nodeId = props.node.id
|
|
||||||
|
|
||||||
// Only fetch detail for types that have detail APIs
|
|
||||||
if (!['capability', 'module', 'entity'].includes(nodeType)) {
|
|
||||||
detailData.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
detailData.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
let data: Record<string, unknown>
|
|
||||||
switch (nodeType) {
|
|
||||||
case 'capability':
|
|
||||||
data = await getCapabilityDetail(props.projectId, nodeId) as unknown as Record<string, unknown>
|
|
||||||
break
|
|
||||||
case 'module':
|
|
||||||
data = await getModuleDetail(props.projectId, nodeId) as unknown as Record<string, unknown>
|
|
||||||
break
|
|
||||||
case 'entity':
|
|
||||||
data = await getEntityDetail(props.projectId, nodeId) as unknown as Record<string, unknown>
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
detailData.value = extractAttributes(data)
|
|
||||||
} catch (err) {
|
|
||||||
error.value = 'Failed to load detail. Showing basic fields.'
|
|
||||||
detailData.value = null
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.node,
|
|
||||||
() => {
|
|
||||||
fetchDetail()
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
function onRelatedEntityClick(nodeId: string) {
|
|
||||||
const found = props.graphView?.nodes.find(n => n.id === nodeId)
|
|
||||||
if (found) emit('selectNode', found)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditor() {
|
|
||||||
if (!sourceFile.value) return
|
|
||||||
router.push({
|
|
||||||
path: `/projects/${props.projectId}/editor`,
|
|
||||||
query: { file: sourceFile.value },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.graph-detail {
|
.graph-detail {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 0;
|
right: 0; top: 0; bottom: 0;
|
||||||
top: 0;
|
width: 320px;
|
||||||
bottom: 0;
|
|
||||||
width: 360px;
|
|
||||||
background: white;
|
background: white;
|
||||||
border-left: 1px solid #e0e0e0;
|
border-left: 1px solid #e0e0e0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
box-shadow: -4px 0 8px rgba(0, 0, 0, 0.1);
|
box-shadow: -4px 0 8px rgba(0,0,0,0.1);
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-header {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:hover {
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-identity {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.identity-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-id {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-btn {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid #1976d2;
|
|
||||||
color: #1976d2;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-btn:hover {
|
|
||||||
background: #e3f2fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-meta {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-section {
|
|
||||||
padding: 12px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 2px solid #ccc;
|
|
||||||
border-top-color: #1976d2;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-section {
|
|
||||||
padding: 8px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #e65100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #999;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field .label {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.related-entity {
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.related-entity:hover {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.direction {
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relation-tag {
|
|
||||||
color: #999;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
}
|
||||||
|
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
|
.detail-header h3 { font-size: 16px; }
|
||||||
|
.detail-header button { background: none; font-size: 18px; color: #666; }
|
||||||
|
.field { margin-bottom: 8px; font-size: 14px; }
|
||||||
|
.label { font-weight: 600; color: #666; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="graph-legend" :class="{ collapsed: !expanded }">
|
|
||||||
<div class="legend-header" @click="expanded = !expanded">
|
|
||||||
<span>图例</span>
|
|
||||||
<span class="toggle">{{ expanded ? '▼' : '▶' }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="expanded" class="legend-body">
|
|
||||||
<div class="legend-section">
|
|
||||||
<div class="legend-title">形状</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><circle cx="10" cy="10" r="8" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Capability</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><rect x="2" y="2" width="16" height="16" rx="2" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Module</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><polygon points="10,2 18,10 10,18 2,10" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Entity</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><circle cx="10" cy="10" r="6" fill="#9E9E9E"/></svg>
|
|
||||||
<span>Runtime</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<svg width="20" height="20"><rect x="1" y="4" width="18" height="12" rx="2" fill="none" stroke="#9E9E9E" stroke-width="2"/></svg>
|
|
||||||
<span>Document</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="legend-section">
|
|
||||||
<div class="legend-title">状态</div>
|
|
||||||
<div class="legend-item" v-for="s in statuses" :key="s.label">
|
|
||||||
<svg width="20" height="20"><circle cx="10" cy="10" r="6" :fill="s.color"/></svg>
|
|
||||||
<span>{{ s.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="legend-section">
|
|
||||||
<div class="legend-title">边线</div>
|
|
||||||
<div class="legend-item" v-for="e in edgeTypes" :key="e.label">
|
|
||||||
<svg width="40" height="12">
|
|
||||||
<line x1="0" y1="6" x2="40" y2="6" :stroke="e.color" stroke-width="2" :stroke-dasharray="e.dash" />
|
|
||||||
</svg>
|
|
||||||
<span>{{ e.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const expanded = ref(true)
|
|
||||||
|
|
||||||
const statuses = [
|
|
||||||
{ label: 'OK', color: '#4CAF50' },
|
|
||||||
{ label: 'Sparse', color: '#FFC107' },
|
|
||||||
{ label: 'Missing', color: '#F44336' },
|
|
||||||
{ label: 'Template Residue', color: '#FF9800' },
|
|
||||||
{ label: 'Placeholder Heavy', color: '#9C27B0' },
|
|
||||||
{ label: 'Unknown', color: '#9E9E9E' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const edgeTypes = [
|
|
||||||
{ label: 'traces_to', color: '#666', dash: '0' },
|
|
||||||
{ label: 'depends_on', color: '#999', dash: '6,3' },
|
|
||||||
{ label: 'documents', color: '#42A5F5', dash: '0' },
|
|
||||||
{ label: 'integrates_with', color: '#AB47BC', dash: '4,2' },
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.graph-legend {
|
|
||||||
position: absolute; bottom: 16px; right: 16px;
|
|
||||||
background: rgba(255,255,255,0.92); border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
|
||||||
padding: 8px 12px; z-index: 10; min-width: 180px;
|
|
||||||
font-size: 12px; pointer-events: auto;
|
|
||||||
}
|
|
||||||
.legend-header {
|
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
|
||||||
cursor: pointer; font-weight: 600; font-size: 13px; padding-bottom: 4px;
|
|
||||||
}
|
|
||||||
.toggle { font-size: 10px; color: #999; }
|
|
||||||
.legend-body { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
|
|
||||||
.legend-section { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.legend-title { font-weight: 600; color: #666; margin-bottom: 2px; }
|
|
||||||
.legend-item { display: flex; align-items: center; gap: 6px; }
|
|
||||||
.legend-item span { color: #333; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -9,28 +9,9 @@
|
||||||
<div class="summary-item" style="color: var(--color-missing)">Missing <strong>{{ scanResult.summary.missing }}</strong></div>
|
<div class="summary-item" style="color: var(--color-missing)">Missing <strong>{{ scanResult.summary.missing }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isDrillDown" class="toolbar">
|
|
||||||
<button class="toggle-btn" :class="{ active: showDocumentView }" @click="toggleDocumentView">
|
|
||||||
{{ showDocumentView ? '默认视图' : '文档视图' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<svg ref="svgRef" class="graph-svg"></svg>
|
<svg ref="svgRef" class="graph-svg"></svg>
|
||||||
|
|
||||||
<GraphLegend />
|
<GraphDetail :node="selectedNode" @close="clearSelection" />
|
||||||
|
|
||||||
<div v-if="isDrillDown" class="drill-down-bar">
|
|
||||||
<button @click="returnToPanorama" class="back-btn">← 返回全景图</button>
|
|
||||||
<span class="drill-down-label">当前: {{ drillDownNodeLabel }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GraphDetail
|
|
||||||
:node="selectedNode"
|
|
||||||
:graphView="graphView"
|
|
||||||
:projectId="(route.params.id as string)"
|
|
||||||
@close="clearSelection"
|
|
||||||
@selectNode="store.selectNode"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -41,7 +22,6 @@ import { storeToRefs } from 'pinia'
|
||||||
import * as d3 from 'd3'
|
import * as d3 from 'd3'
|
||||||
import { useGraphStore } from '../composables/useGraph'
|
import { useGraphStore } from '../composables/useGraph'
|
||||||
import GraphDetail from './GraphDetail.vue'
|
import GraphDetail from './GraphDetail.vue'
|
||||||
import GraphLegend from './GraphLegend.vue'
|
|
||||||
import type { GraphNode, GraphEdge } from '@/shared/types/api'
|
import type { GraphNode, GraphEdge } from '@/shared/types/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -51,55 +31,19 @@ const { clearSelection } = store
|
||||||
|
|
||||||
const svgRef = ref<SVGSVGElement | null>(null)
|
const svgRef = ref<SVGSVGElement | null>(null)
|
||||||
|
|
||||||
const showDocumentView = ref(false)
|
|
||||||
const isDrillDown = ref(false)
|
|
||||||
const drillDownNodeLabel = ref('')
|
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
ok: '#4CAF50', sparse: '#FFC107', missing: '#F44336',
|
ok: '#4CAF50', sparse: '#FFC107', missing: '#F44336',
|
||||||
'template-residue': '#FF9800', 'placeholder-heavy': '#9C27B0', unknown: '#9E9E9E',
|
'template-residue': '#FF9800', 'placeholder-heavy': '#9C27B0', unknown: '#9E9E9E',
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_POSITIONS: Record<string, { x: number; y: number }> = {
|
|
||||||
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<string, string> = {
|
|
||||||
traces_to: '#666',
|
|
||||||
depends_on: '#999',
|
|
||||||
documents: '#42A5F5',
|
|
||||||
integrates_with: '#AB47BC',
|
|
||||||
}
|
|
||||||
|
|
||||||
const EDGE_STYLES: Record<string, string> = {
|
const EDGE_STYLES: Record<string, string> = {
|
||||||
traces_to: '0', depends_on: '6,3', integrates_with: '4,2', documents: '0',
|
traces_to: '0', depends_on: '6,3', owns: '0', integrates_with: '4,2', documents: '2,2',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNodeColor(status: string): string {
|
function getNodeColor(status: string): string {
|
||||||
return STATUS_COLORS[status] || '#9E9E9E'
|
return STATUS_COLORS[status] || '#9E9E9E'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SimNode extends GraphNode, d3.SimulationNodeDatum {
|
|
||||||
w?: number
|
|
||||||
h?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDocumentView() {
|
|
||||||
showDocumentView.value = !showDocumentView.value
|
|
||||||
drawGraph()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function returnToPanorama() {
|
|
||||||
const projectId = route.params.id as string
|
|
||||||
isDrillDown.value = false
|
|
||||||
drillDownNodeLabel.value = ''
|
|
||||||
await store.loadGraph(projectId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawGraph() {
|
function drawGraph() {
|
||||||
if (!svgRef.value || !graphView.value) return
|
if (!svgRef.value || !graphView.value) return
|
||||||
|
|
||||||
|
|
@ -119,83 +63,34 @@ function drawGraph() {
|
||||||
.on('zoom', (event) => { g.attr('transform', event.transform) })
|
.on('zoom', (event) => { g.attr('transform', event.transform) })
|
||||||
svg.call(zoom)
|
svg.call(zoom)
|
||||||
|
|
||||||
// Filter nodes and edges based on view mode
|
const nodes = graphView.value.nodes.map(n => ({ ...n } as GraphNode & d3.SimulationNodeDatum))
|
||||||
let filteredNodes: GraphNode[]
|
const edges = graphView.value.edges.map(e => ({
|
||||||
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<string, SimNode> = {}
|
|
||||||
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,
|
...e,
|
||||||
source: e.source,
|
source: e.source,
|
||||||
target: e.target,
|
target: e.target,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Force simulation with group-partitioned layout
|
// Force simulation
|
||||||
const simulation = d3.forceSimulation(simNodes as any)
|
const simulation = d3.forceSimulation(nodes as any)
|
||||||
.force('link', d3.forceLink(simEdges as any).id((d: any) => d.id).distance(60))
|
.force('link', d3.forceLink(edges as any).id((d: any) => d.id).distance(100))
|
||||||
.force('charge', d3.forceManyBody().strength(-150))
|
.force('charge', d3.forceManyBody().strength(-200))
|
||||||
.force('x', d3.forceX((d: any) =>
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||||
(GROUP_POSITIONS[d.group_id] || GROUP_POSITIONS['cross-layer']).x * width
|
.force('collision', d3.forceCollide().radius(30))
|
||||||
).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
|
// Draw edges
|
||||||
const link = g.append('g')
|
const link = g.append('g')
|
||||||
.selectAll('line')
|
.selectAll('line')
|
||||||
.data(simEdges)
|
.data(edges)
|
||||||
.join('line')
|
.join('line')
|
||||||
.attr('stroke', (d: any) => EDGE_COLORS[d.relation] || '#999')
|
.attr('stroke', '#999')
|
||||||
.attr('stroke-opacity', 0.6)
|
.attr('stroke-opacity', 0.6)
|
||||||
.attr('stroke-width', 1.5)
|
.attr('stroke-width', (d: any) => d.relation === 'owns' ? 3 : 1.5)
|
||||||
.attr('stroke-dasharray', (d: any) => EDGE_STYLES[d.relation] || '0')
|
.attr('stroke-dasharray', (d: any) => EDGE_STYLES[d.relation] || '0')
|
||||||
|
|
||||||
// Draw nodes
|
// Draw nodes
|
||||||
const node = g.append('g')
|
const node = g.append('g')
|
||||||
.selectAll('g')
|
.selectAll('g')
|
||||||
.data(simNodes)
|
.data(nodes)
|
||||||
.join('g')
|
.join('g')
|
||||||
.attr('cursor', 'pointer')
|
.attr('cursor', 'pointer')
|
||||||
.call(d3.drag<any, any>()
|
.call(d3.drag<any, any>()
|
||||||
|
|
@ -215,48 +110,17 @@ function drawGraph() {
|
||||||
const el = d3.select(this)
|
const el = d3.select(this)
|
||||||
const color = getNodeColor(d.status)
|
const color = getNodeColor(d.status)
|
||||||
|
|
||||||
if (d.type === 'document') {
|
if (d.type === 'module') {
|
||||||
// Document container node (only in document view)
|
el.append('rect').attr('width', 20).attr('height', 20).attr('x', -10).attr('y', -10)
|
||||||
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)
|
.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') {
|
} else if (d.type === 'entity') {
|
||||||
el.append('polygon').attr('points', '0,-24 24,0 0,24 -24,0').attr('fill', color)
|
el.append('polygon').attr('points', '0,-12 12,0 0,12 -12,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 {
|
} else {
|
||||||
// fallback
|
el.append('circle').attr('r', 10).attr('fill', color)
|
||||||
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
|
// Tooltip on hover
|
||||||
|
|
@ -268,26 +132,10 @@ function drawGraph() {
|
||||||
// Double-click -> drill down
|
// Double-click -> drill down
|
||||||
node.on('dblclick', (_event: any, d: any) => {
|
node.on('dblclick', (_event: any, d: any) => {
|
||||||
const projectId = route.params.id as string
|
const projectId = route.params.id as string
|
||||||
isDrillDown.value = true
|
|
||||||
drillDownNodeLabel.value = d.label
|
|
||||||
store.loadNeighbors(projectId, d.id)
|
store.loadNeighbors(projectId, d.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
simulation.on('tick', () => {
|
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
|
link
|
||||||
.attr('x1', (d: any) => d.source.x)
|
.attr('x1', (d: any) => d.source.x)
|
||||||
.attr('y1', (d: any) => d.source.y)
|
.attr('y1', (d: any) => d.source.y)
|
||||||
|
|
@ -322,38 +170,4 @@ watch(graphView, () => { drawGraph() })
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10;
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10;
|
||||||
}
|
}
|
||||||
.summary-item { font-size: 13px; }
|
.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;
|
|
||||||
}
|
|
||||||
.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; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ export interface GraphNode {
|
||||||
label: string
|
label: string
|
||||||
status: string
|
status: string
|
||||||
group_id: string
|
group_id: string
|
||||||
parent: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphEdge {
|
export interface GraphEdge {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user