Skip to content

Commit 1bf2aff

Browse files
authored
Merge pull request #291 from pathsim/feature/hide-nodes
Hide nodes
2 parents d3a5ef3 + e5f665b commit 1bf2aff

4 files changed

Lines changed: 214 additions & 13 deletions

File tree

src/lib/components/FlowCanvas.svelte

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -521,8 +521,13 @@
521521
cleanups.push(graphStore.nodes.subscribe((graphNodesMap: Map<string, NodeInstance>) => {
522522
if (isSyncing) return;
523523
524-
// Convert Map to array for processing
525-
const filteredGraphNodes = Array.from(graphNodesMap.values());
524+
// Convert Map to array for processing — exclude nodes hidden via `_hidden`
525+
// param. Hidden nodes stay in the store (simulation still uses them) but
526+
// disappear from the canvas; toggling `_hidden` triggers this subscriber
527+
// again so they come back the same way they were removed.
528+
const filteredGraphNodes = Array.from(graphNodesMap.values()).filter(
529+
(n) => !n.params?.['_hidden']
530+
);
526531
527532
// Track nodes that need handle updates (port count changed)
528533
const nodesToUpdate: string[] = [];
@@ -689,25 +694,46 @@
689694
annotationNodes = Array.from(annotationsMap.values()).map(toAnnotationNode);
690695
}));
691696
697+
// Compute the set of node IDs currently visible (not `_hidden`). Read on
698+
// every edge rebuild so connections to hidden nodes can be filtered out.
699+
function getVisibleNodeIds(): Set<string> {
700+
const graphNodesMap = get(graphStore.nodes);
701+
const ids = new Set<string>();
702+
for (const n of graphNodesMap.values()) {
703+
if (!n.params?.['_hidden']) ids.add(n.id);
704+
}
705+
return ids;
706+
}
707+
708+
function rebuildEdges(connections: Connection[]): void {
709+
const visibleIds = getVisibleNodeIds();
710+
const currentEdgeSelection = new Map(edges.map((e) => [e.id, e.selected]));
711+
edges = connections
712+
.filter((c) => visibleIds.has(c.sourceNodeId) && visibleIds.has(c.targetNodeId))
713+
.map((conn) => {
714+
const edge = toFlowEdge(conn);
715+
if (currentEdgeSelection.get(conn.id)) edge.selected = true;
716+
return edge;
717+
});
718+
}
719+
692720
// Subscribe to current connections (filtered by current navigation context)
693721
cleanups.push(graphStore.connections.subscribe((connections: Connection[]) => {
694722
if (isSyncing) return;
695-
// Preserve selection state from existing edges
696-
const currentEdgeSelection = new Map(edges.map(e => [e.id, e.selected]));
697-
edges = connections.map(conn => {
698-
const edge = toFlowEdge(conn);
699-
// Preserve selection state
700-
const wasSelected = currentEdgeSelection.get(conn.id);
701-
if (wasSelected) {
702-
edge.selected = true;
703-
}
704-
return edge;
705-
});
723+
rebuildEdges(connections);
706724
// Recalculate routes when connections change
707725
// Debounced to coalesce rapid changes (paste, undo, bulk operations)
708726
scheduleRoutingUpdate();
709727
}));
710728
729+
// When `_hidden` flips on a node, the connections store doesn't fire — the
730+
// connections themselves are unchanged. Subscribe to the nodes store too
731+
// and rebuild edges so newly hidden/visible nodes' edges follow along.
732+
cleanups.push(graphStore.nodes.subscribe(() => {
733+
if (isSyncing) return;
734+
rebuildEdges(get(graphStore.connections));
735+
}));
736+
711737
// Track last snapped positions during drag for discrete routing updates
712738
let lastDraggedPositions = new Map<string, { x: number; y: number }>();
713739

