From 20e1820a4e4a652e0abd777ae97ccf623482ed88 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 29 May 2026 01:34:21 +0200 Subject: [PATCH] refactor(canvas): promote collapsed to first-class NodeData field + edge rewire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups to PR #158 review: 1. Promote collapsed to NodeData.collapsed The flag was previously stashed inside NodeData.custom_colors, which is a colors/style object — semantically wrong. Move it to a first-class boolean on NodeData. Persistence keeps the existing API shape: serialize writes it into the custom_colors blob (alongside width/height/z_order, matching how groupRect already stashes layout metadata), and deserialize hoists it back. Legacy saves from the original PR shape load correctly. 2. Re-route cross-zone edges to the collapsed ancestor Previously any edge touching a hidden node was dropped, so a Zigbee coordinator outside a collapsed mesh lost all visible links to it. rewireEdgesForCollapse now walks each endpoint up the parentId chain to its nearest visible ancestor, surfaces a single stub edge on the collapsed zone, de-dupes parallel rewires (a 20-device mesh becomes one stub, not twenty), and drops edges that would self-loop on a zone or reference an orphan. 3. Revert package-lock.json churn The 63-line diff from the original PR was npm-version drift (libc arrays stripped from optional deps), unrelated to the feature. Tests: - canvasStore.collapse: updated to assert on data.collapsed. - collapseFilter: 8 cases for visibility + 7 for edge rewire, covering cross-boundary, nested collapse, sibling self-loop, mesh dedup, and orphan endpoints. - canvasSerializer.collapse: round-trip + legacy-shape compat. --- frontend/package-lock.json | 63 ++++++++++++++ .../src/components/canvas/CanvasContainer.tsx | 6 +- .../components/canvas/nodes/GroupRectNode.tsx | 2 +- .../__tests__/GroupRectNode.collapse.test.tsx | 64 +++----------- .../__tests__/canvasStore.collapse.test.ts | 6 +- frontend/src/stores/canvasStore.ts | 11 +-- frontend/src/types/index.ts | 8 +- .../canvasSerializer.collapse.test.ts | 83 ++++++++++++++++++ .../utils/__tests__/collapseFilter.test.ts | 86 +++++++++++++++---- frontend/src/utils/canvasSerializer.ts | 9 +- frontend/src/utils/collapseFilter.ts | 56 ++++++++++-- 11 files changed, 299 insertions(+), 95 deletions(-) create mode 100644 frontend/src/utils/__tests__/canvasSerializer.collapse.test.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d20aea1..3c89582 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2520,6 +2520,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2534,6 +2537,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2548,6 +2554,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2562,6 +2571,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2576,6 +2588,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2590,6 +2605,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2604,6 +2622,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2618,6 +2639,9 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2632,6 +2656,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2646,6 +2673,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2660,6 +2690,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2674,6 +2707,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2688,6 +2724,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2936,6 +2975,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2953,6 +2995,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2970,6 +3015,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2987,6 +3035,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6884,6 +6935,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6905,6 +6959,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6926,6 +6983,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6947,6 +7007,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index a8d95cc..9bdcf83 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -17,7 +17,7 @@ import '@xyflow/react/dist/style.css' import { useCanvasStore } from '@/stores/canvasStore' import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' -import { getVisibleNodeIds, filterVisibleEdges } from '@/utils/collapseFilter' +import { getVisibleNodeIds, rewireEdgesForCollapse } from '@/utils/collapseFilter' import { nodeTypes } from './nodes/nodeTypes' import { edgeTypes } from './edges/edgeTypes' import { SearchBar } from './SearchBar' @@ -63,8 +63,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o [nodes, visibleNodeIds], ) const visibleEdges = useMemo( - () => filterVisibleEdges(edges, visibleNodeIds), - [edges, visibleNodeIds], + () => rewireEdgesForCollapse(edges, nodes, visibleNodeIds), + [edges, nodes, visibleNodeIds], ) const onNodeClick = useCallback((e: React.MouseEvent, node: Node) => { diff --git a/frontend/src/components/canvas/nodes/GroupRectNode.tsx b/frontend/src/components/canvas/nodes/GroupRectNode.tsx index cbb8a5b..113b01d 100644 --- a/frontend/src/components/canvas/nodes/GroupRectNode.tsx +++ b/frontend/src/components/canvas/nodes/GroupRectNode.tsx @@ -42,7 +42,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps>) const [hovered, setHovered] = useState(false) const rc = data.custom_colors ?? {} - const isCollapsed = rc.collapsed ?? false + const isCollapsed = data.collapsed ?? false const borderColor = rc.border ?? '#00d4ff' const borderStyle = rc.border_style ?? 'solid' const borderWidth = rc.border_width ?? 2 diff --git a/frontend/src/components/canvas/nodes/__tests__/GroupRectNode.collapse.test.tsx b/frontend/src/components/canvas/nodes/__tests__/GroupRectNode.collapse.test.tsx index 256e1fc..178cd48 100644 --- a/frontend/src/components/canvas/nodes/__tests__/GroupRectNode.collapse.test.tsx +++ b/frontend/src/components/canvas/nodes/__tests__/GroupRectNode.collapse.test.tsx @@ -2,73 +2,31 @@ import { describe, it, expect } from 'vitest' import type { NodeData } from '@/types' /** - * Unit tests for GroupRectNode collapse/expand feature - * - * Integration tests verify: - * - Type definitions for collapsed state - * - Store action toggles collapse flag - * - Component renders with proper state - * - UI updates reflect collapse state - * - * Full end-to-end testing happens in CanvasContainer tests - * which render the complete canvas with store integration + * Type-level assertions for the collapse feature. Behavioral coverage lives + * in: + * - src/stores/__tests__/canvasStore.collapse.test.ts (store action) + * - src/utils/__tests__/collapseFilter.test.ts (BFS + edge rewire) + * - src/utils/__tests__/canvasSerializer.collapse.test.ts (round-trip) */ -describe('GroupRectNode - Collapse/Expand Feature', () => { - it('NodeData type supports collapsed property', () => { +describe('NodeData.collapsed', () => { + it('accepts a boolean collapsed flag as a first-class field', () => { const nodeData: NodeData = { label: 'Test Zone', type: 'groupRect', status: 'online', services: [], - custom_colors: { - collapsed: true, - }, + collapsed: true, } - - expect(nodeData.custom_colors?.collapsed).toBe(true) + expect(nodeData.collapsed).toBe(true) }) - it('NodeData supports collapsed as optional property', () => { + it('treats a missing flag as expanded', () => { const nodeData: NodeData = { label: 'Test Zone', type: 'groupRect', status: 'online', services: [], } - - expect(nodeData.custom_colors?.collapsed).toBeUndefined() - }) - - it('collapsed state can be toggled', () => { - let isCollapsed = false - const toggle = () => { - isCollapsed = !isCollapsed - } - - toggle() - expect(isCollapsed).toBe(true) - toggle() - expect(isCollapsed).toBe(false) - }) - - it('supports multi-level zone nesting with collapse state', () => { - const parentZone: NodeData = { - label: 'Parent Zone', - type: 'groupRect', - status: 'online', - services: [], - custom_colors: { collapsed: false }, - } - - const childZone: NodeData = { - label: 'Child Zone', - type: 'groupRect', - status: 'online', - services: [], - custom_colors: { collapsed: false }, - } - - expect(parentZone.custom_colors?.collapsed).toBe(false) - expect(childZone.custom_colors?.collapsed).toBe(false) + expect(nodeData.collapsed).toBeUndefined() }) }) diff --git a/frontend/src/stores/__tests__/canvasStore.collapse.test.ts b/frontend/src/stores/__tests__/canvasStore.collapse.test.ts index d5e21c0..b2169df 100644 --- a/frontend/src/stores/__tests__/canvasStore.collapse.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.collapse.test.ts @@ -19,21 +19,21 @@ describe('canvasStore - toggleNodeCollapsed', () => { }) let zone = result.current.nodes.find((n) => n.id === 'zone-1') - expect(zone?.data.custom_colors?.collapsed).toBeUndefined() + expect(zone?.data.collapsed).toBeUndefined() act(() => { result.current.toggleNodeCollapsed('zone-1') }) zone = result.current.nodes.find((n) => n.id === 'zone-1') - expect(zone?.data.custom_colors?.collapsed).toBe(true) + expect(zone?.data.collapsed).toBe(true) act(() => { result.current.toggleNodeCollapsed('zone-1') }) zone = result.current.nodes.find((n) => n.id === 'zone-1') - expect(zone?.data.custom_colors?.collapsed).toBe(false) + expect(zone?.data.collapsed).toBe(false) }) it('marks canvas as unsaved when toggling collapse', () => { diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index b706a47..ef28809 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -379,16 +379,7 @@ export const useCanvasStore = create((set) => ({ set((state) => ({ nodes: state.nodes.map((n) => n.id === id - ? { - ...n, - data: { - ...n.data, - custom_colors: { - ...n.data.custom_colors, - collapsed: !n.data.custom_colors?.collapsed, - }, - }, - } + ? { ...n, data: { ...n.data, collapsed: !n.data.collapsed } } : n ), hasUnsavedChanges: true, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d75f1dc..37939a8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -96,9 +96,13 @@ export interface NodeData extends Record { show_border?: boolean width?: number height?: number - // Collapsible zone state (type === 'groupRect') - collapsed?: boolean } + /** + * Collapsible zone state (type === 'groupRect'). When true, the zone hides + * its descendants on the canvas. Persisted via `custom_colors.collapsed` + * round-trip for back-compat with older saves. + */ + collapsed?: boolean custom_icon?: string /** Number of bottom connection points, 1..48. Default 1 (centered). */ bottom_handles?: number diff --git a/frontend/src/utils/__tests__/canvasSerializer.collapse.test.ts b/frontend/src/utils/__tests__/canvasSerializer.collapse.test.ts new file mode 100644 index 0000000..03aca1d --- /dev/null +++ b/frontend/src/utils/__tests__/canvasSerializer.collapse.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest' +import type { Node } from '@xyflow/react' +import type { NodeData } from '@/types' +import { serializeNode, deserializeApiNode, type ApiNode } from '@/utils/canvasSerializer' + +/** + * Persistence contract for the collapse flag on groupRect nodes: + * + * 1. Serialize stashes `data.collapsed` into `custom_colors.collapsed` + * so the existing API blob shape can carry it without a schema change. + * 2. Deserialize hoists it back to the first-class `data.collapsed` field. + * 3. Legacy saves that already had `custom_colors.collapsed` (the original + * shape from PR #158 before the field was promoted) still load + * correctly. + */ + +function makeGroupRectRfNode(collapsed?: boolean): Node { + return { + id: 'zone-1', + type: 'groupRect', + position: { x: 0, y: 0 }, + data: { + label: 'Zigbee Mesh', + type: 'groupRect', + status: 'unknown', + services: [], + ...(collapsed !== undefined ? { collapsed } : {}), + }, + } +} + +describe('canvasSerializer — groupRect collapse', () => { + it('stashes data.collapsed=true into custom_colors on serialize', () => { + const rf = makeGroupRectRfNode(true) + const api = serializeNode(rf) as Record + const cc = api.custom_colors as Record + expect(cc.collapsed).toBe(true) + }) + + it('writes collapsed=false when the flag is missing (explicit default)', () => { + const rf = makeGroupRectRfNode(undefined) + const api = serializeNode(rf) as Record + const cc = api.custom_colors as Record + expect(cc.collapsed).toBe(false) + }) + + it('hoists custom_colors.collapsed back to data.collapsed on deserialize', () => { + const apiNode: ApiNode = { + id: 'zone-1', + type: 'groupRect', + label: 'Zone', + pos_x: 0, + pos_y: 0, + status: 'unknown', + services: [], + custom_colors: { collapsed: true, width: 360, height: 240 }, + } + const rf = deserializeApiNode(apiNode, new Map()) + expect(rf.data.collapsed).toBe(true) + }) + + it('treats missing custom_colors.collapsed as false on deserialize', () => { + const apiNode: ApiNode = { + id: 'zone-1', + type: 'groupRect', + label: 'Zone', + pos_x: 0, + pos_y: 0, + status: 'unknown', + services: [], + custom_colors: { width: 360, height: 240 }, + } + const rf = deserializeApiNode(apiNode, new Map()) + expect(rf.data.collapsed).toBe(false) + }) + + it('round-trips the collapse flag through serialize → deserialize', () => { + const rf = makeGroupRectRfNode(true) + const api = serializeNode(rf) as unknown as ApiNode + const back = deserializeApiNode(api, new Map()) + expect(back.data.collapsed).toBe(true) + }) +}) diff --git a/frontend/src/utils/__tests__/collapseFilter.test.ts b/frontend/src/utils/__tests__/collapseFilter.test.ts index aadc022..c91eae6 100644 --- a/frontend/src/utils/__tests__/collapseFilter.test.ts +++ b/frontend/src/utils/__tests__/collapseFilter.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import type { Edge, Node } from '@xyflow/react' -import { getVisibleNodeIds, filterVisibleEdges } from '../collapseFilter' +import { getVisibleNodeIds, rewireEdgesForCollapse } from '../collapseFilter' import type { EdgeData, NodeData } from '@/types' const mkNode = ( @@ -16,7 +16,7 @@ const mkNode = ( type: parentId ? 'server' : 'groupRect', status: 'online', services: [], - ...(collapsed !== undefined ? { custom_colors: { collapsed } } : {}), + ...(collapsed !== undefined ? { collapsed } : {}), }, }) @@ -91,30 +91,82 @@ describe('getVisibleNodeIds', () => { }) }) -describe('filterVisibleEdges', () => { - it('keeps edges whose endpoints are both visible', () => { - const visible = new Set(['a', 'b']) +describe('rewireEdgesForCollapse', () => { + it('keeps edges between two visible nodes unchanged (same reference)', () => { + const nodes = [mkNode('a'), mkNode('b')] const edges = [mkEdge('e1', 'a', 'b')] - expect(filterVisibleEdges(edges, visible)).toHaveLength(1) + const out = rewireEdgesForCollapse(edges, nodes, new Set(['a', 'b'])) + expect(out).toHaveLength(1) + expect(out[0]).toBe(edges[0]) }) - it('drops an edge whose target is inside a collapsed subtree', () => { - const visible = new Set(['outside', 'zone']) // 'leaf' hidden - const edges = [ - mkEdge('e-outside-leaf', 'outside', 'leaf'), - mkEdge('e-outside-zone', 'outside', 'zone'), + it('reroutes a cross-boundary edge to the collapsed ancestor', () => { + const nodes = [ + mkNode('zone', undefined, true), + mkNode('leaf', 'zone'), + mkNode('outside'), ] - const out = filterVisibleEdges(edges, visible) - expect(out.map((e) => e.id)).toEqual(['e-outside-zone']) + const visible = getVisibleNodeIds(nodes) + const edges = [mkEdge('e1', 'outside', 'leaf')] + const out = rewireEdgesForCollapse(edges, nodes, visible) + expect(out).toHaveLength(1) + expect(out[0].source).toBe('outside') + expect(out[0].target).toBe('zone') + // Handle hints stripped when the endpoint moved. + expect(out[0].sourceHandle).toBeNull() + expect(out[0].targetHandle).toBeNull() }) - it('drops an edge whose source is hidden', () => { - const visible = new Set(['b']) + it('drops an edge between two siblings inside the same collapsed zone (self-loop)', () => { + const nodes = [ + mkNode('zone', undefined, true), + mkNode('a', 'zone'), + mkNode('b', 'zone'), + ] + const visible = getVisibleNodeIds(nodes) const edges = [mkEdge('e1', 'a', 'b')] - expect(filterVisibleEdges(edges, visible)).toHaveLength(0) + expect(rewireEdgesForCollapse(edges, nodes, visible)).toEqual([]) + }) + + it('de-dupes multiple cross-boundary edges that rewire to the same pair', () => { + // 20-device Zigbee mesh: many edges from outside coordinator to leaves + // inside a collapsed zone should collapse to a single stub. + const nodes = [ + mkNode('zone', undefined, true), + mkNode('coord'), + ...Array.from({ length: 5 }, (_, i) => mkNode(`leaf-${i}`, 'zone')), + ] + const visible = getVisibleNodeIds(nodes) + const edges = Array.from({ length: 5 }, (_, i) => + mkEdge(`e-${i}`, 'coord', `leaf-${i}`), + ) + const out = rewireEdgesForCollapse(edges, nodes, visible) + expect(out).toHaveLength(1) + expect(out[0].source).toBe('coord') + expect(out[0].target).toBe('zone') + }) + + it('walks the chain to the nearest visible ancestor (nested collapse)', () => { + const nodes = [ + mkNode('root', undefined, true), + mkNode('mid', 'root'), + mkNode('leaf', 'mid'), + mkNode('outside'), + ] + const visible = getVisibleNodeIds(nodes) + const edges = [mkEdge('e1', 'outside', 'leaf')] + const out = rewireEdgesForCollapse(edges, nodes, visible) + expect(out[0].target).toBe('root') + }) + + it('drops an edge whose endpoint has no visible ancestor', () => { + const nodes = [mkNode('orphan-parent', undefined, true)] + const visible = new Set() // nothing visible at all + const edges = [mkEdge('e1', 'ghost', 'orphan-parent')] + expect(rewireEdgesForCollapse(edges, nodes, visible)).toEqual([]) }) it('returns an empty array for empty input', () => { - expect(filterVisibleEdges([], new Set(['a']))).toEqual([]) + expect(rewireEdgesForCollapse([], [], new Set())).toEqual([]) }) }) diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index ccd85ae..78d678c 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -76,6 +76,9 @@ export function serializeNode(n: Node): Record { ...n.data.custom_colors, width: n.measured?.width ?? n.width ?? 360, height: n.measured?.height ?? n.height ?? 240, + // Stash collapse state inside custom_colors so the API/YAML blob does + // not need a new column. Hoisted back to `data.collapsed` on load. + collapsed: n.data.collapsed ?? false, }, } } @@ -139,11 +142,15 @@ export function deserializeApiNode( const w = (n.custom_colors?.width as number | undefined) ?? 360 const h = (n.custom_colors?.height as number | undefined) ?? 240 const z = (n.custom_colors?.z_order as number | undefined) ?? 1 + // Hoist persisted collapse flag from the custom_colors stash to a + // first-class field on NodeData. Tolerates legacy saves that already had + // it there from before the type was promoted. + const collapsed = Boolean(n.custom_colors?.collapsed) return { id: n.id, type: 'groupRect', position: { x: n.pos_x, y: n.pos_y }, - data: n as unknown as NodeData, + data: { ...(n as unknown as NodeData), collapsed }, width: w, height: h, zIndex: z - 10, diff --git a/frontend/src/utils/collapseFilter.ts b/frontend/src/utils/collapseFilter.ts index 33dae9e..8db0659 100644 --- a/frontend/src/utils/collapseFilter.ts +++ b/frontend/src/utils/collapseFilter.ts @@ -35,7 +35,7 @@ export function getVisibleNodeIds(nodes: Node[]): Set { const id = queue.shift()! visible.add(id) const node = byId.get(id) - if (node && !node.data.custom_colors?.collapsed) { + if (node && !node.data.collapsed) { const children = childrenByParent.get(id) if (children) queue.push(...children) } @@ -45,12 +45,58 @@ export function getVisibleNodeIds(nodes: Node[]): Set { } /** - * Filter edges to those whose source and target are both in `visibleIds`. - * Edges crossing into a collapsed subtree are dropped. + * Rewire edges so that any endpoint inside a collapsed subtree is replaced + * with the nearest visible ancestor (the collapsed zone the user actually + * sees). Behaviour: + * + * - Both endpoints visible → edge kept as-is. + * - One endpoint hidden → endpoint replaced by its nearest visible + * ancestor; edge surfaces as a "stub" on the + * collapsed zone so the connection is not lost. + * - Both endpoints hidden under the *same* collapsed ancestor → dropped + * (would be a self-loop on the zone). + * - Multiple original edges that rewire to the same (source, target) pair + * are de-duplicated; only the first is kept. Prevents a 20-device Zigbee + * mesh from rendering 20 stacked stub edges on the collapsed parent. + * + * Edges with an endpoint whose ancestor chain never reaches a visible node + * (orphaned reference) are dropped. */ -export function filterVisibleEdges( +export function rewireEdgesForCollapse( edges: Edge[], + nodes: Node[], visibleIds: Set, ): Edge[] { - return edges.filter((e) => visibleIds.has(e.source) && visibleIds.has(e.target)) + const parentOf = new Map() + for (const n of nodes) parentOf.set(n.id, n.parentId) + + const nearestVisible = (id: string): string | null => { + let cur: string | undefined = id + // Walk up parentId chain until we hit a visible node or run out. + while (cur !== undefined) { + if (visibleIds.has(cur)) return cur + cur = parentOf.get(cur) + } + return null + } + + const seen = new Set() + const out: Edge[] = [] + for (const e of edges) { + const src = nearestVisible(e.source) + const tgt = nearestVisible(e.target) + if (src === null || tgt === null) continue + if (src === tgt) continue + const key = `${src}->${tgt}` + if (seen.has(key)) continue + seen.add(key) + if (src === e.source && tgt === e.target) { + out.push(e) + } else { + // Endpoint moved → strip handle hints that referred to the original + // (hidden) node; let React Flow pick defaults on the visible ancestor. + out.push({ ...e, source: src, target: tgt, sourceHandle: null, targetHandle: null }) + } + } + return out }