feat(dashboard): add DomainGraphView with domain overview and detail views

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-04-02 12:34:06 +08:00
Unverified
parent c6433c0352
commit 0674ba3f39
2 changed files with 246 additions and 1 deletions
@@ -3,6 +3,7 @@ import { validateGraph } from "@understand-anything/core/schema";
import type { GraphIssue } from "@understand-anything/core/schema";
import { useDashboardStore } from "./store";
import GraphView from "./components/GraphView";
import DomainGraphView from "./components/DomainGraphView";
import CodeViewer from "./components/CodeViewer";
import SearchBar from "./components/SearchBar";
import NodeInfo from "./components/NodeInfo";
@@ -448,7 +449,11 @@ function Dashboard({ accessToken }: { accessToken: string }) {
<div className="flex-1 flex min-h-0 relative">
{/* Graph area */}
<div className="flex-1 min-w-0 min-h-0 relative">
<GraphView />
{viewMode === "domain" && domainGraph ? (
<DomainGraphView />
) : (
<GraphView />
)}
<div className="absolute top-3 right-3 text-sm text-text-muted/60 pointer-events-none select-none">
Press <kbd className="kbd">?</kbd> for keyboard shortcuts
</div>
@@ -0,0 +1,240 @@
import { useCallback, useMemo } from "react";
import {
ReactFlow,
ReactFlowProvider,
Background,
BackgroundVariant,
Controls,
MiniMap,
} from "@xyflow/react";
import type { Edge, Node } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import DomainClusterNode from "./DomainClusterNode";
import FlowNode from "./FlowNode";
import StepNode from "./StepNode";
import { useDashboardStore } from "../store";
import { useTheme } from "../themes/index.ts";
import { applyDagreLayout } from "../utils/layout";
import type { KnowledgeGraph, GraphNode } from "@understand-anything/core/types";
const nodeTypes = {
"domain-cluster": DomainClusterNode,
"flow-node": FlowNode,
"step-node": StepNode,
};
// Dimensions for domain-specific nodes
const DOMAIN_NODE_DIMENSIONS = new Map<string, { width: number; height: number }>();
function getDomainMeta(node: GraphNode): Record<string, unknown> | undefined {
return (node as any).domainMeta;
}
function buildDomainOverview(graph: KnowledgeGraph): { nodes: Node[]; edges: Edge[] } {
const domainNodes = graph.nodes.filter((n) => n.type === "domain");
// Count flows per domain
const flowCountMap = new Map<string, number>();
for (const edge of graph.edges) {
if (edge.type === "contains_flow") {
flowCountMap.set(edge.source, (flowCountMap.get(edge.source) ?? 0) + 1);
}
}
const rfNodes: Node[] = domainNodes.map((node) => {
const meta = getDomainMeta(node);
const data = {
label: node.name,
summary: node.summary,
entities: meta?.entities as string[] | undefined,
flowCount: flowCountMap.get(node.id) ?? 0,
businessRules: meta?.businessRules as string[] | undefined,
domainId: node.id,
};
DOMAIN_NODE_DIMENSIONS.set(node.id, { width: 320, height: 180 });
return {
id: node.id,
type: "domain-cluster" as const,
position: { x: 0, y: 0 },
data,
};
});
const rfEdges: Edge[] = graph.edges
.filter((e) => e.type === "cross_domain")
.map((e) => ({
id: `${e.source}-${e.target}`,
source: e.source,
target: e.target,
label: e.description ?? "",
style: { stroke: "var(--color-accent)", strokeDasharray: "6 3", strokeWidth: 2 },
labelStyle: { fill: "var(--color-text-muted)", fontSize: 10 },
animated: true,
}));
return applyDagreLayout(rfNodes, rfEdges, "LR", DOMAIN_NODE_DIMENSIONS);
}
function buildDomainDetail(
graph: KnowledgeGraph,
domainId: string,
): { nodes: Node[]; edges: Edge[] } {
// Find flows for this domain
const flowIds = new Set(
graph.edges
.filter((e) => e.type === "contains_flow" && e.source === domainId)
.map((e) => e.target),
);
const flowNodes = graph.nodes.filter((n) => flowIds.has(n.id));
const stepEdges = graph.edges.filter(
(e) => e.type === "flow_step" && flowIds.has(e.source),
);
const stepIds = new Set(stepEdges.map((e) => e.target));
const stepNodes = graph.nodes.filter((n) => stepIds.has(n.id));
// Build step order map
const stepOrderMap = new Map<string, number>();
for (const edge of stepEdges) {
stepOrderMap.set(edge.target, edge.weight);
}
// Count steps per flow
const stepCountMap = new Map<string, number>();
for (const edge of stepEdges) {
stepCountMap.set(edge.source, (stepCountMap.get(edge.source) ?? 0) + 1);
}
const dims = new Map<string, { width: number; height: number }>();
const rfNodes: Node[] = [
...flowNodes.map((node) => {
const meta = getDomainMeta(node);
dims.set(node.id, { width: 260, height: 120 });
return {
id: node.id,
type: "flow-node" as const,
position: { x: 0, y: 0 },
data: {
label: node.name,
summary: node.summary,
entryPoint: meta?.entryPoint as string | undefined,
entryType: meta?.entryType as string | undefined,
stepCount: stepCountMap.get(node.id) ?? 0,
flowId: node.id,
},
};
}),
...stepNodes.map((node) => {
dims.set(node.id, { width: 200, height: 90 });
return {
id: node.id,
type: "step-node" as const,
position: { x: 0, y: 0 },
data: {
label: node.name,
summary: node.summary,
filePath: node.filePath,
stepId: node.id,
order: Math.round((stepOrderMap.get(node.id) ?? 0) * 10),
},
};
}),
];
const rfEdges: Edge[] = stepEdges.map((e) => ({
id: `${e.source}-${e.target}`,
source: e.source,
target: e.target,
style: { stroke: "var(--color-border-medium)", strokeWidth: 1.5 },
animated: false,
}));
return applyDagreLayout(rfNodes, rfEdges, "LR", dims);
}
function DomainGraphViewInner() {
const domainGraph = useDashboardStore((s) => s.domainGraph);
const activeDomainId = useDashboardStore((s) => s.activeDomainId);
const navigateToDomain = useDashboardStore((s) => s.navigateToDomain);
const theme = useTheme();
const { nodes, edges } = useMemo(() => {
if (!domainGraph) return { nodes: [], edges: [] };
if (activeDomainId) {
return buildDomainDetail(domainGraph, activeDomainId);
}
return buildDomainOverview(domainGraph);
}, [domainGraph, activeDomainId]);
const onNodeDoubleClick = useCallback(
(_: React.MouseEvent, node: Node) => {
if (node.type === "domain-cluster" && node.data && "domainId" in node.data) {
navigateToDomain(node.data.domainId as string);
}
},
[navigateToDomain],
);
if (!domainGraph) {
return (
<div className="h-full flex items-center justify-center text-text-muted text-sm">
No domain graph available. Run /understand-domain to generate one.
</div>
);
}
return (
<div className="h-full w-full relative">
{activeDomainId && (
<div className="absolute top-3 left-3 z-10">
<button
type="button"
onClick={() => {
useDashboardStore.setState({ activeDomainId: null });
}}
className="px-3 py-1.5 text-xs rounded-lg bg-elevated border border-border-subtle text-text-secondary hover:text-text-primary transition-colors"
>
Back to domains
</button>
</div>
)}
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodeDoubleClick={onNodeDoubleClick}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="var(--color-border-subtle)"
/>
<Controls
showInteractive={false}
style={{ bottom: 16, left: 16 }}
/>
<MiniMap
nodeColor={() => theme.preset.colors.accent ?? "#d4a574"}
maskColor="rgba(0,0,0,0.7)"
style={{ bottom: 16, right: 16, width: 160, height: 100 }}
/>
</ReactFlow>
</div>
);
}
export default function DomainGraphView() {
return (
<ReactFlowProvider>
<DomainGraphViewInner />
</ReactFlowProvider>
);
}