feat: drill-down into sub-routes with breadcrumb navigation
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>
This commit is contained in:
@@ -18,6 +18,7 @@ interface CompoundNodeProps {
|
|||||||
hoveredNodeId: string | null;
|
hoveredNodeId: string | null;
|
||||||
nodeConfigs?: Map<string, NodeConfig>;
|
nodeConfigs?: Map<string, NodeConfig>;
|
||||||
onNodeClick: (nodeId: string) => void;
|
onNodeClick: (nodeId: string) => void;
|
||||||
|
onNodeDoubleClick?: (nodeId: string) => void;
|
||||||
onNodeEnter: (nodeId: string) => void;
|
onNodeEnter: (nodeId: string) => void;
|
||||||
onNodeLeave: () => void;
|
onNodeLeave: () => void;
|
||||||
}
|
}
|
||||||
@@ -25,7 +26,7 @@ interface CompoundNodeProps {
|
|||||||
export function CompoundNode({
|
export function CompoundNode({
|
||||||
node, edges, parentX = 0, parentY = 0,
|
node, edges, parentX = 0, parentY = 0,
|
||||||
selectedNodeId, hoveredNodeId, nodeConfigs,
|
selectedNodeId, hoveredNodeId, nodeConfigs,
|
||||||
onNodeClick, onNodeEnter, onNodeLeave,
|
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
|
||||||
}: CompoundNodeProps) {
|
}: CompoundNodeProps) {
|
||||||
const x = (node.x ?? 0) - parentX;
|
const x = (node.x ?? 0) - parentX;
|
||||||
const y = (node.y ?? 0) - parentY;
|
const y = (node.y ?? 0) - parentY;
|
||||||
@@ -102,6 +103,7 @@ export function CompoundNode({
|
|||||||
hoveredNodeId={hoveredNodeId}
|
hoveredNodeId={hoveredNodeId}
|
||||||
nodeConfigs={nodeConfigs}
|
nodeConfigs={nodeConfigs}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
|
onNodeDoubleClick={onNodeDoubleClick}
|
||||||
onNodeEnter={onNodeEnter}
|
onNodeEnter={onNodeEnter}
|
||||||
onNodeLeave={onNodeLeave}
|
onNodeLeave={onNodeLeave}
|
||||||
/>
|
/>
|
||||||
@@ -119,6 +121,7 @@ export function CompoundNode({
|
|||||||
isSelected={selectedNodeId === child.id}
|
isSelected={selectedNodeId === child.id}
|
||||||
config={child.id ? nodeConfigs?.get(child.id) : undefined}
|
config={child.id ? nodeConfigs?.get(child.id) : undefined}
|
||||||
onClick={() => child.id && onNodeClick(child.id)}
|
onClick={() => child.id && onNodeClick(child.id)}
|
||||||
|
onDoubleClick={() => child.id && onNodeDoubleClick?.(child.id)}
|
||||||
onMouseEnter={() => child.id && onNodeEnter(child.id)}
|
onMouseEnter={() => child.id && onNodeEnter(child.id)}
|
||||||
onMouseLeave={onNodeLeave}
|
onMouseLeave={onNodeLeave}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ interface DiagramNodeProps {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
config?: NodeConfig;
|
config?: NodeConfig;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
onMouseEnter: () => void;
|
onMouseEnter: () => void;
|
||||||
onMouseLeave: () => void;
|
onMouseLeave: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiagramNode({
|
export function DiagramNode({
|
||||||
node, isHovered, isSelected, config, onClick, onMouseEnter, onMouseLeave,
|
node, isHovered, isSelected, config, onClick, onDoubleClick, onMouseEnter, onMouseLeave,
|
||||||
}: DiagramNodeProps) {
|
}: DiagramNodeProps) {
|
||||||
const x = node.x ?? 0;
|
const x = node.x ?? 0;
|
||||||
const y = node.y ?? 0;
|
const y = node.y ?? 0;
|
||||||
@@ -35,6 +36,7 @@ export function DiagramNode({
|
|||||||
data-node-id={node.id}
|
data-node-id={node.id}
|
||||||
transform={`translate(${x}, ${y})`}
|
transform={`translate(${x}, ${y})`}
|
||||||
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||||
|
onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick?.(); }}
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface ErrorSectionProps {
|
|||||||
hoveredNodeId: string | null;
|
hoveredNodeId: string | null;
|
||||||
nodeConfigs?: Map<string, NodeConfig>;
|
nodeConfigs?: Map<string, NodeConfig>;
|
||||||
onNodeClick: (nodeId: string) => void;
|
onNodeClick: (nodeId: string) => void;
|
||||||
|
onNodeDoubleClick?: (nodeId: string) => void;
|
||||||
onNodeEnter: (nodeId: string) => void;
|
onNodeEnter: (nodeId: string) => void;
|
||||||
onNodeLeave: () => void;
|
onNodeLeave: () => void;
|
||||||
}
|
}
|
||||||
@@ -28,7 +29,7 @@ const VARIANT_COLORS: Record<string, string> = {
|
|||||||
|
|
||||||
export function ErrorSection({
|
export function ErrorSection({
|
||||||
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs,
|
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs,
|
||||||
onNodeClick, onNodeEnter, onNodeLeave,
|
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
|
||||||
}: ErrorSectionProps) {
|
}: ErrorSectionProps) {
|
||||||
const color = VARIANT_COLORS[section.variant ?? 'error'] ?? VARIANT_COLORS.error;
|
const color = VARIANT_COLORS[section.variant ?? 'error'] ?? VARIANT_COLORS.error;
|
||||||
const boxHeight = useMemo(() => {
|
const boxHeight = useMemo(() => {
|
||||||
@@ -99,6 +100,7 @@ export function ErrorSection({
|
|||||||
hoveredNodeId={hoveredNodeId}
|
hoveredNodeId={hoveredNodeId}
|
||||||
nodeConfigs={nodeConfigs}
|
nodeConfigs={nodeConfigs}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
|
onNodeDoubleClick={onNodeDoubleClick}
|
||||||
onNodeEnter={onNodeEnter}
|
onNodeEnter={onNodeEnter}
|
||||||
onNodeLeave={onNodeLeave}
|
onNodeLeave={onNodeLeave}
|
||||||
/>
|
/>
|
||||||
@@ -112,6 +114,7 @@ export function ErrorSection({
|
|||||||
isSelected={selectedNodeId === node.id}
|
isSelected={selectedNodeId === node.id}
|
||||||
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
||||||
onClick={() => node.id && onNodeClick(node.id)}
|
onClick={() => node.id && onNodeClick(node.id)}
|
||||||
|
onDoubleClick={() => node.id && onNodeDoubleClick?.(node.id)}
|
||||||
onMouseEnter={() => node.id && onNodeEnter(node.id)}
|
onMouseEnter={() => node.id && onNodeEnter(node.id)}
|
||||||
onMouseLeave={onNodeLeave}
|
onMouseLeave={onNodeLeave}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -78,6 +78,50 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.breadcrumbs {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: var(--bg-surface, #FFFFFF);
|
||||||
|
border: 1px solid var(--border, #E4DFD8);
|
||||||
|
border-radius: var(--radius-sm, 5px);
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 2px rgba(44, 37, 32, 0.06));
|
||||||
|
z-index: 10;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbSep {
|
||||||
|
margin: 0 6px;
|
||||||
|
color: var(--text-muted, #9C9184);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--running, #1A7F8E);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbCurrent {
|
||||||
|
color: var(--text-primary, #1A1612);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.nodeToolbar {
|
.nodeToolbar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import type { ProcessDiagramProps } from './types';
|
import type { ProcessDiagramProps } from './types';
|
||||||
|
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
|
||||||
import { useDiagramData } from './useDiagramData';
|
import { useDiagramData } from './useDiagramData';
|
||||||
import { useZoomPan } from './useZoomPan';
|
import { useZoomPan } from './useZoomPan';
|
||||||
import { useToolbarHover, NodeToolbar } from './NodeToolbar';
|
import { useToolbarHover, NodeToolbar } from './NodeToolbar';
|
||||||
@@ -13,6 +14,33 @@ import styles from './ProcessDiagram.module.css';
|
|||||||
|
|
||||||
const PADDING = 40;
|
const PADDING = 40;
|
||||||
|
|
||||||
|
/** Types that support drill-down — double-click navigates to the target route */
|
||||||
|
const DRILLDOWN_TYPES = new Set(['DIRECT', 'SEDA']);
|
||||||
|
|
||||||
|
/** Extract the target endpoint name from a node's label */
|
||||||
|
function extractTargetEndpoint(node: DiagramNodeType): string | null {
|
||||||
|
// Labels like "to: direct:orderProcessing" or "direct:orderProcessing"
|
||||||
|
const label = node.label ?? '';
|
||||||
|
const match = label.match(/(?:to:\s*)?(?:direct|seda):(\S+)/i);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert camelCase to kebab-case: "callGetProduct" → "call-get-product" */
|
||||||
|
function camelToKebab(s: string): string {
|
||||||
|
return s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a direct/seda endpoint name to a routeId.
|
||||||
|
* Tries: exact match, kebab-case conversion, then gives up.
|
||||||
|
*/
|
||||||
|
function resolveRouteId(endpoint: string, knownRouteIds: Set<string>): string | null {
|
||||||
|
if (knownRouteIds.has(endpoint)) return endpoint;
|
||||||
|
const kebab = camelToKebab(endpoint);
|
||||||
|
if (knownRouteIds.has(kebab)) return kebab;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function ProcessDiagram({
|
export function ProcessDiagram({
|
||||||
application,
|
application,
|
||||||
routeId,
|
routeId,
|
||||||
@@ -21,10 +49,21 @@ export function ProcessDiagram({
|
|||||||
onNodeSelect,
|
onNodeSelect,
|
||||||
onNodeAction,
|
onNodeAction,
|
||||||
nodeConfigs,
|
nodeConfigs,
|
||||||
|
knownRouteIds,
|
||||||
className,
|
className,
|
||||||
}: ProcessDiagramProps) {
|
}: ProcessDiagramProps) {
|
||||||
|
// Route stack for drill-down navigation
|
||||||
|
const [routeStack, setRouteStack] = useState<string[]>([routeId]);
|
||||||
|
|
||||||
|
// Reset stack when the external routeId prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
setRouteStack([routeId]);
|
||||||
|
}, [routeId]);
|
||||||
|
|
||||||
|
const currentRouteId = routeStack[routeStack.length - 1];
|
||||||
|
|
||||||
const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData(
|
const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData(
|
||||||
application, routeId, direction,
|
application, currentRouteId, direction,
|
||||||
);
|
);
|
||||||
|
|
||||||
const zoom = useZoomPan();
|
const zoom = useZoomPan();
|
||||||
@@ -33,18 +72,43 @@ export function ProcessDiagram({
|
|||||||
const contentWidth = totalWidth + PADDING * 2;
|
const contentWidth = totalWidth + PADDING * 2;
|
||||||
const contentHeight = totalHeight + PADDING * 2;
|
const contentHeight = totalHeight + PADDING * 2;
|
||||||
|
|
||||||
// Reset to 100% at top-left on first data load
|
// Reset to 100% at top-left when route changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (totalWidth > 0 && totalHeight > 0) {
|
if (totalWidth > 0 && totalHeight > 0) {
|
||||||
zoom.resetView();
|
zoom.resetView();
|
||||||
}
|
}
|
||||||
}, [totalWidth, totalHeight]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [totalWidth, totalHeight, currentRouteId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handleNodeClick = useCallback(
|
const handleNodeClick = useCallback(
|
||||||
(nodeId: string) => { onNodeSelect?.(nodeId); },
|
(nodeId: string) => { onNodeSelect?.(nodeId); },
|
||||||
[onNodeSelect],
|
[onNodeSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleNodeDoubleClick = useCallback(
|
||||||
|
(nodeId: string) => {
|
||||||
|
const node = findNodeById(sections, nodeId);
|
||||||
|
if (!node || !DRILLDOWN_TYPES.has(node.type ?? '')) return;
|
||||||
|
const endpoint = extractTargetEndpoint(node);
|
||||||
|
if (!endpoint) return;
|
||||||
|
const resolved = knownRouteIds
|
||||||
|
? resolveRouteId(endpoint, knownRouteIds)
|
||||||
|
: endpoint;
|
||||||
|
if (resolved) {
|
||||||
|
onNodeSelect?.('');
|
||||||
|
setRouteStack(prev => [...prev, resolved]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sections, onNodeSelect, knownRouteIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBreadcrumbClick = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
onNodeSelect?.('');
|
||||||
|
setRouteStack(prev => prev.slice(0, index + 1));
|
||||||
|
},
|
||||||
|
[onNodeSelect],
|
||||||
|
);
|
||||||
|
|
||||||
const handleNodeAction = useCallback(
|
const handleNodeAction = useCallback(
|
||||||
(nodeId: string, action: import('./types').NodeAction) => {
|
(nodeId: string, action: import('./types').NodeAction) => {
|
||||||
if (action === 'inspect') onNodeSelect?.(nodeId);
|
if (action === 'inspect') onNodeSelect?.(nodeId);
|
||||||
@@ -56,12 +120,17 @@ export function ProcessDiagram({
|
|||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
onNodeSelect?.('');
|
if (routeStack.length > 1) {
|
||||||
|
// Go back one level
|
||||||
|
setRouteStack(prev => prev.slice(0, -1));
|
||||||
|
} else {
|
||||||
|
onNodeSelect?.('');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
zoom.onKeyDown(e, contentWidth, contentHeight);
|
zoom.onKeyDown(e, contentWidth, contentHeight);
|
||||||
},
|
},
|
||||||
[onNodeSelect, zoom, contentWidth, contentHeight],
|
[onNodeSelect, zoom, contentWidth, contentHeight, routeStack.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -89,13 +158,34 @@ export function ProcessDiagram({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mainSection = sections[0];
|
const mainSection = sections[0];
|
||||||
const errorSections = sections.slice(1);
|
const handlerSections = sections.slice(1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={zoom.containerRef}
|
ref={zoom.containerRef}
|
||||||
className={`${styles.container} ${className ?? ''}`}
|
className={`${styles.container} ${className ?? ''}`}
|
||||||
>
|
>
|
||||||
|
{/* Breadcrumb bar — only shown when drilled down */}
|
||||||
|
{routeStack.length > 1 && (
|
||||||
|
<div className={styles.breadcrumbs}>
|
||||||
|
{routeStack.map((route, i) => (
|
||||||
|
<span key={i} className={styles.breadcrumbItem}>
|
||||||
|
{i > 0 && <span className={styles.breadcrumbSep}>/</span>}
|
||||||
|
{i < routeStack.length - 1 ? (
|
||||||
|
<button
|
||||||
|
className={styles.breadcrumbLink}
|
||||||
|
onClick={() => handleBreadcrumbClick(i)}
|
||||||
|
>
|
||||||
|
{route}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={styles.breadcrumbCurrent}>{route}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
className={styles.svg}
|
className={styles.svg}
|
||||||
onWheel={zoom.onWheel}
|
onWheel={zoom.onWheel}
|
||||||
@@ -140,6 +230,7 @@ export function ProcessDiagram({
|
|||||||
hoveredNodeId={toolbar.hoveredNodeId}
|
hoveredNodeId={toolbar.hoveredNodeId}
|
||||||
nodeConfigs={nodeConfigs}
|
nodeConfigs={nodeConfigs}
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeEnter={toolbar.onNodeEnter}
|
onNodeEnter={toolbar.onNodeEnter}
|
||||||
onNodeLeave={toolbar.onNodeLeave}
|
onNodeLeave={toolbar.onNodeLeave}
|
||||||
/>
|
/>
|
||||||
@@ -153,6 +244,7 @@ export function ProcessDiagram({
|
|||||||
isSelected={selectedNodeId === node.id}
|
isSelected={selectedNodeId === node.id}
|
||||||
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
||||||
onClick={() => node.id && handleNodeClick(node.id)}
|
onClick={() => node.id && handleNodeClick(node.id)}
|
||||||
|
onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)}
|
||||||
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}
|
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}
|
||||||
onMouseLeave={toolbar.onNodeLeave}
|
onMouseLeave={toolbar.onNodeLeave}
|
||||||
/>
|
/>
|
||||||
@@ -162,16 +254,17 @@ export function ProcessDiagram({
|
|||||||
|
|
||||||
{/* Toolbar rendered as HTML overlay below */}
|
{/* Toolbar rendered as HTML overlay below */}
|
||||||
|
|
||||||
{/* Error handler sections */}
|
{/* Handler sections (completion, then error) */}
|
||||||
{errorSections.map((section, i) => (
|
{handlerSections.map((section, i) => (
|
||||||
<ErrorSection
|
<ErrorSection
|
||||||
key={`error-${i}`}
|
key={`handler-${i}`}
|
||||||
section={section}
|
section={section}
|
||||||
totalWidth={totalWidth}
|
totalWidth={totalWidth}
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
hoveredNodeId={toolbar.hoveredNodeId}
|
hoveredNodeId={toolbar.hoveredNodeId}
|
||||||
nodeConfigs={nodeConfigs}
|
nodeConfigs={nodeConfigs}
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeEnter={toolbar.onNodeEnter}
|
onNodeEnter={toolbar.onNodeEnter}
|
||||||
onNodeLeave={toolbar.onNodeLeave}
|
onNodeLeave={toolbar.onNodeLeave}
|
||||||
/>
|
/>
|
||||||
@@ -183,7 +276,6 @@ export function ProcessDiagram({
|
|||||||
{toolbar.hoveredNodeId && onNodeAction && (() => {
|
{toolbar.hoveredNodeId && onNodeAction && (() => {
|
||||||
const hNode = findNodeById(sections, toolbar.hoveredNodeId!);
|
const hNode = findNodeById(sections, toolbar.hoveredNodeId!);
|
||||||
if (!hNode) return null;
|
if (!hNode) return null;
|
||||||
// Convert SVG coordinates to screen-space using zoom transform
|
|
||||||
const nodeCenter = (hNode.x ?? 0) + (hNode.width ?? 160) / 2;
|
const nodeCenter = (hNode.x ?? 0) + (hNode.width ?? 160) / 2;
|
||||||
const nodeTop = hNode.y ?? 0;
|
const nodeTop = hNode.y ?? 0;
|
||||||
const screenX = nodeCenter * zoom.state.scale + zoom.state.translateX;
|
const screenX = nodeCenter * zoom.state.scale + zoom.state.translateX;
|
||||||
@@ -213,7 +305,7 @@ export function ProcessDiagram({
|
|||||||
function findNodeById(
|
function findNodeById(
|
||||||
sections: import('./types').DiagramSection[],
|
sections: import('./types').DiagramSection[],
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
): import('../../api/queries/diagrams').DiagramNode | undefined {
|
): DiagramNodeType | undefined {
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
for (const node of section.nodes) {
|
for (const node of section.nodes) {
|
||||||
if (node.id === nodeId) return node;
|
if (node.id === nodeId) return node;
|
||||||
@@ -227,9 +319,9 @@ function findNodeById(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findInChildren(
|
function findInChildren(
|
||||||
nodes: import('../../api/queries/diagrams').DiagramNode[],
|
nodes: DiagramNodeType[],
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
): import('../../api/queries/diagrams').DiagramNode | undefined {
|
): DiagramNodeType | undefined {
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.id === nodeId) return n;
|
if (n.id === nodeId) return n;
|
||||||
if (n.children) {
|
if (n.children) {
|
||||||
@@ -240,12 +332,10 @@ function findInChildren(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true if the edge connects two top-level nodes (not inside any compound). */
|
|
||||||
function topLevelEdge(
|
function topLevelEdge(
|
||||||
edge: import('../../api/queries/diagrams').DiagramEdge,
|
edge: import('../../api/queries/diagrams').DiagramEdge,
|
||||||
nodes: import('../../api/queries/diagrams').DiagramNode[],
|
nodes: DiagramNodeType[],
|
||||||
): boolean {
|
): boolean {
|
||||||
// Collect all IDs that are children of compound nodes (at any depth)
|
|
||||||
const compoundChildIds = new Set<string>();
|
const compoundChildIds = new Set<string>();
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.children && n.children.length > 0) {
|
if (n.children && n.children.length > 0) {
|
||||||
@@ -255,10 +345,7 @@ function topLevelEdge(
|
|||||||
return !compoundChildIds.has(edge.sourceId) && !compoundChildIds.has(edge.targetId);
|
return !compoundChildIds.has(edge.sourceId) && !compoundChildIds.has(edge.targetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectDescendantIds(
|
function collectDescendantIds(nodes: DiagramNodeType[], set: Set<string>) {
|
||||||
nodes: import('../../api/queries/diagrams').DiagramNode[],
|
|
||||||
set: Set<string>,
|
|
||||||
) {
|
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.id) set.add(n.id);
|
if (n.id) set.add(n.id);
|
||||||
if (n.children) collectDescendantIds(n.children, set);
|
if (n.children) collectDescendantIds(n.children, set);
|
||||||
|
|||||||
@@ -23,5 +23,7 @@ export interface ProcessDiagramProps {
|
|||||||
onNodeSelect?: (nodeId: string) => void;
|
onNodeSelect?: (nodeId: string) => void;
|
||||||
onNodeAction?: (nodeId: string, action: NodeAction) => void;
|
onNodeAction?: (nodeId: string, action: NodeAction) => void;
|
||||||
nodeConfigs?: Map<string, NodeConfig>;
|
nodeConfigs?: Map<string, NodeConfig>;
|
||||||
|
/** Known route IDs for this application (enables drill-down resolution) */
|
||||||
|
knownRouteIds?: Set<string>;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ export default function DevDiagram() {
|
|||||||
return { apps: appArr, routes: filtered };
|
return { apps: appArr, routes: filtered };
|
||||||
}, [catalog, selectedApp]);
|
}, [catalog, selectedApp]);
|
||||||
|
|
||||||
|
// All route IDs for the selected app (for drill-down resolution)
|
||||||
|
const knownRouteIds = useMemo(() => {
|
||||||
|
if (!catalog || !selectedApp) return new Set<string>();
|
||||||
|
const app = (catalog as Array<{ appId: string; routes?: Array<{ routeId: string }> }>)
|
||||||
|
.find(a => a.appId === selectedApp);
|
||||||
|
return new Set((app?.routes ?? []).map(r => r.routeId));
|
||||||
|
}, [catalog, selectedApp]);
|
||||||
|
|
||||||
// Mock node configs for testing
|
// Mock node configs for testing
|
||||||
const nodeConfigs = useMemo(() => {
|
const nodeConfigs = useMemo(() => {
|
||||||
const map = new Map<string, NodeConfig>();
|
const map = new Map<string, NodeConfig>();
|
||||||
@@ -85,6 +93,7 @@ export default function DevDiagram() {
|
|||||||
onNodeSelect={setSelectedNodeId}
|
onNodeSelect={setSelectedNodeId}
|
||||||
onNodeAction={handleNodeAction}
|
onNodeAction={handleNodeAction}
|
||||||
nodeConfigs={nodeConfigs}
|
nodeConfigs={nodeConfigs}
|
||||||
|
knownRouteIds={knownRouteIds}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.placeholder}>
|
<div className={styles.placeholder}>
|
||||||
|
|||||||
Reference in New Issue
Block a user