From 67f2464659225b3fe3572eb532c70bf19eaca647 Mon Sep 17 00:00:00 2001 From: Stephan Huber Date: Mon, 2 Feb 2026 00:05:18 +0100 Subject: [PATCH] feat: distribute terminal node handles evenly along circle arc Use cos/sin calculations to position multiple input/output handles along the circular edge of terminal nodes, preventing overlap when nodes have multiple ports. - Add reusable getCircleHandlePosition utility for circular handle placement - Update TerminalNode to use arc-based positioning (150 degree spread) - Add test terminal nodes with multiple handles for visual testing --- src/lib/components/nodes/TerminalNode.svelte | 33 ++--- src/lib/utils/handlePositioning.ts | 48 +++++++ src/mocks/data/nodes.ts | 139 +++++++++++++++++++ 3 files changed, 197 insertions(+), 23 deletions(-) create mode 100644 src/lib/utils/handlePositioning.ts diff --git a/src/lib/components/nodes/TerminalNode.svelte b/src/lib/components/nodes/TerminalNode.svelte index ad89177..f854115 100644 --- a/src/lib/components/nodes/TerminalNode.svelte +++ b/src/lib/components/nodes/TerminalNode.svelte @@ -14,6 +14,7 @@ import Icon from '@iconify/svelte'; import { getDataTypeColor, getCategoryColorToken } from '$lib/utils/colors.js'; import { getNodeIcon } from '../../utils/icons.js'; + import { getCircleHandlePosition } from '$lib/utils/handlePositioning.js'; /** * Terminal node variant types @@ -316,13 +317,14 @@
{#if showInputs} - {#each inputPorts as port (port.id)} + {#each inputPorts as port, index (port.id)} + {@const pos = getCircleHandlePosition(index, inputPorts.length, 'left')} {/each} @@ -343,14 +345,15 @@ {#if showOutputs} - {#each outputPorts as port (port.id)} + {#each outputPorts as port, index (port.id)} + {@const pos = getCircleHandlePosition(index, outputPorts.length, 'right')} {/each} {/if} @@ -614,7 +617,7 @@ } } - /* Handle styles - positioned relative to circle wrapper */ + /* Handle styles - positioned along circle arc using cos/sin */ :global(.flowdrop-terminal-node__circle-wrapper .svelte-flow__handle) { width: 16px !important; height: 16px !important; @@ -626,16 +629,8 @@ pointer-events: auto !important; } - :global(.flowdrop-terminal-node__circle-wrapper .svelte-flow__handle-left) { - left: -8px !important; - } - - :global(.flowdrop-terminal-node__circle-wrapper .svelte-flow__handle-right) { - right: -8px !important; - } - :global(.flowdrop-terminal-node__circle-wrapper .svelte-flow__handle:hover) { - transform: translateY(-50%) scale(1.2) !important; + transform: translate(-50%, -50%) scale(1.2) !important; } :global(.flowdrop-terminal-node__circle-wrapper .svelte-flow__handle:focus) { @@ -655,16 +650,8 @@ pointer-events: auto !important; } - :global(.svelte-flow__node-terminal .svelte-flow__handle-left) { - left: -8px !important; - } - - :global(.svelte-flow__node-terminal .svelte-flow__handle-right) { - right: -8px !important; - } - :global(.svelte-flow__node-terminal .svelte-flow__handle:hover) { - transform: translateY(-50%) scale(1.2) !important; + transform: translate(-50%, -50%) scale(1.2) !important; } :global(.svelte-flow__node-terminal .svelte-flow__handle:focus) { diff --git a/src/lib/utils/handlePositioning.ts b/src/lib/utils/handlePositioning.ts new file mode 100644 index 0000000..a0f1577 --- /dev/null +++ b/src/lib/utils/handlePositioning.ts @@ -0,0 +1,48 @@ +/** + * Utility functions for calculating handle positions on nodes + */ + +export interface HandlePosition { + left: number; + top: number; +} + +/** + * Calculate handle position along a circle arc using cos/sin + * + * Distributes handles evenly along an arc on the left or right side of a circle. + * For N handles, they are positioned at angles calculated as: + * angle = centerAngle - arcSpan/2 + arcSpan * (index + 1) / (count + 1) + * + * @param index - The index of the handle (0-based) + * @param count - Total number of handles on this side + * @param side - 'left' for inputs, 'right' for outputs + * @param radius - The radius of the circle (default: 36px for 72px diameter) + * @param arcSpan - The arc span in radians (default: 5π/6 = 150°) + * @returns Object with left and top pixel values relative to the circle's bounding box + * + * @example + * // Single handle on left side - positioned at center (180°) + * getCircleHandlePosition(0, 1, 'left') // { left: 0, top: 36 } + * + * @example + * // Two handles on left side - positioned at 150° and 210° + * getCircleHandlePosition(0, 2, 'left') // { left: ~4.8, top: ~18 } + * getCircleHandlePosition(1, 2, 'left') // { left: ~4.8, top: ~54 } + */ +export function getCircleHandlePosition( + index: number, + count: number, + side: 'left' | 'right', + radius: number = 36, + arcSpan: number = (Math.PI * 5) / 6 +): HandlePosition { + const centerAngle = side === 'left' ? Math.PI : 0; // 180° for left, 0° for right + const angle = centerAngle - arcSpan / 2 + (arcSpan * (index + 1)) / (count + 1); + const centerOffset = radius; // center of the circle (assuming square bounding box) + + return { + left: centerOffset + radius * Math.cos(angle), + top: centerOffset + radius * Math.sin(angle) + }; +} diff --git a/src/mocks/data/nodes.ts b/src/mocks/data/nodes.ts index 8a8c9ed..6823521 100644 --- a/src/mocks/data/nodes.ts +++ b/src/mocks/data/nodes.ts @@ -4904,6 +4904,145 @@ export const mockNodes: NodeMetadata[] = [ }, required: ['recipient'] } + }, + // Test terminal nodes for multi-handle positioning + { + id: 'test_terminal_multi_output', + name: 'Test Multi-Output Start', + type: 'terminal', + supportedTypes: ['terminal'], + description: 'Test terminal node with multiple outputs for handle positioning', + category: 'helpers', + icon: 'mdi:play-circle', + color: '#10b981', + version: '1.0.0', + tags: ['test', 'start', 'multi-output'], + inputs: [], + outputs: [ + { + id: 'trigger1', + name: 'Trigger 1', + type: 'output', + dataType: 'trigger', + required: false, + description: 'First trigger output' + }, + { + id: 'trigger2', + name: 'Trigger 2', + type: 'output', + dataType: 'trigger', + required: false, + description: 'Second trigger output' + }, + { + id: 'data', + name: 'Data', + type: 'output', + dataType: 'json', + required: false, + description: 'Data output' + } + ], + config: { + variant: 'start' + }, + configSchema: { + type: 'object', + properties: {} + } + }, + { + id: 'test_terminal_multi_input', + name: 'Test Multi-Input End', + type: 'terminal', + supportedTypes: ['terminal'], + description: 'Test terminal node with multiple inputs for handle positioning', + category: 'helpers', + icon: 'mdi:stop-circle', + color: '#6b7280', + version: '1.0.0', + tags: ['test', 'end', 'multi-input'], + inputs: [ + { + id: 'trigger1', + name: 'Trigger 1', + type: 'input', + dataType: 'trigger', + required: false, + description: 'First trigger input' + }, + { + id: 'trigger2', + name: 'Trigger 2', + type: 'input', + dataType: 'trigger', + required: false, + description: 'Second trigger input' + } + ], + outputs: [], + config: { + variant: 'end' + }, + configSchema: { + type: 'object', + properties: {} + } + }, + { + id: 'test_terminal_four_inputs', + name: 'Test 4-Input End', + type: 'terminal', + supportedTypes: ['terminal'], + description: 'Test terminal node with four inputs for handle positioning', + category: 'helpers', + icon: 'mdi:stop-circle', + color: '#ef4444', + version: '1.0.0', + tags: ['test', 'exit', 'multi-input'], + inputs: [ + { + id: 'input1', + name: 'Input 1', + type: 'input', + dataType: 'trigger', + required: false, + description: 'First input' + }, + { + id: 'input2', + name: 'Input 2', + type: 'input', + dataType: 'json', + required: false, + description: 'Second input' + }, + { + id: 'input3', + name: 'Input 3', + type: 'input', + dataType: 'string', + required: false, + description: 'Third input' + }, + { + id: 'input4', + name: 'Input 4', + type: 'input', + dataType: 'number', + required: false, + description: 'Fourth input' + } + ], + outputs: [], + config: { + variant: 'exit' + }, + configSchema: { + type: 'object', + properties: {} + } } ];