From 85892d31009f1bcfcfa592e26a414e62ea9979f8 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 15:05:51 +0100 Subject: [PATCH 01/15] Add port labels store and keyboard shortcut (L) --- .../dialogs/KeyboardShortcutsDialog.svelte | 1 + src/lib/stores/index.ts | 1 + src/lib/stores/portLabels.ts | 38 +++++++++++++++++++ src/routes/+page.svelte | 5 +++ 4 files changed, 45 insertions(+) create mode 100644 src/lib/stores/portLabels.ts diff --git a/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte b/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte index be3eb955..af9bae29 100644 --- a/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte +++ b/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte @@ -58,6 +58,7 @@ { keys: ['H'], description: 'Go to root' }, { keys: ['+'], description: 'Zoom in' }, { keys: ['-'], description: 'Zoom out' }, + { keys: ['L'], description: 'Port labels' }, { keys: ['T'], description: 'Theme' } ] }, diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index 54eb9348..93368c75 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -19,6 +19,7 @@ export { contextMenuStore } from './contextMenu'; export { nodeUpdatesStore } from './nodeUpdates'; export { pinnedPreviewsStore } from './pinnedPreviews'; export { hoveredHandle, selectedNodeHighlight } from './hoveredHandle'; +export { portLabelsStore } from './portLabels'; // View actions (re-exports triggers and utils) export * from './viewActions'; diff --git a/src/lib/stores/portLabels.ts b/src/lib/stores/portLabels.ts new file mode 100644 index 00000000..4e8722cd --- /dev/null +++ b/src/lib/stores/portLabels.ts @@ -0,0 +1,38 @@ +/** + * Port Labels Store + * + * Controls global visibility of port labels inside nodes. + * Toggle with 'L' key. Persists to localStorage. + */ + +import { writable, get } from 'svelte/store'; +import { browser } from '$app/environment'; + +const STORAGE_KEY = 'pathview-portLabels'; + +function getInitialValue(): boolean { + if (!browser) return false; + return localStorage.getItem(STORAGE_KEY) === 'true'; +} + +const store = writable(getInitialValue()); + +// Persist to localStorage on change +store.subscribe((value) => { + if (browser) { + localStorage.setItem(STORAGE_KEY, String(value)); + } +}); + +export const portLabelsStore = { + subscribe: store.subscribe, + toggle(): void { + store.update((current) => !current); + }, + set(value: boolean): void { + store.set(value); + }, + get(): boolean { + return get(store); + } +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7f755dcc..e9de62d2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -48,6 +48,7 @@ import { triggerFitView, triggerZoomIn, triggerZoomOut, triggerPan, getViewportCenter, screenToFlow, triggerClearSelection, triggerNudge, hasAnySelection, setFitViewPadding, triggerFlyInAnimation } from '$lib/stores/viewActions'; import { nodeUpdatesStore } from '$lib/stores/nodeUpdates'; import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews'; + import { portLabelsStore } from '$lib/stores/portLabels'; import { clipboardStore } from '$lib/stores/clipboard'; import Tooltip, { tooltip } from '$lib/components/Tooltip.svelte'; import { isInputFocused } from '$lib/utils/focus'; @@ -653,6 +654,10 @@ event.preventDefault(); pinnedPreviewsStore.toggle(); return; + case 'l': + event.preventDefault(); + portLabelsStore.toggle(); + return; case 'b': event.preventDefault(); toggleNodeLibrary(); From 601b15f7cae626df845f33d188f926e4dd0fda40 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 15:08:08 +0100 Subject: [PATCH 02/15] Add showPortLabels parameter to node dimension calculation --- src/lib/constants/dimensions.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index f95c5b24..dd6d4a2d 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -42,6 +42,14 @@ export const EVENT = { /** Export padding: 4 grid units = 40px */ export const EXPORT_PADDING = G.x4; +/** Port label dimensions (when labels are shown) */ +export const PORT_LABEL = { + /** Width of label column for horizontal ports: 4 grid units = 40px */ + columnWidth: G.x4, + /** Height of label row for vertical ports: 2 grid units = 20px */ + rowHeight: G.x2 +} as const; + /** * Round up to next 2G (20px) boundary. * This ensures nodes expand by 1G in each direction (symmetric from center). @@ -75,6 +83,8 @@ export function getPortPositionCalc(index: number, total: number): string { /** * Calculate node dimensions from node data. * Used by both SvelteFlow (for bounds) and BaseNode (for CSS). + * + * @param showPortLabels - If true, adds space for port label columns/rows */ export function calculateNodeDimensions( name: string, @@ -82,7 +92,8 @@ export function calculateNodeDimensions( outputCount: number, pinnedParamCount: number, rotation: number, - typeName?: string + typeName?: string, + showPortLabels?: boolean ): { width: number; height: number } { const isVertical = rotation === 1 || rotation === 3; const maxPortsOnSide = Math.max(inputCount, outputCount); @@ -97,7 +108,7 @@ export function calculateNodeDimensions( const nameWidth = name.length * 6 + 20; const typeWidth = typeName ? typeName.length * 5 + 20 : 0; const pinnedParamsWidth = pinnedParamCount > 0 ? 160 : 0; - const width = snapTo2G(Math.max( + let width = snapTo2G(Math.max( NODE.baseWidth, nameWidth, typeWidth, @@ -107,9 +118,22 @@ export function calculateNodeDimensions( // Height: content height vs port dimension (they share vertical space) const contentHeight = NODE.baseHeight + pinnedParamsHeight; - const height = isVertical + let height = isVertical ? snapTo2G(contentHeight) : snapTo2G(Math.max(contentHeight, minPortDimension)); + // Add space for port labels if enabled + if (showPortLabels) { + if (isVertical) { + // Vertical ports: add rows for labels above/below content + if (inputCount > 0) height += PORT_LABEL.rowHeight; + if (outputCount > 0) height += PORT_LABEL.rowHeight; + } else { + // Horizontal ports: add columns for labels on left/right + if (inputCount > 0) width += PORT_LABEL.columnWidth; + if (outputCount > 0) width += PORT_LABEL.columnWidth; + } + } + return { width, height }; } From 035856e0216e03f4f42e2fec545cd44b01ddff61 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 15:21:38 +0100 Subject: [PATCH 03/15] Implement port labels rendering in BaseNode --- src/lib/components/nodes/BaseNode.svelte | 169 ++++++++++++++++++++++- 1 file changed, 165 insertions(+), 4 deletions(-) diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index fb851536..153260e7 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -8,11 +8,12 @@ import { graphStore } from '$lib/stores/graph'; import { historyStore } from '$lib/stores/history'; import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews'; + import { portLabelsStore } from '$lib/stores/portLabels'; import { hoveredHandle, selectedNodeHighlight } from '$lib/stores/hoveredHandle'; import { showTooltip, hideTooltip } from '$lib/components/Tooltip.svelte'; import { paramInput } from '$lib/actions/paramInput'; import { plotDataStore } from '$lib/plotting/processing/plotDataStore'; - import { NODE, getPortPositionCalc, calculateNodeDimensions, snapTo2G } from '$lib/constants/dimensions'; + import { NODE, PORT_LABEL, getPortPositionCalc, calculateNodeDimensions, snapTo2G } from '$lib/constants/dimensions'; import { containsMath, renderInlineMath, renderInlineMathSync, measureRenderedMath, getBaselineTextHeight } from '$lib/utils/inlineMathRenderer'; import { getKatexCssUrl } from '$lib/utils/katexLoader'; import PlotPreview from './PlotPreview.svelte'; @@ -58,9 +59,24 @@ hasPlotData = state.plots.has(id); }); + // Port labels visibility + let showPortLabels = $state(false); + const unsubscribePortLabels = portLabelsStore.subscribe((value) => { + showPortLabels = value; + }); + + // Re-measure node when port labels toggle changes + $effect(() => { + // Dependency on showPortLabels + if (showPortLabels !== undefined) { + updateNodeInternals(id); + } + }); + onDestroy(() => { unsubscribePinned(); unsubscribePlotData(); + unsubscribePortLabels(); if (hoverTimeout) clearTimeout(hoverTimeout); }); @@ -186,7 +202,8 @@ data.outputs.length, pinnedCount, rotation, - typeDef?.name + typeDef?.name, + showPortLabels )); // Use measured width if math is rendered and measured, otherwise use calculated const nodeWidth = $derived(() => { @@ -200,13 +217,19 @@ const pinnedParamsWidth = pinnedCount > 0 ? 160 : 0; // Minimum width for layout (without name string-length estimate) - const minLayoutWidth = snapTo2G(Math.max( + let minLayoutWidth = snapTo2G(Math.max( NODE.baseWidth, typeWidth, pinnedParamsWidth, isVertical ? minPortDimension : 0 )); + // Add port label columns if enabled (horizontal ports only) + if (showPortLabels && !isVertical) { + if (data.inputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; + if (data.outputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; + } + // Add horizontal padding from .node-content (12px each side = 24px) const measuredMathWidth = snapTo2G(measuredNameWidth + 24); return Math.max(minLayoutWidth, measuredMathWidth); @@ -229,7 +252,13 @@ const pinnedParamsHeight = pinnedCount > 0 ? 7 + 24 * pinnedCount : 0; // Content height: math height + type label (12px) + padding (12px) - const contentHeight = measuredNameHeight + 24 + pinnedParamsHeight; + let contentHeight = measuredNameHeight + 24 + pinnedParamsHeight; + + // Add port label rows if enabled (vertical ports only) + if (showPortLabels && isVertical) { + if (data.inputs.length > 0) contentHeight += PORT_LABEL.rowHeight; + if (data.outputs.length > 0) contentHeight += PORT_LABEL.rowHeight; + } return isVertical ? snapTo2G(contentHeight) @@ -305,6 +334,11 @@ return String(value); } + // Truncate port label for display + function truncateLabel(name: string, maxChars: number = 5): string { + return name.length > maxChars ? name.slice(0, maxChars) : name; + } + // Format default value for placeholder (Python style) function formatDefault(value: unknown): string { if (value === null || value === undefined) return 'None'; @@ -379,6 +413,9 @@ class:vertical={isVertical} class:preview-hovered={showPreview} class:subsystem-type={isSubsystemType} + class:show-labels={showPortLabels} + class:has-inputs={data.inputs.length > 0} + class:has-outputs={data.outputs.length > 0} data-rotation={rotation} style="width: {nodeWidth()}px; height: {nodeHeight()}px; --node-color: {nodeColor};" ondblclick={handleDoubleClick} @@ -400,6 +437,26 @@
{/if} + + {#if showPortLabels && data.inputs.length > 0 && !isVertical} +
+ {#each data.inputs as port, i} + + {truncateLabel(port.name)} + + {/each} +
+ {/if} + + + {#if showPortLabels && data.inputs.length > 0 && isVertical} +
+ {#each data.inputs as port} + {truncateLabel(port.name)} + {/each} +
+ {/if} +
@@ -440,6 +497,26 @@ {/if}
+ + {#if showPortLabels && data.outputs.length > 0 && !isVertical} +
+ {#each data.outputs as port, i} + + {truncateLabel(port.name)} + + {/each} +
+ {/if} + + + {#if showPortLabels && data.outputs.length > 0 && isVertical} +
+ {#each data.outputs as port} + {truncateLabel(port.name)} + {/each} +
+ {/if} + {#if allowsDynamicInputs && selected}
@@ -870,4 +947,88 @@ opacity: 1; } } + + /* Port labels - 3-column grid layout when labels are shown (horizontal) */ + .node.show-labels:not(.vertical) { + display: grid; + grid-template-columns: var(--input-col, 0px) 1fr var(--output-col, 0px); + } + .node.show-labels.has-inputs:not(.vertical) { + --input-col: 40px; + } + .node.show-labels.has-outputs:not(.vertical) { + --output-col: 40px; + } + + /* Port labels - 3-row grid layout when labels are shown (vertical) */ + .node.show-labels.vertical { + display: grid; + grid-template-rows: var(--input-row, 0px) 1fr var(--output-row, 0px); + } + .node.show-labels.vertical.has-inputs { + --input-row: 20px; + } + .node.show-labels.vertical.has-outputs { + --output-row: 20px; + } + + /* Label containers */ + .port-labels { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + } + + /* Horizontal layout: left/right columns */ + .port-labels-input:not(.port-labels-right) { + text-align: right; + padding-right: 4px; + } + .port-labels-output:not(.port-labels-left) { + text-align: left; + padding-left: 4px; + } + + /* Flipped for rotation 2 */ + .port-labels-input.port-labels-right { + text-align: left; + padding-left: 4px; + } + .port-labels-output.port-labels-left { + text-align: right; + padding-right: 4px; + } + + /* Individual port labels (absolute positioning for horizontal) */ + .port-label { + position: absolute; + font-size: 8px; + color: var(--text-muted); + white-space: nowrap; + transform: translateY(-50%); + overflow: hidden; + text-overflow: ellipsis; + max-width: 36px; + left: 0; + right: 0; + } + + /* Vertical rotation - horizontal row of labels */ + .port-labels-row { + display: flex; + align-items: center; + justify-content: center; + gap: 20px; + height: 100%; + } + + .port-labels-row .port-label { + position: relative; + transform: none; + top: auto; + left: auto; + right: auto; + text-align: center; + } From 3f173d0fcb430cdd8651445c5fd14fc7ba41d013 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 16:47:12 +0100 Subject: [PATCH 04/15] Add per-node port label toggles for input/output visibility --- src/lib/components/contextMenuBuilders.ts | 59 +++ src/lib/components/nodes/BaseNode.svelte | 415 ++++++++++++++-------- src/lib/constants/dimensions.ts | 30 +- 3 files changed, 347 insertions(+), 157 deletions(-) diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index 39da3924..0faaab0d 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -25,6 +25,7 @@ import { hasExportableData, exportRecordingData } from '$lib/utils/csvExport'; import { exportToSVG } from '$lib/export/svg'; import { downloadSvg } from '$lib/utils/download'; import { plotSettingsStore, DEFAULT_BLOCK_SETTINGS } from '$lib/stores/plotSettings'; +import { portLabelsStore } from '$lib/stores/portLabels'; /** Divider menu item */ const DIVIDER: MenuItemType = { label: '', action: () => {}, divider: true }; @@ -73,6 +74,10 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { // Interface blocks have limited options if (isInterface) { + const globalLabels = get(portLabelsStore); + const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; + const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + return [ { label: 'Properties', @@ -86,6 +91,21 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { action: () => graphStore.drillUp() }, DIVIDER, + { + label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) + ) + }, + { + label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) + ) + }, + DIVIDER, { label: 'View Code', icon: 'braces', @@ -96,6 +116,10 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { // Subsystem blocks get "Enter" option if (isSubsystem) { + const globalLabels = get(portLabelsStore); + const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; + const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + return [ { label: 'Properties', @@ -109,6 +133,21 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { action: () => graphStore.drillDown(nodeId) }, DIVIDER, + { + label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) + ) + }, + { + label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) + ) + }, + DIVIDER, { label: 'View Code', icon: 'braces', @@ -152,6 +191,11 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { const isRecordingNode = node.type === 'Scope' || node.type === 'Spectrum'; const dataSource = node.type === 'Scope' ? 'scope' : 'spectrum'; + // Per-node port label visibility (undefined = follow global) + const globalLabels = get(portLabelsStore); + const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; + const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + // Regular blocks const items: MenuItemType[] = [ { @@ -161,6 +205,21 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { action: () => openNodeDialog(nodeId) }, DIVIDER, + { + label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) + ) + }, + { + label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) + ) + }, + DIVIDER, { label: 'View Code', icon: 'braces', diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index 153260e7..87e99586 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -59,16 +59,27 @@ hasPlotData = state.plots.has(id); }); - // Port labels visibility - let showPortLabels = $state(false); + // Global port labels visibility + let globalShowPortLabels = $state(false); const unsubscribePortLabels = portLabelsStore.subscribe((value) => { - showPortLabels = value; + globalShowPortLabels = value; }); + // Per-node overrides (undefined = follow global) + const nodeShowInputLabels = $derived(data.params?.['_showInputLabels'] as boolean | undefined); + const nodeShowOutputLabels = $derived(data.params?.['_showOutputLabels'] as boolean | undefined); + + // Effective visibility (per-node overrides global) + const showInputLabels = $derived(nodeShowInputLabels ?? globalShowPortLabels); + const showOutputLabels = $derived(nodeShowOutputLabels ?? globalShowPortLabels); + + // For CSS class (show-labels when either is visible) + const showPortLabels = $derived(showInputLabels || showOutputLabels); + // Re-measure node when port labels toggle changes $effect(() => { - // Dependency on showPortLabels - if (showPortLabels !== undefined) { + // Dependency on showInputLabels and showOutputLabels + if (showInputLabels !== undefined || showOutputLabels !== undefined) { updateNodeInternals(id); } }); @@ -203,7 +214,8 @@ pinnedCount, rotation, typeDef?.name, - showPortLabels + showInputLabels, + showOutputLabels )); // Use measured width if math is rendered and measured, otherwise use calculated const nodeWidth = $derived(() => { @@ -225,9 +237,9 @@ )); // Add port label columns if enabled (horizontal ports only) - if (showPortLabels && !isVertical) { - if (data.inputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; - if (data.outputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; + if (!isVertical) { + if (showInputLabels && data.inputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; + if (showOutputLabels && data.outputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; } // Add horizontal padding from .node-content (12px each side = 24px) @@ -255,9 +267,9 @@ let contentHeight = measuredNameHeight + 24 + pinnedParamsHeight; // Add port label rows if enabled (vertical ports only) - if (showPortLabels && isVertical) { - if (data.inputs.length > 0) contentHeight += PORT_LABEL.rowHeight; - if (data.outputs.length > 0) contentHeight += PORT_LABEL.rowHeight; + if (isVertical) { + if (showInputLabels && data.inputs.length > 0) contentHeight += PORT_LABEL.rowHeight; + if (showOutputLabels && data.outputs.length > 0) contentHeight += PORT_LABEL.rowHeight; } return isVertical @@ -414,8 +426,8 @@ class:preview-hovered={showPreview} class:subsystem-type={isSubsystemType} class:show-labels={showPortLabels} - class:has-inputs={data.inputs.length > 0} - class:has-outputs={data.outputs.length > 0} + class:has-inputs={showInputLabels && data.inputs.length > 0} + class:has-outputs={showOutputLabels && data.outputs.length > 0} data-rotation={rotation} style="width: {nodeWidth()}px; height: {nodeHeight()}px; --node-color: {nodeColor};" ondblclick={handleDoubleClick} @@ -437,85 +449,90 @@
{/if} - - {#if showPortLabels && data.inputs.length > 0 && !isVertical} -
- {#each data.inputs as port, i} - - {truncateLabel(port.name)} - - {/each} -
- {/if} - - - {#if showPortLabels && data.inputs.length > 0 && isVertical} -
- {#each data.inputs as port} - {truncateLabel(port.name)} - {/each} -
- {/if} - - -
- -
- {#if renderedNameHtml} - {@html renderedNameHtml} + +
+ + {#if showInputLabels && data.inputs.length > 0} + {#if isVertical} +
+ {#each data.inputs as port, i} + + {truncateLabel(port.name)} + + {/each} +
{:else} - {data.name} +
+ {#each data.inputs as port, i} + + {truncateLabel(port.name)} + + {/each} +
{/if} - {#if typeDef} - {typeDef.name} - {/if} -
+ {/if} - - {#if validPinnedParams().length > 0 && typeDef} - -
e.stopPropagation()} ondblclick={(e) => e.stopPropagation()}> - {#each validPinnedParams() as paramName} - {@const paramDef = typeDef.params.find(p => p.name === paramName)} - {#if paramDef} -
- - handlePinnedParamChange(paramName, e.currentTarget.value)} - onmousedown={(e) => e.stopPropagation()} - onfocus={(e) => e.stopPropagation()} - use:paramInput - /> -
- {/if} - {/each} + +
+ +
+ {#if renderedNameHtml} + {@html renderedNameHtml} + {:else} + {data.name} + {/if} + {#if typeDef} + {typeDef.name} + {/if}
- {/if} -
- - {#if showPortLabels && data.outputs.length > 0 && !isVertical} -
- {#each data.outputs as port, i} - - {truncateLabel(port.name)} - - {/each} + + {#if validPinnedParams().length > 0 && typeDef} + +
e.stopPropagation()} ondblclick={(e) => e.stopPropagation()}> + {#each validPinnedParams() as paramName} + {@const paramDef = typeDef.params.find(p => p.name === paramName)} + {#if paramDef} +
+ + handlePinnedParamChange(paramName, e.currentTarget.value)} + onmousedown={(e) => e.stopPropagation()} + onfocus={(e) => e.stopPropagation()} + use:paramInput + /> +
+ {/if} + {/each} +
+ {/if}
- {/if} - - {#if showPortLabels && data.outputs.length > 0 && isVertical} -
- {#each data.outputs as port} - {truncateLabel(port.name)} - {/each} -
- {/if} + + {#if showOutputLabels && data.outputs.length > 0} + {#if isVertical} +
+ {#each data.outputs as port, i} + + {truncateLabel(port.name)} + + {/each} +
+ {:else} +
+ {#each data.outputs as port, i} + + {truncateLabel(port.name)} + + {/each} +
+ {/if} + {/if} +
{#if allowsDynamicInputs && selected} @@ -569,29 +586,32 @@ position: relative; /* Dimensions set via inline style using grid constants */ /* Note: center-origin handled by SvelteFlow's nodeOrigin={[0.5, 0.5]} */ - display: flex; - flex-direction: column; background: var(--surface-raised); border: 1px solid var(--edge); font-size: 10px; - overflow: visible; + overflow: visible; /* Allow handles to extend outside */ + --node-radius: 8px; } - /* Shape variants */ + /* Shape variants - set both border-radius and custom property for inner clipping */ .shape-pill { - border-radius: 20px; + --node-radius: 20px; + border-radius: var(--node-radius); } .shape-rect { - border-radius: 4px; + --node-radius: 4px; + border-radius: var(--node-radius); } .shape-circle { - border-radius: 16px; + --node-radius: 16px; + border-radius: var(--node-radius); } .shape-diamond { - border-radius: 4px; + --node-radius: 4px; + border-radius: var(--node-radius); transform: rotate(45deg); } @@ -600,11 +620,13 @@ } .shape-mixed { + --node-radius: 12px; border-radius: 12px 4px 12px 4px; } .shape-default { - border-radius: 8px; + --node-radius: 8px; + border-radius: var(--node-radius); } /* Subsystem/Interface dashed border */ @@ -627,13 +649,21 @@ z-index: 1000 !important; } - /* Inner wrapper for content - fills node, clips to rounded corners */ + /* Clip wrapper - fills node, clips content to rounded corners */ + .node-clip { + position: absolute; + inset: 0; + overflow: hidden; + border-radius: max(0px, calc(var(--node-radius, 8px) - 1px)); + display: flex; + flex-direction: column; + } + + /* Inner wrapper for content */ .node-inner { flex: 1; display: flex; flex-direction: column; - border-radius: inherit; - overflow: hidden; min-height: 0; } @@ -690,21 +720,24 @@ margin-top: 2px; } - /* Pinned parameters */ + /* Pinned parameters - rectangular, clipped by node-clip's overflow:hidden */ .pinned-params { display: flex; flex-direction: column; gap: 4px; - padding: 4px 10px 6px; + padding: 4px 8px 6px; border-top: 1px solid var(--border); background: var(--surface); + border-radius: 0; + overflow: hidden; } .pinned-param { display: flex; align-items: center; - gap: 6px; + gap: 4px; min-width: 0; + max-width: 100%; } .pinned-param label { @@ -721,7 +754,7 @@ flex: 1; min-width: 0; height: 20px; - padding: 2px 8px; + padding: 2px 6px; font-size: 8px; font-family: var(--font-mono); background: var(--surface-raised); @@ -949,27 +982,45 @@ } /* Port labels - 3-column grid layout when labels are shown (horizontal) */ - .node.show-labels:not(.vertical) { + .node.show-labels:not(.vertical) .node-clip { display: grid; - grid-template-columns: var(--input-col, 0px) 1fr var(--output-col, 0px); + grid-template-columns: var(--left-col, 0px) 1fr var(--right-col, 0px); + grid-template-rows: 1fr; } - .node.show-labels.has-inputs:not(.vertical) { - --input-col: 40px; + /* Rotation 0: inputs left, outputs right */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs { + --left-col: 40px; } - .node.show-labels.has-outputs:not(.vertical) { - --output-col: 40px; + .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs { + --right-col: 40px; + } + /* Rotation 2: inputs right, outputs left */ + .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs { + --left-col: 40px; + } + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs { + --right-col: 40px; } /* Port labels - 3-row grid layout when labels are shown (vertical) */ - .node.show-labels.vertical { + .node.show-labels.vertical .node-clip { display: grid; - grid-template-rows: var(--input-row, 0px) 1fr var(--output-row, 0px); + grid-template-columns: 1fr; + grid-template-rows: var(--top-row, 0px) 1fr var(--bottom-row, 0px); + } + /* Rotation 1: inputs top, outputs bottom */ + .node.show-labels.vertical[data-rotation="1"].has-inputs { + --top-row: 40px; } - .node.show-labels.vertical.has-inputs { - --input-row: 20px; + .node.show-labels.vertical[data-rotation="1"].has-outputs { + --bottom-row: 40px; } - .node.show-labels.vertical.has-outputs { - --output-row: 20px; + /* Rotation 3: inputs bottom, outputs top */ + .node.show-labels.vertical[data-rotation="3"].has-outputs { + --top-row: 40px; + } + .node.show-labels.vertical[data-rotation="3"].has-inputs { + --bottom-row: 40px; } /* Label containers */ @@ -977,27 +1028,63 @@ position: relative; min-width: 0; min-height: 0; - overflow: hidden; + overflow: visible; } - /* Horizontal layout: left/right columns */ - .port-labels-input:not(.port-labels-right) { - text-align: right; - padding-right: 4px; + /* Horizontal layout: explicit grid column placement */ + .node.show-labels:not(.vertical) .node-clip > .port-labels-input { + grid-column: 1; + grid-row: 1; + border-right: 1px solid var(--border); } - .port-labels-output:not(.port-labels-left) { - text-align: left; - padding-left: 4px; + .node.show-labels:not(.vertical) .node-clip > .node-inner { + grid-column: 2; + grid-row: 1; + } + .node.show-labels:not(.vertical) .node-clip > .port-labels-output { + grid-column: 3; + grid-row: 1; + border-left: 1px solid var(--border); } - /* Flipped for rotation 2 */ - .port-labels-input.port-labels-right { - text-align: left; - padding-left: 4px; + /* Rotation 2: swap input/output column positions */ + .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-input { + grid-column: 3; + border-right: none; + border-left: 1px solid var(--border); } - .port-labels-output.port-labels-left { - text-align: right; - padding-right: 4px; + .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-output { + grid-column: 1; + border-left: none; + border-right: 1px solid var(--border); + } + + /* Vertical layout: explicit grid row placement */ + .node.show-labels.vertical .node-clip > .port-labels-input { + grid-column: 1; + grid-row: 1; + border-bottom: 1px solid var(--border); + } + .node.show-labels.vertical .node-clip > .node-inner { + grid-column: 1; + grid-row: 2; + } + .node.show-labels.vertical .node-clip > .port-labels-output { + grid-column: 1; + grid-row: 3; + border-top: 1px solid var(--border); + } + + /* Rotation 3: swap input/output row positions */ + .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-input { + grid-row: 3; + border-bottom: none; + border-top: 1px solid var(--border); + } + .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-output { + grid-row: 1; + border-top: none; + border-bottom: 1px solid var(--border); } /* Individual port labels (absolute positioning for horizontal) */ @@ -1010,25 +1097,69 @@ overflow: hidden; text-overflow: ellipsis; max-width: 36px; - left: 0; - right: 0; + line-height: 1; + } + + /* Input labels: align right (near separator), away from handle edge */ + .port-labels-input .port-label { + right: 6px; + text-align: right; + } + + /* Output labels: align left (near separator), away from handle edge */ + .port-labels-output .port-label { + left: 6px; + text-align: left; + } + + /* Rotation 2: swap alignment */ + .node[data-rotation="2"] .port-labels-input .port-label { + right: auto; + left: 6px; + text-align: left; + } + .node[data-rotation="2"] .port-labels-output .port-label { + left: auto; + right: 6px; + text-align: right; } - /* Vertical rotation - horizontal row of labels */ + /* Vertical rotation - row of labels with 90deg rotation */ .port-labels-row { - display: flex; - align-items: center; - justify-content: center; - gap: 20px; - height: 100%; + position: relative; } + /* Reset horizontal-specific styles for vertical labels */ .port-labels-row .port-label { - position: relative; - transform: none; - top: auto; - left: auto; + position: absolute; + width: auto; + max-width: none; right: auto; - text-align: center; + /* Use center origin for simpler positioning */ + transform-origin: center center; + /* text-align: left = text starts at original left edge = visual bottom after -90deg rotation */ + text-align: left; + } + + /* Input labels at top row: center vertically, shift toward bottom separator */ + .node.show-labels.vertical .port-labels-input .port-label { + top: 50%; + bottom: auto; + transform: translateX(-50%) translateY(calc(-50% + 6px)) rotate(-90deg); + } + + /* Output labels at bottom row: center vertically, shift toward top separator */ + .node.show-labels.vertical .port-labels-output .port-label { + top: 50%; + bottom: auto; + transform: translateX(-50%) translateY(calc(-50% - 6px)) rotate(-90deg); + } + + /* Rotation 3: swap the vertical shifts */ + .node.show-labels.vertical[data-rotation="3"] .port-labels-input .port-label { + transform: translateX(-50%) translateY(calc(-50% - 6px)) rotate(-90deg); + } + .node.show-labels.vertical[data-rotation="3"] .port-labels-output .port-label { + transform: translateX(-50%) translateY(calc(-50% + 6px)) rotate(-90deg); } diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index dd6d4a2d..3a5bf0e8 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -46,8 +46,8 @@ export const EXPORT_PADDING = G.x4; export const PORT_LABEL = { /** Width of label column for horizontal ports: 4 grid units = 40px */ columnWidth: G.x4, - /** Height of label row for vertical ports: 2 grid units = 20px */ - rowHeight: G.x2 + /** Height of label row for vertical ports: 4 grid units = 40px (same as column width) */ + rowHeight: G.x4 } as const; /** @@ -84,7 +84,8 @@ export function getPortPositionCalc(index: number, total: number): string { * Calculate node dimensions from node data. * Used by both SvelteFlow (for bounds) and BaseNode (for CSS). * - * @param showPortLabels - If true, adds space for port label columns/rows + * @param showInputLabels - If true, adds space for input port label column/row + * @param showOutputLabels - If true, adds space for output port label column/row */ export function calculateNodeDimensions( name: string, @@ -93,7 +94,8 @@ export function calculateNodeDimensions( pinnedParamCount: number, rotation: number, typeName?: string, - showPortLabels?: boolean + showInputLabels?: boolean, + showOutputLabels?: boolean ): { width: number; height: number } { const isVertical = rotation === 1 || rotation === 3; const maxPortsOnSide = Math.max(inputCount, outputCount); @@ -122,17 +124,15 @@ export function calculateNodeDimensions( ? snapTo2G(contentHeight) : snapTo2G(Math.max(contentHeight, minPortDimension)); - // Add space for port labels if enabled - if (showPortLabels) { - if (isVertical) { - // Vertical ports: add rows for labels above/below content - if (inputCount > 0) height += PORT_LABEL.rowHeight; - if (outputCount > 0) height += PORT_LABEL.rowHeight; - } else { - // Horizontal ports: add columns for labels on left/right - if (inputCount > 0) width += PORT_LABEL.columnWidth; - if (outputCount > 0) width += PORT_LABEL.columnWidth; - } + // Add space for port labels if enabled (separately for inputs and outputs) + if (isVertical) { + // Vertical ports: add rows for labels above/below content + if (showInputLabels && inputCount > 0) height += PORT_LABEL.rowHeight; + if (showOutputLabels && outputCount > 0) height += PORT_LABEL.rowHeight; + } else { + // Horizontal ports: add columns for labels on left/right + if (showInputLabels && inputCount > 0) width += PORT_LABEL.columnWidth; + if (showOutputLabels && outputCount > 0) width += PORT_LABEL.columnWidth; } return { width, height }; From 5f831d974fcf75d228104244d74b17ea04d0556b Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 16:50:33 +0100 Subject: [PATCH 05/15] Fix port label menu icon to use existing type icon --- src/lib/components/contextMenuBuilders.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index 0faaab0d..b2be54d5 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -93,14 +93,14 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { DIVIDER, { label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'tag', + icon: 'type', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) ) }, { label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'tag', + icon: 'type', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) ) @@ -135,14 +135,14 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { DIVIDER, { label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'tag', + icon: 'type', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) ) }, { label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'tag', + icon: 'type', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) ) @@ -207,14 +207,14 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { DIVIDER, { label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'tag', + icon: 'type', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) ) }, { label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'tag', + icon: 'type', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) ) From 3e7cbefd0c3aecaaa12a12e3baaf700268fad470 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 16:51:19 +0100 Subject: [PATCH 06/15] Add tag icon for port label menu items --- src/lib/components/contextMenuBuilders.ts | 4 ++-- src/lib/components/icons/Icon.svelte | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index b2be54d5..6c665bf2 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -207,14 +207,14 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { DIVIDER, { label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'type', + icon: 'tag', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) ) }, { label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'type', + icon: 'tag', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) ) diff --git a/src/lib/components/icons/Icon.svelte b/src/lib/components/icons/Icon.svelte index b8cbc042..336c3626 100644 --- a/src/lib/components/icons/Icon.svelte +++ b/src/lib/components/icons/Icon.svelte @@ -415,6 +415,11 @@ +{:else if name === 'tag'} + + + + {:else if name === 'font-size-increase'} A From 27a12192cf0264717f741e466cf835c1ad2ed0d8 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 16:53:48 +0100 Subject: [PATCH 07/15] Only show port label menu items when node has inputs/outputs --- src/lib/components/contextMenuBuilders.ts | 146 ++++++++++++++-------- 1 file changed, 93 insertions(+), 53 deletions(-) diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index 6c665bf2..dc1ed488 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -77,8 +77,10 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { const globalLabels = get(portLabelsStore); const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + const hasInputs = node.inputs && node.inputs.length > 0; + const hasOutputs = node.outputs && node.outputs.length > 0; - return [ + const items: MenuItemType[] = [ { label: 'Properties', icon: 'settings', @@ -89,29 +91,41 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { label: 'Exit Subsystem', icon: 'exit', action: () => graphStore.drillUp() - }, - DIVIDER, - { - label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'type', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) - ) - }, - { - label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'type', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) - ) - }, + } + ]; + + if (hasInputs || hasOutputs) { + items.push(DIVIDER); + if (hasInputs) { + items.push({ + label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) + ) + }); + } + if (hasOutputs) { + items.push({ + label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) + ) + }); + } + } + + items.push( DIVIDER, { label: 'View Code', icon: 'braces', action: () => showBlockCode(nodeId) } - ]; + ); + + return items; } // Subsystem blocks get "Enter" option @@ -119,8 +133,10 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { const globalLabels = get(portLabelsStore); const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + const hasInputs = node.inputs && node.inputs.length > 0; + const hasOutputs = node.outputs && node.outputs.length > 0; - return [ + const items: MenuItemType[] = [ { label: 'Properties', icon: 'settings', @@ -131,22 +147,32 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { icon: 'enter', shortcut: 'Dbl-click', action: () => graphStore.drillDown(nodeId) - }, - DIVIDER, - { - label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'type', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) - ) - }, - { - label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'type', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) - ) - }, + } + ]; + + if (hasInputs || hasOutputs) { + items.push(DIVIDER); + if (hasInputs) { + items.push({ + label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) + ) + }); + } + if (hasOutputs) { + items.push({ + label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) + ) + }); + } + } + + items.push( DIVIDER, { label: 'View Code', @@ -184,7 +210,9 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { shortcut: 'Del', action: () => historyStore.mutate(() => graphStore.removeNode(nodeId)) } - ]; + ); + + return items; } // Check if this is a recording node (Scope or Spectrum) @@ -195,6 +223,8 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { const globalLabels = get(portLabelsStore); const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + const hasInputs = node.inputs && node.inputs.length > 0; + const hasOutputs = node.outputs && node.outputs.length > 0; // Regular blocks const items: MenuItemType[] = [ @@ -203,29 +233,39 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { icon: 'settings', shortcut: 'Dbl-click', action: () => openNodeDialog(nodeId) - }, - DIVIDER, - { - label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'tag', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) - ) - }, - { - label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'tag', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) - ) - }, + } + ]; + + if (hasInputs || hasOutputs) { + items.push(DIVIDER); + if (hasInputs) { + items.push({ + label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) + ) + }); + } + if (hasOutputs) { + items.push({ + label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) + ) + }); + } + } + + items.push( DIVIDER, { label: 'View Code', icon: 'braces', action: () => showBlockCode(nodeId) } - ]; + ); // Add CSV export for recording nodes if (isRecordingNode) { From b149f11839c6e9a320389c70199da4eb1fa58128 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 17:04:54 +0100 Subject: [PATCH 08/15] Fix port label grid layout for source/sink nodes --- src/lib/components/nodes/BaseNode.svelte | 207 ++++++++++++----------- 1 file changed, 111 insertions(+), 96 deletions(-) diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index 87e99586..d0ef0ac4 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -228,23 +228,27 @@ const typeWidth = typeDef ? typeDef.name.length * 5 + 20 : 0; const pinnedParamsWidth = pinnedCount > 0 ? 160 : 0; - // Minimum width for layout (without name string-length estimate) - let minLayoutWidth = snapTo2G(Math.max( + // Minimum content width for layout (without name string-length estimate) + const minContentWidth = snapTo2G(Math.max( NODE.baseWidth, typeWidth, pinnedParamsWidth, isVertical ? minPortDimension : 0 )); - // Add port label columns if enabled (horizontal ports only) + // Add horizontal padding from .node-content (12px each side = 24px) + const measuredMathWidth = snapTo2G(measuredNameWidth + 24); + + // Content width is max of minimum and measured + let totalWidth = Math.max(minContentWidth, measuredMathWidth); + + // Add port label columns on top of content width (horizontal ports only) if (!isVertical) { - if (showInputLabels && data.inputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; - if (showOutputLabels && data.outputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; + if (showInputLabels && data.inputs.length > 0) totalWidth += PORT_LABEL.columnWidth; + if (showOutputLabels && data.outputs.length > 0) totalWidth += PORT_LABEL.columnWidth; } - // Add horizontal padding from .node-content (12px each side = 24px) - const measuredMathWidth = snapTo2G(measuredNameWidth + 24); - return Math.max(minLayoutWidth, measuredMathWidth); + return totalWidth; } return nodeDimensions.width; }); @@ -264,17 +268,19 @@ const pinnedParamsHeight = pinnedCount > 0 ? 7 + 24 * pinnedCount : 0; // Content height: math height + type label (12px) + padding (12px) - let contentHeight = measuredNameHeight + 24 + pinnedParamsHeight; + const contentHeight = measuredNameHeight + 24 + pinnedParamsHeight; - // Add port label rows if enabled (vertical ports only) + let totalHeight = isVertical + ? snapTo2G(contentHeight) + : snapTo2G(Math.max(contentHeight, minPortDimension)); + + // Add port label rows on top of content height (vertical ports only) if (isVertical) { - if (showInputLabels && data.inputs.length > 0) contentHeight += PORT_LABEL.rowHeight; - if (showOutputLabels && data.outputs.length > 0) contentHeight += PORT_LABEL.rowHeight; + if (showInputLabels && data.inputs.length > 0) totalHeight += PORT_LABEL.rowHeight; + if (showOutputLabels && data.outputs.length > 0) totalHeight += PORT_LABEL.rowHeight; } - return isVertical - ? snapTo2G(contentHeight) - : snapTo2G(Math.max(contentHeight, minPortDimension)); + return totalHeight; } } return nodeDimensions.height; @@ -981,46 +987,47 @@ } } - /* Port labels - 3-column grid layout when labels are shown (horizontal) */ + /* Port labels - grid layout when labels are shown (horizontal) */ + /* Base: enable grid on .node-clip */ .node.show-labels:not(.vertical) .node-clip { display: grid; - grid-template-columns: var(--left-col, 0px) 1fr var(--right-col, 0px); grid-template-rows: 1fr; } - /* Rotation 0: inputs left, outputs right */ - .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs { - --left-col: 40px; - } - .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs { - --right-col: 40px; + + /* Rotation 0/2: Both inputs and outputs - 3 columns */ + .node.show-labels:not(.vertical).has-inputs.has-outputs .node-clip { + grid-template-columns: 40px 1fr 40px; } - /* Rotation 2: inputs right, outputs left */ - .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs { - --left-col: 40px; + /* Rotation 0: inputs only - 2 columns (labels left, content right) */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs:not(.has-outputs) .node-clip, + .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs:not(.has-inputs) .node-clip { + grid-template-columns: 40px 1fr; } - .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs { - --right-col: 40px; + /* Rotation 0: outputs only - 2 columns (content left, labels right) */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs:not(.has-inputs) .node-clip, + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs:not(.has-outputs) .node-clip { + grid-template-columns: 1fr 40px; } - /* Port labels - 3-row grid layout when labels are shown (vertical) */ + /* Port labels - grid layout when labels are shown (vertical) */ .node.show-labels.vertical .node-clip { display: grid; grid-template-columns: 1fr; - grid-template-rows: var(--top-row, 0px) 1fr var(--bottom-row, 0px); - } - /* Rotation 1: inputs top, outputs bottom */ - .node.show-labels.vertical[data-rotation="1"].has-inputs { - --top-row: 40px; } - .node.show-labels.vertical[data-rotation="1"].has-outputs { - --bottom-row: 40px; + + /* Rotation 1/3: Both inputs and outputs - 3 rows */ + .node.show-labels.vertical.has-inputs.has-outputs .node-clip { + grid-template-rows: 40px 1fr 40px; } - /* Rotation 3: inputs bottom, outputs top */ - .node.show-labels.vertical[data-rotation="3"].has-outputs { - --top-row: 40px; + /* Rotation 1: inputs only - 2 rows (labels top, content bottom) */ + .node.show-labels.vertical[data-rotation="1"].has-inputs:not(.has-outputs) .node-clip, + .node.show-labels.vertical[data-rotation="3"].has-outputs:not(.has-inputs) .node-clip { + grid-template-rows: 40px 1fr; } - .node.show-labels.vertical[data-rotation="3"].has-inputs { - --bottom-row: 40px; + /* Rotation 1: outputs only - 2 rows (content top, labels bottom) */ + .node.show-labels.vertical[data-rotation="1"].has-outputs:not(.has-inputs) .node-clip, + .node.show-labels.vertical[data-rotation="3"].has-inputs:not(.has-outputs) .node-clip { + grid-template-rows: 1fr 40px; } /* Label containers */ @@ -1031,61 +1038,69 @@ overflow: visible; } - /* Horizontal layout: explicit grid column placement */ - .node.show-labels:not(.vertical) .node-clip > .port-labels-input { - grid-column: 1; - grid-row: 1; - border-right: 1px solid var(--border); - } - .node.show-labels:not(.vertical) .node-clip > .node-inner { - grid-column: 2; - grid-row: 1; - } - .node.show-labels:not(.vertical) .node-clip > .port-labels-output { - grid-column: 3; - grid-row: 1; - border-left: 1px solid var(--border); - } - - /* Rotation 2: swap input/output column positions */ - .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-input { - grid-column: 3; - border-right: none; - border-left: 1px solid var(--border); - } - .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-output { - grid-column: 1; - border-left: none; - border-right: 1px solid var(--border); - } - - /* Vertical layout: explicit grid row placement */ - .node.show-labels.vertical .node-clip > .port-labels-input { - grid-column: 1; - grid-row: 1; - border-bottom: 1px solid var(--border); - } - .node.show-labels.vertical .node-clip > .node-inner { - grid-column: 1; - grid-row: 2; - } - .node.show-labels.vertical .node-clip > .port-labels-output { - grid-column: 1; - grid-row: 3; - border-top: 1px solid var(--border); - } - - /* Rotation 3: swap input/output row positions */ - .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-input { - grid-row: 3; - border-bottom: none; - border-top: 1px solid var(--border); - } - .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-output { - grid-row: 1; - border-top: none; - border-bottom: 1px solid var(--border); - } + /* Horizontal layout: grid column placement - rotation 0 (inputs left, outputs right) */ + /* Both labels: input=1, content=2, output=3 */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .node-inner { grid-column: 2; } + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-column: 3; } + /* Input labels only: input=1, content=2 */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-column: 2; } + /* Output labels only: content=1, output=2 */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-column: 2; } + + /* Horizontal layout: grid column placement - rotation 2 (inputs right, outputs left) */ + /* Both labels: output=1, content=2, input=3 */ + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .node-inner { grid-column: 2; } + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-column: 3; } + /* Output labels only (left side): output=1, content=2 */ + .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-column: 2; } + /* Input labels only (right side): content=1, input=2 */ + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-column: 2; } + + /* Horizontal borders */ + .node.show-labels:not(.vertical) .node-clip > .port-labels-input { grid-row: 1; border-right: 1px solid var(--border); } + .node.show-labels:not(.vertical) .node-clip > .node-inner { grid-row: 1; } + .node.show-labels:not(.vertical) .node-clip > .port-labels-output { grid-row: 1; border-left: 1px solid var(--border); } + /* Rotation 2: swap borders */ + .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-input { border-right: none; border-left: 1px solid var(--border); } + .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-output { border-left: none; border-right: 1px solid var(--border); } + + /* Vertical layout: grid row placement - rotation 1 (inputs top, outputs bottom) */ + /* Both labels: input=1, content=2, output=3 */ + .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-row: 1; } + .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .node-inner { grid-row: 2; } + .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-row: 3; } + /* Input labels only: input=1, content=2 */ + .node.show-labels.vertical[data-rotation="1"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-row: 1; } + .node.show-labels.vertical[data-rotation="1"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-row: 2; } + /* Output labels only: content=1, output=2 */ + .node.show-labels.vertical[data-rotation="1"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-row: 1; } + .node.show-labels.vertical[data-rotation="1"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-row: 2; } + + /* Vertical layout: grid row placement - rotation 3 (inputs bottom, outputs top) */ + /* Both labels: output=1, content=2, input=3 */ + .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-row: 1; } + .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .node-inner { grid-row: 2; } + .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-row: 3; } + /* Output labels only (top): output=1, content=2 */ + .node.show-labels.vertical[data-rotation="3"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-row: 1; } + .node.show-labels.vertical[data-rotation="3"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-row: 2; } + /* Input labels only (bottom): content=1, input=2 */ + .node.show-labels.vertical[data-rotation="3"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-row: 1; } + .node.show-labels.vertical[data-rotation="3"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-row: 2; } + + /* Vertical borders */ + .node.show-labels.vertical .node-clip > .port-labels-input { grid-column: 1; border-bottom: 1px solid var(--border); } + .node.show-labels.vertical .node-clip > .node-inner { grid-column: 1; } + .node.show-labels.vertical .node-clip > .port-labels-output { grid-column: 1; border-top: 1px solid var(--border); } + /* Rotation 3: swap borders */ + .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-input { border-bottom: none; border-top: 1px solid var(--border); } + .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-output { border-top: none; border-bottom: 1px solid var(--border); } /* Individual port labels (absolute positioning for horizontal) */ .port-label { From 58403272610f7c2612a93b10ed41ff4c33b49724 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 17:14:18 +0100 Subject: [PATCH 09/15] Fix show-labels class to only apply when labels actually displayed --- src/lib/components/nodes/BaseNode.svelte | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index d0ef0ac4..e25cc016 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -73,8 +73,11 @@ const showInputLabels = $derived(nodeShowInputLabels ?? globalShowPortLabels); const showOutputLabels = $derived(nodeShowOutputLabels ?? globalShowPortLabels); - // For CSS class (show-labels when either is visible) - const showPortLabels = $derived(showInputLabels || showOutputLabels); + // For CSS class (show-labels only when labels are actually displayed) + const showPortLabels = $derived( + (showInputLabels && data.inputs.length > 0) || + (showOutputLabels && data.outputs.length > 0) + ); // Re-measure node when port labels toggle changes $effect(() => { From 76a6fdf75c493465e4fdf96d872f78e2a91ef209 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 17:20:22 +0100 Subject: [PATCH 10/15] Add min-width:0 to node-inner and grid fallback for robustness --- src/lib/components/nodes/BaseNode.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index e25cc016..86ad1a98 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -673,6 +673,7 @@ flex: 1; display: flex; flex-direction: column; + min-width: 0; min-height: 0; } @@ -991,9 +992,10 @@ } /* Port labels - grid layout when labels are shown (horizontal) */ - /* Base: enable grid on .node-clip */ + /* Base: enable grid on .node-clip with fallback column template */ .node.show-labels:not(.vertical) .node-clip { display: grid; + grid-template-columns: 1fr; grid-template-rows: 1fr; } From a992e9ee161960cb5a748e1838a6dd78c352655a Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 18:51:47 +0100 Subject: [PATCH 11/15] Consolidate dimension calculation and port label utilities - Move nodeWidth/nodeHeight logic from BaseNode to dimensions.ts - Add getPortOffset() for numeric port position calculation - Create shared portLabels.ts with visibility and truncation helpers - Simplify BaseNode with gridTemplate() computed property --- src/lib/components/nodes/BaseNode.svelte | 197 ++++++++--------------- src/lib/constants/dimensions.ts | 86 ++++++---- src/lib/utils/portLabels.ts | 42 +++++ 3 files changed, 167 insertions(+), 158 deletions(-) create mode 100644 src/lib/utils/portLabels.ts diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index 86ad1a98..e14dac84 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -13,8 +13,9 @@ import { showTooltip, hideTooltip } from '$lib/components/Tooltip.svelte'; import { paramInput } from '$lib/actions/paramInput'; import { plotDataStore } from '$lib/plotting/processing/plotDataStore'; - import { NODE, PORT_LABEL, getPortPositionCalc, calculateNodeDimensions, snapTo2G } from '$lib/constants/dimensions'; - import { containsMath, renderInlineMath, renderInlineMathSync, measureRenderedMath, getBaselineTextHeight } from '$lib/utils/inlineMathRenderer'; + import { PORT_LABEL, getPortPositionCalc, calculateNodeDimensions } from '$lib/constants/dimensions'; + import { truncatePortLabel } from '$lib/utils/portLabels'; + import { containsMath, renderInlineMath, renderInlineMathSync, measureRenderedMath } from '$lib/utils/inlineMathRenderer'; import { getKatexCssUrl } from '$lib/utils/katexLoader'; import PlotPreview from './PlotPreview.svelte'; @@ -69,15 +70,16 @@ const nodeShowInputLabels = $derived(data.params?.['_showInputLabels'] as boolean | undefined); const nodeShowOutputLabels = $derived(data.params?.['_showOutputLabels'] as boolean | undefined); - // Effective visibility (per-node overrides global) + // Effective visibility settings (per-node overrides global) const showInputLabels = $derived(nodeShowInputLabels ?? globalShowPortLabels); const showOutputLabels = $derived(nodeShowOutputLabels ?? globalShowPortLabels); - // For CSS class (show-labels only when labels are actually displayed) - const showPortLabels = $derived( - (showInputLabels && data.inputs.length > 0) || - (showOutputLabels && data.outputs.length > 0) - ); + // Actual visibility: setting is ON and ports exist (single source of truth) + const hasVisibleInputLabels = $derived(showInputLabels && data.inputs.length > 0); + const hasVisibleOutputLabels = $derived(showOutputLabels && data.outputs.length > 0); + + // For CSS class (show-labels when any labels are actually displayed) + const showPortLabels = $derived(hasVisibleInputLabels || hasVisibleOutputLabels); // Re-measure node when port labels toggle changes $effect(() => { @@ -209,6 +211,13 @@ const maxPortsOnSide = $derived(Math.max(data.inputs.length, data.outputs.length)); const pinnedCount = $derived(validPinnedParams().length); + // Measured name dimensions for math rendering (null if not measured or no math) + const measuredName = $derived( + nameHasMath && measuredNameWidth !== null && measuredNameHeight !== null + ? { width: measuredNameWidth, height: measuredNameHeight } + : null + ); + // Node dimensions - calculated from shared utility (same as SvelteFlow bounds) const nodeDimensions = $derived(calculateNodeDimensions( data.name, @@ -217,76 +226,43 @@ pinnedCount, rotation, typeDef?.name, - showInputLabels, - showOutputLabels + hasVisibleInputLabels, + hasVisibleOutputLabels, + measuredName )); - // Use measured width if math is rendered and measured, otherwise use calculated - const nodeWidth = $derived(() => { - if (measuredNameWidth !== null && nameHasMath) { - // For math names, use measured width instead of string-length estimate - // But still respect minimum width needed for ports, pinned params, type label - const isVertical = rotation === 1 || rotation === 3; - const maxPortsOnSide = Math.max(data.inputs.length, data.outputs.length); - const minPortDimension = Math.max(1, maxPortsOnSide) * NODE.portSpacing; - const typeWidth = typeDef ? typeDef.name.length * 5 + 20 : 0; - const pinnedParamsWidth = pinnedCount > 0 ? 160 : 0; - - // Minimum content width for layout (without name string-length estimate) - const minContentWidth = snapTo2G(Math.max( - NODE.baseWidth, - typeWidth, - pinnedParamsWidth, - isVertical ? minPortDimension : 0 - )); - - // Add horizontal padding from .node-content (12px each side = 24px) - const measuredMathWidth = snapTo2G(measuredNameWidth + 24); - - // Content width is max of minimum and measured - let totalWidth = Math.max(minContentWidth, measuredMathWidth); - - // Add port label columns on top of content width (horizontal ports only) - if (!isVertical) { - if (showInputLabels && data.inputs.length > 0) totalWidth += PORT_LABEL.columnWidth; - if (showOutputLabels && data.outputs.length > 0) totalWidth += PORT_LABEL.columnWidth; - } - - return totalWidth; - } - return nodeDimensions.width; - }); - // Height calculation - only override for tall math (like \displaystyle) - // Compare measured math height to baseline text height for robustness - const nodeHeight = $derived(() => { - if (measuredNameHeight !== null && nameHasMath) { - // Get baseline height of standard text - only grow if math is significantly taller - const baselineHeight = getBaselineTextHeight(); - if (measuredNameHeight > baselineHeight * 1.2) { - const isVertical = rotation === 1 || rotation === 3; - const maxPortsOnSide = Math.max(data.inputs.length, data.outputs.length); - const minPortDimension = Math.max(1, maxPortsOnSide) * NODE.portSpacing; - - // Pinned params height: border(1) + padding(10) + rows(24 each) - const pinnedParamsHeight = pinnedCount > 0 ? 7 + 24 * pinnedCount : 0; - - // Content height: math height + type label (12px) + padding (12px) - const contentHeight = measuredNameHeight + 24 + pinnedParamsHeight; - - let totalHeight = isVertical - ? snapTo2G(contentHeight) - : snapTo2G(Math.max(contentHeight, minPortDimension)); - - // Add port label rows on top of content height (vertical ports only) - if (isVertical) { - if (showInputLabels && data.inputs.length > 0) totalHeight += PORT_LABEL.rowHeight; - if (showOutputLabels && data.outputs.length > 0) totalHeight += PORT_LABEL.rowHeight; - } - - return totalHeight; + // Grid template for port labels layout (computed in JS instead of CSS selectors) + const gridTemplate = $derived(() => { + if (!showPortLabels) return { columns: undefined, rows: undefined }; + + const labelSize = `${PORT_LABEL.columnWidth}px`; + + if (isVertical) { + // Vertical layout: rows for input/output labels + if (hasVisibleInputLabels && hasVisibleOutputLabels) { + // Both: [input-labels] [content] [output-labels] + return { columns: undefined, rows: `${labelSize} 1fr ${labelSize}` }; + } else if (hasVisibleInputLabels) { + // Input only: rotation 1 = top, rotation 3 = bottom + return { columns: undefined, rows: rotation === 1 ? `${labelSize} 1fr` : `1fr ${labelSize}` }; + } else if (hasVisibleOutputLabels) { + // Output only: rotation 1 = bottom, rotation 3 = top + return { columns: undefined, rows: rotation === 1 ? `1fr ${labelSize}` : `${labelSize} 1fr` }; + } + } else { + // Horizontal layout: columns for input/output labels + if (hasVisibleInputLabels && hasVisibleOutputLabels) { + // Both: [input-labels] [content] [output-labels] + return { columns: `${labelSize} 1fr ${labelSize}`, rows: undefined }; + } else if (hasVisibleInputLabels) { + // Input only: rotation 0 = left, rotation 2 = right + return { columns: rotation === 0 ? `${labelSize} 1fr` : `1fr ${labelSize}`, rows: undefined }; + } else if (hasVisibleOutputLabels) { + // Output only: rotation 0 = right, rotation 2 = left + return { columns: rotation === 0 ? `1fr ${labelSize}` : `${labelSize} 1fr`, rows: undefined }; } } - return nodeDimensions.height; + return { columns: undefined, rows: undefined }; }); // Check if this is a Subsystem or Interface node (using shapes utility) @@ -355,11 +331,6 @@ return String(value); } - // Truncate port label for display - function truncateLabel(name: string, maxChars: number = 5): string { - return name.length > maxChars ? name.slice(0, maxChars) : name; - } - // Format default value for placeholder (Python style) function formatDefault(value: unknown): string { if (value === null || value === undefined) return 'None'; @@ -435,10 +406,10 @@ class:preview-hovered={showPreview} class:subsystem-type={isSubsystemType} class:show-labels={showPortLabels} - class:has-inputs={showInputLabels && data.inputs.length > 0} - class:has-outputs={showOutputLabels && data.outputs.length > 0} + class:has-inputs={hasVisibleInputLabels} + class:has-outputs={hasVisibleOutputLabels} data-rotation={rotation} - style="width: {nodeWidth()}px; height: {nodeHeight()}px; --node-color: {nodeColor};" + style="width: {nodeDimensions.width}px; height: {nodeDimensions.height}px; --node-color: {nodeColor};" ondblclick={handleDoubleClick} onmouseenter={handleMouseEnter} onmouseleave={handleMouseLeave} @@ -459,14 +430,18 @@ {/if} -
+
- {#if showInputLabels && data.inputs.length > 0} + {#if hasVisibleInputLabels} {#if isVertical}
{#each data.inputs as port, i} - {truncateLabel(port.name)} + {truncatePortLabel(port.name)} {/each}
@@ -474,7 +449,7 @@
{#each data.inputs as port, i} - {truncateLabel(port.name)} + {truncatePortLabel(port.name)} {/each}
@@ -522,12 +497,12 @@
- {#if showOutputLabels && data.outputs.length > 0} + {#if hasVisibleOutputLabels} {#if isVertical}
{#each data.outputs as port, i} - {truncateLabel(port.name)} + {truncatePortLabel(port.name)} {/each}
@@ -535,7 +510,7 @@
{#each data.outputs as port, i} - {truncateLabel(port.name)} + {truncatePortLabel(port.name)} {/each}
@@ -991,48 +966,10 @@ } } - /* Port labels - grid layout when labels are shown (horizontal) */ - /* Base: enable grid on .node-clip with fallback column template */ - .node.show-labels:not(.vertical) .node-clip { + /* Port labels - grid layout when labels are shown */ + /* Grid template columns/rows are set via inline style from JS */ + .node.show-labels .node-clip { display: grid; - grid-template-columns: 1fr; - grid-template-rows: 1fr; - } - - /* Rotation 0/2: Both inputs and outputs - 3 columns */ - .node.show-labels:not(.vertical).has-inputs.has-outputs .node-clip { - grid-template-columns: 40px 1fr 40px; - } - /* Rotation 0: inputs only - 2 columns (labels left, content right) */ - .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs:not(.has-outputs) .node-clip, - .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs:not(.has-inputs) .node-clip { - grid-template-columns: 40px 1fr; - } - /* Rotation 0: outputs only - 2 columns (content left, labels right) */ - .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs:not(.has-inputs) .node-clip, - .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs:not(.has-outputs) .node-clip { - grid-template-columns: 1fr 40px; - } - - /* Port labels - grid layout when labels are shown (vertical) */ - .node.show-labels.vertical .node-clip { - display: grid; - grid-template-columns: 1fr; - } - - /* Rotation 1/3: Both inputs and outputs - 3 rows */ - .node.show-labels.vertical.has-inputs.has-outputs .node-clip { - grid-template-rows: 40px 1fr 40px; - } - /* Rotation 1: inputs only - 2 rows (labels top, content bottom) */ - .node.show-labels.vertical[data-rotation="1"].has-inputs:not(.has-outputs) .node-clip, - .node.show-labels.vertical[data-rotation="3"].has-outputs:not(.has-inputs) .node-clip { - grid-template-rows: 40px 1fr; - } - /* Rotation 1: outputs only - 2 rows (content top, labels bottom) */ - .node.show-labels.vertical[data-rotation="1"].has-outputs:not(.has-inputs) .node-clip, - .node.show-labels.vertical[data-rotation="3"].has-inputs:not(.has-outputs) .node-clip { - grid-template-rows: 1fr 40px; } /* Label containers */ diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index 3a5bf0e8..83500862 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -58,6 +58,23 @@ export function snapTo2G(value: number): number { return Math.ceil(value / G.x2) * G.x2; } +/** + * Calculate port offset from center in pixels. + * Used for SVG rendering and numeric calculations. + * + * @param index - Port index (0-based) + * @param total - Total number of ports on this edge + * @returns Offset in pixels from center (negative = before center, positive = after) + */ +export function getPortOffset(index: number, total: number): number { + if (total <= 0 || total === 1) { + return 0; // Single port at center + } + // For N ports with spacing S: span = (N-1)*S, offset from center = -span/2 + i*S + const span = (total - 1) * NODE.portSpacing; + return -span / 2 + index * NODE.portSpacing; +} + /** * Calculate port position as CSS calc() expression. * Uses offset from center to ensure grid alignment regardless of node size, @@ -68,24 +85,23 @@ export function snapTo2G(value: number): number { * @returns CSS position value (e.g., "50%" or "calc(50% + 10px)") */ export function getPortPositionCalc(index: number, total: number): string { - if (total <= 0 || total === 1) { - return '50%'; // Single port at center - } - // For N ports with spacing S: span = (N-1)*S, offset from center = -span/2 + i*S - const span = (total - 1) * NODE.portSpacing; - const offsetFromCenter = -span / 2 + index * NODE.portSpacing; - if (offsetFromCenter === 0) { + const offset = getPortOffset(index, total); + if (offset === 0) { return '50%'; } - return `calc(50% + ${offsetFromCenter}px)`; + return `calc(50% + ${offset}px)`; } +/** Baseline text height for comparing math rendering (approximate) */ +const BASELINE_TEXT_HEIGHT = 14; + /** * Calculate node dimensions from node data. * Used by both SvelteFlow (for bounds) and BaseNode (for CSS). * - * @param showInputLabels - If true, adds space for input port label column/row - * @param showOutputLabels - If true, adds space for output port label column/row + * @param hasVisibleInputLabels - True if input labels are visible (setting ON and inputs exist) + * @param hasVisibleOutputLabels - True if output labels are visible (setting ON and outputs exist) + * @param measuredName - Optional measured dimensions for math-rendered names */ export function calculateNodeDimensions( name: string, @@ -94,23 +110,29 @@ export function calculateNodeDimensions( pinnedParamCount: number, rotation: number, typeName?: string, - showInputLabels?: boolean, - showOutputLabels?: boolean + hasVisibleInputLabels?: boolean, + hasVisibleOutputLabels?: boolean, + measuredName?: { width: number; height: number } | null ): { width: number; height: number } { const isVertical = rotation === 1 || rotation === 3; const maxPortsOnSide = Math.max(inputCount, outputCount); const minPortDimension = Math.max(1, maxPortsOnSide) * NODE.portSpacing; - // Pinned params height: border(1) + padding(10) + rows(20 each) + gaps(4 between) + // Pinned params dimensions const pinnedParamsHeight = pinnedParamCount > 0 ? 7 + 24 * pinnedParamCount : 0; + const pinnedParamsWidth = pinnedParamCount > 0 ? 160 : 0; - // Width: base, name estimate, type name estimate, pinned params minimum, port dimension (if vertical) - // Name uses 10px font (~6px per char), type uses 8px font (~5px per char), plus padding for node margins - // Use slightly larger estimates to ensure text fits (ceil behavior) - const nameWidth = name.length * 6 + 20; + // Type label width estimate (8px font, ~5px per char) const typeWidth = typeName ? typeName.length * 5 + 20 : 0; - const pinnedParamsWidth = pinnedParamCount > 0 ? 160 : 0; - let width = snapTo2G(Math.max( + + // Name width: use measured if available, otherwise estimate (10px font, ~6px per char) + // Add 24px for horizontal padding in .node-content (12px each side) + const nameWidth = measuredName + ? snapTo2G(measuredName.width + 24) + : name.length * 6 + 20; + + // Content width (without port labels) + let contentWidth = snapTo2G(Math.max( NODE.baseWidth, nameWidth, typeWidth, @@ -118,21 +140,29 @@ export function calculateNodeDimensions( isVertical ? minPortDimension : 0 )); - // Height: content height vs port dimension (they share vertical space) - const contentHeight = NODE.baseHeight + pinnedParamsHeight; + // Content height: check if math is significantly taller than baseline text + let contentHeight: number; + if (measuredName && measuredName.height > BASELINE_TEXT_HEIGHT * 1.2) { + // Math is tall (e.g., \displaystyle fractions) - use measured height + type label + padding + contentHeight = measuredName.height + 24 + pinnedParamsHeight; + } else { + // Normal text height + contentHeight = NODE.baseHeight + pinnedParamsHeight; + } + + // Final dimensions accounting for port space + let width = contentWidth; let height = isVertical ? snapTo2G(contentHeight) : snapTo2G(Math.max(contentHeight, minPortDimension)); - // Add space for port labels if enabled (separately for inputs and outputs) + // Add space for port labels if visible if (isVertical) { - // Vertical ports: add rows for labels above/below content - if (showInputLabels && inputCount > 0) height += PORT_LABEL.rowHeight; - if (showOutputLabels && outputCount > 0) height += PORT_LABEL.rowHeight; + if (hasVisibleInputLabels) height += PORT_LABEL.rowHeight; + if (hasVisibleOutputLabels) height += PORT_LABEL.rowHeight; } else { - // Horizontal ports: add columns for labels on left/right - if (showInputLabels && inputCount > 0) width += PORT_LABEL.columnWidth; - if (showOutputLabels && outputCount > 0) width += PORT_LABEL.columnWidth; + if (hasVisibleInputLabels) width += PORT_LABEL.columnWidth; + if (hasVisibleOutputLabels) width += PORT_LABEL.columnWidth; } return { width, height }; diff --git a/src/lib/utils/portLabels.ts b/src/lib/utils/portLabels.ts new file mode 100644 index 00000000..67acca7c --- /dev/null +++ b/src/lib/utils/portLabels.ts @@ -0,0 +1,42 @@ +/** + * Port Labels Utility + * + * Shared logic for determining port label visibility. + * Used by both BaseNode.svelte (canvas) and SVG renderer (export). + */ + +import type { NodeInstance } from '$lib/types/nodes'; + +/** + * Get effective port label visibility for a node. + * Per-node settings override global setting. + * + * @param node - The node instance + * @param globalShowLabels - Global port labels setting (from portLabelsStore) + * @returns Object with hasVisibleInputLabels and hasVisibleOutputLabels + */ +export function getEffectivePortLabelVisibility( + node: NodeInstance, + globalShowLabels: boolean +): { inputs: boolean; outputs: boolean } { + // Per-node overrides (undefined = follow global) + const inputSetting = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalShowLabels; + const outputSetting = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalShowLabels; + + // Actual visibility: setting is ON and ports exist + return { + inputs: inputSetting && node.inputs.length > 0, + outputs: outputSetting && node.outputs.length > 0 + }; +} + +/** + * Truncate port label for display. + * + * @param name - Port name + * @param maxChars - Maximum characters (default: 5) + * @returns Truncated name + */ +export function truncatePortLabel(name: string, maxChars: number = 5): string { + return name.length > maxChars ? name.slice(0, maxChars) : name; +} From 28fa9736e114644071eeb67f7fd4f2a9d50215af Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 18:51:51 +0100 Subject: [PATCH 12/15] Fix theme colors to match app.css and add textDisabled --- src/lib/constants/theme.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lib/constants/theme.ts b/src/lib/constants/theme.ts index fd58fa02..4165cda0 100644 --- a/src/lib/constants/theme.ts +++ b/src/lib/constants/theme.ts @@ -17,6 +17,8 @@ export interface ThemeColors { text: string; /** Muted text color */ textMuted: string; + /** Disabled text color (lighter than muted) */ + textDisabled: string; /** Accent color (default node color) */ accent: string; } @@ -27,18 +29,20 @@ export const THEMES: Record<'light' | 'dark', ThemeColors> = { surface: '#08080c', surfaceRaised: '#1c1c26', border: 'rgba(255, 255, 255, 0.08)', - edge: '#7F7F7F', + edge: '#808090', text: '#f0f0f5', textMuted: '#808090', + textDisabled: '#505060', accent: '#0070C0' }, light: { surface: '#f0f0f4', surfaceRaised: '#ffffff', border: 'rgba(0, 0, 0, 0.10)', - edge: '#7F7F7F', + edge: '#808090', // inherits from :root text: '#1a1a1f', - textMuted: '#606068', + textMuted: '#808090', // inherits from :root + textDisabled: '#909098', accent: '#0070C0' } } as const; From c224db76c1418dcf24e9f45eeccdb2a3cd7df346 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 18:51:54 +0100 Subject: [PATCH 13/15] Refactor SVG export to use dom-to-svg library --- package-lock.json | 42 ++- package.json | 1 + src/lib/export/svg/renderer.ts | 602 +++++++-------------------------- src/lib/export/svg/types.ts | 5 +- 4 files changed, 159 insertions(+), 491 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1186be83..a3f8eafd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@codemirror/theme-one-dark": "^6.0.0", "@xyflow/svelte": "^1.5.0", "codemirror": "^6.0.0", + "dom-to-svg": "^0.12.2", "katex": "^0.16.0", "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", @@ -1220,7 +1221,6 @@ "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1260,7 +1260,6 @@ "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -1369,7 +1368,6 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1423,7 +1421,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1721,7 +1718,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -1811,6 +1807,17 @@ "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", "license": "MIT" }, + "node_modules/dom-to-svg": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/dom-to-svg/-/dom-to-svg-0.12.2.tgz", + "integrity": "sha512-zVlswIYMj3669dUfErBszLcYOy+NzPEFhMezdzLVaqFcaj7VQecS0o8r9bqzBSczDtex2X/4HMZktFoj4EDqOA==", + "license": "MIT", + "dependencies": { + "gradient-parser": "^1.0.2", + "postcss": "^8.2.9", + "postcss-value-parser": "^4.1.0" + } + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1872,7 +1879,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2218,6 +2224,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gradient-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/gradient-parser/-/gradient-parser-1.1.1.tgz", + "integrity": "sha512-Hu0YfNU+38EsTmnUfLXUKFMXq9yz7htGYpF4x+dlbBhUCvIvzLt0yVLT/gJRmvLKFJdqNFrz4eKkIUjIXSr7Tw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2492,7 +2506,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -2609,7 +2622,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -2618,7 +2630,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2636,7 +2647,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2652,7 +2662,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2760,6 +2769,12 @@ "node": ">=4" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2776,7 +2791,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -2961,7 +2975,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3004,7 +3017,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.0.tgz", "integrity": "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3172,7 +3184,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3211,7 +3222,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index 85e6d96b..942daeed 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@codemirror/theme-one-dark": "^6.0.0", "@xyflow/svelte": "^1.5.0", "codemirror": "^6.0.0", + "dom-to-svg": "^0.12.2", "katex": "^0.16.0", "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", diff --git a/src/lib/export/svg/renderer.ts b/src/lib/export/svg/renderer.ts index a7e2d851..8b8d4628 100644 --- a/src/lib/export/svg/renderer.ts +++ b/src/lib/export/svg/renderer.ts @@ -1,512 +1,166 @@ /** * SVG Renderer * - * Renders the current graph view as SVG using a hybrid approach: - * - Edges: cloned directly from SvelteFlow's SVG (already vector graphics) - * - Nodes/Events: pure SVG with dimensions and styles read from DOM - * - * This approach ensures pixel-perfect accuracy while producing clean SVG output. + * Renders the current graph view as SVG using dom-to-svg library. + * This captures the exact visual appearance of the canvas. */ -import { get } from 'svelte/store'; -import { graphStore } from '$lib/stores/graph'; -import { eventStore } from '$lib/stores/events'; +import { elementToSVG, inlineResources } from 'dom-to-svg'; import { getThemeColors } from '$lib/constants/theme'; -import { NODE, EVENT } from '$lib/constants/dimensions'; -import { getHandlePath } from '$lib/constants/handlePaths'; -import { latexToSvg, getSvgDimensions, preloadMathJax } from '$lib/utils/mathjaxSvg'; - -// Preload MathJax when module loads -if (typeof window !== 'undefined') { - preloadMathJax(); -} -import type { ExportOptions, RenderContext, Bounds } from './types'; +import { EXPORT_PADDING } from '$lib/constants/dimensions'; +import type { ExportOptions } from './types'; import { DEFAULT_OPTIONS } from './types'; -import type { NodeInstance } from '$lib/types/nodes'; -import type { EventInstance } from '$lib/types/events'; - -// ============================================================================ -// DOM UTILITIES -// ============================================================================ - -function getZoom(): number { - const viewport = document.querySelector('.svelte-flow__viewport') as HTMLElement; - if (!viewport) return 1; - const match = viewport.style.transform.match(/scale\(([^)]+)\)/); - return match ? parseFloat(match[1]) : 1; -} - -function escapeXml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} /** - * Extract LaTeX from a string with $...$ delimiters + * Get the current viewport transform values */ -function extractLatex(text: string): { before: string; latex: string; after: string } | null { - const match = text.match(/^(.*?)\$([^$]+)\$(.*)$/); - if (!match) return null; - return { before: match[1], latex: match[2], after: match[3] }; -} +function getViewportTransform(): { x: number; y: number; scale: number } { + const viewport = document.querySelector('.svelte-flow__viewport') as HTMLElement; + if (!viewport) return { x: 0, y: 0, scale: 1 }; -/** - * Render a plain text label as SVG - */ -function renderPlainTextLabel( - text: string, - centerX: number, - centerY: number, - color: string, - fontSize: number, - fontWeight: string -): string { - return `${escapeXml(text)}`; + const transform = viewport.style.transform; + const translateMatch = transform.match(/translate\(([^,]+),\s*([^)]+)\)/); + const scaleMatch = transform.match(/scale\(([^)]+)\)/); + + return { + x: translateMatch ? parseFloat(translateMatch[1]) : 0, + y: translateMatch ? parseFloat(translateMatch[2]) : 0, + scale: scaleMatch ? parseFloat(scaleMatch[1]) : 1 + }; } /** - * Render a label that may contain math as native SVG using MathJax - * @param originalText - The original text with $...$ LaTeX delimiters (NOT the rendered DOM content) + * Calculate the bounding box of all content in graph coordinates */ -async function renderMathLabel( - originalText: string, - centerX: number, - centerY: number, - color: string, - fontSize: number, - fontWeight: string, - ctx: RenderContext -): Promise { - const mathParts = extractLatex(originalText); - - if (!mathParts) { - // Plain text - use regular SVG text - return renderPlainTextLabel(originalText, centerX, centerY, color, fontSize, fontWeight); - } - - try { - // Render the LaTeX to SVG using MathJax - // Wrap in \boldsymbol to match the bold font-weight (600) used on canvas - const boldLatex = `\\boldsymbol{${mathParts.latex}}`; - let svg = await latexToSvg(boldLatex, false); - const dims = getSvgDimensions(svg); - - // Apply color to the SVG - svg = svg.replace(/currentColor/g, color); - - // Add stroke to math paths to match system font weight (600) - // MathJax math fonts are lighter than system-ui bold - svg = svg.replace(/${svg}`; - } catch (e) { - console.error('MathJax SVG rendering error:', e); - // Fall back to plain text showing the raw LaTeX - return renderPlainTextLabel(originalText, centerX, centerY, color, fontSize, fontWeight); - } -} - -// ============================================================================ -// EDGE RENDERING - Clone from DOM -// ============================================================================ - -function renderEdges(ctx: RenderContext): string { - const container = document.querySelector('.svelte-flow__edges'); - if (!container) return ''; - - const parts: string[] = []; - - container.querySelectorAll('.svelte-flow__edge').forEach((edge) => { - const edgeParts: string[] = []; - - // Get all paths and groups within this edge - edge.querySelectorAll('path').forEach((pathEl) => { - const d = pathEl.getAttribute('d'); - if (!d) return; - - // Check if it's the main edge path or arrow - if (pathEl.classList.contains('svelte-flow__edge-path')) { - edgeParts.push( - `` - ); - } - }); - - // Find arrow groups (have transform with rotate) - edge.querySelectorAll('g').forEach((g) => { - const transform = g.getAttribute('transform'); - if (transform && transform.includes('rotate')) { - const arrowPath = g.querySelector('path'); - if (arrowPath) { - const d = arrowPath.getAttribute('d'); - if (d) { - edgeParts.push(``); - } - } - } - }); - - if (edgeParts.length > 0) { - parts.push(`${edgeParts.join('')}`); - } - }); - - return parts.length > 0 ? `\n${parts.join('\n')}\n` : ''; -} - -// ============================================================================ -// HANDLE RENDERING -// ============================================================================ - -function renderHandles(nodeId: string, nodeX: number, nodeY: number, ctx: RenderContext): string { - const wrapper = document.querySelector(`[data-id="${nodeId}"]`); - if (!wrapper) return ''; +function calculateContentBounds(): { minX: number; minY: number; maxX: number; maxY: number } { + const bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; + const viewport = document.querySelector('.svelte-flow__viewport') as HTMLElement; + if (!viewport) return { minX: 0, minY: 0, maxX: 200, maxY: 200 }; - const nodeEl = wrapper.querySelector('[data-rotation]') || wrapper; - const rotation = parseInt(nodeEl.getAttribute('data-rotation') || '0'); - const paths = getHandlePath(rotation); - const zoom = getZoom(); - const nodeRect = wrapper.getBoundingClientRect(); + const { scale } = getViewportTransform(); - const handles: string[] = []; + // Get all nodes and events + const elements = viewport.querySelectorAll('.svelte-flow__node, .svelte-flow__edge'); + elements.forEach((el) => { + const rect = (el as HTMLElement).getBoundingClientRect(); + const viewportRect = viewport.getBoundingClientRect(); - nodeEl.querySelectorAll('.svelte-flow__handle').forEach((handle) => { - const rect = handle.getBoundingClientRect(); - const cx = (rect.left + rect.width / 2 - nodeRect.left) / zoom; - const cy = (rect.top + rect.height / 2 - nodeRect.top) / zoom; - const x = nodeX + cx - paths.width / 2; - const y = nodeY + cy - paths.height / 2; + // Convert to viewport-relative coordinates, accounting for scale + const left = (rect.left - viewportRect.left) / scale; + const top = (rect.top - viewportRect.top) / scale; + const right = left + rect.width / scale; + const bottom = top + rect.height / scale; - handles.push(` - - -`); + bounds.minX = Math.min(bounds.minX, left); + bounds.minY = Math.min(bounds.minY, top); + bounds.maxX = Math.max(bounds.maxX, right); + bounds.maxY = Math.max(bounds.maxY, bottom); }); - return handles.join('\n'); -} - -// ============================================================================ -// NODE RENDERING - Pure SVG with DOM-read styles -// ============================================================================ - -async function renderNode(node: NodeInstance, ctx: RenderContext): Promise { - const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; - if (!wrapper) return ''; - - const nodeEl = wrapper.querySelector('.node') as HTMLElement; - if (!nodeEl) return ''; - - // Get dimensions from the actual .node element (not SvelteFlow wrapper) - // This ensures we use our dynamic width calculation for math names - const zoom = getZoom(); - const nodeRect = nodeEl.getBoundingClientRect(); - const width = nodeRect.width / zoom; - const height = nodeRect.height / zoom; - - // Position is center-origin, convert to top-left for SVG - const x = node.position.x - width / 2; - const y = node.position.y - height / 2; - - // Read styles from DOM - const computed = getComputedStyle(nodeEl); - const borderRadius = parseFloat(computed.borderRadius) || 8; - const isSubsystem = node.type === 'Subsystem' || node.type === 'Interface'; - const color = node.color || ctx.theme.accent; - - // Get text content - const nameEl = nodeEl.querySelector('.node-name'); - const typeEl = nodeEl.querySelector('.node-type'); - const nodeName = nameEl?.textContent || node.name; - const nodeType = typeEl?.textContent || ''; - - const parts: string[] = []; - - // Background fill - parts.push( - `` - ); - - // Border - const strokeDasharray = isSubsystem ? ' stroke-dasharray="4 2"' : ''; - parts.push( - `` - ); - - // Check for pinned params section in DOM - const pinnedParamsEl = nodeEl.querySelector('.pinned-params') as HTMLElement; - - // Calculate content center (above pinned params if present) - let contentCenterY = y + height / 2; - if (pinnedParamsEl) { - const pinnedRect = pinnedParamsEl.getBoundingClientRect(); - const pinnedTop = (pinnedRect.top - nodeRect.top) / zoom; - contentCenterY = y + pinnedTop / 2; - } - - // Labels - if (ctx.options.showLabels) { - const centerX = x + width / 2; - - if (ctx.options.showTypeLabels && nodeType) { - // Name above center (may contain math) - use original node.name for LaTeX source - // Spacing: name at -6 and type at +10 gives 16px gap for better separation - parts.push(await renderMathLabel(node.name, centerX, contentCenterY - 6, color, 10, '600', ctx)); - // Type below center - parts.push( - `${escapeXml(nodeType)}` - ); - } else { - // Just name, centered (may contain math) - use original node.name for LaTeX source - parts.push(await renderMathLabel(node.name, centerX, contentCenterY, color, 10, '600', ctx)); - } - } - - // Pinned parameters - read positions from DOM - if (pinnedParamsEl) { - const pinnedRect = pinnedParamsEl.getBoundingClientRect(); - const pinnedTop = y + (pinnedRect.top - nodeRect.top) / zoom; - const pinnedHeight = pinnedRect.height / zoom; - - // Separator line - parts.push( - `` - ); - - // Background for pinned params area (square top, rounded bottom to match node) - const px = x + 1; - const py = pinnedTop + 1; - const pw = width - 2; - const ph = pinnedHeight - 1; - const br = Math.max(0, borderRadius - 1); - // Path: start top-left, go right, down, rounded bottom-right, left, rounded bottom-left, up - parts.push( - `` - ); - - // Each pinned param row - read from DOM - pinnedParamsEl.querySelectorAll('.pinned-param').forEach((paramEl) => { - const labelEl = paramEl.querySelector('label') as HTMLElement; - const inputEl = paramEl.querySelector('input') as HTMLInputElement; - if (!labelEl || !inputEl) return; - - const labelRect = labelEl.getBoundingClientRect(); - const inputRect = inputEl.getBoundingClientRect(); - - // Label position - const labelX = x + (labelRect.left - nodeRect.left) / zoom; - const labelY = y + (labelRect.top + labelRect.height / 2 - nodeRect.top) / zoom; - const labelText = labelEl.textContent || ''; - - parts.push( - `${escapeXml(labelText)}` - ); - - // Input box position - const inputX = x + (inputRect.left - nodeRect.left) / zoom; - const inputY = y + (inputRect.top - nodeRect.top) / zoom; - const inputW = inputRect.width / zoom; - const inputH = inputRect.height / zoom; - const inputValue = inputEl.value || inputEl.placeholder || ''; - const inputBorderRadius = parseFloat(getComputedStyle(inputEl).borderRadius) || inputH / 2; - - // Input background (pill shape) - parts.push( - `` - ); - - // Input value - parts.push( - `${escapeXml(inputValue)}` - ); - }); - } - - // Handles - if (ctx.options.showHandles) { - const handles = renderHandles(node.id, x, y, ctx); - if (handles) parts.push(handles); - } - - return `\n${parts.join('\n')}\n`; + return isFinite(bounds.minX) ? bounds : { minX: 0, minY: 0, maxX: 200, maxY: 200 }; } -// ============================================================================ -// EVENT RENDERING - Pure SVG -// ============================================================================ - -function renderEvent(event: EventInstance, ctx: RenderContext): string { - const wrapper = document.querySelector(`[data-id="${event.id}"]`) as HTMLElement; - if (!wrapper) return ''; - - const zoom = getZoom(); - - // Get text from DOM - const nameEl = wrapper.querySelector('.event-name'); - const typeEl = wrapper.querySelector('.event-type'); - const eventName = nameEl?.textContent || event.name; - const eventType = typeEl?.textContent || ''; - - // Position is center-origin, so position IS the center - const cx = event.position.x; - const cy = event.position.y; - const color = event.color || ctx.theme.accent; - - const parts: string[] = []; - - // Get diamond element and its dimensions from DOM - const diamondEl = wrapper.querySelector('.diamond') as HTMLElement; - if (diamondEl) { - const diamondRect = diamondEl.getBoundingClientRect(); - const diamondSize = diamondRect.width / zoom; // Diamond is square - const diamondOffset = diamondSize / 2; - const borderRadius = parseFloat(getComputedStyle(diamondEl).borderRadius) || 4; - - // Diamond background - parts.push( - `` - ); - - // Diamond border - parts.push( - `` - ); - } +/** + * Export the current graph view as SVG + */ +export async function exportToSVG(options: ExportOptions = {}): Promise { + const opts: Required = { ...DEFAULT_OPTIONS, ...options }; + const theme = getThemeColors(opts.theme); + const padding = opts.padding ?? EXPORT_PADDING; - // Labels - if (ctx.options.showLabels) { - if (ctx.options.showTypeLabels && eventType) { - parts.push( - `${escapeXml(eventName)}` - ); - parts.push( - `${escapeXml(eventType)}` - ); - } else { - parts.push( - `${escapeXml(eventName)}` - ); - } + // Find the SvelteFlow container + const flowContainer = document.querySelector('.svelte-flow') as HTMLElement; + if (!flowContainer) { + throw new Error('SvelteFlow container not found'); } - return `\n${parts.join('\n')}\n`; -} - -// ============================================================================ -// BOUNDS & MAIN EXPORT -// ============================================================================ + // Store original styles to restore later + const viewport = flowContainer.querySelector('.svelte-flow__viewport') as HTMLElement; + const originalTransform = viewport?.style.transform || ''; + const originalContainerStyle = flowContainer.style.cssText; -function calculateBounds(nodes: NodeInstance[], events: EventInstance[]): Bounds { - const bounds: Bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; - const zoom = getZoom(); - - for (const node of nodes) { - // Get dimensions from the actual .node element (not SvelteFlow wrapper) - const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; - const nodeEl = wrapper?.querySelector('.node') as HTMLElement; - let width = NODE.baseWidth; - let height = NODE.baseHeight; - if (nodeEl) { - const rect = nodeEl.getBoundingClientRect(); - width = rect.width / zoom; - height = rect.height / zoom; + try { + // Calculate content bounds before resetting transform + const bounds = calculateContentBounds(); + const contentWidth = bounds.maxX - bounds.minX; + const contentHeight = bounds.maxY - bounds.minY; + + // Reset viewport transform to identity for clean capture + // Position viewport so content starts at (padding, padding) + if (viewport) { + const offsetX = -bounds.minX + padding; + const offsetY = -bounds.minY + padding; + viewport.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(1)`; } - // Position is center-origin, calculate corners - const left = node.position.x - width / 2; - const top = node.position.y - height / 2; - bounds.minX = Math.min(bounds.minX, left); - bounds.minY = Math.min(bounds.minY, top); - bounds.maxX = Math.max(bounds.maxX, left + width); - bounds.maxY = Math.max(bounds.maxY, top + height); - } - - for (const event of events) { - // Events use center-origin, get actual bounding box from DOM - const wrapper = document.querySelector(`[data-id="${event.id}"]`) as HTMLElement; - let boundingSize = EVENT.size; // Fallback - if (wrapper) { - const diamondEl = wrapper.querySelector('.diamond') as HTMLElement; - if (diamondEl) { - const zoom = getZoom(); - const diamondSize = diamondEl.getBoundingClientRect().width / zoom; - // Rotated 45° square has bounding box of size * sqrt(2) - boundingSize = diamondSize * Math.SQRT2; - } + // Set container size to match content + padding + const svgWidth = contentWidth + padding * 2; + const svgHeight = contentHeight + padding * 2; + flowContainer.style.width = `${svgWidth}px`; + flowContainer.style.height = `${svgHeight}px`; + flowContainer.style.overflow = 'visible'; + + // Force reflow + flowContainer.offsetHeight; + + // Convert DOM to SVG using dom-to-svg + const svgDocument = elementToSVG(flowContainer); + + // Inline external resources (fonts, images) + await inlineResources(svgDocument.documentElement); + + // Get the SVG element + const svgElement = svgDocument.documentElement; + + // Set proper dimensions and viewBox + svgElement.setAttribute('width', String(svgWidth)); + svgElement.setAttribute('height', String(svgHeight)); + svgElement.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`); + + // Add background if requested + if (opts.background === 'solid') { + const bgRect = svgDocument.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bgRect.setAttribute('x', '0'); + bgRect.setAttribute('y', '0'); + bgRect.setAttribute('width', String(svgWidth)); + bgRect.setAttribute('height', String(svgHeight)); + bgRect.setAttribute('fill', theme.surface); + svgElement.insertBefore(bgRect, svgElement.firstChild); } - const left = event.position.x - boundingSize / 2; - const top = event.position.y - boundingSize / 2; - bounds.minX = Math.min(bounds.minX, left); - bounds.minY = Math.min(bounds.minY, top); - bounds.maxX = Math.max(bounds.maxX, left + boundingSize); - bounds.maxY = Math.max(bounds.maxY, top + boundingSize); - } - - return isFinite(bounds.minX) ? bounds : { minX: 0, minY: 0, maxX: 200, maxY: 200 }; -} - -export async function exportToSVG(options: ExportOptions = {}): Promise { - const opts: Required = { ...DEFAULT_OPTIONS, ...options }; - const themeColors = getThemeColors(opts.theme); - const ctx: RenderContext = { theme: themeColors, options: opts }; - - const nodes = get(graphStore.nodesArray); - const events = get(eventStore.eventsArray); - const bounds = calculateBounds(nodes, events); - - const width = bounds.maxX - bounds.minX + opts.padding * 2; - const height = bounds.maxY - bounds.minY + opts.padding * 2; - const viewBox = `${bounds.minX - opts.padding} ${bounds.minY - opts.padding} ${width} ${height}`; - - const parts: string[] = [ - ``, - `` - ]; + // Remove elements we don't want in export + // Remove selection box, minimap, controls, etc. + const selectorsToRemove = [ + '.svelte-flow__minimap', + '.svelte-flow__controls', + '.svelte-flow__attribution', + '.svelte-flow__selection', + '.svelte-flow__nodesselection', + '.svelte-flow__background', // Remove default background (we add our own) + '.port-controls', // Remove +/- port buttons + '.selection-glow' // Remove selection glow effects + ]; + + selectorsToRemove.forEach((selector) => { + svgElement.querySelectorAll(selector).forEach((el) => el.remove()); + }); - // Background - if (opts.background === 'solid') { - parts.push( - `` - ); - } + // Serialize to string + const serializer = new XMLSerializer(); + let svgString = serializer.serializeToString(svgDocument); - // Edges - const edges = renderEdges(ctx); - if (edges) parts.push(edges); + // Add XML declaration + svgString = '\n' + svgString; - // Events - if (events.length > 0) { - parts.push(''); - for (const event of events) { - parts.push(renderEvent(event, ctx)); + return svgString; + } finally { + // Restore original styles + if (viewport) { + viewport.style.transform = originalTransform; } - parts.push(''); - } + flowContainer.style.cssText = originalContainerStyle; - // Nodes (render in parallel for performance) - if (nodes.length > 0) { - parts.push(''); - const renderedNodes = await Promise.all(nodes.map((node) => renderNode(node, ctx))); - for (const rendered of renderedNodes) { - if (rendered) parts.push(rendered); - } - parts.push(''); + // Force reflow to apply restored styles + flowContainer.offsetHeight; } - - parts.push(''); - return parts.join('\n'); } diff --git a/src/lib/export/svg/types.ts b/src/lib/export/svg/types.ts index 9b54a7cb..904cd231 100644 --- a/src/lib/export/svg/types.ts +++ b/src/lib/export/svg/types.ts @@ -19,6 +19,8 @@ export interface ExportOptions { showTypeLabels?: boolean; /** Whether to render handle shapes (default: true) */ showHandles?: boolean; + /** Whether to render port labels: true, false, or 'auto' to match canvas state (default: 'auto') */ + showPortLabels?: boolean | 'auto'; } /** Render context passed to all renderers */ @@ -44,5 +46,6 @@ export const DEFAULT_OPTIONS: Required = { padding: EXPORT_PADDING, showLabels: true, showTypeLabels: true, - showHandles: true + showHandles: true, + showPortLabels: 'auto' }; From 65cceec4a26fd697f19543e0bb7c0334d1030c5b Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 18:51:58 +0100 Subject: [PATCH 14/15] Update README with port labels docs and dom-to-svg dependency --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 86dfeb67..a2716df7 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A web-based visual node editor for building and simulating dynamic systems with - [Pyodide](https://pyodide.org/) for in-browser Python/NumPy/SciPy - [Plotly.js](https://plotly.com/javascript/) for interactive plots - [CodeMirror 6](https://codemirror.net/) for code editing +- [dom-to-svg](https://github.com/nicolo-ribaudo/dom-to-svg) for SVG graph export ## Getting Started @@ -504,6 +505,7 @@ Press `?` to see all shortcuts in the app. Key shortcuts: | | `X` / `Y` | Flip H/V | | | `Arrows` | Nudge selection | | **Wires** | `\` | Add waypoint to selected edge | +| **Labels** | `L` | Toggle port labels | | **View** | `F` | Fit view | | | `H` | Go to root | | | `T` | Toggle theme | @@ -621,6 +623,15 @@ Shapes are defined in `src/lib/nodes/shapes/registry.ts` and applied via CSS cla Colors are CSS-driven - see `src/app.css` for variables and `src/lib/utils/colors.ts` for palettes. +### Port Labels + +Port labels show the name of each input/output port alongside the node. Toggle globally with `L` key, or per-node via right-click menu. + +- **Global toggle**: Press `L` to show/hide port labels for all nodes +- **Per-node override**: Right-click node → "Show Input Labels" / "Show Output Labels" +- **Truncation**: Labels are truncated to 5 characters for compact display +- **SVG export**: Port labels are included when exporting the graph as SVG + ### Adding Custom Shapes 1. Register the shape in `src/lib/nodes/shapes/registry.ts`: From b9d476e58a03cf00faa3c3bfed57fb95b13d7555 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 18:55:02 +0100 Subject: [PATCH 15/15] Revert to manual SVG renderer (dom-to-svg incompatible with SvelteFlow) --- README.md | 1 - package-lock.json | 30 +- package.json | 1 - src/lib/export/svg/renderer.ts | 602 ++++++++++++++++++++++++++------- 4 files changed, 478 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index a2716df7..aa8dc0d1 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ A web-based visual node editor for building and simulating dynamic systems with - [Pyodide](https://pyodide.org/) for in-browser Python/NumPy/SciPy - [Plotly.js](https://plotly.com/javascript/) for interactive plots - [CodeMirror 6](https://codemirror.net/) for code editing -- [dom-to-svg](https://github.com/nicolo-ribaudo/dom-to-svg) for SVG graph export ## Getting Started diff --git a/package-lock.json b/package-lock.json index a3f8eafd..2781aeed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@codemirror/theme-one-dark": "^6.0.0", "@xyflow/svelte": "^1.5.0", "codemirror": "^6.0.0", - "dom-to-svg": "^0.12.2", "katex": "^0.16.0", "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", @@ -1807,17 +1806,6 @@ "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", "license": "MIT" }, - "node_modules/dom-to-svg": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/dom-to-svg/-/dom-to-svg-0.12.2.tgz", - "integrity": "sha512-zVlswIYMj3669dUfErBszLcYOy+NzPEFhMezdzLVaqFcaj7VQecS0o8r9bqzBSczDtex2X/4HMZktFoj4EDqOA==", - "license": "MIT", - "dependencies": { - "gradient-parser": "^1.0.2", - "postcss": "^8.2.9", - "postcss-value-parser": "^4.1.0" - } - }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -2224,14 +2212,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gradient-parser": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/gradient-parser/-/gradient-parser-1.1.1.tgz", - "integrity": "sha512-Hu0YfNU+38EsTmnUfLXUKFMXq9yz7htGYpF4x+dlbBhUCvIvzLt0yVLT/gJRmvLKFJdqNFrz4eKkIUjIXSr7Tw==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2506,6 +2486,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -2622,6 +2603,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -2647,6 +2629,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -2769,12 +2752,6 @@ "node": ">=4" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT" - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2975,6 +2952,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index 942daeed..85e6d96b 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "@codemirror/theme-one-dark": "^6.0.0", "@xyflow/svelte": "^1.5.0", "codemirror": "^6.0.0", - "dom-to-svg": "^0.12.2", "katex": "^0.16.0", "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", diff --git a/src/lib/export/svg/renderer.ts b/src/lib/export/svg/renderer.ts index 8b8d4628..a7e2d851 100644 --- a/src/lib/export/svg/renderer.ts +++ b/src/lib/export/svg/renderer.ts @@ -1,166 +1,512 @@ /** * SVG Renderer * - * Renders the current graph view as SVG using dom-to-svg library. - * This captures the exact visual appearance of the canvas. + * Renders the current graph view as SVG using a hybrid approach: + * - Edges: cloned directly from SvelteFlow's SVG (already vector graphics) + * - Nodes/Events: pure SVG with dimensions and styles read from DOM + * + * This approach ensures pixel-perfect accuracy while producing clean SVG output. */ -import { elementToSVG, inlineResources } from 'dom-to-svg'; +import { get } from 'svelte/store'; +import { graphStore } from '$lib/stores/graph'; +import { eventStore } from '$lib/stores/events'; import { getThemeColors } from '$lib/constants/theme'; -import { EXPORT_PADDING } from '$lib/constants/dimensions'; -import type { ExportOptions } from './types'; +import { NODE, EVENT } from '$lib/constants/dimensions'; +import { getHandlePath } from '$lib/constants/handlePaths'; +import { latexToSvg, getSvgDimensions, preloadMathJax } from '$lib/utils/mathjaxSvg'; + +// Preload MathJax when module loads +if (typeof window !== 'undefined') { + preloadMathJax(); +} +import type { ExportOptions, RenderContext, Bounds } from './types'; import { DEFAULT_OPTIONS } from './types'; +import type { NodeInstance } from '$lib/types/nodes'; +import type { EventInstance } from '$lib/types/events'; -/** - * Get the current viewport transform values - */ -function getViewportTransform(): { x: number; y: number; scale: number } { +// ============================================================================ +// DOM UTILITIES +// ============================================================================ + +function getZoom(): number { const viewport = document.querySelector('.svelte-flow__viewport') as HTMLElement; - if (!viewport) return { x: 0, y: 0, scale: 1 }; + if (!viewport) return 1; + const match = viewport.style.transform.match(/scale\(([^)]+)\)/); + return match ? parseFloat(match[1]) : 1; +} - const transform = viewport.style.transform; - const translateMatch = transform.match(/translate\(([^,]+),\s*([^)]+)\)/); - const scaleMatch = transform.match(/scale\(([^)]+)\)/); +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} - return { - x: translateMatch ? parseFloat(translateMatch[1]) : 0, - y: translateMatch ? parseFloat(translateMatch[2]) : 0, - scale: scaleMatch ? parseFloat(scaleMatch[1]) : 1 - }; +/** + * Extract LaTeX from a string with $...$ delimiters + */ +function extractLatex(text: string): { before: string; latex: string; after: string } | null { + const match = text.match(/^(.*?)\$([^$]+)\$(.*)$/); + if (!match) return null; + return { before: match[1], latex: match[2], after: match[3] }; } /** - * Calculate the bounding box of all content in graph coordinates + * Render a plain text label as SVG */ -function calculateContentBounds(): { minX: number; minY: number; maxX: number; maxY: number } { - const bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; - const viewport = document.querySelector('.svelte-flow__viewport') as HTMLElement; - if (!viewport) return { minX: 0, minY: 0, maxX: 200, maxY: 200 }; +function renderPlainTextLabel( + text: string, + centerX: number, + centerY: number, + color: string, + fontSize: number, + fontWeight: string +): string { + return `${escapeXml(text)}`; +} + +/** + * Render a label that may contain math as native SVG using MathJax + * @param originalText - The original text with $...$ LaTeX delimiters (NOT the rendered DOM content) + */ +async function renderMathLabel( + originalText: string, + centerX: number, + centerY: number, + color: string, + fontSize: number, + fontWeight: string, + ctx: RenderContext +): Promise { + const mathParts = extractLatex(originalText); - const { scale } = getViewportTransform(); + if (!mathParts) { + // Plain text - use regular SVG text + return renderPlainTextLabel(originalText, centerX, centerY, color, fontSize, fontWeight); + } - // Get all nodes and events - const elements = viewport.querySelectorAll('.svelte-flow__node, .svelte-flow__edge'); - elements.forEach((el) => { - const rect = (el as HTMLElement).getBoundingClientRect(); - const viewportRect = viewport.getBoundingClientRect(); + try { + // Render the LaTeX to SVG using MathJax + // Wrap in \boldsymbol to match the bold font-weight (600) used on canvas + const boldLatex = `\\boldsymbol{${mathParts.latex}}`; + let svg = await latexToSvg(boldLatex, false); + const dims = getSvgDimensions(svg); - // Convert to viewport-relative coordinates, accounting for scale - const left = (rect.left - viewportRect.left) / scale; - const top = (rect.top - viewportRect.top) / scale; - const right = left + rect.width / scale; - const bottom = top + rect.height / scale; + // Apply color to the SVG + svg = svg.replace(/currentColor/g, color); - bounds.minX = Math.min(bounds.minX, left); - bounds.minY = Math.min(bounds.minY, top); - bounds.maxX = Math.max(bounds.maxX, right); - bounds.maxY = Math.max(bounds.maxY, bottom); + // Add stroke to math paths to match system font weight (600) + // MathJax math fonts are lighter than system-ui bold + svg = svg.replace(/${svg}`; + } catch (e) { + console.error('MathJax SVG rendering error:', e); + // Fall back to plain text showing the raw LaTeX + return renderPlainTextLabel(originalText, centerX, centerY, color, fontSize, fontWeight); + } +} + +// ============================================================================ +// EDGE RENDERING - Clone from DOM +// ============================================================================ + +function renderEdges(ctx: RenderContext): string { + const container = document.querySelector('.svelte-flow__edges'); + if (!container) return ''; + + const parts: string[] = []; + + container.querySelectorAll('.svelte-flow__edge').forEach((edge) => { + const edgeParts: string[] = []; + + // Get all paths and groups within this edge + edge.querySelectorAll('path').forEach((pathEl) => { + const d = pathEl.getAttribute('d'); + if (!d) return; + + // Check if it's the main edge path or arrow + if (pathEl.classList.contains('svelte-flow__edge-path')) { + edgeParts.push( + `` + ); + } + }); + + // Find arrow groups (have transform with rotate) + edge.querySelectorAll('g').forEach((g) => { + const transform = g.getAttribute('transform'); + if (transform && transform.includes('rotate')) { + const arrowPath = g.querySelector('path'); + if (arrowPath) { + const d = arrowPath.getAttribute('d'); + if (d) { + edgeParts.push(``); + } + } + } + }); + + if (edgeParts.length > 0) { + parts.push(`${edgeParts.join('')}`); + } }); - return isFinite(bounds.minX) ? bounds : { minX: 0, minY: 0, maxX: 200, maxY: 200 }; + return parts.length > 0 ? `\n${parts.join('\n')}\n` : ''; } -/** - * Export the current graph view as SVG - */ -export async function exportToSVG(options: ExportOptions = {}): Promise { - const opts: Required = { ...DEFAULT_OPTIONS, ...options }; - const theme = getThemeColors(opts.theme); - const padding = opts.padding ?? EXPORT_PADDING; +// ============================================================================ +// HANDLE RENDERING +// ============================================================================ + +function renderHandles(nodeId: string, nodeX: number, nodeY: number, ctx: RenderContext): string { + const wrapper = document.querySelector(`[data-id="${nodeId}"]`); + if (!wrapper) return ''; + + const nodeEl = wrapper.querySelector('[data-rotation]') || wrapper; + const rotation = parseInt(nodeEl.getAttribute('data-rotation') || '0'); + const paths = getHandlePath(rotation); + const zoom = getZoom(); + const nodeRect = wrapper.getBoundingClientRect(); + + const handles: string[] = []; + + nodeEl.querySelectorAll('.svelte-flow__handle').forEach((handle) => { + const rect = handle.getBoundingClientRect(); + const cx = (rect.left + rect.width / 2 - nodeRect.left) / zoom; + const cy = (rect.top + rect.height / 2 - nodeRect.top) / zoom; + const x = nodeX + cx - paths.width / 2; + const y = nodeY + cy - paths.height / 2; + + handles.push(` + + +`); + }); + + return handles.join('\n'); +} + +// ============================================================================ +// NODE RENDERING - Pure SVG with DOM-read styles +// ============================================================================ + +async function renderNode(node: NodeInstance, ctx: RenderContext): Promise { + const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; + if (!wrapper) return ''; + + const nodeEl = wrapper.querySelector('.node') as HTMLElement; + if (!nodeEl) return ''; + + // Get dimensions from the actual .node element (not SvelteFlow wrapper) + // This ensures we use our dynamic width calculation for math names + const zoom = getZoom(); + const nodeRect = nodeEl.getBoundingClientRect(); + const width = nodeRect.width / zoom; + const height = nodeRect.height / zoom; + + // Position is center-origin, convert to top-left for SVG + const x = node.position.x - width / 2; + const y = node.position.y - height / 2; + + // Read styles from DOM + const computed = getComputedStyle(nodeEl); + const borderRadius = parseFloat(computed.borderRadius) || 8; + const isSubsystem = node.type === 'Subsystem' || node.type === 'Interface'; + const color = node.color || ctx.theme.accent; + + // Get text content + const nameEl = nodeEl.querySelector('.node-name'); + const typeEl = nodeEl.querySelector('.node-type'); + const nodeName = nameEl?.textContent || node.name; + const nodeType = typeEl?.textContent || ''; + + const parts: string[] = []; + + // Background fill + parts.push( + `` + ); + + // Border + const strokeDasharray = isSubsystem ? ' stroke-dasharray="4 2"' : ''; + parts.push( + `` + ); + + // Check for pinned params section in DOM + const pinnedParamsEl = nodeEl.querySelector('.pinned-params') as HTMLElement; - // Find the SvelteFlow container - const flowContainer = document.querySelector('.svelte-flow') as HTMLElement; - if (!flowContainer) { - throw new Error('SvelteFlow container not found'); + // Calculate content center (above pinned params if present) + let contentCenterY = y + height / 2; + if (pinnedParamsEl) { + const pinnedRect = pinnedParamsEl.getBoundingClientRect(); + const pinnedTop = (pinnedRect.top - nodeRect.top) / zoom; + contentCenterY = y + pinnedTop / 2; } - // Store original styles to restore later - const viewport = flowContainer.querySelector('.svelte-flow__viewport') as HTMLElement; - const originalTransform = viewport?.style.transform || ''; - const originalContainerStyle = flowContainer.style.cssText; + // Labels + if (ctx.options.showLabels) { + const centerX = x + width / 2; - try { - // Calculate content bounds before resetting transform - const bounds = calculateContentBounds(); - const contentWidth = bounds.maxX - bounds.minX; - const contentHeight = bounds.maxY - bounds.minY; - - // Reset viewport transform to identity for clean capture - // Position viewport so content starts at (padding, padding) - if (viewport) { - const offsetX = -bounds.minX + padding; - const offsetY = -bounds.minY + padding; - viewport.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(1)`; + if (ctx.options.showTypeLabels && nodeType) { + // Name above center (may contain math) - use original node.name for LaTeX source + // Spacing: name at -6 and type at +10 gives 16px gap for better separation + parts.push(await renderMathLabel(node.name, centerX, contentCenterY - 6, color, 10, '600', ctx)); + // Type below center + parts.push( + `${escapeXml(nodeType)}` + ); + } else { + // Just name, centered (may contain math) - use original node.name for LaTeX source + parts.push(await renderMathLabel(node.name, centerX, contentCenterY, color, 10, '600', ctx)); } + } - // Set container size to match content + padding - const svgWidth = contentWidth + padding * 2; - const svgHeight = contentHeight + padding * 2; - flowContainer.style.width = `${svgWidth}px`; - flowContainer.style.height = `${svgHeight}px`; - flowContainer.style.overflow = 'visible'; - - // Force reflow - flowContainer.offsetHeight; - - // Convert DOM to SVG using dom-to-svg - const svgDocument = elementToSVG(flowContainer); - - // Inline external resources (fonts, images) - await inlineResources(svgDocument.documentElement); - - // Get the SVG element - const svgElement = svgDocument.documentElement; - - // Set proper dimensions and viewBox - svgElement.setAttribute('width', String(svgWidth)); - svgElement.setAttribute('height', String(svgHeight)); - svgElement.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`); - - // Add background if requested - if (opts.background === 'solid') { - const bgRect = svgDocument.createElementNS('http://www.w3.org/2000/svg', 'rect'); - bgRect.setAttribute('x', '0'); - bgRect.setAttribute('y', '0'); - bgRect.setAttribute('width', String(svgWidth)); - bgRect.setAttribute('height', String(svgHeight)); - bgRect.setAttribute('fill', theme.surface); - svgElement.insertBefore(bgRect, svgElement.firstChild); - } + // Pinned parameters - read positions from DOM + if (pinnedParamsEl) { + const pinnedRect = pinnedParamsEl.getBoundingClientRect(); + const pinnedTop = y + (pinnedRect.top - nodeRect.top) / zoom; + const pinnedHeight = pinnedRect.height / zoom; + + // Separator line + parts.push( + `` + ); + + // Background for pinned params area (square top, rounded bottom to match node) + const px = x + 1; + const py = pinnedTop + 1; + const pw = width - 2; + const ph = pinnedHeight - 1; + const br = Math.max(0, borderRadius - 1); + // Path: start top-left, go right, down, rounded bottom-right, left, rounded bottom-left, up + parts.push( + `` + ); + + // Each pinned param row - read from DOM + pinnedParamsEl.querySelectorAll('.pinned-param').forEach((paramEl) => { + const labelEl = paramEl.querySelector('label') as HTMLElement; + const inputEl = paramEl.querySelector('input') as HTMLInputElement; + if (!labelEl || !inputEl) return; + + const labelRect = labelEl.getBoundingClientRect(); + const inputRect = inputEl.getBoundingClientRect(); + + // Label position + const labelX = x + (labelRect.left - nodeRect.left) / zoom; + const labelY = y + (labelRect.top + labelRect.height / 2 - nodeRect.top) / zoom; + const labelText = labelEl.textContent || ''; - // Remove elements we don't want in export - // Remove selection box, minimap, controls, etc. - const selectorsToRemove = [ - '.svelte-flow__minimap', - '.svelte-flow__controls', - '.svelte-flow__attribution', - '.svelte-flow__selection', - '.svelte-flow__nodesselection', - '.svelte-flow__background', // Remove default background (we add our own) - '.port-controls', // Remove +/- port buttons - '.selection-glow' // Remove selection glow effects - ]; - - selectorsToRemove.forEach((selector) => { - svgElement.querySelectorAll(selector).forEach((el) => el.remove()); + parts.push( + `${escapeXml(labelText)}` + ); + + // Input box position + const inputX = x + (inputRect.left - nodeRect.left) / zoom; + const inputY = y + (inputRect.top - nodeRect.top) / zoom; + const inputW = inputRect.width / zoom; + const inputH = inputRect.height / zoom; + const inputValue = inputEl.value || inputEl.placeholder || ''; + const inputBorderRadius = parseFloat(getComputedStyle(inputEl).borderRadius) || inputH / 2; + + // Input background (pill shape) + parts.push( + `` + ); + + // Input value + parts.push( + `${escapeXml(inputValue)}` + ); }); + } + + // Handles + if (ctx.options.showHandles) { + const handles = renderHandles(node.id, x, y, ctx); + if (handles) parts.push(handles); + } + + return `\n${parts.join('\n')}\n`; +} + +// ============================================================================ +// EVENT RENDERING - Pure SVG +// ============================================================================ + +function renderEvent(event: EventInstance, ctx: RenderContext): string { + const wrapper = document.querySelector(`[data-id="${event.id}"]`) as HTMLElement; + if (!wrapper) return ''; + + const zoom = getZoom(); + + // Get text from DOM + const nameEl = wrapper.querySelector('.event-name'); + const typeEl = wrapper.querySelector('.event-type'); + const eventName = nameEl?.textContent || event.name; + const eventType = typeEl?.textContent || ''; + + // Position is center-origin, so position IS the center + const cx = event.position.x; + const cy = event.position.y; + const color = event.color || ctx.theme.accent; + + const parts: string[] = []; + + // Get diamond element and its dimensions from DOM + const diamondEl = wrapper.querySelector('.diamond') as HTMLElement; + if (diamondEl) { + const diamondRect = diamondEl.getBoundingClientRect(); + const diamondSize = diamondRect.width / zoom; // Diamond is square + const diamondOffset = diamondSize / 2; + const borderRadius = parseFloat(getComputedStyle(diamondEl).borderRadius) || 4; + + // Diamond background + parts.push( + `` + ); + + // Diamond border + parts.push( + `` + ); + } + + // Labels + if (ctx.options.showLabels) { + if (ctx.options.showTypeLabels && eventType) { + parts.push( + `${escapeXml(eventName)}` + ); + parts.push( + `${escapeXml(eventType)}` + ); + } else { + parts.push( + `${escapeXml(eventName)}` + ); + } + } - // Serialize to string - const serializer = new XMLSerializer(); - let svgString = serializer.serializeToString(svgDocument); + return `\n${parts.join('\n')}\n`; +} + +// ============================================================================ +// BOUNDS & MAIN EXPORT +// ============================================================================ + +function calculateBounds(nodes: NodeInstance[], events: EventInstance[]): Bounds { + const bounds: Bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; + const zoom = getZoom(); + + for (const node of nodes) { + // Get dimensions from the actual .node element (not SvelteFlow wrapper) + const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; + const nodeEl = wrapper?.querySelector('.node') as HTMLElement; + let width = NODE.baseWidth; + let height = NODE.baseHeight; + if (nodeEl) { + const rect = nodeEl.getBoundingClientRect(); + width = rect.width / zoom; + height = rect.height / zoom; + } + // Position is center-origin, calculate corners + const left = node.position.x - width / 2; + const top = node.position.y - height / 2; + bounds.minX = Math.min(bounds.minX, left); + bounds.minY = Math.min(bounds.minY, top); + bounds.maxX = Math.max(bounds.maxX, left + width); + bounds.maxY = Math.max(bounds.maxY, top + height); + } - // Add XML declaration - svgString = '\n' + svgString; + for (const event of events) { + // Events use center-origin, get actual bounding box from DOM + const wrapper = document.querySelector(`[data-id="${event.id}"]`) as HTMLElement; + let boundingSize = EVENT.size; // Fallback - return svgString; - } finally { - // Restore original styles - if (viewport) { - viewport.style.transform = originalTransform; + if (wrapper) { + const diamondEl = wrapper.querySelector('.diamond') as HTMLElement; + if (diamondEl) { + const zoom = getZoom(); + const diamondSize = diamondEl.getBoundingClientRect().width / zoom; + // Rotated 45° square has bounding box of size * sqrt(2) + boundingSize = diamondSize * Math.SQRT2; + } } - flowContainer.style.cssText = originalContainerStyle; - // Force reflow to apply restored styles - flowContainer.offsetHeight; + const left = event.position.x - boundingSize / 2; + const top = event.position.y - boundingSize / 2; + bounds.minX = Math.min(bounds.minX, left); + bounds.minY = Math.min(bounds.minY, top); + bounds.maxX = Math.max(bounds.maxX, left + boundingSize); + bounds.maxY = Math.max(bounds.maxY, top + boundingSize); } + + return isFinite(bounds.minX) ? bounds : { minX: 0, minY: 0, maxX: 200, maxY: 200 }; +} + +export async function exportToSVG(options: ExportOptions = {}): Promise { + const opts: Required = { ...DEFAULT_OPTIONS, ...options }; + const themeColors = getThemeColors(opts.theme); + const ctx: RenderContext = { theme: themeColors, options: opts }; + + const nodes = get(graphStore.nodesArray); + const events = get(eventStore.eventsArray); + const bounds = calculateBounds(nodes, events); + + const width = bounds.maxX - bounds.minX + opts.padding * 2; + const height = bounds.maxY - bounds.minY + opts.padding * 2; + const viewBox = `${bounds.minX - opts.padding} ${bounds.minY - opts.padding} ${width} ${height}`; + + const parts: string[] = [ + ``, + `` + ]; + + // Background + if (opts.background === 'solid') { + parts.push( + `` + ); + } + + // Edges + const edges = renderEdges(ctx); + if (edges) parts.push(edges); + + // Events + if (events.length > 0) { + parts.push(''); + for (const event of events) { + parts.push(renderEvent(event, ctx)); + } + parts.push(''); + } + + // Nodes (render in parallel for performance) + if (nodes.length > 0) { + parts.push(''); + const renderedNodes = await Promise.all(nodes.map((node) => renderNode(node, ctx))); + for (const rendered of renderedNodes) { + if (rendered) parts.push(rendered); + } + parts.push(''); + } + + parts.push(''); + return parts.join('\n'); }