Skip to content

Commit eec3a47

Browse files
authored
Merge pull request #278 from pathsim/feature/block-icons
Block icons: Simulink-style icon rendering with toggle
2 parents 217030e + 11cd2d1 commit eec3a47

27 files changed

Lines changed: 1615 additions & 62 deletions

src/lib/components/FlowCanvas.svelte

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,36 @@
414414
});
415415
});
416416
417+
// Reroute connections when a block's measured size changes (icon-mode toggle,
418+
// pinned-params, name-length, port-label visibility, …). The initial
419+
// measurement after mount is recorded silently — only later changes trigger
420+
// a recalculation.
421+
const lastMeasuredDims = new Map<string, { w: number; h: number }>();
422+
$effect(() => {
423+
const changed = new Set<string>();
424+
for (const node of nodes) {
425+
if (node.type !== 'pathview') continue;
426+
const w = node.measured?.width;
427+
const h = node.measured?.height;
428+
if (w === undefined || h === undefined) continue;
429+
const last = lastMeasuredDims.get(node.id);
430+
if (!last || last.w !== w || last.h !== h) {
431+
if (last) changed.add(node.id);
432+
lastMeasuredDims.set(node.id, { w, h });
433+
routingStore.updateNodeBounds(node.id, {
434+
x: node.position.x - w / 2,
435+
y: node.position.y - h / 2,
436+
width: w,
437+
height: h
438+
});
439+
}
440+
}
441+
if (changed.size > 0) {
442+
const connections = get(graphStore.connections);
443+
routingStore.recalculateRoutesForNodes(changed, connections, getPortInfo);
444+
}
445+
});
446+
417447
// Track if we're currently syncing to prevent loops
418448
let isSyncing = false;
419449

src/lib/components/FlowUpdater.svelte

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
import { importFile } from '$lib/schema/fileOps';
1515
import { ALL_COMPONENT_EXTENSIONS } from '$lib/types/component';
1616
import { GRID_SIZE } from '$lib/constants/grid';
17+
import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews';
18+
import { plotDataStore } from '$lib/plotting/processing/plotDataStore';
19+
import { previewSideForRotation, extendBoundsForPreview } from '$lib/utils/previewBounds';
20+
import type { NodeInstance } from '$lib/types/nodes';
1721
1822
interface Props {
1923
pendingUpdates: string[];
@@ -33,6 +37,9 @@
3337
return;
3438
}
3539
40+
const previewsPinned = get(pinnedPreviewsStore);
41+
const plotState = get(plotDataStore);
42+
3643
// Calculate bounding box of all nodes, accounting for origin
3744
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
3845
for (const node of nodes) {
@@ -42,10 +49,19 @@
4249
const origin = (node.origin as [number, number]) ?? [0.5, 0.5];
4350
const left = node.position.x - width * origin[0];
4451
const top = node.position.y - height * origin[1];
45-
minX = Math.min(minX, left);
46-
minY = Math.min(minY, top);
47-
maxX = Math.max(maxX, left + width);
48-
maxY = Math.max(maxY, top + height);
52+
let bounds = { left, top, right: left + width, bottom: top + height };
53+
54+
// Extend bounds for pinned plot previews on recording blocks (Scope/Spectrum)
55+
if (previewsPinned && node.type === 'pathview' && plotState.plots.has(node.id)) {
56+
const data = node.data as NodeInstance;
57+
const rotation = (data.params?.['_rotation'] as number) || 0;
58+
bounds = extendBoundsForPreview(bounds, previewSideForRotation(rotation));
59+
}
60+
61+
minX = Math.min(minX, bounds.left);
62+
minY = Math.min(minY, bounds.top);
63+
maxX = Math.max(maxX, bounds.right);
64+
maxY = Math.max(maxY, bounds.bottom);
4965
}
5066
5167
// Add some padding around the nodes themselves

src/lib/components/contextMenuBuilders.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ import { exportToSVG, exportToPDF } from '$lib/export/svg';
2626
import { downloadSvg } from '$lib/utils/download';
2727
import { plotSettingsStore, DEFAULT_BLOCK_SETTINGS } from '$lib/stores/plotSettings';
2828
import { portLabelsStore } from '$lib/stores/portLabels';
29+
import { iconModeStore } from '$lib/stores/iconMode';
2930
import { getEffectivePortLabelVisibility } from '$lib/utils/portLabels';
31+
import { hasBlockIcon } from '$lib/components/icons/BlockIcon.svelte';
32+
import { nodeRegistry } from '$lib/nodes';
3033
import type { NodeInstance } from '$lib/types/nodes';
3134

