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 {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -17,6 +17,7 @@ import '@xyflow/react/dist/style.css'
|
|||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
|
import { getVisibleNodeIds, filterVisibleEdges } from '@/utils/collapseFilter'
|
||||||
import { nodeTypes } from './nodes/nodeTypes'
|
import { nodeTypes } from './nodes/nodeTypes'
|
||||||
import { edgeTypes } from './edges/edgeTypes'
|
import { edgeTypes } from './edges/edgeTypes'
|
||||||
import { SearchBar } from './SearchBar'
|
import { SearchBar } from './SearchBar'
|
||||||
@@ -55,24 +56,16 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
|
|
||||||
// Filter nodes and edges based on collapsed state
|
// Filter nodes and edges based on collapsed state (memoized — O(n)).
|
||||||
const getVisibleNodeIds = (): Set<string> => {
|
const visibleNodeIds = useMemo(() => getVisibleNodeIds(nodes), [nodes])
|
||||||
const visible = new Set<string>()
|
const visibleNodes = useMemo(
|
||||||
const queue = nodes.filter((n) => !n.parentId).map((n) => n.id)
|
() => nodes.filter((n) => visibleNodeIds.has(n.id)),
|
||||||
while (queue.length > 0) {
|
[nodes, visibleNodeIds],
|
||||||
const id = queue.shift()!
|
)
|
||||||
visible.add(id)
|
const visibleEdges = useMemo(
|
||||||
const node = nodes.find((n) => n.id === id)
|
() => filterVisibleEdges(edges, visibleNodeIds),
|
||||||
if (node && !node.data.custom_colors?.collapsed) {
|
[edges, visibleNodeIds],
|
||||||
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))
|
|
||||||
|
|
||||||
const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {
|
const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
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