fix: diagram rendering improvements
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 57s
CI / docker (push) Successful in 52s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s

- 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:
hsiegeln
2026-03-27 16:33:24 +01:00
parent 20d1182259
commit 9b7626f6ff
8 changed files with 326 additions and 176 deletions

View File

@@ -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);
}
}

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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',
]);

View File

@@ -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);

View File

@@ -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,