3235
/** Divider menu item */
@@ -63,6 +66,27 @@ function buildPortLabelItems(nodeId: string, node: NodeInstance): MenuItemType[]
6366
return items;
6467
}
6568

69+
/** Build icon-mode toggle menu item for a node, only when an icon exists */
70+
function buildIconModeItem(nodeId: string, node: NodeInstance): MenuItemType[] {
71+
const typeDef = nodeRegistry.get(node.type);
72+
const blockKey = typeDef?.blockClass ?? typeDef?.type;
73+
if (!hasBlockIcon(blockKey)) return [];
74+
75+
const globalIconMode = get(iconModeStore);
76+
const override = node.params?.['_iconMode'] as boolean | undefined;
77+
const effective = override ?? globalIconMode;
78+
79+
return [
80+
{
81+
label: effective ? 'Show as Text' : 'Show as Icon',
82+
icon: 'image',
83+
action: () => historyStore.mutate(() =>
84+
graphStore.updateNodeParams(nodeId, { _iconMode: !effective })
85+
)
86+
}
87+
];
88+
}
89+
6690
/** Show block code in preview dialog */
6791
function showBlockCode(nodeId: string): void {
6892
const node = graphStore.getNode(nodeId);
@@ -211,6 +235,7 @@ function buildNodeMenu(nodeId: string): MenuItemType[] {
211235
];
212236

213237
items.push(...buildPortLabelItems(nodeId, node));
238+
items.push(...buildIconModeItem(nodeId, node));
214239

215240
items.push(
216241
DIVIDER,

src/lib/components/dialogs/KeyboardShortcutsDialog.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
{ keys: ['+'], description: 'Zoom in' },
6060
{ keys: ['-'], description: 'Zoom out' },
6161
{ keys: ['L'], description: 'Port labels' },
62+
{ keys: ['I'], description: 'Block icons' },
6263
{ keys: ['T'], description: 'Theme' }
6364
]
6465
},
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<script lang="ts" module>
2+
import { getIconDef, hasBlockIcon as registryHas } from './blocks/registry';
3+
4+
const svgModules = import.meta.glob('./blocks/svg/*.svg', {
5+
query: '?raw',
6+
import: 'default',
7+
eager: true
8+
}) as Record<string, string>;
9+
10+
const svgMap = new Map<string, string>();
11+
for (const [path, raw] of Object.entries(svgModules)) {
12+
const match = path.match(/\/([^/]+)\.svg$/);
13+
if (match) svgMap.set(match[1], raw);
14+
}
15+
16+
export function hasBlockIcon(blockClass: string | undefined): boolean {
17+
return registryHas(blockClass);
18+
}
19+
</script>
20+
21+
<script lang="ts">
22+
import IconPlot from './blocks/IconPlot.svelte';
23+
import IconMath from './blocks/IconMath.svelte';
24+
import IconGlyph from './blocks/IconGlyph.svelte';
25+
import IconScope from './blocks/IconScope.svelte';
26+
import IconSurface from './blocks/IconSurface.svelte';
27+
28+
interface Props {
29+
blockClass: string | undefined;
30+
title?: string;
31+
}
32+
33+
let { blockClass, title }: Props = $props();
34+
const def = $derived(getIconDef(blockClass));
35+
const svgRaw = $derived(def?.kind === 'svg' ? svgMap.get(def.name) : undefined);
36+
</script>
37+
38+
{#if def}
39+
<span class="block-icon" aria-label={title} role={title ? 'img' : undefined}>
40+
{#if def.kind === 'plot'}
41+
<IconPlot
42+
samples={def.samples()}
43+
xRange={def.xRange}
44+
yRange={def.yRange}
45+
axes={def.axes}
46+
markers={def.markers}
47+
decoration={def.decoration}
48+
/>
49+
{:else if def.kind === 'scope'}
50+
<IconScope
51+
samples={def.samples()}
52+
samples2={def.samples2?.()}
53+
yRange={def.yRange}
54+
gridX={def.gridX}
55+
gridY={def.gridY}
56+
/>
57+
{:else if def.kind === 'surface'}
58+
<IconSurface fn={def.fn} rows={def.rows} cols={def.cols} />
59+
{:else if def.kind === 'math'}
60+
<IconMath latex={def.latex} fit={def.fit} />
61+
{:else if def.kind === 'glyph'}
62+
<IconGlyph text={def.text} size={def.size} />
63+
{:else if def.kind === 'svg' && svgRaw}
64+
{@html svgRaw}
65+
{/if}
66+
</span>
67+
{/if}
68+
69+
<style>
70+
.block-icon {
71+
display: flex;
72+
align-items: center;
73+
justify-content: center;
74+
width: 100%;
75+
height: 100%;
76+
color: currentColor;
77+
}
78+
79+
.block-icon :global(svg) {
80+
width: 100%;
81+
height: 100%;
82+
display: block;
83+
color: inherit;
84+
}
85+
</style>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script lang="ts">
2+
interface Props {
3+
text: string;
4+
/** Approximate fraction of viewBox height for the glyph */
5+
size?: number;
6+
bold?: boolean;
7+
}
8+
9+
let { text, size = 0.45, bold = true }: Props = $props();
10+
11+
const VIEW_W = 96;
12+
const VIEW_H = 64;
13+
const fontSize = $derived(VIEW_H * size);
14+
</script>
15+
16+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {VIEW_W} {VIEW_H}">
17+
<text
18+
x={VIEW_W / 2}
19+
y={VIEW_H / 2}
20+
text-anchor="middle"
21+
dominant-baseline="central"
22+
fill="currentColor"
23+
stroke="none"
24+
font-family="ui-monospace, 'JetBrains Mono', 'SF Mono', Menlo, monospace"
25+
font-size={fontSize}
26+
font-weight={bold ? 700 : 500}
27+
letter-spacing="-1"
28+
>{text}</text>
29+
</svg>
30+
31+
<style>
32+
svg {
33+
width: 100%;
34+
height: 100%;
35+
display: block;
36+
color: currentColor;
37+
}
38+
</style>
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<script lang="ts">
2+
import { onMount, tick } from 'svelte';
3+
import { loadKatex } from '$lib/utils/katexLoader';
4+
5+
interface Props {
6+
latex: string;
7+
}
8+
9+
let { latex }: Props = $props();
10+
let html = $state<string>('');
11+
let inner: HTMLSpanElement | undefined = $state();
12+
let wrap: HTMLSpanElement | undefined = $state();
13+
let scale = $state(1);
14+
15+
const MAX_SCALE = 1.6;
16+
const MIN_SCALE = 0.4;
17+
const PADDING_X = 6;
18+
const PADDING_Y = 4;
19+
20+
async function measure() {
21+
await tick();
22+
if (!inner || !wrap) return;
23+
const cw = wrap.clientWidth - 2 * PADDING_X;
24+
const ch = wrap.clientHeight - 2 * PADDING_Y;
25+
const w = inner.scrollWidth;
26+
const h = inner.scrollHeight;
27+
if (w === 0 || h === 0 || cw <= 0 || ch <= 0) return;
28+
const fitScale = Math.min(cw / w, ch / h);
29+
scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fitScale));
30+
}
31+
32+
onMount(async () => {
33+
const katex = await loadKatex();
34+
try {
35+
html = katex.default.renderToString(latex, {
36+
displayMode: true,
37+
throwOnError: false,
38+
strict: false,
39+
output: 'html'
40+
});
41+
} catch {
42+
html = latex;
43+
}
44+
await measure();
45+
});
46+
47+
$effect(() => {
48+
if (html) measure();
49+
});
50+
51+
$effect(() => {
52+
if (!wrap) return;
53+
const ro = new ResizeObserver(() => measure());
54+
ro.observe(wrap);
55+
return () => ro.disconnect();
56+
});
57+
</script>
58+
59+
<span class="math" bind:this={wrap}>
60+
<span class="inner" bind:this={inner} style="transform: scale({scale});">
61+
{#if html}
62+
{@html html}
63+
{/if}
64+
</span>
65+
</span>
66+
67+
<style>
68+
.math {
69+
width: 100%;
70+
height: 100%;
71+
display: flex;
72+
align-items: center;
73+
justify-content: center;
74+
color: currentColor;
75+
overflow: visible;
76+
}
77+
78+
.inner {
79+
display: inline-block;
80+
transform-origin: center;
81+
white-space: nowrap;
82+
color: inherit;
83+
}
84+
85+
.inner :global(.katex-display) {
86+
margin: 0;
87+
}
88+
89+
.inner :global(.katex) {
90+
font-size: 16px;
91+
font-weight: 600;
92+
color: inherit;
93+
}
94+
95+
.inner :global(.katex .mord),
96+
.inner :global(.katex .mop),
97+
.inner :global(.katex .mbin),
98+
.inner :global(.katex .mrel),
99+
.inner :global(.katex .mathnormal),
100+
.inner :global(.katex .mathit) {
101+
font-weight: 600;
102+
}
103+
</style>

0 commit comments

Comments
 (0)