diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index 4042997e..911f3991 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -414,6 +414,36 @@ }); }); + // Reroute connections when a block's measured size changes (icon-mode toggle, + // pinned-params, name-length, port-label visibility, …). The initial + // measurement after mount is recorded silently — only later changes trigger + // a recalculation. + const lastMeasuredDims = new Map(); + $effect(() => { + const changed = new Set(); + for (const node of nodes) { + if (node.type !== 'pathview') continue; + const w = node.measured?.width; + const h = node.measured?.height; + if (w === undefined || h === undefined) continue; + const last = lastMeasuredDims.get(node.id); + if (!last || last.w !== w || last.h !== h) { + if (last) changed.add(node.id); + lastMeasuredDims.set(node.id, { w, h }); + routingStore.updateNodeBounds(node.id, { + x: node.position.x - w / 2, + y: node.position.y - h / 2, + width: w, + height: h + }); + } + } + if (changed.size > 0) { + const connections = get(graphStore.connections); + routingStore.recalculateRoutesForNodes(changed, connections, getPortInfo); + } + }); + // Track if we're currently syncing to prevent loops let isSyncing = false; diff --git a/src/lib/components/FlowUpdater.svelte b/src/lib/components/FlowUpdater.svelte index a2325cf5..98d5757e 100644 --- a/src/lib/components/FlowUpdater.svelte +++ b/src/lib/components/FlowUpdater.svelte @@ -14,6 +14,10 @@ import { importFile } from '$lib/schema/fileOps'; import { ALL_COMPONENT_EXTENSIONS } from '$lib/types/component'; import { GRID_SIZE } from '$lib/constants/grid'; + import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews'; + import { plotDataStore } from '$lib/plotting/processing/plotDataStore'; + import { previewSideForRotation, extendBoundsForPreview } from '$lib/utils/previewBounds'; + import type { NodeInstance } from '$lib/types/nodes'; interface Props { pendingUpdates: string[]; @@ -33,6 +37,9 @@ return; } + const previewsPinned = get(pinnedPreviewsStore); + const plotState = get(plotDataStore); + // Calculate bounding box of all nodes, accounting for origin let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const node of nodes) { @@ -42,10 +49,19 @@ const origin = (node.origin as [number, number]) ?? [0.5, 0.5]; const left = node.position.x - width * origin[0]; const top = node.position.y - height * origin[1]; - minX = Math.min(minX, left); - minY = Math.min(minY, top); - maxX = Math.max(maxX, left + width); - maxY = Math.max(maxY, top + height); + let bounds = { left, top, right: left + width, bottom: top + height }; + + // Extend bounds for pinned plot previews on recording blocks (Scope/Spectrum) + if (previewsPinned && node.type === 'pathview' && plotState.plots.has(node.id)) { + const data = node.data as NodeInstance; + const rotation = (data.params?.['_rotation'] as number) || 0; + bounds = extendBoundsForPreview(bounds, previewSideForRotation(rotation)); + } + + minX = Math.min(minX, bounds.left); + minY = Math.min(minY, bounds.top); + maxX = Math.max(maxX, bounds.right); + maxY = Math.max(maxY, bounds.bottom); } // Add some padding around the nodes themselves diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index 61f8eb9f..ccbbefb1 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -26,7 +26,10 @@ import { exportToSVG, exportToPDF } 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'; +import { iconModeStore } from '$lib/stores/iconMode'; import { getEffectivePortLabelVisibility } from '$lib/utils/portLabels'; +import { hasBlockIcon } from '$lib/components/icons/BlockIcon.svelte'; +import { nodeRegistry } from '$lib/nodes'; import type { NodeInstance } from '$lib/types/nodes'; /** Divider menu item */ @@ -63,6 +66,27 @@ function buildPortLabelItems(nodeId: string, node: NodeInstance): MenuItemType[] return items; } +/** Build icon-mode toggle menu item for a node, only when an icon exists */ +function buildIconModeItem(nodeId: string, node: NodeInstance): MenuItemType[] { + const typeDef = nodeRegistry.get(node.type); + const blockKey = typeDef?.blockClass ?? typeDef?.type; + if (!hasBlockIcon(blockKey)) return []; + + const globalIconMode = get(iconModeStore); + const override = node.params?.['_iconMode'] as boolean | undefined; + const effective = override ?? globalIconMode; + + return [ + { + label: effective ? 'Show as Text' : 'Show as Icon', + icon: 'image', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _iconMode: !effective }) + ) + } + ]; +} + /** Show block code in preview dialog */ function showBlockCode(nodeId: string): void { const node = graphStore.getNode(nodeId); @@ -211,6 +235,7 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { ]; items.push(...buildPortLabelItems(nodeId, node)); + items.push(...buildIconModeItem(nodeId, node)); items.push( DIVIDER, diff --git a/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte b/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte index 863b1dae..20f8d669 100644 --- a/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte +++ b/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte @@ -59,6 +59,7 @@ { keys: ['+'], description: 'Zoom in' }, { keys: ['-'], description: 'Zoom out' }, { keys: ['L'], description: 'Port labels' }, + { keys: ['I'], description: 'Block icons' }, { keys: ['T'], description: 'Theme' } ] }, diff --git a/src/lib/components/icons/BlockIcon.svelte b/src/lib/components/icons/BlockIcon.svelte new file mode 100644 index 00000000..a5a7ad66 --- /dev/null +++ b/src/lib/components/icons/BlockIcon.svelte @@ -0,0 +1,85 @@ + + + + +{#if def} + + {#if def.kind === 'plot'} + + {:else if def.kind === 'scope'} + + {:else if def.kind === 'surface'} + + {:else if def.kind === 'math'} + + {:else if def.kind === 'glyph'} + + {:else if def.kind === 'svg' && svgRaw} + {@html svgRaw} + {/if} + +{/if} + + diff --git a/src/lib/components/icons/blocks/IconGlyph.svelte b/src/lib/components/icons/blocks/IconGlyph.svelte new file mode 100644 index 00000000..fbfba81d --- /dev/null +++ b/src/lib/components/icons/blocks/IconGlyph.svelte @@ -0,0 +1,38 @@ + + + + {text} + + + diff --git a/src/lib/components/icons/blocks/IconMath.svelte b/src/lib/components/icons/blocks/IconMath.svelte new file mode 100644 index 00000000..c0cee4d9 --- /dev/null +++ b/src/lib/components/icons/blocks/IconMath.svelte @@ -0,0 +1,103 @@ + + + + + {#if html} + {@html html} + {/if} + + + + diff --git a/src/lib/components/icons/blocks/IconPlot.svelte b/src/lib/components/icons/blocks/IconPlot.svelte new file mode 100644 index 00000000..9a5503a3 --- /dev/null +++ b/src/lib/components/icons/blocks/IconPlot.svelte @@ -0,0 +1,70 @@ + + + + {#if axes === 'baseline' || axes === 'cross'} + + {/if} + {#if axes === 'cross'} + + {/if} + + {#if markers} + {#each finiteSamples as [x, v]} + + {/each} + {/if} + {#if decoration === 'arrow-up'} + + {:else if decoration === 'arrow-down'} + + {/if} + + + diff --git a/src/lib/components/icons/blocks/IconScope.svelte b/src/lib/components/icons/blocks/IconScope.svelte new file mode 100644 index 00000000..16f353fe --- /dev/null +++ b/src/lib/components/icons/blocks/IconScope.svelte @@ -0,0 +1,78 @@ + + + + + + + + {#each gridXLines as gx} + + {/each} + {#each gridYLines as gy} + + {/each} + + + + {#if path2} + + {/if} + + + diff --git a/src/lib/components/icons/blocks/IconSurface.svelte b/src/lib/components/icons/blocks/IconSurface.svelte new file mode 100644 index 00000000..ec99f6f4 --- /dev/null +++ b/src/lib/components/icons/blocks/IconSurface.svelte @@ -0,0 +1,91 @@ + + + + + + + + diff --git a/src/lib/components/icons/blocks/curves.ts b/src/lib/components/icons/blocks/curves.ts new file mode 100644 index 00000000..77ea04ac --- /dev/null +++ b/src/lib/components/icons/blocks/curves.ts @@ -0,0 +1,699 @@ +/** + * Programmatic curve sample generators for block icons. + * Samples are in real-value domain. The IconPlot's `xRange` and `yRange` + * props control how values map to viewBox pixels — if 0 is inside a range, + * the corresponding axis sits at 0; otherwise at the box edge. + */ + +export type Sample = [number, number]; + +/** Box used to draw axes — the visible "frame" of the plot. */ +export const AXIS_BOX = { + x0: 12, + x1: 88, + y0: 8, + y1: 56 +} as const; + +/** Inset between axes box and the actual signal area, gives axes headroom. */ +const SIGNAL_INSET = 8; + +/** Box used to map sample values into pixels — strictly inside AXIS_BOX. */ +export const PLOT_BOX = { + x0: AXIS_BOX.x0 + SIGNAL_INSET / 2, + x1: AXIS_BOX.x1 - SIGNAL_INSET, + y0: AXIS_BOX.y0 + SIGNAL_INSET, + y1: AXIS_BOX.y1 - SIGNAL_INSET / 2, + get width() { + return this.x1 - this.x0; + }, + get height() { + return this.y1 - this.y0; + } +} as const; + +export function mapX(x: number, xMin = 0, xMax = 1): number { + const t = (x - xMin) / (xMax - xMin); + return PLOT_BOX.x0 + t * PLOT_BOX.width; +} + +export function mapY(v: number, yMin = 0, yMax = 1): number { + const t = (v - yMin) / (yMax - yMin); + return PLOT_BOX.y1 - t * PLOT_BOX.height; +} + +export function buildPath( + samples: Sample[], + xMin = 0, + xMax = 1, + yMin = 0, + yMax = 1 +): string { + if (samples.length === 0) return ''; + const cmds: string[] = []; + let penDown = false; + for (const [x, v] of samples) { + if (!Number.isFinite(v)) { + penDown = false; + continue; + } + const px = mapX(x, xMin, xMax).toFixed(2); + const py = mapY(v, yMin, yMax).toFixed(2); + cmds.push(`${penDown ? 'L' : 'M'} ${px} ${py}`); + penDown = true; + } + return cmds.join(' '); +} + +/* --- Time-domain signals (x = t, real values) -------------------------- */ + +export function sineSamples(cycles = 1.5, n = 64): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + out.push([t, Math.sin(2 * Math.PI * cycles * t)]); + } + return out; +} + +export function squareSamples(cycles = 1.5): Sample[] { + const out: Sample[] = []; + const period = 1 / cycles; + let t = 0; + out.push([0, 1]); + while (t < 1) { + const tHigh = Math.min(1, t + period / 2); + out.push([tHigh, 1]); + out.push([tHigh, -1]); + const tLow = Math.min(1, t + period); + out.push([tLow, -1]); + if (tLow < 1) { + out.push([tLow, 1]); + } + t += period; + } + return out; +} + +export function triangleSamples(cycles = 1.5, n = 80): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + const phase = (t * cycles * 2) % 2; + const v = phase < 1 ? phase : 2 - phase; + out.push([t, v * 2 - 1]); + } + return out; +} + +export function pulseSamples( + period = 0.6, + duty = 0.5, + t0 = 0.05, + tRise = 0.15, + tFall = 0.07 +): Sample[] { + const out: Sample[] = [[0, 0]]; + let t = t0; + while (t < 1) { + out.push([t, 0]); + out.push([Math.min(1, t + tRise), 1]); + const tHigh = t + period * duty; + out.push([Math.min(1, tHigh), 1]); + out.push([Math.min(1, tHigh + tFall), 0]); + t += period; + } + out.push([1, 0]); + return out; +} + +export function stepSamples(t0 = 0.25): Sample[] { + return [ + [0, 0], + [t0, 0], + [t0, 1], + [1, 1] + ]; +} + +export function gaussianSamples(mu = 0.5, sigma = 0.13, n = 80): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + out.push([t, Math.exp(-((t - mu) ** 2) / (2 * sigma * sigma))]); + } + return out; +} + +export function chirpSamples(f0 = 1, f1 = 6, n = 120): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + const k = (f1 - f0); + const phase = 2 * Math.PI * (f0 * t + 0.5 * k * t * t); + out.push([t, Math.sin(phase)]); + } + return out; +} + +/** White noise — Gaussian-distributed samples (Box-Muller), normalised to ±1. */ +export function whiteNoiseSamples(n = 28, seed = 5): Sample[] { + let s = seed; + const rand = () => { + s = (s * 9301 + 49297) % 233280; + return s / 233280; + }; + const trace: number[] = []; + for (let i = 0; i < n; i++) { + const u1 = Math.max(1e-6, rand()); + const u2 = rand(); + trace.push(Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)); + } + const max = Math.max(...trace.map(Math.abs)); + return trace.map((v, i) => [i / (n - 1), (v / max) * 0.95] as Sample); +} + +/** Pink-noise approximation via Voss-McCartney: sum of independent octaves, + * each updated at half the rate of the previous one — gives true 1/f-style + * noise with a visible LF trend plus HF detail. */ +export function pinkNoiseSamples(n = 35, seed = 11): Sample[] { + let s = seed; + const rand = () => { + s = (s * 9301 + 49297) % 233280; + return s / 233280 - 0.5; + }; + const octaves = 5; + const values = new Array(octaves).fill(0); + const counters = new Array(octaves).fill(0); + const trace: number[] = []; + for (let i = 0; i < n; i++) { + let total = 0; + for (let o = 0; o < octaves; o++) { + if (counters[o] === 0) { + values[o] = rand(); + counters[o] = 1 << o; + } + counters[o]--; + total += values[o]; + } + trace.push(total); + } + const max = Math.max(...trace.map(Math.abs)); + return trace.map((value, i) => [i / (n - 1), (value / max) * 0.9] as Sample); +} + +export function constantSamples(value = 1): Sample[] { + return [[0, value], [1, value]]; +} + +/** Clock source — fixed-period discrete 0/1 pulse train (sharp edges) */ +export function clockSamples(period = 0.32, t0 = 0.06): Sample[] { + const out: Sample[] = [[0, 0]]; + let t = t0; + while (t < 1) { + out.push([t, 0]); + out.push([t, 1]); + const tHigh = Math.min(1, t + period / 2); + out.push([tHigh, 1]); + out.push([tHigh, 0]); + t += period; + } + out.push([1, 0]); + return out; +} + +/* --- Step responses ---------------------------------------------------- */ + +export function pt1StepSamples(T = 0.18, t0 = 0.15, n = 60): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + if (t < t0) out.push([t, 0]); + else out.push([t, 1 - Math.exp(-(t - t0) / T)]); + } + return out; +} + +export function pt2StepSamples(zeta = 0.25, wn = 22, t0 = 0.15, n = 100): Sample[] { + const out: Sample[] = []; + const wd = wn * Math.sqrt(1 - zeta * zeta); + const phi = Math.atan2(Math.sqrt(1 - zeta * zeta), zeta); + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + if (t < t0) out.push([t, 0]); + else { + const tt = t - t0; + const env = Math.exp(-zeta * wn * tt) / Math.sqrt(1 - zeta * zeta); + out.push([t, 1 - env * Math.sin(wd * tt + phi)]); + } + } + return out; +} + +export function leadLagStepSamples(n = 60): Sample[] { + const out: Sample[] = []; + const t0 = 0.15; + const T1 = 0.06; + const T2 = 0.25; + const peak = 1.4; + const ss = 1.0; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + if (t < t0) out.push([t, 0]); + else { + const tt = t - t0; + const y = peak * Math.exp(-tt / T1) + ss * (1 - Math.exp(-tt / T2)); + out.push([t, y]); + } + } + return out; +} + +export function rampSamples(t0 = 0.15): Sample[] { + return [ + [0, 0], + [t0, 0], + [1, 1 - t0] + ]; +} + +/** Delayed step — input step at t1, delayed output step at t1+τ */ +export function delaySamples(t1 = 0.15, tau = 0.3): Sample[] { + return [ + [0, 0], + [t1 + tau, 0], + [t1 + tau, 1], + [1, 1] + ]; +} + +/** Differentiator step response: a sharp impulse at the step instant */ +export function impulseSamples(t0 = 0.45, width = 0.04): Sample[] { + return [ + [0, 0], + [t0 - width, 0], + [t0, 1], + [t0 + width, 0], + [1, 0] + ]; +} + +/* --- Bode magnitude (linear scale, 0..1) ------------------------------- */ + +export function butterLowpassBode(order = 4, n = 80): Sample[] { + const out: Sample[] = []; + const fmin = -1.2; + const fmax = 1.2; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + const w = Math.pow(10, fmin + t * (fmax - fmin)); + out.push([t, 1 / Math.sqrt(1 + Math.pow(w, 2 * order))]); + } + return out; +} + +export function butterHighpassBode(order = 4, n = 80): Sample[] { + const out: Sample[] = []; + const fmin = -1.2; + const fmax = 1.2; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + const w = Math.pow(10, fmin + t * (fmax - fmin)); + out.push([t, Math.pow(w, order) / Math.sqrt(1 + Math.pow(w, 2 * order))]); + } + return out; +} + +export function butterBandpassBode(order = 2, Q = 2, n = 100): Sample[] { + const out: Sample[] = []; + const fmin = -1.5; + const fmax = 1.5; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + const w = Math.pow(10, fmin + t * (fmax - fmin)); + const num = Math.pow(w / Q, order); + const denom = Math.sqrt(Math.pow(1 - w * w, 2 * order) + Math.pow(w / Q, 2 * order)); + out.push([t, num / denom]); + } + return out; +} + +export function butterBandstopBode(order = 2, Q = 2, n = 100): Sample[] { + const out: Sample[] = []; + const fmin = -1.5; + const fmax = 1.5; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + const w = Math.pow(10, fmin + t * (fmax - fmin)); + const num = Math.pow(Math.abs(1 - w * w), order); + const denom = Math.sqrt(Math.pow(1 - w * w, 2 * order) + Math.pow(w / Q, 2 * order)); + out.push([t, num / denom]); + } + return out; +} + +export function differentiatorBode(n = 80): Sample[] { + // |H(jω)| = ω, normalised against ω_max so the curve fits [0, 1] + const out: Sample[] = []; + const fmin = -1.2; + const fmax = 1.2; + const wMax = Math.pow(10, fmax); + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + const w = Math.pow(10, fmin + t * (fmax - fmin)); + out.push([t, w / wMax]); + } + return out; +} + +export function firBode(n = 80): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + const w = Math.pow(10, -1.2 + t * 2.4); + const sinc = Math.abs(Math.sin(w * 1.2) / (w * 1.2 + 0.0001)); + const lp = 1 / Math.sqrt(1 + Math.pow(w / 1, 6)); + out.push([t, sinc * lp]); + } + return out; +} + +/* --- Trig and power functions (input-output) -------------------------- */ + +export function sinFunctionSamples(n = 80): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const x = -Math.PI + (2 * Math.PI * i) / (n - 1); + out.push([x, Math.sin(x)]); + } + return out; +} + +export function cosFunctionSamples(n = 80): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const x = -Math.PI + (2 * Math.PI * i) / (n - 1); + out.push([x, Math.cos(x)]); + } + return out; +} + +export function powSamples(exp = 2, n = 60): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const x = -1 + (2 * i) / (n - 1); + out.push([x, Math.pow(x, exp)]); + } + return out; +} + +/** ADC/DAC quantization staircase — input continuous, output stepped */ +export function quantizerSamples(levels = 6): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < levels; i++) { + const xL = -1 + (2 * i) / levels; + const xR = -1 + (2 * (i + 1)) / levels; + const y = -1 + (2 * (i + 0.5)) / levels; + out.push([xL, y]); + out.push([xR, y]); + } + return out; +} + +/** Counter — staircase time series rising up */ +export function counterUpSamples(steps = 6): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < steps; i++) { + const t0 = i / steps; + const t1 = (i + 1) / steps; + const y = i / (steps - 1); + out.push([t0, y]); + out.push([t1, y]); + } + return out; +} + +export function counterDownSamples(steps = 6): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < steps; i++) { + const t0 = i / steps; + const t1 = (i + 1) / steps; + const y = 1 - i / (steps - 1); + out.push([t0, y]); + out.push([t1, y]); + } + return out; +} + +/** PID step response — typical overshoot + settle */ +export function pidStepSamples(t0 = 0.12, n = 100): Sample[] { + return pt2StepSamples(0.4, 18, t0, n); +} + +/** Modulo / sawtooth: y = x mod period over x ∈ [0, 1] */ +export function modSamples(period = 0.3): Sample[] { + const out: Sample[] = []; + let t = 0; + while (t < 1) { + out.push([t, 0]); + const tEnd = Math.min(1, t + period); + out.push([tEnd, (tEnd - t) / period]); + if (tEnd < 1) { + out.push([tEnd, 0]); + } + t = tEnd; + } + return out; +} + +/** Tangent over x ∈ [-π, π]; out-of-range points are dropped so the curve + * appears cropped at the plot edges instead of clamped to a flat line. */ +export function tanFunctionSamples(limit = 1.55, n = 240): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const x = -Math.PI + (2 * Math.PI * i) / (n - 1); + const y = Math.tan(x); + if (!Number.isFinite(y) || Math.abs(y) > limit) { + out.push([x, NaN]); + } else { + out.push([x, y]); + } + } + return out; +} + +/** 1D lookup table — pronounced S-curve through six break points */ +export function lut1dSamples(): Sample[] { + return [ + [-1, -0.6], + [-0.5, -0.55], + [-0.05, 0.0], + [0.4, 0.55], + [0.75, 0.75], + [1, 0.78] + ]; +} + +/** 2D lookup table (MISO) — two distinctly different output characteristics: + * one monotonically rising, one non-monotonic with a hump. */ +export function lut2dSamples(): Sample[] { + return [ + // rising characteristic + [-1, -0.7], + [-0.3, -0.05], + [0.4, 0.55], + [1, 0.8], + [0, NaN], + // hump characteristic (rises then falls) + [-1, -0.4], + [-0.3, 0.3], + [0.4, -0.05], + [1, -0.55] + ]; +} + +/** Generic 2nd-order Bode magnitude — used for generic transfer-function blocks */ +export function genericBode(zeta = 0.4, n = 80): Sample[] { + const out: Sample[] = []; + const fmin = -1.2; + const fmax = 1.2; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + const w = Math.pow(10, fmin + t * (fmax - fmin)); + const denom = Math.sqrt(Math.pow(1 - w * w, 2) + Math.pow(2 * zeta * w, 2)); + out.push([t, 1 / denom]); + } + return out; +} + +/* --- Static nonlinearities (real x domain, typically [-1, 1] or [0, 1]) */ + +export function tanhSamples(gain = 4, n = 60): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const x = -1 + 2 * (i / (n - 1)); + out.push([x, Math.tanh(x * gain)]); + } + return out; +} + +export function expSamples(n = 60): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + out.push([t, (Math.exp(t * 2.2) - 1) / (Math.exp(2.2) - 1)]); + } + return out; +} + +export function logSamples(n = 60): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + out.push([t, Math.log(1 + 9 * t) / Math.log(10)]); + } + return out; +} + +export function sqrtSamples(n = 60): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + out.push([t, Math.sqrt(t)]); + } + return out; +} + +export function absSamples(n = 60): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const x = -1 + 2 * (i / (n - 1)); + out.push([x, Math.abs(x)]); + } + return out; +} + +export function clipSamples(limit = 0.6): Sample[] { + return [ + [-1, -limit], + [-limit, -limit], + [limit, limit], + [1, limit] + ]; +} + +export function deadbandSamples(width = 0.3): Sample[] { + return [ + [-1, -1 + width], + [-width, 0], + [width, 0], + [1, 1 - width] + ]; +} + +export function relaySamples(threshold = 0.3): Sample[] { + return [ + [-1, -1], + [threshold, -1], + [threshold, 1], + [1, 1], + [-threshold, 1], + [-threshold, -1], + [-1, -1] + ]; +} + +export function rateLimiterSamples(): Sample[] { + const t0 = 0.15; + const ramp = 0.45; + return [ + [0, 0], + [t0, 0], + [t0 + ramp, 1], + [1, 1] + ]; +} + +export function sampleHoldSamples(n = 6): Sample[] { + const out: Sample[] = []; + const samplesY = [0.1, 0.3, 0.55, 0.75, 0.55, 0.85]; + for (let i = 0; i < n; i++) { + const t0 = i / n; + const t1 = (i + 1) / n; + out.push([t0, samplesY[i]]); + out.push([t1, samplesY[i]]); + } + return out; +} + +export function backlashSamples(): Sample[] { + return [ + [-1, -0.7], + [-0.3, -0.7], + [0.3, 0.7], + [1, 0.7] + ]; +} + +/* --- Scope-style display signals --------------------------------------- */ + +/** Superposition of three sin/cos components — visually rich oscilloscope trace */ +export function superposedSignal(n = 220): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + const v = + 0.55 * Math.sin(2 * Math.PI * 1.2 * t) + + 0.4 * Math.sin(2 * Math.PI * 5.2 * t + 0.4) + + 0.08 * Math.cos(2 * Math.PI * 11 * t); + out.push([t, v]); + } + return out; +} + +/** Growing cosine — secondary oscilloscope channel (start at peak, amplitude grows) */ +export function growingCosine(growth = 0.01, cycles = 4.5, n = 140): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + const env = Math.exp(growth * 2 * Math.PI * cycles * t); + out.push([t, env * Math.cos(2 * Math.PI * cycles * t)]); + } + return out; +} + +export function dampedOscillation(zeta = 0.06, cycles = 2.5, t0 = 0.05, n = 140): Sample[] { + const out: Sample[] = []; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + if (t < t0) out.push([t, 0]); + else { + const tt = t - t0; + const env = Math.exp(-zeta * 2 * Math.PI * cycles * tt); + out.push([t, env * Math.sin(2 * Math.PI * cycles * tt)]); + } + } + return out; +} + +export function spectrumBars(): Sample[] { + const peaks = [ + [0.08, 0.15], + [0.18, 0.55], + [0.28, 0.85], + [0.38, 0.65], + [0.5, 0.4], + [0.62, 0.7], + [0.74, 0.3], + [0.86, 0.5], + [0.94, 0.18] + ] as Array<[number, number]>; + const out: Sample[] = [[0, 0]]; + for (const [t, h] of peaks) { + out.push([t, 0]); + out.push([t, h]); + out.push([t, 0]); + } + out.push([1, 0]); + return out; +} diff --git a/src/lib/components/icons/blocks/registry.ts b/src/lib/components/icons/blocks/registry.ts new file mode 100644 index 00000000..03ce0c53 --- /dev/null +++ b/src/lib/components/icons/blocks/registry.ts @@ -0,0 +1,133 @@ +/** + * Block icon registry — maps blockClass to a renderer descriptor. + * + * Renderer types: + * - 'plot': programmatic curve with optional axes (Sources, responses, nonlinearities) + * - 'scope': framed plot with grid (Scope/Spectrum) + * - 'math': KaTeX-rendered LaTeX (transfer functions, ODEs, operators) + * - 'glyph': monospace text label (PID, ADC, DAC, …) + * - 'svg': raw SVG file from ./svg/.svg (geometric symbols) + */ + +import type { Sample } from './curves'; +import * as C from './curves'; + +type AxesMode = 'none' | 'baseline' | 'cross'; + +export type IconDef = + | { kind: 'plot'; samples: () => Sample[]; xRange?: [number, number]; yRange?: [number, number]; axes?: AxesMode; markers?: boolean; decoration?: 'arrow-up' | 'arrow-down' } + | { kind: 'scope'; samples: () => Sample[]; samples2?: () => Sample[]; yRange?: [number, number]; gridX?: number; gridY?: number } + | { kind: 'surface'; fn?: (u: number, v: number) => number; rows?: number; cols?: number } + | { kind: 'math'; latex: string; fit?: number } + | { kind: 'glyph'; text: string; size?: number } + | { kind: 'svg'; name: string }; + +const X_BIPOLAR: [number, number] = [-1.05, 1.05]; +const Y_BIPOLAR: [number, number] = [-1.1, 1.1]; +const Y_TIGHT: [number, number] = [-0.7, 0.7]; +const PT2_RANGE: [number, number] = [0, 1.5]; +const LEADLAG_RANGE: [number, number] = [0, 1.6]; +const TRIG_X_RANGE: [number, number] = [-Math.PI * 1.05, Math.PI * 1.05]; +const GENERIC_BODE_Y: [number, number] = [0, 1.6]; + +export const iconRegistry: Record = { + /* --- Sources (time-domain signals) --- */ + Constant: { kind: 'plot', samples: () => C.constantSamples() }, + StepSource: { kind: 'plot', samples: () => C.stepSamples() }, + SinusoidalSource: { kind: 'plot', samples: () => C.sineSamples(), yRange: Y_BIPOLAR }, + SquareWaveSource: { kind: 'plot', samples: () => C.squareSamples(), yRange: Y_BIPOLAR }, + TriangleWaveSource: { kind: 'plot', samples: () => C.triangleSamples(), yRange: Y_BIPOLAR }, + PulseSource: { kind: 'plot', samples: () => C.pulseSamples() }, + GaussianPulseSource: { kind: 'plot', samples: () => C.gaussianSamples() }, + ChirpPhaseNoiseSource: { kind: 'plot', samples: () => C.chirpSamples(), yRange: Y_BIPOLAR }, + WhiteNoise: { kind: 'plot', samples: () => C.whiteNoiseSamples(), yRange: Y_BIPOLAR }, + PinkNoise: { kind: 'plot', samples: () => C.pinkNoiseSamples(), yRange: Y_BIPOLAR }, + RandomNumberGenerator: { kind: 'plot', samples: () => C.whiteNoiseSamples(16, 13), yRange: Y_BIPOLAR }, + ClockSource: { kind: 'plot', samples: () => C.clockSamples() }, + Source: { kind: 'math', latex: 'f(t)' }, + + /* --- Step responses (Dynamic) --- */ + PT1: { kind: 'plot', samples: () => C.pt1StepSamples() }, + PT2: { kind: 'plot', samples: () => C.pt2StepSamples(), yRange: PT2_RANGE }, + LeadLag: { kind: 'plot', samples: () => C.leadLagStepSamples(), yRange: LEADLAG_RANGE }, + Integrator: { kind: 'plot', samples: () => C.rampSamples() }, + Differentiator: { kind: 'plot', samples: () => C.differentiatorBode() }, + Delay: { kind: 'plot', samples: () => C.delaySamples() }, + PID: { kind: 'plot', samples: () => C.pidStepSamples(), yRange: PT2_RANGE }, + AntiWindupPID: { kind: 'plot', samples: () => C.pt1StepSamples(0.12, 0.12) }, + + /* --- Bode magnitude (Filters / generic transfer functions) --- */ + ButterworthLowpassFilter: { kind: 'plot', samples: () => C.butterLowpassBode() }, + ButterworthHighpassFilter: { kind: 'plot', samples: () => C.butterHighpassBode() }, + ButterworthBandpassFilter: { kind: 'plot', samples: () => C.butterBandpassBode() }, + ButterworthBandstopFilter: { kind: 'plot', samples: () => C.butterBandstopBode() }, + FIR: { kind: 'plot', samples: () => C.firBode() }, + TransferFunctionNumDen: { kind: 'plot', samples: () => C.genericBode(0.35), yRange: GENERIC_BODE_Y }, + TransferFunctionZPG: { kind: 'plot', samples: () => C.genericBode(0.35), yRange: GENERIC_BODE_Y }, + + /* --- Static nonlinearities (input-output, x in real domain) --- */ + Tanh: { kind: 'plot', samples: () => C.tanhSamples(), xRange: X_BIPOLAR, yRange: Y_BIPOLAR }, + Exp: { kind: 'plot', samples: () => C.expSamples() }, + Log: { kind: 'plot', samples: () => C.logSamples() }, + Log10: { kind: 'plot', samples: () => C.logSamples() }, + Sqrt: { kind: 'plot', samples: () => C.sqrtSamples() }, + Abs: { kind: 'plot', samples: () => C.absSamples(), xRange: X_BIPOLAR }, + Clip: { kind: 'plot', samples: () => C.clipSamples(), xRange: X_BIPOLAR, yRange: Y_TIGHT }, + Deadband: { kind: 'plot', samples: () => C.deadbandSamples(), xRange: X_BIPOLAR, yRange: Y_TIGHT }, + Relay: { kind: 'plot', samples: () => C.relaySamples(), xRange: X_BIPOLAR, yRange: Y_BIPOLAR }, + RateLimiter: { kind: 'plot', samples: () => C.rateLimiterSamples() }, + SampleHold: { kind: 'plot', samples: () => C.sampleHoldSamples() }, + Backlash: { kind: 'plot', samples: () => C.backlashSamples(), xRange: X_BIPOLAR, yRange: Y_BIPOLAR }, + + /* --- Trig / power --- */ + Sin: { kind: 'plot', samples: () => C.sinFunctionSamples(), xRange: TRIG_X_RANGE, yRange: Y_BIPOLAR }, + Cos: { kind: 'plot', samples: () => C.cosFunctionSamples(), xRange: TRIG_X_RANGE, yRange: Y_BIPOLAR }, + Tan: { kind: 'plot', samples: () => C.tanFunctionSamples(), xRange: TRIG_X_RANGE, yRange: [-1.6, 1.6] }, + Pow: { kind: 'plot', samples: () => C.powSamples(2), xRange: X_BIPOLAR }, + Mod: { kind: 'plot', samples: () => C.modSamples() }, + + /* --- Quantisation / counters --- */ + ADC: { kind: 'plot', samples: () => C.quantizerSamples(), xRange: X_BIPOLAR, yRange: Y_BIPOLAR }, + DAC: { kind: 'plot', samples: () => C.quantizerSamples(), xRange: X_BIPOLAR, yRange: Y_BIPOLAR }, + Counter: { kind: 'plot', samples: () => C.counterUpSamples() }, + CounterUp: { kind: 'plot', samples: () => C.counterUpSamples(), decoration: 'arrow-up' }, + CounterDown: { kind: 'plot', samples: () => C.counterUpSamples(), decoration: 'arrow-down' }, + + /* --- Lookup tables --- */ + LUT1D: { kind: 'plot', samples: () => C.lut1dSamples(), xRange: X_BIPOLAR, yRange: Y_BIPOLAR, markers: true }, + LUT: { kind: 'surface', fn: (u, v) => -0.18 * (u + v) + 0.3 * u * v }, + + /* --- Math (KaTeX) --- */ + ODE: { kind: 'math', latex: '\\dot{x} = f(x, u, t)' }, + StateSpace: { kind: 'math', latex: '\\begin{aligned}\\dot{x} &= Ax{+}Bu\\\\ y &= Cx{+}Du\\end{aligned}' }, + DynamicalSystem: { kind: 'math', latex: '\\begin{aligned}\\dot{x} &= f(x, u, t)\\\\ y &= g(x, u, t)\\end{aligned}' }, + DynamicalFunction: { kind: 'math', latex: 'f(u, t)' }, + Function: { kind: 'math', latex: 'f(u)' }, + + /* --- Geometric SVGs (kept as files) --- */ + Adder: { kind: 'svg', name: 'Adder' }, + Multiplier: { kind: 'svg', name: 'Multiplier' }, + Amplifier: { kind: 'svg', name: 'Amplifier' }, + Rescale: { kind: 'svg', name: 'Amplifier' }, + LogicAnd: { kind: 'svg', name: 'LogicAnd' }, + LogicOr: { kind: 'svg', name: 'LogicOr' }, + LogicNot: { kind: 'svg', name: 'LogicNot' }, + Switch: { kind: 'svg', name: 'Switch' }, + Subsystem: { kind: 'svg', name: 'Subsystem' }, + Scope: { + kind: 'scope', + samples: () => C.superposedSignal(), + yRange: [-1.05, 1.05], + gridX: 0, + gridY: 0 + }, + Spectrum: { kind: 'scope', samples: () => C.spectrumBars(), yRange: [0, 1], gridX: 0, gridY: 0 } +}; + +export function getIconDef(blockClass: string | undefined): IconDef | undefined { + return blockClass ? iconRegistry[blockClass] : undefined; +} + +export function hasBlockIcon(blockClass: string | undefined): boolean { + return !!blockClass && blockClass in iconRegistry; +} diff --git a/src/lib/components/icons/blocks/svg/Adder.svg b/src/lib/components/icons/blocks/svg/Adder.svg new file mode 100644 index 00000000..83baff2e --- /dev/null +++ b/src/lib/components/icons/blocks/svg/Adder.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/components/icons/blocks/svg/Amplifier.svg b/src/lib/components/icons/blocks/svg/Amplifier.svg new file mode 100644 index 00000000..08a6e1a9 --- /dev/null +++ b/src/lib/components/icons/blocks/svg/Amplifier.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/components/icons/blocks/svg/LogicAnd.svg b/src/lib/components/icons/blocks/svg/LogicAnd.svg new file mode 100644 index 00000000..c40f7632 --- /dev/null +++ b/src/lib/components/icons/blocks/svg/LogicAnd.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/components/icons/blocks/svg/LogicNot.svg b/src/lib/components/icons/blocks/svg/LogicNot.svg new file mode 100644 index 00000000..9a2938e7 --- /dev/null +++ b/src/lib/components/icons/blocks/svg/LogicNot.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/lib/components/icons/blocks/svg/LogicOr.svg b/src/lib/components/icons/blocks/svg/LogicOr.svg new file mode 100644 index 00000000..4ee6d6e9 --- /dev/null +++ b/src/lib/components/icons/blocks/svg/LogicOr.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/components/icons/blocks/svg/Multiplier.svg b/src/lib/components/icons/blocks/svg/Multiplier.svg new file mode 100644 index 00000000..28f582f2 --- /dev/null +++ b/src/lib/components/icons/blocks/svg/Multiplier.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/components/icons/blocks/svg/Subsystem.svg b/src/lib/components/icons/blocks/svg/Subsystem.svg new file mode 100644 index 00000000..1ad964a1 --- /dev/null +++ b/src/lib/components/icons/blocks/svg/Subsystem.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/lib/components/icons/blocks/svg/Switch.svg b/src/lib/components/icons/blocks/svg/Switch.svg new file mode 100644 index 00000000..5d54a6b2 --- /dev/null +++ b/src/lib/components/icons/blocks/svg/Switch.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index f4bcf493..4ee46c40 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -9,6 +9,9 @@ import { historyStore } from '$lib/stores/history'; import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews'; import { portLabelsStore } from '$lib/stores/portLabels'; + import { iconModeStore } from '$lib/stores/iconMode'; + import BlockIcon, { hasBlockIcon } from '$lib/components/icons/BlockIcon.svelte'; + import { PREVIEW_GAP, previewSideForRotation } from '$lib/utils/previewBounds'; import { hoveredHandle, selectedNodeHighlight } from '$lib/stores/hoveredHandle'; import { showTooltip, hideTooltip } from '$lib/components/Tooltip.svelte'; import { paramInput } from '$lib/actions/paramInput'; @@ -66,9 +69,21 @@ globalShowPortLabels = value; }); + // Global icon mode (icon vs text) + let globalIconMode = $state(false); + const unsubscribeIconMode = iconModeStore.subscribe((value) => { + globalIconMode = 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); + const nodeIconMode = $derived(data.params?.['_iconMode'] as boolean | undefined); + + // Effective icon-mode and whether an icon exists for this block class + const effectiveIconMode = $derived(nodeIconMode ?? globalIconMode); + const blockIconKey = $derived(typeDef?.blockClass ?? typeDef?.type); + const showIcon = $derived(effectiveIconMode && hasBlockIcon(blockIconKey)); // Effective visibility settings (per-node overrides global) const showInputLabels = $derived(nodeShowInputLabels ?? globalShowPortLabels); @@ -89,10 +104,17 @@ } }); + // Re-measure when icon-mode flips + $effect(() => { + void showIcon; + updateNodeInternals(id); + }); + onDestroy(() => { unsubscribePinned(); unsubscribePlotData(); unsubscribePortLabels(); + unsubscribeIconMode(); if (hoverTimeout) clearTimeout(hoverTimeout); }); @@ -194,19 +216,8 @@ // Port is horizontal (left/right) or vertical (top/bottom) const isVertical = $derived(rotation === 1 || rotation === 3); - // Preview position: opposite side of inputs - // rotation 0: inputs left → preview right - // rotation 1: inputs top → preview bottom - // rotation 2: inputs right → preview left - // rotation 3: inputs bottom → preview top - const previewPosition = $derived(() => { - switch (rotation) { - case 1: return 'bottom'; - case 2: return 'left'; - case 3: return 'top'; - default: return 'right'; - } - }); + // Preview position: opposite side of inputs (rotation → side mapping is in utils) + const previewPosition = $derived(() => previewSideForRotation(rotation)); const maxPortsOnSide = $derived(Math.max(data.inputs.length, data.outputs.length)); const pinnedCount = $derived(validPinnedParams().length); @@ -228,7 +239,8 @@ typeDef?.name, hasVisibleInputLabels, hasVisibleOutputLabels, - measuredName + measuredName, + showIcon )); // Grid layout for port labels (computed in JS, replaces CSS grid-placement selectors) @@ -463,7 +475,7 @@ class:show-labels={showPortLabels} class:missing-type={!typeDef && data.type !== NODE_TYPES.SUBSYSTEM && data.type !== NODE_TYPES.INTERFACE} data-rotation={rotation} - style="width: {nodeDimensions.width}px; height: {nodeDimensions.height}px; --node-color: {nodeColor};" + style="width: {nodeDimensions.width}px; height: {nodeDimensions.height}px; --node-color: {nodeColor}; --preview-gap: {PREVIEW_GAP}px;" ondblclick={handleDoubleClick} onmouseenter={handleMouseEnter} onmouseleave={handleMouseLeave} @@ -513,13 +525,17 @@
-
+
{#if renderedNameHtml} {@html renderedNameHtml} {:else} {data.name} {/if} - {#if typeDef} + {#if showIcon} +
+ +
+ {:else if typeDef} {typeDef.name} {:else if data.type !== NODE_TYPES.SUBSYSTEM && data.type !== NODE_TYPES.INTERFACE} {data.type} (missing) @@ -761,6 +777,33 @@ margin-top: 2px; } + .node-content.has-icon { + padding: 2px 4px 4px; + } + + .node-content.has-icon .node-name { + font-size: 9px; + font-weight: 500; + } + + .node-icon { + flex: 1; + min-height: 0; + margin-top: 1px; + display: flex; + align-items: center; + justify-content: center; + color: var(--node-color); + } + + .node-icon :global(svg) { + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + display: block; + } + .node-type.missing { color: var(--warning); } @@ -1003,28 +1046,28 @@ /* Preview position: right (default, inputs on left) */ .plot-preview-popup.preview-right { - left: calc(100% + 12px); + left: calc(100% + var(--preview-gap)); top: 50%; transform: translateY(-50%); } /* Preview position: left (inputs on right) */ .plot-preview-popup.preview-left { - right: calc(100% + 12px); + right: calc(100% + var(--preview-gap)); top: 50%; transform: translateY(-50%); } /* Preview position: top (inputs on bottom) */ .plot-preview-popup.preview-top { - bottom: calc(100% + 12px); + bottom: calc(100% + var(--preview-gap)); left: 50%; transform: translateX(-50%); } /* Preview position: bottom (inputs on top) */ .plot-preview-popup.preview-bottom { - top: calc(100% + 12px); + top: calc(100% + var(--preview-gap)); left: 50%; transform: translateX(-50%); } diff --git a/src/lib/components/nodes/NodePreview.svelte b/src/lib/components/nodes/NodePreview.svelte index c4487078..75b152b8 100644 --- a/src/lib/components/nodes/NodePreview.svelte +++ b/src/lib/components/nodes/NodePreview.svelte @@ -9,8 +9,6 @@ let { node }: Props = $props(); const isSubsystemType = $derived(node.category === 'Subsystem'); - - // Get shape class from unified shapes utility const shapeClass = $derived(() => getShapeCssClass(node)); @@ -31,34 +29,13 @@ transition: all 0.15s ease; } - .shape-pill { - border-radius: 20px; - } - - .shape-rect { - border-radius: 4px; - } - - .shape-circle { - border-radius: 16px; - } - - .shape-diamond { - border-radius: 4px; - transform: rotate(45deg); - } - - .shape-diamond .node-name { - transform: rotate(-45deg); - } - - .shape-mixed { - border-radius: 12px 4px 12px 4px; - } - - .shape-default { - border-radius: 8px; - } + .shape-pill { border-radius: 20px; } + .shape-rect { border-radius: 4px; } + .shape-circle { border-radius: 16px; } + .shape-diamond { border-radius: 4px; transform: rotate(45deg); } + .shape-diamond .node-name { transform: rotate(-45deg); } + .shape-mixed { border-radius: 12px 4px 12px 4px; } + .shape-default { border-radius: 8px; } .subsystem-type { border-style: dashed; diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index 83500862..f87f32bd 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -95,6 +95,10 @@ export function getPortPositionCalc(index: number, total: number): string { /** Baseline text height for comparing math rendering (approximate) */ const BASELINE_TEXT_HEIGHT = 14; +/** Icon-mode block content size (name above icon) */ +const ICON_CONTENT_WIDTH = G.px(7); // 70 +const ICON_CONTENT_HEIGHT = G.px(6); // 60 + /** * Calculate node dimensions from node data. * Used by both SvelteFlow (for bounds) and BaseNode (for CSS). @@ -112,7 +116,8 @@ export function calculateNodeDimensions( typeName?: string, hasVisibleInputLabels?: boolean, hasVisibleOutputLabels?: boolean, - measuredName?: { width: number; height: number } | null + measuredName?: { width: number; height: number } | null, + showIcon?: boolean ): { width: number; height: number } { const isVertical = rotation === 1 || rotation === 3; const maxPortsOnSide = Math.max(inputCount, outputCount); @@ -125,24 +130,32 @@ export function calculateNodeDimensions( // Type label width estimate (8px font, ~5px per char) const typeWidth = typeName ? typeName.length * 5 + 20 : 0; - // Name width: use measured if available, otherwise estimate (10px font, ~6px per char) - // Add 24px for horizontal padding in .node-content (12px each side) + // Name width: use measured if available, otherwise estimate. + // Standard mode: 10px font, ~6px per char + 24px padding. + // Icon mode: 9px font weight 500, ~5px per char. + const charWidth = showIcon ? 5 : 6; + const namePadding = showIcon ? 14 : 20; const nameWidth = measuredName ? snapTo2G(measuredName.width + 24) - : name.length * 6 + 20; + : name.length * charWidth + namePadding; - // Content width (without port labels) + // Content width (without port labels) — type label is replaced by the + // icon in icon-mode, so it must not contribute to the block width. let contentWidth = snapTo2G(Math.max( NODE.baseWidth, nameWidth, - typeWidth, + showIcon ? 0 : typeWidth, pinnedParamsWidth, + showIcon ? ICON_CONTENT_WIDTH : 0, isVertical ? minPortDimension : 0 )); // Content height: check if math is significantly taller than baseline text let contentHeight: number; - if (measuredName && measuredName.height > BASELINE_TEXT_HEIGHT * 1.2) { + if (showIcon) { + // Icon mode: name above icon, fixed content height + contentHeight = ICON_CONTENT_HEIGHT + pinnedParamsHeight; + } else 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 { diff --git a/src/lib/stores/iconMode.ts b/src/lib/stores/iconMode.ts new file mode 100644 index 00000000..94008c61 --- /dev/null +++ b/src/lib/stores/iconMode.ts @@ -0,0 +1,37 @@ +/** + * Icon Mode Store + * + * Controls global display mode for blocks: text-only or icon (Simulink-style). + * Toggle with 'I' key. Persists to localStorage. + */ + +import { writable, get } from 'svelte/store'; +import { browser } from '$app/environment'; + +const STORAGE_KEY = 'pathview-iconMode'; + +function getInitialValue(): boolean { + if (!browser) return false; + return localStorage.getItem(STORAGE_KEY) === 'true'; +} + +const store = writable(getInitialValue()); + +store.subscribe((value) => { + if (browser) { + localStorage.setItem(STORAGE_KEY, String(value)); + } +}); + +export const iconModeStore = { + 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/stores/index.ts b/src/lib/stores/index.ts index 93368c75..c8d71d38 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -20,6 +20,7 @@ export { nodeUpdatesStore } from './nodeUpdates'; export { pinnedPreviewsStore } from './pinnedPreviews'; export { hoveredHandle, selectedNodeHighlight } from './hoveredHandle'; export { portLabelsStore } from './portLabels'; +export { iconModeStore } from './iconMode'; // View actions (re-exports triggers and utils) export * from './viewActions'; diff --git a/src/lib/utils/previewBounds.ts b/src/lib/utils/previewBounds.ts new file mode 100644 index 00000000..97941e54 --- /dev/null +++ b/src/lib/utils/previewBounds.ts @@ -0,0 +1,74 @@ +/** + * Plot-preview popup geometry (Scope / Spectrum). + * + * Single source of truth for the gap between a recording block and its + * pinned plot-preview popup, the side it appears on for each rotation, + * and how those bounds extend the block's bounding box (used for fit-view). + */ + +import { PREVIEW_WIDTH, PREVIEW_HEIGHT } from '$lib/plotting/core/constants'; + +/** Pixel gap between block edge and preview popup. CSS reads this via the + * `--preview-gap` custom property set on the block. */ +export const PREVIEW_GAP = 12; + +export type PreviewSide = 'top' | 'right' | 'bottom' | 'left'; + +/** Side the preview appears on for a given block rotation (matches handle layout). */ +export function previewSideForRotation(rotation: number): PreviewSide { + switch (rotation) { + case 1: + return 'bottom'; + case 2: + return 'left'; + case 3: + return 'top'; + default: + return 'right'; + } +} + +export interface BlockBounds { + left: number; + top: number; + right: number; + bottom: number; +} + +/** Extend a block's bounding box by the preview popup placed on the given side. */ +export function extendBoundsForPreview(bounds: BlockBounds, side: PreviewSide): BlockBounds { + const cx = (bounds.left + bounds.right) / 2; + const cy = (bounds.top + bounds.bottom) / 2; + const halfW = PREVIEW_WIDTH / 2; + const halfH = PREVIEW_HEIGHT / 2; + switch (side) { + case 'right': + return { + left: bounds.left, + top: Math.min(bounds.top, cy - halfH), + right: bounds.right + PREVIEW_GAP + PREVIEW_WIDTH, + bottom: Math.max(bounds.bottom, cy + halfH) + }; + case 'left': + return { + left: bounds.left - PREVIEW_GAP - PREVIEW_WIDTH, + top: Math.min(bounds.top, cy - halfH), + right: bounds.right, + bottom: Math.max(bounds.bottom, cy + halfH) + }; + case 'bottom': + return { + left: Math.min(bounds.left, cx - halfW), + top: bounds.top, + right: Math.max(bounds.right, cx + halfW), + bottom: bounds.bottom + PREVIEW_GAP + PREVIEW_HEIGHT + }; + case 'top': + return { + left: Math.min(bounds.left, cx - halfW), + top: bounds.top - PREVIEW_GAP - PREVIEW_HEIGHT, + right: Math.max(bounds.right, cx + halfW), + bottom: bounds.bottom + }; + } +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 010bcce2..e9f8f3b2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -54,6 +54,7 @@ import { nodeUpdatesStore } from '$lib/stores/nodeUpdates'; import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews'; import { portLabelsStore } from '$lib/stores/portLabels'; + import { iconModeStore } from '$lib/stores/iconMode'; import { clipboardStore } from '$lib/stores/clipboard'; import Tooltip, { tooltip } from '$lib/components/Tooltip.svelte'; import { isInputFocused } from '$lib/utils/focus'; @@ -796,6 +797,10 @@ event.preventDefault(); portLabelsStore.toggle(); return; + case 'i': + event.preventDefault(); + iconModeStore.toggle(); + return; case 'b': event.preventDefault(); toggleNodeLibrary();