src/lib/components/contextMenuBuilders.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,13 @@ function buildNodeMenu(nodeId: string): MenuItemType[] {
179179

180180
items.push(
181181
DIVIDER,
182+
{
183+
label: 'Hide',
184+
icon: 'eye-off',
185+
action: () => historyStore.mutate(() =>
186+
graphStore.updateNodeParams(nodeId, { _hidden: true })
187+
)
188+
},
182189
{
183190
label: 'View Code',
184191
icon: 'braces',
@@ -239,6 +246,13 @@ function buildNodeMenu(nodeId: string): MenuItemType[] {
239246

240247
items.push(
241248
DIVIDER,
249+
{
250+
label: 'Hide',
251+
icon: 'eye-off',
252+
action: () => historyStore.mutate(() =>
253+
graphStore.updateNodeParams(nodeId, { _hidden: true })
254+
)
255+
},
242256
{
243257
label: 'View Code',
244258
icon: 'braces',
@@ -316,6 +330,16 @@ function buildSelectionMenu(
316330
action: () => clipboardStore.copy()
317331
},
318332
DIVIDER,
333+
{
334+
label: `Hide ${count} nodes`,
335+
icon: 'eye-off',
336+
action: () => historyStore.mutate(() => {
337+
for (const id of nodeIds) {
338+
graphStore.updateNodeParams(id, { _hidden: true });
339+
}
340+
})
341+
},
342+
DIVIDER,
319343
{
320344
label: `Delete ${count} nodes`,
321345
icon: 'trash',

src/lib/components/dialogs/BlockPropertiesDialog.svelte

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,18 @@
165165
// Check if node can be exported (not Interface blocks)
166166
const canExport = $derived(node?.type !== NODE_TYPES.INTERFACE);
167167
168+
// Hide button is meaningless for Interface blocks (they define the
169+
// subsystem's outer ports) — skip them. All other nodes can be hidden.
170+
const canHide = $derived(node?.type !== NODE_TYPES.INTERFACE);
171+
172+
function handleHide() {
173+
if (!node) return;
174+
const id = node.id;
175+
historyStore.mutate(() => graphStore.updateNodeParams(id, { _hidden: true }));
176+
// Dialog targets a node that's now invisible; close it.
177+
closeNodeDialog();
178+
}
179+
168180
// Check if node is a recording node (Scope or Spectrum)
169181
const isRecordingNode = $derived(node?.type === 'Scope' || node?.type === 'Spectrum');
170182
@@ -304,6 +316,17 @@
304316
<Icon name="upload" size={16} />
305317
</button>
306318
{/if}
319+
<!-- Hide button -->
320+
{#if canHide}
321+
<button
322+
class="icon-btn"
323+
onclick={handleHide}
324+
use:tooltip={"Hide"}
325+
aria-label="Hide"
326+
>
327+
<Icon name="eye-off" size={16} />
328+
</button>
329+
{/if}
307330
{/if}
308331
<!-- Toggle code view button -->
309332
<button

src/routes/+page.svelte

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,50 @@
508508
509509
// App state
510510
let nodeCount = $state(0);
511+
let hiddenNodes = $state<import('$lib/nodes/types').NodeInstance[]>([]);
512+
let hiddenMenuOpen = $state(false);
513+
let hiddenOpenTimer: ReturnType<typeof setTimeout> | null = null;
514+
let hiddenCloseTimer: ReturnType<typeof setTimeout> | null = null;
515+
516+
function clearHiddenTimers() {
517+
if (hiddenOpenTimer) {
518+
clearTimeout(hiddenOpenTimer);
519+
hiddenOpenTimer = null;
520+
}
521+
if (hiddenCloseTimer) {
522+
clearTimeout(hiddenCloseTimer);
523+
hiddenCloseTimer = null;
524+
}
525+
}
526+
527+
function handleHiddenGroupEnter() {
528+
clearHiddenTimers();
529+
hiddenOpenTimer = setTimeout(() => {
530+
hiddenMenuOpen = true;
531+
hiddenOpenTimer = null;
532+
}, 250);
533+
}
534+
535+
function handleHiddenGroupLeave() {
536+
clearHiddenTimers();
537+
hiddenCloseTimer = setTimeout(() => {
538+
hiddenMenuOpen = false;
539+
hiddenCloseTimer = null;
540+
}, 180);
541+
}
542+
543+
function handleUnhide(nodeId: string) {
544+
historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _hidden: false }));
545+
}
546+
547+
function handleShowAll() {
548+
clearHiddenTimers();
549+
hiddenMenuOpen = false;
550+
const ids = hiddenNodes.map((n) => n.id);
551+
historyStore.mutate(() => {
552+
for (const id of ids) graphStore.updateNodeParams(id, { _hidden: false });
553+
});
554+
}
511555
let pyodideReady = $state(false);
512556
let pyodideLoading = $state(false);
513557
let simRunning = $state(false);
@@ -575,6 +619,7 @@
575619
576620
const unsubNodeCount = graphStore.nodesArray.subscribe((nodes) => {
577621
nodeCount = nodes.length;
622+
hiddenNodes = nodes.filter((n) => n.params?.['_hidden']);
578623
});
579624
580625
const unsubPyodide = pyodideState.subscribe((s) => {
@@ -1355,6 +1400,46 @@
13551400
</button>
13561401
</div>
13571402

1403+
<!-- Hidden nodes -->
1404+
{#if hiddenNodes.length > 0}
1405+
<!-- svelte-ignore a11y_no_static_element_interactions -->
1406+
<div
1407+
class="toolbar-group hidden-group"
1408+
onmouseenter={handleHiddenGroupEnter}
1409+
onmouseleave={handleHiddenGroupLeave}
1410+
>
1411+
<button
1412+
class="toolbar-btn hidden-btn"
1413+
use:tooltip={`${hiddenNodes.length} hidden node${hiddenNodes.length === 1 ? '' : 's'}`}
1414+
aria-label="Hidden nodes"
1415+
>
1416+
<Icon name="eye-off" size={16} />
1417+
<span class="hidden-badge">{hiddenNodes.length}</span>
1418+
</button>
1419+
{#if hiddenMenuOpen}
1420+
<div class="recent-menu" role="menu">
1421+
<div class="recent-menu-header">Hidden nodes</div>
1422+
{#each hiddenNodes as node (node.id)}
1423+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
1424+
<div class="recent-item" role="menuitem" tabindex="0" onclick={() => handleUnhide(node.id)}>
1425+
<Icon name="eye" size={14} />
1426+
<span class="recent-name" title={node.name}>{node.name}</span>
1427+
<span class="hidden-type">{node.type}</span>
1428+
</div>
1429+
{/each}
1430+
{#if hiddenNodes.length > 1}
1431+
<div class="recent-divider"></div>
1432+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
1433+
<div class="recent-item show-all" role="menuitem" tabindex="0" onclick={handleShowAll}>
1434+
<Icon name="eye" size={14} />
1435+
<span class="recent-name">Show all</span>
1436+
</div>
1437+
{/if}
1438+
</div>
1439+
{/if}
1440+
</div>
1441+
{/if}
1442+
13581443
<!-- Help -->
13591444
<div class="toolbar-group">
13601445
<button
@@ -1937,6 +2022,49 @@
19372022
background: color-mix(in srgb, var(--error) 15%, transparent);
19382023
}
19392024
2025+
/* Hidden-nodes group reuses .open-group/.recent-menu layout */
2026+
.hidden-group {
2027+
position: relative;
2028+
}
2029+
2030+
.hidden-btn {
2031+
position: relative;
2032+
}
2033+
2034+
.hidden-badge {
2035+
position: absolute;
2036+
top: -4px;
2037+
right: -4px;
2038+
min-width: 16px;
2039+
height: 16px;
2040+
padding: 0 4px;
2041+
border-radius: 8px;
2042+
background: var(--accent);
2043+
color: var(--surface);
2044+
font-size: 10px;
2045+
font-weight: 600;
2046+
display: flex;
2047+
align-items: center;
2048+
justify-content: center;
2049+
box-shadow: 0 0 0 2px var(--surface);
2050+
}
2051+
2052+
.hidden-type {
2053+
font-size: 10px;
2054+
color: var(--text-disabled);
2055+
font-family: var(--font-mono);
2056+
}
2057+
2058+
.recent-divider {
2059+
height: 1px;
2060+
background: var(--border);
2061+
margin: 4px 0;
2062+
}
2063+
2064+
.recent-item.show-all {
2065+
color: var(--accent);
2066+
}
2067+
19402068
.mutation-badge {
19412069
position: absolute;
19422070
top: -4px;

0 commit comments

Comments
 (0)