Double-click a DIRECT or SEDA node to navigate into that route's diagram. Breadcrumbs show the route stack and allow clicking back to any level. Escape key goes back one level. Route ID resolution handles camelCase endpoint URIs mapping to kebab-case route IDs (e.g. direct:callGetProduct → call-get-product) using the catalog's known route IDs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
140 lines
4.5 KiB
TypeScript
140 lines
4.5 KiB
TypeScript
import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
|
|
import type { NodeConfig } from './types';
|
|
import { colorForType, isCompoundType } from './node-colors';
|
|
import { DiagramNode } from './DiagramNode';
|
|
import { DiagramEdge } from './DiagramEdge';
|
|
|
|
const HEADER_HEIGHT = 22;
|
|
const CORNER_RADIUS = 4;
|
|
|
|
interface CompoundNodeProps {
|
|
node: DiagramNodeType;
|
|
/** All edges for this section — compound filters to its own internal edges */
|
|
edges: DiagramEdgeType[];
|
|
/** Absolute offset of the nearest compound ancestor (for coordinate adjustment) */
|
|
parentX?: number;
|
|
parentY?: number;
|
|
selectedNodeId?: string;
|
|
hoveredNodeId: string | null;
|
|
nodeConfigs?: Map<string, NodeConfig>;
|
|
onNodeClick: (nodeId: string) => void;
|
|
onNodeDoubleClick?: (nodeId: string) => void;
|
|
onNodeEnter: (nodeId: string) => void;
|
|
onNodeLeave: () => void;
|
|
}
|
|
|
|
export function CompoundNode({
|
|
node, edges, parentX = 0, parentY = 0,
|
|
selectedNodeId, hoveredNodeId, nodeConfigs,
|
|
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
|
|
}: CompoundNodeProps) {
|
|
const x = (node.x ?? 0) - parentX;
|
|
const y = (node.y ?? 0) - parentY;
|
|
const absX = node.x ?? 0;
|
|
const absY = node.y ?? 0;
|
|
const w = node.width ?? 200;
|
|
const h = node.height ?? 100;
|
|
const color = colorForType(node.type);
|
|
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
|
|
const label = node.label ? `${typeName}: ${node.label}` : typeName;
|
|
|
|
// Collect all descendant node IDs to filter edges that belong inside this compound
|
|
const descendantIds = new Set<string>();
|
|
collectIds(node.children ?? [], descendantIds);
|
|
|
|
const internalEdges = edges.filter(
|
|
e => descendantIds.has(e.sourceId) && descendantIds.has(e.targetId),
|
|
);
|
|
|
|
return (
|
|
<g data-node-id={node.id} transform={`translate(${x}, ${y})`}>
|
|
{/* Container body */}
|
|
<rect
|
|
x={0}
|
|
y={0}
|
|
width={w}
|
|
height={h}
|
|
rx={CORNER_RADIUS}
|
|
fill="white"
|
|
stroke={color}
|
|
strokeWidth={1.5}
|
|
/>
|
|
|
|
{/* Colored header bar */}
|
|
<rect x={0} y={0} width={w} height={HEADER_HEIGHT} rx={CORNER_RADIUS} fill={color} />
|
|
<rect x={CORNER_RADIUS} y={CORNER_RADIUS} width={w - CORNER_RADIUS * 2} height={HEADER_HEIGHT - CORNER_RADIUS} fill={color} />
|
|
|
|
{/* Header label */}
|
|
<text
|
|
x={w / 2}
|
|
y={HEADER_HEIGHT / 2 + 4}
|
|
fill="white"
|
|
fontSize={10}
|
|
fontWeight={600}
|
|
textAnchor="middle"
|
|
>
|
|
{label}
|
|
</text>
|
|
|
|
{/* Internal edges (rendered after background, before children) */}
|
|
<g className="edges">
|
|
{internalEdges.map((edge, i) => (
|
|
<DiagramEdge
|
|
key={`${edge.sourceId}-${edge.targetId}-${i}`}
|
|
edge={{
|
|
...edge,
|
|
points: edge.points.map(p => [p[0] - absX, p[1] - absY]),
|
|
}}
|
|
/>
|
|
))}
|
|
</g>
|
|
|
|
{/* Children — recurse into compound children, render leaves as DiagramNode */}
|
|
{node.children?.map(child => {
|
|
if (isCompoundType(child.type) && child.children && child.children.length > 0) {
|
|
return (
|
|
<CompoundNode
|
|
key={child.id}
|
|
node={child}
|
|
edges={edges}
|
|
parentX={absX}
|
|
parentY={absY}
|
|
selectedNodeId={selectedNodeId}
|
|
hoveredNodeId={hoveredNodeId}
|
|
nodeConfigs={nodeConfigs}
|
|
onNodeClick={onNodeClick}
|
|
onNodeDoubleClick={onNodeDoubleClick}
|
|
onNodeEnter={onNodeEnter}
|
|
onNodeLeave={onNodeLeave}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<DiagramNode
|
|
key={child.id}
|
|
node={{
|
|
...child,
|
|
x: (child.x ?? 0) - absX,
|
|
y: (child.y ?? 0) - absY,
|
|
}}
|
|
isHovered={hoveredNodeId === child.id}
|
|
isSelected={selectedNodeId === child.id}
|
|
config={child.id ? nodeConfigs?.get(child.id) : undefined}
|
|
onClick={() => child.id && onNodeClick(child.id)}
|
|
onDoubleClick={() => child.id && onNodeDoubleClick?.(child.id)}
|
|
onMouseEnter={() => child.id && onNodeEnter(child.id)}
|
|
onMouseLeave={onNodeLeave}
|
|
/>
|
|
);
|
|
})}
|
|
</g>
|
|
);
|
|
}
|
|
|
|
function collectIds(nodes: DiagramNodeType[], set: Set<string>) {
|
|
for (const n of nodes) {
|
|
if (n.id) set.add(n.id);
|
|
if (n.children) collectIds(n.children, set);
|
|
}
|
|
}
|