diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index c91ecbd..a8d95cc 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { ReactFlow, Background, @@ -17,6 +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 { nodeTypes } from './nodes/nodeTypes' import { edgeTypes } from './edges/edgeTypes' import { SearchBar } from './SearchBar' @@ -55,24 +56,16 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o const activeTheme = useThemeStore((s) => s.activeTheme) const theme = THEMES[activeTheme] - // Filter nodes and edges based on collapsed state - const getVisibleNodeIds = (): Set => { - const visible = new Set() - const queue = nodes.filter((n) => !n.parentId).map((n) => n.id) - while (queue.length > 0) { - const id = queue.shift()! - visible.add(id) - const node = nodes.find((n) => n.id === id) - if (node && !node.data.custom_colors?.collapsed) { - const children = nodes.filter((n) => n.parentId === id).map((n) => n.id) - queue.push(...children) - } - } - return visible - } - const visibleNodeIds = getVisibleNodeIds() - const visibleNodes = nodes.filter((n) => visibleNodeIds.has(n.id)) - const visibleEdges = edges.filter((e) => visibleNodeIds.has(e.source) && visibleNodeIds.has(e.target)) + // Filter nodes and edges based on collapsed state (memoized — O(n)). + const visibleNodeIds = useMemo(() => getVisibleNodeIds(nodes), [nodes]) + const visibleNodes = useMemo( + () => nodes.filter((n) => visibleNodeIds.has(n.id)), + [nodes, visibleNodeIds], + ) + const visibleEdges = useMemo( + () => filterVisibleEdges(edges, visibleNodeIds), + [edges, visibleNodeIds], + ) const onNodeClick = useCallback((e: React.MouseEvent, node: Node) => { if (e.ctrlKey || e.metaKey) { diff --git a/frontend/src/utils/__tests__/collapseFilter.test.ts b/frontend/src/utils/__tests__/collapseFilter.test.ts new file mode 100644 index 0000000..aadc022 --- /dev/null +++ b/frontend/src/utils/__tests__/collapseFilter.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest' +import type { Edge, Node } from '@xyflow/react' +import { getVisibleNodeIds, filterVisibleEdges } from '../collapseFilter' +import type { EdgeData, NodeData } from '@/types' + +const mkNode = ( + id: string, + parentId?: string, + collapsed?: boolean, +): Node => ({ + id, + position: { x: 0, y: 0 }, + ...(parentId ? { parentId } : {}), + data: { + label: id, + type: parentId ? 'server' : 'groupRect', + status: 'online', + services: [], + ...(collapsed !== undefined ? { custom_colors: { collapsed } } : {}), + }, +}) + +const mkEdge = (id: string, source: string, target: string): Edge => ({ + id, + source, + target, +}) + +describe('getVisibleNodeIds', () => { + it('returns all nodes when nothing is collapsed', () => { + const nodes = [ + mkNode('zone'), + mkNode('child-a', 'zone'), + mkNode('child-b', 'zone'), + ] + expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'child-a', 'child-b'])) + }) + + it('hides direct children of a collapsed zone but keeps the zone itself', () => { + const nodes = [ + mkNode('zone', undefined, true), + mkNode('child-a', 'zone'), + mkNode('child-b', 'zone'), + mkNode('outside'), + ] + expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'outside'])) + }) + + it('hides the entire subtree when an ancestor is collapsed (multi-level)', () => { + const nodes = [ + mkNode('root', undefined, true), + mkNode('mid', 'root', false), // expanded but parent collapsed → still hidden + mkNode('leaf', 'mid'), + ] + const visible = getVisibleNodeIds(nodes) + expect(visible.has('root')).toBe(true) + expect(visible.has('mid')).toBe(false) + expect(visible.has('leaf')).toBe(false) + }) + + it('hides only the nested subtree when an inner zone is collapsed', () => { + const nodes = [ + mkNode('root', undefined, false), + mkNode('inner', 'root', true), + mkNode('leaf', 'inner'), + mkNode('sibling', 'root'), + ] + expect(getVisibleNodeIds(nodes)).toEqual(new Set(['root', 'inner', 'sibling'])) + }) + + it('handles a zone with no children', () => { + const nodes = [mkNode('empty-zone', undefined, true)] + expect(getVisibleNodeIds(nodes)).toEqual(new Set(['empty-zone'])) + }) + + it('returns an empty set for empty input', () => { + expect(getVisibleNodeIds([])).toEqual(new Set()) + }) + + it('treats nodes with no custom_colors as expanded', () => { + const nodes = [mkNode('zone'), mkNode('child', 'zone')] + expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'child'])) + }) + + it('is independent of insertion order (children declared before parent)', () => { + const nodes = [ + mkNode('child', 'zone'), + mkNode('zone', undefined, true), + ] + expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone'])) + }) +}) + +describe('filterVisibleEdges', () => { + it('keeps edges whose endpoints are both visible', () => { + const visible = new Set(['a', 'b']) + const edges = [mkEdge('e1', 'a', 'b')] + expect(filterVisibleEdges(edges, visible)).toHaveLength(1) + }) + + 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'), + ] + const out = filterVisibleEdges(edges, visible) + expect(out.map((e) => e.id)).toEqual(['e-outside-zone']) + }) + + it('drops an edge whose source is hidden', () => { + const visible = new Set(['b']) + const edges = [mkEdge('e1', 'a', 'b')] + expect(filterVisibleEdges(edges, visible)).toHaveLength(0) + }) + + it('returns an empty array for empty input', () => { + expect(filterVisibleEdges([], new Set(['a']))).toEqual([]) + }) +}) diff --git a/frontend/src/utils/collapseFilter.ts b/frontend/src/utils/collapseFilter.ts new file mode 100644 index 0000000..33dae9e --- /dev/null +++ b/frontend/src/utils/collapseFilter.ts @@ -0,0 +1,56 @@ +import type { Edge, Node } from '@xyflow/react' +import type { EdgeData, NodeData } from '@/types' + +/** + * Compute the set of node IDs that should be visible on the canvas given the + * current collapse state of group/zone nodes. + * + * A node is hidden if any ancestor (via `parentId`) has + * `data.custom_colors.collapsed === true`. Root nodes (no `parentId`) are + * always visible. + * + * O(n) — builds a `parentId -> children[]` index once, then BFS from roots. + */ +export function getVisibleNodeIds(nodes: Node[]): Set { + const childrenByParent = new Map() + for (const n of nodes) { + if (n.parentId) { + const arr = childrenByParent.get(n.parentId) + if (arr) arr.push(n.id) + else childrenByParent.set(n.parentId, [n.id]) + } + } + + // Fast lookup for collapse flag. + const byId = new Map>() + for (const n of nodes) byId.set(n.id, n) + + const visible = new Set() + const queue: string[] = [] + for (const n of nodes) { + if (!n.parentId) queue.push(n.id) + } + + while (queue.length > 0) { + const id = queue.shift()! + visible.add(id) + const node = byId.get(id) + if (node && !node.data.custom_colors?.collapsed) { + const children = childrenByParent.get(id) + if (children) queue.push(...children) + } + } + + return visible +} + +/** + * Filter edges to those whose source and target are both in `visibleIds`. + * Edges crossing into a collapsed subtree are dropped. + */ +export function filterVisibleEdges( + edges: Edge[], + visibleIds: Set, +): Edge[] { + return edges.filter((e) => visibleIds.has(e.source) && visibleIds.has(e.target)) +}