perf(canvas): memoize collapse visibility filter + add tests
Extract getVisibleNodeIds/filterVisibleEdges from CanvasContainer into src/utils/collapseFilter.ts. Replace inline O(n^2) BFS (nested array .find per node) with O(n) traversal backed by parentId->children and id->node Maps, and wrap consumer calls in useMemo so visibility is recomputed only when nodes/edges change rather than on every render. Add 12 unit tests covering the filter logic that the original PR left untested: single-level collapse, multi-level subtree hiding via collapsed ancestor, sibling isolation when an inner zone is collapsed, empty zones, missing custom_colors, insertion-order independence, and edge filtering for hidden source/target.
This commit is contained in:
@@ -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<string> => {
|
||||
const visible = new Set<string>()
|
||||
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<NodeData>) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
|
||||
@@ -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<NodeData> => ({
|
||||
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<EdgeData> => ({
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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<NodeData>[]): Set<string> {
|
||||
const childrenByParent = new Map<string, string[]>()
|
||||
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<string, Node<NodeData>>()
|
||||
for (const n of nodes) byId.set(n.id, n)
|
||||
|
||||
const visible = new Set<string>()
|
||||
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<EdgeData>[],
|
||||
visibleIds: Set<string>,
|
||||
): Edge<EdgeData>[] {
|
||||
return edges.filter((e) => visibleIds.has(e.source) && visibleIds.has(e.target))
|
||||
}
|
||||
Reference in New Issue
Block a user