- {#if renderedNameHtml}
-
{@html renderedNameHtml}
+
+
+
+ {#if hasVisibleInputLabels}
+ {#if isVertical}
+
+ {#each data.inputs as port, i}
+
+ {truncatePortLabel(port.name)}
+
+ {/each}
+
{:else}
-
{data.name}
+
+ {#each data.inputs as port, i}
+
+ {truncatePortLabel(port.name)}
+
+ {/each}
+
{/if}
- {#if typeDef}
-
{typeDef.name}
+ {/if}
+
+
+
+
+
+ {#if renderedNameHtml}
+ {@html renderedNameHtml}
+ {:else}
+ {data.name}
+ {/if}
+ {#if typeDef}
+ {typeDef.name}
+ {/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}
-
- {#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 hasVisibleOutputLabels}
+ {#if isVertical}
+
+ {#each data.outputs as port, i}
+
+ {truncatePortLabel(port.name)}
+
+ {/each}
+
+ {:else}
+
+ {#each data.outputs as port, i}
+
+ {truncatePortLabel(port.name)}
+
+ {/each}
+
+ {/if}
{/if}
@@ -492,29 +623,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);
}
@@ -523,11 +657,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 */
@@ -550,13 +686,22 @@
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-width: 0;
min-height: 0;
}
@@ -613,21 +758,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 {
@@ -644,7 +792,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);
@@ -870,4 +1018,94 @@
opacity: 1;
}
}
+
+ /* 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;
+ }
+
+ /* Label containers */
+ .port-labels {
+ position: relative;
+ min-width: 0;
+ min-height: 0;
+ overflow: visible;
+ }
+
+ /* 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;
+ 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 - row of labels with 90deg rotation */
+ .port-labels-row {
+ position: relative;
+ }
+
+ /* Reset horizontal-specific styles for vertical labels */
+ .port-labels-row .port-label {
+ position: absolute;
+ width: auto;
+ max-width: none;
+ right: auto;
+ /* 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 f95c5b24..83500862 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: 4 grid units = 40px (same as column width) */
+ rowHeight: G.x4
+} as const;
+
/**
* Round up to next 2G (20px) boundary.
* This ensures nodes expand by 1G in each direction (symmetric from center).
@@ -50,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,
@@ -60,21 +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 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,
@@ -82,22 +109,30 @@ export function calculateNodeDimensions(
outputCount: number,
pinnedParamCount: number,
rotation: number,
- typeName?: string
+ typeName?: string,
+ 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;
- const 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,
@@ -105,11 +140,30 @@ export function calculateNodeDimensions(
isVertical ? minPortDimension : 0
));
- // Height: content height vs port dimension (they share vertical space)
- const contentHeight = NODE.baseHeight + pinnedParamsHeight;
- const height = isVertical
+ // 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 visible
+ if (isVertical) {
+ if (hasVisibleInputLabels) height += PORT_LABEL.rowHeight;
+ if (hasVisibleOutputLabels) height += PORT_LABEL.rowHeight;
+ } else {
+ if (hasVisibleInputLabels) width += PORT_LABEL.columnWidth;
+ if (hasVisibleOutputLabels) width += PORT_LABEL.columnWidth;
+ }
+
return { width, height };
}
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;
diff --git a/src/lib/export/svg/renderer.ts b/src/lib/export/svg/renderer.ts
index a7e2d851..fc402f4d 100644
--- a/src/lib/export/svg/renderer.ts
+++ b/src/lib/export/svg/renderer.ts
@@ -12,8 +12,10 @@ import { get } from 'svelte/store';
import { graphStore } from '$lib/stores/graph';
import { eventStore } from '$lib/stores/events';
import { getThemeColors } from '$lib/constants/theme';
-import { NODE, EVENT } from '$lib/constants/dimensions';
+import { NODE, EVENT, PORT_LABEL, getPortOffset } from '$lib/constants/dimensions';
import { getHandlePath } from '$lib/constants/handlePaths';
+import { portLabelsStore } from '$lib/stores/portLabels';
+import { getEffectivePortLabelVisibility, truncatePortLabel } from '$lib/utils/portLabels';
import { latexToSvg, getSvgDimensions, preloadMathJax } from '$lib/utils/mathjaxSvg';
// Preload MathJax when module loads
@@ -199,6 +201,143 @@ function renderHandles(nodeId: string, nodeX: number, nodeY: number, ctx: Render
return handles.join('\n');
}
+// ============================================================================
+// PORT LABEL RENDERING
+// ============================================================================
+
+/**
+ * Resolve showPortLabels option: 'auto' reads from store, otherwise use boolean value
+ */
+function resolveShowPortLabels(option: boolean | 'auto'): boolean {
+ if (option === 'auto') {
+ return portLabelsStore.get();
+ }
+ return option;
+}
+
+/**
+ * Render port labels for a node as SVG text + separator lines.
+ * Returns SVG string and the pixel offsets consumed by label columns/rows
+ * (used to shift content center).
+ */
+function renderPortLabels(
+ node: NodeInstance,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ globalShowLabels: boolean,
+ ctx: RenderContext
+): { svg: string; inputOffset: number; outputOffset: number } {
+ const { inputs: hasInputLabels, outputs: hasOutputLabels } = getEffectivePortLabelVisibility(node, globalShowLabels);
+ if (!hasInputLabels && !hasOutputLabels) {
+ return { svg: '', inputOffset: 0, outputOffset: 0 };
+ }
+
+ const parts: string[] = [];
+ const rotation = (node.params?.['_rotation'] as number) || 0;
+ const isVertical = rotation === 1 || rotation === 3;
+ const labelColumnWidth = PORT_LABEL.columnWidth;
+
+ if (isVertical) {
+ // Vertical: label rows at top/bottom
+ // rotation 1: inputs top, outputs bottom
+ // rotation 3: inputs bottom, outputs top
+ const inputRowY = rotation === 1
+ ? y // top
+ : y + height - labelColumnWidth; // bottom
+ const outputRowY = rotation === 1
+ ? y + height - labelColumnWidth // bottom
+ : y; // top
+
+ if (hasInputLabels) {
+ // Separator line
+ const sepY = rotation === 1 ? inputRowY + labelColumnWidth : inputRowY;
+ parts.push(`
`);
+
+ // Port label text (rotated -90deg)
+ const centerY = inputRowY + labelColumnWidth / 2;
+ for (let i = 0; i < node.inputs.length; i++) {
+ const offset = getPortOffset(i, node.inputs.length);
+ const labelX = x + width / 2 + offset;
+ const label = truncatePortLabel(node.inputs[i].name);
+ parts.push(`
${escapeXml(label)}`);
+ }
+ }
+
+ if (hasOutputLabels) {
+ // Separator line
+ const sepY = rotation === 1 ? outputRowY : outputRowY + labelColumnWidth;
+ parts.push(`
`);
+
+ // Port label text (rotated -90deg)
+ const centerY = outputRowY + labelColumnWidth / 2;
+ for (let i = 0; i < node.outputs.length; i++) {
+ const offset = getPortOffset(i, node.outputs.length);
+ const labelX = x + width / 2 + offset;
+ const label = truncatePortLabel(node.outputs[i].name);
+ parts.push(`
${escapeXml(label)}`);
+ }
+ }
+
+ return {
+ svg: parts.join('\n'),
+ inputOffset: hasInputLabels ? labelColumnWidth : 0,
+ outputOffset: hasOutputLabels ? labelColumnWidth : 0
+ };
+ } else {
+ // Horizontal: label columns at left/right
+ // rotation 0: inputs left, outputs right
+ // rotation 2: inputs right, outputs left
+ const inputColX = rotation === 0
+ ? x // left
+ : x + width - labelColumnWidth; // right
+ const outputColX = rotation === 0
+ ? x + width - labelColumnWidth // right
+ : x; // left
+
+ if (hasInputLabels) {
+ // Separator line
+ const sepX = rotation === 0 ? inputColX + labelColumnWidth : inputColX;
+ parts.push(`
`);
+
+ // Port label text
+ // rotation 0: align right (near separator), rotation 2: align left
+ const textAnchor = rotation === 0 ? 'end' : 'start';
+ const textX = rotation === 0 ? inputColX + labelColumnWidth - 6 : inputColX + 6;
+ for (let i = 0; i < node.inputs.length; i++) {
+ const offset = getPortOffset(i, node.inputs.length);
+ const labelY = y + height / 2 + offset;
+ const label = truncatePortLabel(node.inputs[i].name);
+ parts.push(`
${escapeXml(label)}`);
+ }
+ }
+
+ if (hasOutputLabels) {
+ // Separator line
+ const sepX = rotation === 0 ? outputColX : outputColX + labelColumnWidth;
+ parts.push(`
`);
+
+ // Port label text
+ // rotation 0: align left (near separator), rotation 2: align right
+ const textAnchor = rotation === 0 ? 'start' : 'end';
+ const textX = rotation === 0 ? outputColX + 6 : outputColX + labelColumnWidth - 6;
+ for (let i = 0; i < node.outputs.length; i++) {
+ const offset = getPortOffset(i, node.outputs.length);
+ const labelY = y + height / 2 + offset;
+ const label = truncatePortLabel(node.outputs[i].name);
+ parts.push(`
${escapeXml(label)}`);
+ }
+ }
+
+ return {
+ svg: parts.join('\n'),
+ inputOffset: hasInputLabels ? labelColumnWidth : 0,
+ outputOffset: hasOutputLabels ? labelColumnWidth : 0
+ };
+ }
+}
+
// ============================================================================
// NODE RENDERING - Pure SVG with DOM-read styles
// ============================================================================
@@ -246,20 +385,56 @@ async function renderNode(node: NodeInstance, ctx: RenderContext): Promise
`
);
+ // Port labels
+ const showPortLabels = resolveShowPortLabels(ctx.options.showPortLabels);
+ const portLabelResult = showPortLabels
+ ? renderPortLabels(node, x, y, width, height, showPortLabels, ctx)
+ : { svg: '', inputOffset: 0, outputOffset: 0 };
+ if (portLabelResult.svg) {
+ parts.push(portLabelResult.svg);
+ }
+
// Check for pinned params section in DOM
const pinnedParamsEl = nodeEl.querySelector('.pinned-params') as HTMLElement;
- // Calculate content center (above pinned params if present)
+ // Calculate content center, accounting for port label columns/rows and pinned params
+ const rotation = (node.params?.['_rotation'] as number) || 0;
+ const isVerticalNode = rotation === 1 || rotation === 3;
+
+ let contentCenterX = x + width / 2;
let contentCenterY = y + height / 2;
+
+ // Shift content center for port label columns/rows
+ if (isVerticalNode) {
+ // Vertical: label rows shift Y center
+ const topOffset = (rotation === 1 ? portLabelResult.inputOffset : portLabelResult.outputOffset);
+ const bottomOffset = (rotation === 1 ? portLabelResult.outputOffset : portLabelResult.inputOffset);
+ const contentTop = y + topOffset;
+ const contentBottom = y + height - bottomOffset;
+ contentCenterY = (contentTop + contentBottom) / 2;
+ } else {
+ // Horizontal: label columns shift X center
+ const leftOffset = (rotation === 0 ? portLabelResult.inputOffset : portLabelResult.outputOffset);
+ const rightOffset = (rotation === 0 ? portLabelResult.outputOffset : portLabelResult.inputOffset);
+ const contentLeft = x + leftOffset;
+ const contentRight = x + width - rightOffset;
+ contentCenterX = (contentLeft + contentRight) / 2;
+ }
+
if (pinnedParamsEl) {
+ // pinnedTop is from DOM, already accounts for grid layout including port label rows
const pinnedRect = pinnedParamsEl.getBoundingClientRect();
- const pinnedTop = (pinnedRect.top - nodeRect.top) / zoom;
- contentCenterY = y + pinnedTop / 2;
+ const pinnedTopFromNode = (pinnedRect.top - nodeRect.top) / zoom;
+ // Content area is from content start to pinned params top
+ const contentAreaTop = isVerticalNode
+ ? y + (rotation === 1 ? portLabelResult.inputOffset : portLabelResult.outputOffset)
+ : y;
+ contentCenterY = (contentAreaTop + y + pinnedTopFromNode) / 2;
}
// Labels
if (ctx.options.showLabels) {
- const centerX = x + width / 2;
+ const centerX = contentCenterX;
if (ctx.options.showTypeLabels && nodeType) {
// Name above center (may contain math) - use original node.name for LaTeX source
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'
};
diff --git a/src/lib/nodes/uiConfig.ts b/src/lib/nodes/uiConfig.ts
index f7e367e5..a697c86d 100644
--- a/src/lib/nodes/uiConfig.ts
+++ b/src/lib/nodes/uiConfig.ts
@@ -9,6 +9,28 @@
export interface PortLabelConfig {
param: string;
direction: 'input' | 'output';
+ /** Optional custom parser to convert param value to label strings.
+ * Default uses parsePythonList (for ["a", "b"] format). */
+ parser?: (value: unknown) => string[] | null;
+}
+
+/**
+ * Parse an operations string into individual character labels.
+ * Handles Python-style quoted strings: '+-' or "+-" → ['+', '-']
+ * Also handles unquoted: +- → ['+', '-']
+ */
+function parseOperationsString(value: unknown): string[] | null {
+ if (value === null || value === undefined || value === 'None' || value === '') {
+ return null;
+ }
+ let str = String(value).trim();
+ if (str.length === 0) return null;
+ // Strip surrounding Python quotes (single or double)
+ if ((str.startsWith("'") && str.endsWith("'")) || (str.startsWith('"') && str.endsWith('"'))) {
+ str = str.slice(1, -1);
+ }
+ if (str.length === 0) return null;
+ return [...str];
}
/**
@@ -18,7 +40,8 @@ export interface PortLabelConfig {
*/
export const portLabelParams: Record = {
Scope: { param: 'labels', direction: 'input' },
- Spectrum: { param: 'labels', direction: 'input' }
+ Spectrum: { param: 'labels', direction: 'input' },
+ Adder: { param: 'operations', direction: 'input', parser: parseOperationsString }
};
/**
diff --git a/src/lib/stores/graph/nodes.ts b/src/lib/stores/graph/nodes.ts
index 7637fc53..36071911 100644
--- a/src/lib/stores/graph/nodes.ts
+++ b/src/lib/stores/graph/nodes.ts
@@ -211,7 +211,7 @@ export function updateNodeParams(id: string, params: Record): v
if (node) {
for (const config of getPortLabelConfigs(node.type)) {
if (config.param in params) {
- syncPortNamesFromLabels(id, params[config.param], config.direction);
+ syncPortNamesFromLabels(id, params[config.param], config.direction, config.parser);
}
}
}
diff --git a/src/lib/stores/graph/ports.ts b/src/lib/stores/graph/ports.ts
index 503b9c88..5ddd916f 100644
--- a/src/lib/stores/graph/ports.ts
+++ b/src/lib/stores/graph/ports.ts
@@ -278,13 +278,14 @@ function parsePythonList(value: unknown): string[] | null {
export function syncPortNamesFromLabels(
nodeId: string,
labelsValue: unknown,
- direction: 'input' | 'output'
+ direction: 'input' | 'output',
+ parser?: (value: unknown) => string[] | null
): void {
const currentGraph = getCurrentGraph();
const node = currentGraph.nodes.get(nodeId);
if (!node) return;
- const labels = parsePythonList(labelsValue);
+ const labels = parser ? parser(labelsValue) : parsePythonList(labelsValue);
const config = getPortConfig(direction);
const currentPorts = node[config.portsKey] as PortInstance[];
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/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;
+}
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();