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 type { NodeConfig } from './types';
|
||||||
import { colorForType } from './node-colors';
|
import { colorForType, isCompoundType } from './node-colors';
|
||||||
import { DiagramNode } from './DiagramNode';
|
import { DiagramNode } from './DiagramNode';
|
||||||
|
import { DiagramEdge } from './DiagramEdge';
|
||||||
|
|
||||||
const HEADER_HEIGHT = 22;
|
const HEADER_HEIGHT = 22;
|
||||||
const CORNER_RADIUS = 4;
|
const CORNER_RADIUS = 4;
|
||||||
|
|
||||||
interface CompoundNodeProps {
|
interface CompoundNodeProps {
|
||||||
node: DiagramNodeType;
|
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;
|
selectedNodeId?: string;
|
||||||
hoveredNodeId: string | null;
|
hoveredNodeId: string | null;
|
||||||
nodeConfigs?: Map<string, NodeConfig>;
|
nodeConfigs?: Map<string, NodeConfig>;
|
||||||
@@ -17,17 +23,28 @@ interface CompoundNodeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CompoundNode({
|
export function CompoundNode({
|
||||||
node, selectedNodeId, hoveredNodeId, nodeConfigs,
|
node, edges, parentX = 0, parentY = 0,
|
||||||
|
selectedNodeId, hoveredNodeId, nodeConfigs,
|
||||||
onNodeClick, onNodeEnter, onNodeLeave,
|
onNodeClick, onNodeEnter, onNodeLeave,
|
||||||
}: CompoundNodeProps) {
|
}: CompoundNodeProps) {
|
||||||
const x = node.x ?? 0;
|
const x = (node.x ?? 0) - parentX;
|
||||||
const y = node.y ?? 0;
|
const y = (node.y ?? 0) - parentY;
|
||||||
|
const absX = node.x ?? 0;
|
||||||
|
const absY = node.y ?? 0;
|
||||||
const w = node.width ?? 200;
|
const w = node.width ?? 200;
|
||||||
const h = node.height ?? 100;
|
const h = node.height ?? 100;
|
||||||
const color = colorForType(node.type);
|
const color = colorForType(node.type);
|
||||||
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
|
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
|
||||||
const label = node.label ? `${typeName}: ${node.label}` : typeName;
|
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 (
|
return (
|
||||||
<g data-node-id={node.id} transform={`translate(${x}, ${y})`}>
|
<g data-node-id={node.id} transform={`translate(${x}, ${y})`}>
|
||||||
{/* Container body */}
|
{/* Container body */}
|
||||||
@@ -58,25 +75,62 @@ export function CompoundNode({
|
|||||||
{label}
|
{label}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{/* Children nodes (positioned relative to compound) */}
|
{/* Internal edges (rendered after background, before children) */}
|
||||||
{node.children?.map(child => (
|
<g className="edges">
|
||||||
<DiagramNode
|
{internalEdges.map((edge, i) => (
|
||||||
key={child.id}
|
<DiagramEdge
|
||||||
node={{
|
key={`${edge.sourceId}-${edge.targetId}-${i}`}
|
||||||
...child,
|
edge={{
|
||||||
// Children have absolute coordinates from the backend,
|
...edge,
|
||||||
// but since we're inside the compound's translate, subtract parent offset
|
points: edge.points.map(p => [p[0] - absX, p[1] - absY]),
|
||||||
x: (child.x ?? 0) - x,
|
}}
|
||||||
y: (child.y ?? 0) - y,
|
/>
|
||||||
}}
|
))}
|
||||||
isHovered={hoveredNodeId === child.id}
|
</g>
|
||||||
isSelected={selectedNodeId === child.id}
|
|
||||||
config={child.id ? nodeConfigs?.get(child.id) : undefined}
|
{/* Children — recurse into compound children, render leaves as DiagramNode */}
|
||||||
onClick={() => child.id && onNodeClick(child.id)}
|
{node.children?.map(child => {
|
||||||
onMouseEnter={() => child.id && onNodeEnter(child.id)}
|
if (isCompoundType(child.type) && child.children && child.children.length > 0) {
|
||||||
onMouseLeave={onNodeLeave}
|
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>
|
</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 { DiagramSection } from './types';
|
||||||
import type { NodeConfig } from './types';
|
import type { NodeConfig } from './types';
|
||||||
|
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
|
||||||
import { DiagramEdge } from './DiagramEdge';
|
import { DiagramEdge } from './DiagramEdge';
|
||||||
import { DiagramNode } from './DiagramNode';
|
import { DiagramNode } from './DiagramNode';
|
||||||
import { CompoundNode } from './CompoundNode';
|
import { CompoundNode } from './CompoundNode';
|
||||||
import { isCompoundType } from './node-colors';
|
import { isCompoundType } from './node-colors';
|
||||||
|
|
||||||
|
const CONTENT_PADDING_Y = 20;
|
||||||
|
const CONTENT_PADDING_LEFT = 12;
|
||||||
|
|
||||||
interface ErrorSectionProps {
|
interface ErrorSectionProps {
|
||||||
section: DiagramSection;
|
section: DiagramSection;
|
||||||
totalWidth: number;
|
totalWidth: number;
|
||||||
@@ -20,8 +25,29 @@ export function ErrorSection({
|
|||||||
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs,
|
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs,
|
||||||
onNodeClick, onNodeEnter, onNodeLeave,
|
onNodeClick, onNodeEnter, onNodeLeave,
|
||||||
}: ErrorSectionProps) {
|
}: 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 (
|
return (
|
||||||
<g transform={`translate(0, ${section.offsetY})`}>
|
<g transform={`translate(0, ${section.offsetY})`}>
|
||||||
|
{/* Section label */}
|
||||||
|
<text x={8} y={-6} fill="#C0392B" fontSize={11} fontWeight={600}>
|
||||||
|
{section.label}
|
||||||
|
</text>
|
||||||
|
|
||||||
{/* Divider line */}
|
{/* Divider line */}
|
||||||
<line
|
<line
|
||||||
x1={0}
|
x1={0}
|
||||||
@@ -34,59 +60,58 @@ export function ErrorSection({
|
|||||||
opacity={0.5}
|
opacity={0.5}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Section label */}
|
{/* Subtle red tint background — sized to actual content */}
|
||||||
<text x={8} y={-6} fill="#C0392B" fontSize={11} fontWeight={600}>
|
|
||||||
{section.label}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* Subtle red tint background */}
|
|
||||||
<rect
|
<rect
|
||||||
x={0}
|
x={0}
|
||||||
y={4}
|
y={4}
|
||||||
width={totalWidth}
|
width={totalWidth}
|
||||||
height={300}
|
height={boxHeight}
|
||||||
fill="#C0392B"
|
fill="#C0392B"
|
||||||
opacity={0.03}
|
opacity={0.03}
|
||||||
rx={4}
|
rx={4}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Edges */}
|
{/* Content group with margin from top-left */}
|
||||||
<g className="edges">
|
<g transform={`translate(${CONTENT_PADDING_LEFT}, ${CONTENT_PADDING_Y})`}>
|
||||||
{section.edges.map((edge, i) => (
|
{/* Edges */}
|
||||||
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
|
<g className="edges">
|
||||||
))}
|
{section.edges.map((edge, i) => (
|
||||||
</g>
|
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
|
||||||
{/* Nodes */}
|
{/* Nodes */}
|
||||||
<g className="nodes">
|
<g className="nodes">
|
||||||
{section.nodes.map(node => {
|
{section.nodes.map(node => {
|
||||||
if (isCompoundType(node.type) && node.children && node.children.length > 0) {
|
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 (
|
return (
|
||||||
<CompoundNode
|
<DiagramNode
|
||||||
key={node.id}
|
key={node.id}
|
||||||
node={node}
|
node={node}
|
||||||
selectedNodeId={selectedNodeId}
|
isHovered={hoveredNodeId === node.id}
|
||||||
hoveredNodeId={hoveredNodeId}
|
isSelected={selectedNodeId === node.id}
|
||||||
nodeConfigs={nodeConfigs}
|
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
||||||
onNodeClick={onNodeClick}
|
onClick={() => node.id && onNodeClick(node.id)}
|
||||||
onNodeEnter={onNodeEnter}
|
onMouseEnter={() => node.id && onNodeEnter(node.id)}
|
||||||
onNodeLeave={onNodeLeave}
|
onMouseLeave={onNodeLeave}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
})}
|
||||||
return (
|
</g>
|
||||||
<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>
|
</g>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,82 +1,50 @@
|
|||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import type { NodeAction } from './types';
|
import type { NodeAction } from './types';
|
||||||
|
import styles from './ProcessDiagram.module.css';
|
||||||
|
|
||||||
const TOOLBAR_HEIGHT = 28;
|
|
||||||
const TOOLBAR_WIDTH = 140;
|
|
||||||
const HIDE_DELAY = 150;
|
const HIDE_DELAY = 150;
|
||||||
|
|
||||||
interface NodeToolbarProps {
|
interface NodeToolbarProps {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
nodeX: number;
|
/** Screen-space position (already transformed by zoom/pan) */
|
||||||
nodeY: number;
|
screenX: number;
|
||||||
nodeWidth: number;
|
screenY: number;
|
||||||
onAction: (nodeId: string, action: NodeAction) => void;
|
onAction: (nodeId: string, action: NodeAction) => void;
|
||||||
onMouseEnter: () => void;
|
onMouseEnter: () => void;
|
||||||
onMouseLeave: () => void;
|
onMouseLeave: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIONS: { label: string; icon: string; action: NodeAction; title: string }[] = [
|
const ACTIONS: { icon: string; action: NodeAction; title: string }[] = [
|
||||||
{ label: 'Inspect', icon: '\uD83D\uDD0D', action: 'inspect', title: 'Inspect node' },
|
{ icon: '\uD83D\uDD0D', action: 'inspect', title: 'Inspect' },
|
||||||
{ label: 'Trace', icon: 'T', action: 'toggle-trace', title: 'Toggle tracing' },
|
{ icon: 'T', action: 'toggle-trace', title: 'Toggle tracing' },
|
||||||
{ label: 'Tap', icon: '\u270E', action: 'configure-tap', title: 'Configure tap' },
|
{ icon: '\u270E', action: 'configure-tap', title: 'Configure tap' },
|
||||||
{ label: 'More', icon: '\u22EF', action: 'copy-id', title: 'Copy processor ID' },
|
{ icon: '\u22EF', action: 'copy-id', title: 'Copy ID' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function NodeToolbar({
|
export function NodeToolbar({
|
||||||
nodeId, nodeX, nodeY, nodeWidth, onAction, onMouseEnter, onMouseLeave,
|
nodeId, screenX, screenY, onAction, onMouseEnter, onMouseLeave,
|
||||||
}: NodeToolbarProps) {
|
}: NodeToolbarProps) {
|
||||||
const x = nodeX + (nodeWidth - TOOLBAR_WIDTH) / 2;
|
|
||||||
const y = nodeY - TOOLBAR_HEIGHT - 6;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<foreignObject
|
<div
|
||||||
x={x}
|
className={styles.nodeToolbar}
|
||||||
y={y}
|
style={{ left: screenX, top: screenY }}
|
||||||
width={TOOLBAR_WIDTH}
|
|
||||||
height={TOOLBAR_HEIGHT}
|
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
>
|
>
|
||||||
<div
|
{ACTIONS.map(a => (
|
||||||
style={{
|
<button
|
||||||
display: 'flex',
|
key={a.action}
|
||||||
alignItems: 'center',
|
className={styles.nodeToolbarBtn}
|
||||||
justifyContent: 'center',
|
title={a.title}
|
||||||
gap: '6px',
|
onClick={(e) => {
|
||||||
height: TOOLBAR_HEIGHT,
|
e.stopPropagation();
|
||||||
background: 'rgba(26, 22, 18, 0.92)',
|
onAction(nodeId, a.action);
|
||||||
borderRadius: '6px',
|
}}
|
||||||
padding: '0 8px',
|
>
|
||||||
}}
|
{a.icon}
|
||||||
>
|
</button>
|
||||||
{ACTIONS.map(a => (
|
))}
|
||||||
<button
|
</div>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,3 +77,39 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-variant-numeric: tabular-nums;
|
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 contentWidth = totalWidth + PADDING * 2;
|
||||||
const contentHeight = totalHeight + 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(() => {
|
useEffect(() => {
|
||||||
if (totalWidth > 0 && totalHeight > 0) {
|
if (totalWidth > 0 && totalHeight > 0) {
|
||||||
zoom.fitToView(contentWidth, contentHeight);
|
zoom.resetView();
|
||||||
}
|
}
|
||||||
}, [totalWidth, totalHeight]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [totalWidth, totalHeight]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -98,7 +98,6 @@ export function ProcessDiagram({
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={styles.svg}
|
className={styles.svg}
|
||||||
viewBox={zoom.viewBox(contentWidth, contentHeight)}
|
|
||||||
onWheel={zoom.onWheel}
|
onWheel={zoom.onWheel}
|
||||||
onPointerDown={zoom.onPointerDown}
|
onPointerDown={zoom.onPointerDown}
|
||||||
onPointerMove={zoom.onPointerMove}
|
onPointerMove={zoom.onPointerMove}
|
||||||
@@ -120,10 +119,10 @@ export function ProcessDiagram({
|
|||||||
</marker>
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<g transform={`translate(${PADDING}, ${PADDING})`}>
|
<g style={{ transform: zoom.transform, transformOrigin: '0 0' }}>
|
||||||
{/* Main section edges */}
|
{/* Main section top-level edges (not inside compounds) */}
|
||||||
<g className="edges">
|
<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} />
|
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
@@ -136,6 +135,7 @@ export function ProcessDiagram({
|
|||||||
<CompoundNode
|
<CompoundNode
|
||||||
key={node.id}
|
key={node.id}
|
||||||
node={node}
|
node={node}
|
||||||
|
edges={mainSection.edges}
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
hoveredNodeId={toolbar.hoveredNodeId}
|
hoveredNodeId={toolbar.hoveredNodeId}
|
||||||
nodeConfigs={nodeConfigs}
|
nodeConfigs={nodeConfigs}
|
||||||
@@ -160,22 +160,7 @@ export function ProcessDiagram({
|
|||||||
})}
|
})}
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
{/* Toolbar for hovered node */}
|
{/* Toolbar rendered as HTML overlay below */}
|
||||||
{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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Error handler sections */}
|
{/* Error handler sections */}
|
||||||
{errorSections.map((section, i) => (
|
{errorSections.map((section, i) => (
|
||||||
@@ -194,6 +179,27 @@ export function ProcessDiagram({
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</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
|
<ZoomControls
|
||||||
onZoomIn={zoom.zoomIn}
|
onZoomIn={zoom.zoomIn}
|
||||||
onZoomOut={zoom.zoomOut}
|
onZoomOut={zoom.zoomOut}
|
||||||
@@ -212,10 +218,49 @@ function findNodeById(
|
|||||||
for (const node of section.nodes) {
|
for (const node of section.nodes) {
|
||||||
if (node.id === nodeId) return node;
|
if (node.id === nodeId) return node;
|
||||||
if (node.children) {
|
if (node.children) {
|
||||||
const child = node.children.find(c => c.id === nodeId);
|
const found = findInChildren(node.children, nodeId);
|
||||||
if (child) return child;
|
if (found) return found;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
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([
|
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',
|
'EIP_LOOP', 'EIP_MULTICAST', 'EIP_AGGREGATE',
|
||||||
'ON_EXCEPTION', 'ERROR_HANDLER',
|
'ON_EXCEPTION', 'ERROR_HANDLER',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -56,27 +56,43 @@ export function useDiagramData(
|
|||||||
];
|
];
|
||||||
|
|
||||||
let currentY = mainBounds.maxY + SECTION_GAP;
|
let currentY = mainBounds.maxY + SECTION_GAP;
|
||||||
|
let maxWidth = mainBounds.maxX;
|
||||||
|
|
||||||
for (const es of errorSections) {
|
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>();
|
const errorNodeIds = new Set<string>();
|
||||||
collectNodeIds(es.nodes, errorNodeIds);
|
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({
|
sections.push({
|
||||||
label: es.label,
|
label: es.label,
|
||||||
nodes: es.nodes,
|
nodes: shiftedNodes,
|
||||||
edges: errorEdges,
|
edges: errorEdges,
|
||||||
offsetY: currentY,
|
offsetY: currentY,
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
});
|
});
|
||||||
|
|
||||||
const errorBounds = computeBounds(es.nodes);
|
currentY += sectionHeight + SECTION_GAP;
|
||||||
currentY += (errorBounds.maxY - errorBounds.minY) + 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;
|
const totalHeight = currentY;
|
||||||
|
|
||||||
return { sections, totalWidth, totalHeight };
|
return { sections, totalWidth, totalHeight };
|
||||||
@@ -85,6 +101,16 @@ export function useDiagramData(
|
|||||||
return { ...result, isLoading, error };
|
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>) {
|
function collectNodeIds(nodes: 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);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface ZoomPanState {
|
|||||||
const MIN_SCALE = 0.25;
|
const MIN_SCALE = 0.25;
|
||||||
const MAX_SCALE = 4.0;
|
const MAX_SCALE = 4.0;
|
||||||
const ZOOM_STEP = 0.15;
|
const ZOOM_STEP = 0.15;
|
||||||
const FIT_PADDING = 40;
|
const FIT_PADDING = 20;
|
||||||
|
|
||||||
export function useZoomPan() {
|
export function useZoomPan() {
|
||||||
const [state, setState] = useState<ZoomPanState>({
|
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 clampScale = (s: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
|
||||||
|
|
||||||
const viewBox = useCallback(
|
/** Returns the CSS transform string for the content <g> element. */
|
||||||
(contentWidth: number, contentHeight: number) => {
|
const transform = `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`;
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onWheel = useCallback(
|
const onWheel = useCallback(
|
||||||
(e: React.WheelEvent<SVGSVGElement>) => {
|
(e: React.WheelEvent<SVGSVGElement>) => {
|
||||||
@@ -40,20 +32,18 @@ export function useZoomPan() {
|
|||||||
const direction = e.deltaY < 0 ? 1 : -1;
|
const direction = e.deltaY < 0 ? 1 : -1;
|
||||||
const factor = 1 + direction * ZOOM_STEP;
|
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 rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
|
||||||
const clientX = e.clientX;
|
const cursorX = e.clientX - rect.left;
|
||||||
const clientY = e.clientY;
|
const cursorY = e.clientY - rect.top;
|
||||||
|
|
||||||
setState(prev => {
|
setState(prev => {
|
||||||
const newScale = clampScale(prev.scale * factor);
|
const newScale = clampScale(prev.scale * factor);
|
||||||
const cursorX = clientX - rect.left;
|
|
||||||
const cursorY = clientY - rect.top;
|
|
||||||
const scaleRatio = newScale / prev.scale;
|
const scaleRatio = newScale / prev.scale;
|
||||||
const newTx = cursorX - scaleRatio * (cursorX - prev.translateX);
|
return {
|
||||||
const newTy = cursorY - scaleRatio * (cursorY - prev.translateY);
|
scale: newScale,
|
||||||
return { scale: newScale, translateX: newTx, translateY: newTy };
|
translateX: cursorX - scaleRatio * (cursorX - prev.translateX),
|
||||||
|
translateY: cursorY - scaleRatio * (cursorY - prev.translateY),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -61,7 +51,6 @@ export function useZoomPan() {
|
|||||||
|
|
||||||
const onPointerDown = useCallback(
|
const onPointerDown = useCallback(
|
||||||
(e: React.PointerEvent<SVGSVGElement>) => {
|
(e: React.PointerEvent<SVGSVGElement>) => {
|
||||||
// Only pan on background click (not on nodes)
|
|
||||||
if ((e.target as Element).closest('[data-node-id]')) return;
|
if ((e.target as Element).closest('[data-node-id]')) return;
|
||||||
isPanning.current = true;
|
isPanning.current = true;
|
||||||
panStart.current = { x: e.clientX - state.translateX, y: e.clientY - state.translateY };
|
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(
|
const fitToView = useCallback(
|
||||||
(contentWidth: number, contentHeight: number) => {
|
(contentWidth: number, contentHeight: number) => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const cw = container.clientWidth - FIT_PADDING * 2;
|
const cw = container.clientWidth - FIT_PADDING * 2;
|
||||||
const ch = container.clientHeight - FIT_PADDING * 2;
|
const ch = container.clientHeight - FIT_PADDING * 2;
|
||||||
|
if (contentWidth <= 0 || contentHeight <= 0) return;
|
||||||
const scaleX = cw / contentWidth;
|
const scaleX = cw / contentWidth;
|
||||||
const scaleY = ch / contentHeight;
|
const scaleY = ch / contentHeight;
|
||||||
const newScale = clampScale(Math.min(scaleX, scaleY));
|
const newScale = clampScale(Math.min(scaleX, scaleY));
|
||||||
// Anchor to top-left with padding
|
|
||||||
setState({ scale: newScale, translateX: FIT_PADDING, translateY: FIT_PADDING });
|
setState({ scale: newScale, translateX: FIT_PADDING, translateY: FIT_PADDING });
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -161,7 +154,8 @@ export function useZoomPan() {
|
|||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
containerRef,
|
containerRef,
|
||||||
viewBox,
|
transform,
|
||||||
|
resetView,
|
||||||
onWheel,
|
onWheel,
|
||||||
onPointerDown,
|
onPointerDown,
|
||||||
onPointerMove,
|
onPointerMove,
|
||||||
|
|||||||
Reference in New Issue
Block a user