fix: diagram rendering improvements
- Recursive compound rendering: CompoundNode checks if children are themselves compound types (WHEN inside CHOICE) and renders them recursively. Added EIP_WHEN, EIP_OTHERWISE, DO_CATCH, DO_FINALLY to frontend COMPOUND_TYPES. - Edge z-ordering: edges are distributed to their containing compound and rendered after the background rect, so they're not hidden behind compound containers. - Error section sizing: normalize error handler node coordinates to start at (0,0), compute red tint background height from actual content with symmetric padding for vertical centering. - Toolbar as HTML overlay: moved from SVG foreignObject to absolute- positioned HTML div so it stays fixed size at any zoom level. Uses design system tokens for consistent styling. - Zoom: replaced viewBox approach with CSS transform on content group. Default zoom is 100% anchored top-left. Fit-to-view still available via button. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
|
||||
import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
|
||||
import type { NodeConfig } from './types';
|
||||
import { colorForType } from './node-colors';
|
||||
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>;
|
||||
@@ -17,17 +23,28 @@ interface CompoundNodeProps {
|
||||
}
|
||||
|
||||
export function CompoundNode({
|
||||
node, selectedNodeId, hoveredNodeId, nodeConfigs,
|
||||
node, edges, parentX = 0, parentY = 0,
|
||||
selectedNodeId, hoveredNodeId, nodeConfigs,
|
||||
onNodeClick, onNodeEnter, onNodeLeave,
|
||||
}: CompoundNodeProps) {
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
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 */}
|
||||
@@ -58,25 +75,62 @@ export function CompoundNode({
|
||||
{label}
|
||||
</text>
|
||||
|
||||
{/* Children nodes (positioned relative to compound) */}
|
||||
{node.children?.map(child => (
|
||||
<DiagramNode
|
||||
key={child.id}
|
||||
node={{
|
||||
...child,
|
||||
// Children have absolute coordinates from the backend,
|
||||
// but since we're inside the compound's translate, subtract parent offset
|
||||
x: (child.x ?? 0) - x,
|
||||
y: (child.y ?? 0) - y,
|
||||
}}
|
||||
isHovered={hoveredNodeId === child.id}
|
||||
isSelected={selectedNodeId === child.id}
|
||||
config={child.id ? nodeConfigs?.get(child.id) : undefined}
|
||||
onClick={() => child.id && onNodeClick(child.id)}
|
||||
onMouseEnter={() => child.id && onNodeEnter(child.id)}
|
||||
onMouseLeave={onNodeLeave}
|
||||
/>
|
||||
))}
|
||||
{/* 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}
|
||||
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)}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { DiagramSection } from './types';
|
||||
import type { NodeConfig } from './types';
|
||||
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
|
||||
import { DiagramEdge } from './DiagramEdge';
|
||||
import { DiagramNode } from './DiagramNode';
|
||||
import { CompoundNode } from './CompoundNode';
|
||||
import { isCompoundType } from './node-colors';
|
||||
|
||||
const CONTENT_PADDING_Y = 20;
|
||||
const CONTENT_PADDING_LEFT = 12;
|
||||
|
||||
interface ErrorSectionProps {
|
||||
section: DiagramSection;
|
||||
totalWidth: number;
|
||||
@@ -20,8 +25,29 @@ export function ErrorSection({
|
||||
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs,
|
||||
onNodeClick, onNodeEnter, onNodeLeave,
|
||||
}: ErrorSectionProps) {
|
||||
const boxHeight = useMemo(() => {
|
||||
let maxY = 0;
|
||||
for (const n of section.nodes) {
|
||||
const bottom = (n.y ?? 0) + (n.height ?? 40);
|
||||
if (bottom > maxY) maxY = bottom;
|
||||
if (n.children) {
|
||||
for (const c of n.children) {
|
||||
const cb = (c.y ?? 0) + (c.height ?? 40);
|
||||
if (cb > maxY) maxY = cb;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Content height + top padding + bottom padding (to vertically center)
|
||||
return maxY + CONTENT_PADDING_Y * 2;
|
||||
}, [section.nodes]);
|
||||
|
||||
return (
|
||||
<g transform={`translate(0, ${section.offsetY})`}>
|
||||
{/* Section label */}
|
||||
<text x={8} y={-6} fill="#C0392B" fontSize={11} fontWeight={600}>
|
||||
{section.label}
|
||||
</text>
|
||||
|
||||
{/* Divider line */}
|
||||
<line
|
||||
x1={0}
|
||||
@@ -34,59 +60,58 @@ export function ErrorSection({
|
||||
opacity={0.5}
|
||||
/>
|
||||
|
||||
{/* Section label */}
|
||||
<text x={8} y={-6} fill="#C0392B" fontSize={11} fontWeight={600}>
|
||||
{section.label}
|
||||
</text>
|
||||
|
||||
{/* Subtle red tint background */}
|
||||
{/* Subtle red tint background — sized to actual content */}
|
||||
<rect
|
||||
x={0}
|
||||
y={4}
|
||||
width={totalWidth}
|
||||
height={300}
|
||||
height={boxHeight}
|
||||
fill="#C0392B"
|
||||
opacity={0.03}
|
||||
rx={4}
|
||||
/>
|
||||
|
||||
{/* Edges */}
|
||||
<g className="edges">
|
||||
{section.edges.map((edge, i) => (
|
||||
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
|
||||
))}
|
||||
</g>
|
||||
{/* Content group with margin from top-left */}
|
||||
<g transform={`translate(${CONTENT_PADDING_LEFT}, ${CONTENT_PADDING_Y})`}>
|
||||
{/* Edges */}
|
||||
<g className="edges">
|
||||
{section.edges.map((edge, i) => (
|
||||
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Nodes */}
|
||||
<g className="nodes">
|
||||
{section.nodes.map(node => {
|
||||
if (isCompoundType(node.type) && node.children && node.children.length > 0) {
|
||||
{/* Nodes */}
|
||||
<g className="nodes">
|
||||
{section.nodes.map(node => {
|
||||
if (isCompoundType(node.type) && node.children && node.children.length > 0) {
|
||||
return (
|
||||
<CompoundNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
edges={section.edges}
|
||||
selectedNodeId={selectedNodeId}
|
||||
hoveredNodeId={hoveredNodeId}
|
||||
nodeConfigs={nodeConfigs}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeEnter={onNodeEnter}
|
||||
onNodeLeave={onNodeLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CompoundNode
|
||||
<DiagramNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedNodeId={selectedNodeId}
|
||||
hoveredNodeId={hoveredNodeId}
|
||||
nodeConfigs={nodeConfigs}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeEnter={onNodeEnter}
|
||||
onNodeLeave={onNodeLeave}
|
||||
isHovered={hoveredNodeId === node.id}
|
||||
isSelected={selectedNodeId === node.id}
|
||||
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
||||
onClick={() => node.id && onNodeClick(node.id)}
|
||||
onMouseEnter={() => node.id && onNodeEnter(node.id)}
|
||||
onMouseLeave={onNodeLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DiagramNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
isHovered={hoveredNodeId === node.id}
|
||||
isSelected={selectedNodeId === node.id}
|
||||
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
||||
onClick={() => node.id && onNodeClick(node.id)}
|
||||
onMouseEnter={() => node.id && onNodeEnter(node.id)}
|
||||
onMouseLeave={onNodeLeave}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
})}
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
|
||||
@@ -1,82 +1,50 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { NodeAction } from './types';
|
||||
import styles from './ProcessDiagram.module.css';
|
||||
|
||||
const TOOLBAR_HEIGHT = 28;
|
||||
const TOOLBAR_WIDTH = 140;
|
||||
const HIDE_DELAY = 150;
|
||||
|
||||
interface NodeToolbarProps {
|
||||
nodeId: string;
|
||||
nodeX: number;
|
||||
nodeY: number;
|
||||
nodeWidth: number;
|
||||
/** Screen-space position (already transformed by zoom/pan) */
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
onAction: (nodeId: string, action: NodeAction) => void;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
}
|
||||
|
||||
const ACTIONS: { label: string; icon: string; action: NodeAction; title: string }[] = [
|
||||
{ label: 'Inspect', icon: '\uD83D\uDD0D', action: 'inspect', title: 'Inspect node' },
|
||||
{ label: 'Trace', icon: 'T', action: 'toggle-trace', title: 'Toggle tracing' },
|
||||
{ label: 'Tap', icon: '\u270E', action: 'configure-tap', title: 'Configure tap' },
|
||||
{ label: 'More', icon: '\u22EF', action: 'copy-id', title: 'Copy processor ID' },
|
||||
const ACTIONS: { icon: string; action: NodeAction; title: string }[] = [
|
||||
{ icon: '\uD83D\uDD0D', action: 'inspect', title: 'Inspect' },
|
||||
{ icon: 'T', action: 'toggle-trace', title: 'Toggle tracing' },
|
||||
{ icon: '\u270E', action: 'configure-tap', title: 'Configure tap' },
|
||||
{ icon: '\u22EF', action: 'copy-id', title: 'Copy ID' },
|
||||
];
|
||||
|
||||
export function NodeToolbar({
|
||||
nodeId, nodeX, nodeY, nodeWidth, onAction, onMouseEnter, onMouseLeave,
|
||||
nodeId, screenX, screenY, onAction, onMouseEnter, onMouseLeave,
|
||||
}: NodeToolbarProps) {
|
||||
const x = nodeX + (nodeWidth - TOOLBAR_WIDTH) / 2;
|
||||
const y = nodeY - TOOLBAR_HEIGHT - 6;
|
||||
|
||||
return (
|
||||
<foreignObject
|
||||
x={x}
|
||||
y={y}
|
||||
width={TOOLBAR_WIDTH}
|
||||
height={TOOLBAR_HEIGHT}
|
||||
<div
|
||||
className={styles.nodeToolbar}
|
||||
style={{ left: screenX, top: screenY }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
height: TOOLBAR_HEIGHT,
|
||||
background: 'rgba(26, 22, 18, 0.92)',
|
||||
borderRadius: '6px',
|
||||
padding: '0 8px',
|
||||
}}
|
||||
>
|
||||
{ACTIONS.map(a => (
|
||||
<button
|
||||
key={a.action}
|
||||
title={a.title}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction(nodeId, a.action);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{a.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</foreignObject>
|
||||
{ACTIONS.map(a => (
|
||||
<button
|
||||
key={a.action}
|
||||
className={styles.nodeToolbarBtn}
|
||||
title={a.title}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction(nodeId, a.action);
|
||||
}}
|
||||
>
|
||||
{a.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,3 +77,39 @@
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.nodeToolbar {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px 4px;
|
||||
background: var(--bg-surface, #FFFFFF);
|
||||
border: 1px solid var(--border, #E4DFD8);
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
box-shadow: var(--shadow-lg, 0 4px 16px rgba(44, 37, 32, 0.10));
|
||||
transform: translate(-50%, -100%);
|
||||
margin-top: -6px;
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.nodeToolbarBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #5C5347);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nodeToolbarBtn:hover {
|
||||
background: var(--bg-hover, #F5F0EA);
|
||||
color: var(--text-primary, #1A1612);
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@ export function ProcessDiagram({
|
||||
const contentWidth = totalWidth + PADDING * 2;
|
||||
const contentHeight = totalHeight + PADDING * 2;
|
||||
|
||||
// Fit to view on first data load
|
||||
// Reset to 100% at top-left on first data load
|
||||
useEffect(() => {
|
||||
if (totalWidth > 0 && totalHeight > 0) {
|
||||
zoom.fitToView(contentWidth, contentHeight);
|
||||
zoom.resetView();
|
||||
}
|
||||
}, [totalWidth, totalHeight]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -98,7 +98,6 @@ export function ProcessDiagram({
|
||||
>
|
||||
<svg
|
||||
className={styles.svg}
|
||||
viewBox={zoom.viewBox(contentWidth, contentHeight)}
|
||||
onWheel={zoom.onWheel}
|
||||
onPointerDown={zoom.onPointerDown}
|
||||
onPointerMove={zoom.onPointerMove}
|
||||
@@ -120,10 +119,10 @@ export function ProcessDiagram({
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<g transform={`translate(${PADDING}, ${PADDING})`}>
|
||||
{/* Main section edges */}
|
||||
<g style={{ transform: zoom.transform, transformOrigin: '0 0' }}>
|
||||
{/* Main section top-level edges (not inside compounds) */}
|
||||
<g className="edges">
|
||||
{mainSection.edges.map((edge, i) => (
|
||||
{mainSection.edges.filter(e => topLevelEdge(e, mainSection.nodes)).map((edge, i) => (
|
||||
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
|
||||
))}
|
||||
</g>
|
||||
@@ -136,6 +135,7 @@ export function ProcessDiagram({
|
||||
<CompoundNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
edges={mainSection.edges}
|
||||
selectedNodeId={selectedNodeId}
|
||||
hoveredNodeId={toolbar.hoveredNodeId}
|
||||
nodeConfigs={nodeConfigs}
|
||||
@@ -160,22 +160,7 @@ export function ProcessDiagram({
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Toolbar for hovered node */}
|
||||
{toolbar.hoveredNodeId && onNodeAction && (() => {
|
||||
const hNode = findNodeById(sections, toolbar.hoveredNodeId!);
|
||||
if (!hNode) return null;
|
||||
return (
|
||||
<NodeToolbar
|
||||
nodeId={toolbar.hoveredNodeId!}
|
||||
nodeX={hNode.x ?? 0}
|
||||
nodeY={hNode.y ?? 0}
|
||||
nodeWidth={hNode.width ?? 120}
|
||||
onAction={handleNodeAction}
|
||||
onMouseEnter={toolbar.onToolbarEnter}
|
||||
onMouseLeave={toolbar.onToolbarLeave}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{/* Toolbar rendered as HTML overlay below */}
|
||||
|
||||
{/* Error handler sections */}
|
||||
{errorSections.map((section, i) => (
|
||||
@@ -194,6 +179,27 @@ export function ProcessDiagram({
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Node toolbar — HTML overlay, fixed size regardless of zoom */}
|
||||
{toolbar.hoveredNodeId && onNodeAction && (() => {
|
||||
const hNode = findNodeById(sections, toolbar.hoveredNodeId!);
|
||||
if (!hNode) return null;
|
||||
// Convert SVG coordinates to screen-space using zoom transform
|
||||
const nodeCenter = (hNode.x ?? 0) + (hNode.width ?? 160) / 2;
|
||||
const nodeTop = hNode.y ?? 0;
|
||||
const screenX = nodeCenter * zoom.state.scale + zoom.state.translateX;
|
||||
const screenY = nodeTop * zoom.state.scale + zoom.state.translateY;
|
||||
return (
|
||||
<NodeToolbar
|
||||
nodeId={toolbar.hoveredNodeId!}
|
||||
screenX={screenX}
|
||||
screenY={screenY}
|
||||
onAction={handleNodeAction}
|
||||
onMouseEnter={toolbar.onToolbarEnter}
|
||||
onMouseLeave={toolbar.onToolbarLeave}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
<ZoomControls
|
||||
onZoomIn={zoom.zoomIn}
|
||||
onZoomOut={zoom.zoomOut}
|
||||
@@ -212,10 +218,49 @@ function findNodeById(
|
||||
for (const node of section.nodes) {
|
||||
if (node.id === nodeId) return node;
|
||||
if (node.children) {
|
||||
const child = node.children.find(c => c.id === nodeId);
|
||||
if (child) return child;
|
||||
const found = findInChildren(node.children, nodeId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findInChildren(
|
||||
nodes: import('../../api/queries/diagrams').DiagramNode[],
|
||||
nodeId: string,
|
||||
): import('../../api/queries/diagrams').DiagramNode | undefined {
|
||||
for (const n of nodes) {
|
||||
if (n.id === nodeId) return n;
|
||||
if (n.children) {
|
||||
const found = findInChildren(n.children, nodeId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Returns true if the edge connects two top-level nodes (not inside any compound). */
|
||||
function topLevelEdge(
|
||||
edge: import('../../api/queries/diagrams').DiagramEdge,
|
||||
nodes: import('../../api/queries/diagrams').DiagramNode[],
|
||||
): boolean {
|
||||
// Collect all IDs that are children of compound nodes (at any depth)
|
||||
const compoundChildIds = new Set<string>();
|
||||
for (const n of nodes) {
|
||||
if (n.children && n.children.length > 0) {
|
||||
collectDescendantIds(n.children, compoundChildIds);
|
||||
}
|
||||
}
|
||||
return !compoundChildIds.has(edge.sourceId) && !compoundChildIds.has(edge.targetId);
|
||||
}
|
||||
|
||||
function collectDescendantIds(
|
||||
nodes: import('../../api/queries/diagrams').DiagramNode[],
|
||||
set: Set<string>,
|
||||
) {
|
||||
for (const n of nodes) {
|
||||
if (n.id) set.add(n.id);
|
||||
if (n.children) collectDescendantIds(n.children, set);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,9 @@ const TYPE_MAP: Record<string, string> = {
|
||||
};
|
||||
|
||||
const COMPOUND_TYPES = new Set([
|
||||
'EIP_CHOICE', 'EIP_SPLIT', 'TRY_CATCH', 'DO_TRY',
|
||||
'EIP_CHOICE', 'EIP_WHEN', 'EIP_OTHERWISE',
|
||||
'EIP_SPLIT', 'TRY_CATCH',
|
||||
'DO_TRY', 'DO_CATCH', 'DO_FINALLY',
|
||||
'EIP_LOOP', 'EIP_MULTICAST', 'EIP_AGGREGATE',
|
||||
'ON_EXCEPTION', 'ERROR_HANDLER',
|
||||
]);
|
||||
|
||||
@@ -56,27 +56,43 @@ export function useDiagramData(
|
||||
];
|
||||
|
||||
let currentY = mainBounds.maxY + SECTION_GAP;
|
||||
let maxWidth = mainBounds.maxX;
|
||||
|
||||
for (const es of errorSections) {
|
||||
const errorBounds = computeBounds(es.nodes);
|
||||
const offX = errorBounds.minX;
|
||||
const offY = errorBounds.minY;
|
||||
|
||||
// Normalize node coordinates relative to the section's own origin
|
||||
const shiftedNodes = shiftNodes(es.nodes, offX, offY);
|
||||
|
||||
const errorNodeIds = new Set<string>();
|
||||
collectNodeIds(es.nodes, errorNodeIds);
|
||||
const errorEdges = allEdges.filter(
|
||||
e => errorNodeIds.has(e.sourceId) && errorNodeIds.has(e.targetId),
|
||||
);
|
||||
|
||||
// Shift edge points too
|
||||
const errorEdges = allEdges
|
||||
.filter(e => errorNodeIds.has(e.sourceId) && errorNodeIds.has(e.targetId))
|
||||
.map(e => ({
|
||||
...e,
|
||||
points: e.points.map(p => [p[0] - offX, p[1] - offY]),
|
||||
}));
|
||||
|
||||
const sectionHeight = errorBounds.maxY - errorBounds.minY;
|
||||
const sectionWidth = errorBounds.maxX - errorBounds.minX;
|
||||
|
||||
sections.push({
|
||||
label: es.label,
|
||||
nodes: es.nodes,
|
||||
nodes: shiftedNodes,
|
||||
edges: errorEdges,
|
||||
offsetY: currentY,
|
||||
variant: 'error',
|
||||
});
|
||||
|
||||
const errorBounds = computeBounds(es.nodes);
|
||||
currentY += (errorBounds.maxY - errorBounds.minY) + SECTION_GAP;
|
||||
currentY += sectionHeight + SECTION_GAP;
|
||||
if (sectionWidth > maxWidth) maxWidth = sectionWidth;
|
||||
}
|
||||
|
||||
const totalWidth = layout.width ?? mainBounds.maxX;
|
||||
const totalWidth = Math.max(layout.width ?? 0, mainBounds.maxX, maxWidth);
|
||||
const totalHeight = currentY;
|
||||
|
||||
return { sections, totalWidth, totalHeight };
|
||||
@@ -85,6 +101,16 @@ export function useDiagramData(
|
||||
return { ...result, isLoading, error };
|
||||
}
|
||||
|
||||
/** Shift all node coordinates by subtracting an offset, recursively. */
|
||||
function shiftNodes(nodes: DiagramNode[], offX: number, offY: number): DiagramNode[] {
|
||||
return nodes.map(n => ({
|
||||
...n,
|
||||
x: (n.x ?? 0) - offX,
|
||||
y: (n.y ?? 0) - offY,
|
||||
children: n.children ? shiftNodes(n.children, offX, offY) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function collectNodeIds(nodes: DiagramNode[], set: Set<string>) {
|
||||
for (const n of nodes) {
|
||||
if (n.id) set.add(n.id);
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ZoomPanState {
|
||||
const MIN_SCALE = 0.25;
|
||||
const MAX_SCALE = 4.0;
|
||||
const ZOOM_STEP = 0.15;
|
||||
const FIT_PADDING = 40;
|
||||
const FIT_PADDING = 20;
|
||||
|
||||
export function useZoomPan() {
|
||||
const [state, setState] = useState<ZoomPanState>({
|
||||
@@ -23,16 +23,8 @@ export function useZoomPan() {
|
||||
|
||||
const clampScale = (s: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
|
||||
|
||||
const viewBox = useCallback(
|
||||
(contentWidth: number, contentHeight: number) => {
|
||||
const vw = contentWidth / state.scale;
|
||||
const vh = contentHeight / state.scale;
|
||||
const vx = -state.translateX / state.scale;
|
||||
const vy = -state.translateY / state.scale;
|
||||
return `${vx} ${vy} ${vw} ${vh}`;
|
||||
},
|
||||
[state],
|
||||
);
|
||||
/** Returns the CSS transform string for the content <g> element. */
|
||||
const transform = `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`;
|
||||
|
||||
const onWheel = useCallback(
|
||||
(e: React.WheelEvent<SVGSVGElement>) => {
|
||||
@@ -40,20 +32,18 @@ export function useZoomPan() {
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
const factor = 1 + direction * ZOOM_STEP;
|
||||
|
||||
// Capture rect and cursor position before entering setState updater,
|
||||
// because React clears e.currentTarget after the event handler returns.
|
||||
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
|
||||
const clientX = e.clientX;
|
||||
const clientY = e.clientY;
|
||||
const cursorX = e.clientX - rect.left;
|
||||
const cursorY = e.clientY - rect.top;
|
||||
|
||||
setState(prev => {
|
||||
const newScale = clampScale(prev.scale * factor);
|
||||
const cursorX = clientX - rect.left;
|
||||
const cursorY = clientY - rect.top;
|
||||
const scaleRatio = newScale / prev.scale;
|
||||
const newTx = cursorX - scaleRatio * (cursorX - prev.translateX);
|
||||
const newTy = cursorY - scaleRatio * (cursorY - prev.translateY);
|
||||
return { scale: newScale, translateX: newTx, translateY: newTy };
|
||||
return {
|
||||
scale: newScale,
|
||||
translateX: cursorX - scaleRatio * (cursorX - prev.translateX),
|
||||
translateY: cursorY - scaleRatio * (cursorY - prev.translateY),
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
@@ -61,7 +51,6 @@ export function useZoomPan() {
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent<SVGSVGElement>) => {
|
||||
// Only pan on background click (not on nodes)
|
||||
if ((e.target as Element).closest('[data-node-id]')) return;
|
||||
isPanning.current = true;
|
||||
panStart.current = { x: e.clientX - state.translateX, y: e.clientY - state.translateY };
|
||||
@@ -122,16 +111,20 @@ export function useZoomPan() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
setState({ scale: 1, translateX: FIT_PADDING, translateY: FIT_PADDING });
|
||||
}, []);
|
||||
|
||||
const fitToView = useCallback(
|
||||
(contentWidth: number, contentHeight: number) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const cw = container.clientWidth - FIT_PADDING * 2;
|
||||
const ch = container.clientHeight - FIT_PADDING * 2;
|
||||
if (contentWidth <= 0 || contentHeight <= 0) return;
|
||||
const scaleX = cw / contentWidth;
|
||||
const scaleY = ch / contentHeight;
|
||||
const newScale = clampScale(Math.min(scaleX, scaleY));
|
||||
// Anchor to top-left with padding
|
||||
setState({ scale: newScale, translateX: FIT_PADDING, translateY: FIT_PADDING });
|
||||
},
|
||||
[],
|
||||
@@ -161,7 +154,8 @@ export function useZoomPan() {
|
||||
return {
|
||||
state,
|
||||
containerRef,
|
||||
viewBox,
|
||||
transform,
|
||||
resetView,
|
||||
onWheel,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
|
||||
Reference in New Issue
Block a user