几条设计原则
性能是首要考虑
性能是影响用户体验的重要因素,在极端情况下,性能更是能够使得产品具有排他性的首要因素。
所以我给自己定了一个小目标,在没有连接边的情况下,较为流畅地支持100K节点。
充分利用Hooks,编写声明式代码
这个原则也可以理解成,充分借鉴函数式思想,思考如何更好地管理状态。
多用声明式的代码写法,减少过程式的命令堆叠。
充分利用Hooks实现代码解耦和复用。
丰富的操作接口
一个东西什么叫能用,什么叫好用?
能用,就是想实现的功能,用户都能实现。好用,就是用户凭直觉,都能知道这个功能怎么实现。
由于大家的直觉各不相同,操作接口自然要丰富一些。像各种快捷键、鼠标左中右键和滚轮全都不该闲着!
一切可定制
除了好用,就是好看。
我没有艺术细胞,我相信我的配色拿出去谁都想改一改的。要是原封不动用到产品上去了,设计师或者产品经理恐怕就得一顿吐槽。
所以,轻松的定制化体验是在设计之初就要充分考虑的。
一个选择
Canvas还是SVG?
在同类项目调研时,就发现会有这两个不同的选择,甚至还有SVG和<div>
混搭的。
这个选择困扰了我很久。可以说,我从调研,到开始写第一行代码,花了有一个月时间在纠结这个问题。
SVG的好处不言而喻,利用原生的DOM控制各个视觉元素,享受原生事件处理的高效便捷,还可以利用CSS方便地调节样式、应用动效……
Canvas的好处,就是灵活性极高,掌控程度强,更加便于性能调优。
设计原则里,性能是排第一位的,这让我不得不首先考虑Canvas方案。不过,不要忘记我们还要用到React,我可不希望裸写JavaScript/TypeScript来实现整个画布。推崇声明式的写法也是原则之一。
于是,我调研了一些把React和Canvas相结合的库——要么灵活性差,不容易绘制复杂图形;要么功能缺失,事件都不被支持;要么性能损失大,毕竟是把理应命令式绘制的Canvas抽象成了声明式的组件,这种复杂的抽象很难不引入额外代价。
到这里,我也想通了。HTML/SVG这些声明式的写法,才和React更配嘛!如果真的用Canvas,就不如直接写JavaScript/TypeScript代码去绘制一切、控制一切了。于是,我逐渐倒向SVG。
但上网一查,很多人都说当元素非常多的时候,SVG性能表现很差。想查查有没有具体评测,到底多少元素会显著降低性能——这种具体的数值肯定不容易查得到,毕竟和使用场景以及环境密切相关。不过还是有看到一些人提到一些应用的经验:100K级别的元素,大概是要卡顿了。
于是,又摇摆了。我甚至想放弃React,真的直接写Canvas绘制了。
不过这种冲动被我压制了下来。毕竟,性能虽然是一个首要考虑的原则,但如果选择了Canvas方案,意味着几乎要完全违背第二条原则,剩下几条原则的实现难度恐怕也会大大增高。另外,我实践函数式的初心也就要扔掉了。
不甘心。于是最终还是选择了和React更配的SVG方案。
几个问题
性能问题
由于我的不甘心,这个问题就成为了首要问题……
不过,很快我也想到了解决方案。既然SVG元素过多时渲染效率会下降,那我就做视图裁剪好了。毕竟,用户能看到的、能操作的,都只限于当前屏幕放得下的部分元素。想必用户也不会把100K个节点在一个屏幕摆放完全——如果不想眼花,恐怕100个都难在一个屏幕中容纳。
几百几千个元素自然是没有太大性能问题的了,可是如何做好裁剪视图就是一个件难事了。毕竟,裁剪视图如果性能做得差,下场就是用户平移画布时UI卡顿严重。
工作流场景下,用户应该会频繁移动画布来查看、编辑节点和连线的,所以不应该假设平移画布是一个少量操作,那么这部分的性能也不可以被忽视。
不可变问题
React Hooks刚上手时,几乎开发者们都会遇到这个问题。
export default function App() {
const [state, setState] = React.useState({
x: { x: 1 }
});
return (
<div className="App">
<p>{state.x.x}</p>
<button onClick={() => {
setState(s => {
s.x.x++;
return s;
});
}}>
click
</button>
</div>
);
}
请问,单击按钮后会发生什么?
什么都不会发生。
因为state
没有变呀!
React Reconciler(协调器)在dispatchAction
时,都采用的浅比较(Shallow Compare/Equal):
https://github.com/facebook/react/blob/fdb641629e82df6588c16e158978ac57d9aff9ba/packages/react-reconciler/src/ReactFiberHooks.new.js#L1696
因此,想解决这个问题,我们必须先做一次对象深拷贝,再做修改。更简单的做法,就是借助第三方库来搞定:ImmutableJS、ImmerJS等等。
我个人选择了看起来侵入性较小的ImmerJS,性能损失也不大,用起来还是很顺手的。它可以在原生数据结构List/Map/Set等等的基础上实现不可变数据结构。
ImmutableJS在使用上具有侵入性,也就是说会要求你用特定的写法来使用不可变数据结构,本身学习成本就要高一些。而且,这相当于一种垄断,将来如果有了更好的解决方案,切换的成本也提高了,因此我会倾向于避免引入这样的库。
为了方便通过Hooks使用ImmerJS,可以同时安装use-immer库,这样我们就拥有了useState
的替代品useImmer
,以及useReducer
的替代品useImmerReducer
,其参数和原生Hooks的两个函数可以说是一模一样的:
const initialState = { count: 0 };
function reducer(draft, action) {
switch (action.type) {
case "reset":
return initialState;
case "increment":
return void draft.count++;
case "decrement":
return void draft.count--;
}
}
function Counter() {
const [state, dispatch] = useImmerReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
</>
);
}
一些思路
数据和状态
原始数据模型
用户传入的最原始的数据,即工作流的原始定义,应当是最简洁无冗余的形式。所谓无冗余,可以理解成各项信息互相正交,举个例子:
我们可能希望节点拥有id
属性,而且节点的表示最好类似Map,可以从id
映射到节点的完整定义,就像这样:
{
"node0": {
"id": "node0",
"...": "...",
},
"node1": {
"id": "node1",
"...": "...",
}
}
但这样的数据模型是存在冗余的:每一个节点对应的键,就是节点的id
,但节点本身又存储了一个id
属性。
这样不仅对于用户来说写起来麻烦,容易出错;对于程序而言,在维护数据模型时也需要注意二者的同步。总而言之,用户端的数据模型,还是应该避免冗余。
目前版本的数据模型如下:
import { Rect } from "./BasicTypes";
export interface Port {
name: string;
type: string;
}
export interface Node {
layout: Rect;
title: string;
input: Port[];
output: Port[];
}
export interface NodePort {
nodeId: string;
portName: string;
}
export interface Edge {
start: NodePort;
end: NodePort;
}
export type NodeMap = { [nodeId: string]: Node };
export type EdgeMap = { [edgeId: string]: Edge };
export interface Flow {
nodes: NodeMap;
edges: EdgeMap;
}
可以看到,Node
接口本身没有id
属性,id
仅由NodeMap
这个对象类型的键来维护。
状态
原始数据模型确实简单,没有信息的冗余,但这也为程序开发带来了一些不便。
比如Node
的input
/output
是Port[]
类型,也就是一个列表,而Edge
的start
/end
是NodePort
类型,存有nodeId
和portName
。
我们如果想要正确绘制一条边(Edge
),我们需要知道它起止端点的坐标,也就需要知道它起止端点分别是哪一个节点的第几个输入或输出端点。
哪一个节点可以通过nodeId
直接获得,但想知道相关联的是该节点的第几个端点,就不容易了。因为NodePort
只有portName
属性。我们如何通过nodeId
和portName
,反查到该Port
的索引呢?
朴素的遍历方法,在数据量不大时尚可接受;但当节点、边、输入输出端点的数量增多后,自然会影响整体效率。此时,仅靠原始数据模型肯定是不够的,我们需要为它建立状态。
就上面的例子来说,我们需要两个Map:
type NodeId = string;
type PortName = string;
type PortIndexMap = Map<PortName, number>;
export interface FlowState {
raw: Flow;
inputPortMap: Map<NodeId, PortIndexMap>;
outputPortMap: Map<NodeId, PortIndexMap>;
...
}
这样,我们只需要保证原始数据模型中节点、边、端点的状态更新时能够同步这些状态,就可以借助他们实现更高性能的渲染了!
移动和缩放节点
这个说容易也容易,说难也难。主要是缺乏经验的时候,总要折腾。
最开始的想法很简单,抽象出一个useMoving
的Hooks:提供开始移动的函数,用于记录状态startPos
并标记移动开始;提供鼠标移动的事件处理函数,它会根据当前鼠标位置和startPos
状态计算出deltaX
和deltaY
,触发用户传入的回调函数;提供结束移动的函数,用于清空状态并标记移动结束。
移动
然后画布会利用这个useMoving
,传入一个根据deltaX
和deltaY
更新选中节点的坐标的回调函数;然后在节点鼠标按下事件中调用useMoving
返回的开始移动函数,在画布鼠标移动事件中调用鼠标移动事件处理函数,在画布鼠标释放事件中调用结束移动函数。
大概如下:
export type MovingCallback = (offset: Offset) => void;
export type StartMovingFunction = (e: React.MouseEvent) => void;
export type StopMovingFunction = () => void;
export type MovingEventListener = (e: React.MouseEvent) => void;
export function useMoving(callback: MovingCallback): [StartMovingFunction, StopMovingFunction, MovingEventListener] {
const [startPos, setStartPos] = useState<Point>();
const startMoving = useCallback<StartMovingFunction>((e) => {
setStartPos({ x: e.pageX, y: e.pageY });
}, []);
const stopMoving = useCallback<StopMovingFunction>(() => {
setStartPos(undefined);
}, [callback]);
const onMoving = useCallback<MovingEventListener>((e) => {
if (startPos) {
let offset = { x: e.pageX - startPos.x, y: e.pageY - startPos.y };
setStartPos({ x: e.pageX, y: e.pageY });
callback(offset);
}
}, [startPos, limit, callback]);
return [startMoving, stopMoving, onMoving];
}
const [startMovingNode, stopMovingNode, onMovingNode] = useMoving(useCallback((offset: Offset) => {
dispatch({ type: 'moveSelectedNodes', offset: { x: offset.x, y: offset.y } });
}, [dispatch]));
useEventListener('mouseup', useCallback(() => {
stopMovingNode();
}, [stopMovingNode]));
const onNodeMouseDown = useCallback((e: React.MouseEvent, id: string) => {
startMovingNode(e);
e.stopPropagation();
}, [startMovingNode]);
const onCanvasMouseMove = useCallback((e: React.MouseEvent) => {
return onMovingNode(e);
}, [onMovingNode]);
如果多个节点被选中,也可以同时根据每次移动的delta对各个选中的节点平移坐标,可以在上面的代码看到,这一过程是在reducer中完成的,因为我们调用了dispatch({ type: 'moveSelectedNodes', ... })
。
是的,我似乎没有提到,FlowState
(即上一节提到的,包含状态的数据模型)是通过useImmerReducer
创建的一个状态,会由FlowReducer
负责管理。
可以想到,FlowReducer
对moveSelectedNodes
这个type
的dispatch
的处理,就是将所有选中的节点的坐标按照offset
平移。
缩放
那么缩放呢,也是类似的,只不过要区分不同方向的手柄:
export type HandleDirection = 'left-top' | 'left-middle' | 'left-bottom' | 'right-top' | 'right-middle' | 'right-bottom';
依然利用useMoving
:当手柄被按下时,开始移动;移动的回调函数,会根据delta以及手柄的方向,更新选中节点的坐标和大小;鼠标释放时,停止移动。
似乎没什么问题。直到做好,运行把玩一下,发现我可以轻而易举地,让节点的大小变为负数。
嗯,那么在更新节点大小时,要求{w: max(50, width + offset.x), h: max(30, height + offset.y)}
就可以了吧(最小宽度50,最小高度30)?
这时,更诡异的事情发生了。虽然节点的宽高不会因为我把鼠标向内测拉得过远,而变得更小以至于成为负数了;但当我把鼠标再往反向拉时,会发现节点紧接着就变大了,还没等鼠标回归到手柄的位置(就是说,应该等鼠标反向到达刚才相对节点最小尺寸拉过头的距离,节点才开始跟着变大)。
这是因为,我们一直是根据鼠标每次移动的位移,来计算节点尺寸的变化量。当尺寸过小时,我们是直接忽略了变化量,取最小尺寸;但这段鼠标的行程,我们并未做任何记录,以至于鼠标返程的立刻,位移就被当做有效的变化量,更新到节点尺寸中了。
采用草稿机制
由于我们前面的设计,是每次鼠标的移动都会直接影响到节点实际坐标,这就要求我们计算每次鼠标移动的相对变化,也就增加了设置边界的难度。
如果我们可以始终计算鼠标相对于按下时坐标的位移,而且节点的初始大小能够被保留,问题就简单多了——初始大小加上总体位移,即将要改成的大小。我们只需保证这个目标大小不小于最小尺寸即可。由于位移也总是相对于鼠标按下时的坐标,鼠标走过头的路程也都会折算在内。
那么,与其说是保留节点的初始坐标和大小,不如说我们把新的坐标和大小更新到一个草稿状态中。
在FlowState
中,有一个状态:
draftNodeLayout: Map<NodeId, Rect>;
它对应了正在被移动或缩放的节点的草稿布局。节点在渲染时,也会获得这一草稿布局,如果草稿布局不为空,则采用草稿布局绘制节点的坐标和大小。
Bonus
草稿机制为我们带来了一个额外奖励!那就是我们能够丢弃草稿,恢复编辑前的布局。
于是,我增加了一个Esc
按键事件的响应,可以取消刚才对节点的移动或缩放。
最终代码如下:
export type MovingCallback = (offset: Offset) => void;
export type StartMovingFunction = (e: React.MouseEvent, limit?: LimitRect) => void;
export type StopMovingFunction = (cancel: boolean) => void;
export type MovingEventListener = (e: React.MouseEvent) => boolean;
export function useMoving(callback: MovingCallback): [StartMovingFunction, StopMovingFunction, MovingEventListener] {
const [initPos, setInitPos] = useState<Point>();
const [limit, setLimit] = useState<LimitRect>();
const startMoving = useCallback<StartMovingFunction>((e, limit) => {
const initPos = { x: e.pageX, y: e.pageY };
setInitPos(initPos);
setLimit(limit);
}, []);
const stopMoving = useCallback<StopMovingFunction>(cancel => {
if (cancel)
callback({ x: 0, y: 0 });
setInitPos(undefined);
}, [callback]);
const onMoving = useCallback<MovingEventListener>((e) => {
if (initPos) {
let offset = { x: e.pageX - initPos.x, y: e.pageY - initPos.y };
if (limit) {
if (limit.left !== undefined && offset.x < limit.left) offset.x = limit.left;
if (limit.top !== undefined && offset.y < limit.top) offset.y = limit.top;
if (limit.right !== undefined && offset.x > limit.right) offset.x = limit.right;
if (limit.bottom !== undefined && offset.y > limit.bottom) offset.y = limit.bottom;
}
callback(offset);
return true;
}
return false;
}, [initPos, limit, callback]);
return [startMoving, stopMoving, onMoving];
}
// Correct the offset by current scale factor
const [startMovingNode, stopMovingNode, onMovingNode] = useMoving(useCallback((offset: Offset) => {
dispatch({ type: 'moveSelectedNodes', offset: { x: offset.x / scale, y: offset.y / scale } });
}, [dispatch, scale]));
// Mouse up will stop and confirm moving or resizing to update the draft layout to real layout
useEventListener('mouseup', useCallback(() => {
stopMovingNode(false);
dispatch({ type: 'stopMovingNodes', cancel: false });
}, [stopMovingNode, dispatch]));
useEventListener('keydown', useCallback((e) => {
// Escape will cancel the current moving or resizing and restore the previous layout
if (e.key === 'Escape') {
stopMovingNode(true);
dispatch({ type: 'stopMovingNodes', cancel: true });
}
}, [stopMovingNode, dispatch]))
const onNodeMouseDown = useCallback((e: React.MouseEvent, id: string) => {
startMovingNode(e);
e.stopPropagation();
}, [startMovingNode]);
const onCanvasMouseMove = useCallback((e: React.MouseEvent) => {
return onMovingNode(e);
}, [onMovingNode]);
return { onNodeMouseDown, onCanvasMouseMove };
视图裁剪
这个问题花了我不少工夫,前前后后做了很多次实验。最终也算有一个比较满意的性能表现,也就是满足了最初的小目标,不考虑连接边的情况下,100K节点可以较为顺畅地展示,且画布移动起来也不会感受到明显的卡顿。
快速原型:天真的算法
最开始开发的时候,都会想着先实现基本功能,有个快速原型出来。因此,视图裁剪这个功能也不例外。
在我已经实现好了节点的移动、缩放,以及画布的平移等操作之后,我将测试生成的节点数增加到100K,然后进行测试,确实能够感受到明显的卡顿——甚至一切都是卡住的,许久才能等到顿的那一下。而且,这都已经不是移动节点才有的卡顿,就连节点的hover CSS样式都不能及时地在我鼠标悬浮时更新!
此时,我便开始了视图裁剪的原型设计——天真(Naive)的算法:
在渲染时,枚举全部节点,过滤掉其布局不在当前视图内的节点。
写成简略的代码(本想叫伪代码,但其实除了为了便于展示省略和修改了部分东西,大都还挺真的)大概是:
// ... In utils: ...
const isIntersected = (r1: Rect, r2: Rect) => {
return r1.x <= r2.x + r2.w && r1.y <= r2.y + r2.h && r1.x + r1.w >= r2.x && r1.y + r1.h >= r2.y;
};
// ... In Canvas Component: ...
flow.nodes
.filter(node => isIntersected(node.layout, flow.viewBound))
.map(node => (
<Node
key={node.id}
x={node.layout.x}
y={node.layout.y}
w={node.layout.w}
h={node.layout.h}
draftLayout={flow.draftNodeLayout.get(node.id)}
>
));
再次测试,发现操作性能好了很多!起码能有点反应了!
不过,拖拽移动、缩放等等还是能感受到明显的不跟手,画布平移更是相当卡顿。
记住它们!
上面的做法是在每次Canvas渲染时,都要遍历所有节点来筛选出视图内的节点。这样,即使画布没有平移,当我们移动、缩放节点时,画布都需要重绘,这一耗时的遍历也就会不断地执行。
这也是为什么移动和缩放节点会不跟手,因为每次鼠标的轻微移动,背后都是这庞大的遍历运算。
然而,我们可以假设,只要画布不平移,当前视图内应当显示的节点也不会发生变化——毕竟用户不会把一个节点移动到视图以外,或者把视图外的移动到视图内。
所以,我们完全可以利用useMemo
优化掉不必要的遍历计算。
useMemo(() => (
flow.nodes.filter(node => isIntersected(node.layout, flow.viewBound))
), [flow.nodes, flow.viewBound])
.map(node => (
<Node
key={node.id}
x={node.layout.x}
y={node.layout.y}
w={node.layout.w}
h={node.layout.h}
draftLayout={flow.draftNodeLayout.get(node.id)}
>
));
经过这一次优化,移动和缩放节点都变得流畅跟手了。
但平移画布时,毕竟要重新遍历计算当前试图可见的节点,仍然会很卡。想解决这一问题,看来必须采用更优的算法来裁剪视图。
分块裁剪
我其实知道四叉树搞得定这个问题,但我真心不想写。
分块裁剪是一个比较折中的办法,应该能有效改善计算效率,写起来也比较容易。
做法就是:把整个画布分成200 x 200(其他数值也可以,这里随便写一个)的矩形块,然后把记录每个矩形都包含哪些节点;视图裁剪时,只需根据当前视图的偏移和大小(也就是一个矩形),看看与画布的哪些200 x 200的块相交,然后直接把预存好的这些块里面的节点加到可见节点中即可;当节点布局有变化时,或者增删节点时,增量地更新这个分块信息即可,复杂度也不高。
至于如何找到相交的分块,也不必枚举所有分块;可以在保证分块的顺序后,通过除法和求余算出相交分块的索引。
这种分块裁剪的方案,相当于把之前遍历每一个节点,变成了合并视图内的几个分块所包含的节点。复杂度取决于视图大小和分块大小。
在视图大小恒定的情况下,分块越大,裁剪越快,但最终保留的节点越多,渲染性能相对较低;分块越小,裁剪越慢,但裁剪精细,最终渲染的节点数目少。
大概试验了几个不同的数值,基本上都能让画布平移时的卡顿明显减轻不少。毕竟从遍历全部节点,到只遍历视图内的分块,是巨大的复杂度降低。
当然了,如果视图很大(比如把画布缩小),视图中需要遍历的分块依然很多,再加上要合并每一个分块包含的节点列表,性能仍然不尽如人意。
四叉树
一不做二不休,直接上四叉树吧。
四叉树的节点数据结构如下:
注意区分树节点和画布里面的工作流节点
export interface Node {
data: Flow.NodeId[]; // 用于存放该树节点对应分块内的节点列表
bound: Basic.Rect; // 对应分块边界
left: { // 左侧
top?: Node; // 左上 子分块树节点
bottom?: Node; // 左下
};
right: { // 右侧
top?: Node; // 右上
bottom?: Node; // 右下
};
}
四叉树插入一个节点的布局矩形时,会将该节点的索引,加入所有与该节点布局矩形相交的分块对应的树节点中:
function insert(root: Node, rect: Basic.Rect, data: Flow.NodeId, resolution: number) {
// Not intersected means the node's children cannot contain it either
if (!isIntersected(root.bound, rect)) return;
root.data.push(data);
// If current node's bound size is smaller than resolution, skip children
if (root.bound.w <= resolution && root.bound.h <= resolution) return;
for (const leftOrRight of ['left', 'right'] as LeftOrRight[]) {
for (const topOrBottom of ['top', 'bottom'] as TopOrBottom[]) {
let currentNode = root[leftOrRight][topOrBottom];
if (currentNode === undefined) {
currentNode = {
data: [],
bound: subRect(root, leftOrRight, topOrBottom),
left: {},
right: {},
};
root[leftOrRight][topOrBottom] = currentNode;
}
insert(currentNode, rect, data, resolution);
}
}
}
可以看到,我们为了提高性能,对四叉树的最小分块大小也做了设置,即参数中的resolution
。
四叉树这里,还有一些关于删除
的算法,与这段插入
的代码思路基本一致。
裁剪
算法,则有点不同——只有当树节点分块被当前视图完全包含,才将该树节点存储的全部节点列表追加到可见节点列表中,且不再搜索其叶子;当树节点分块与当前视图不相交时,直接跳过该树节点及其叶子;其他情况,则继续搜索其叶子。
成功应用四叉树以后,用鼠标滚动平移画布真的又流畅了很多。
小优化
尽管比起最开始一动不能动,已经好了太多太多,但在深度试用之后,还是感受到不友好的地方:刚刚开始滚动的一瞬间,可能会卡顿一下,导致刚刚平移进来的一块画布是空白。
为了解决这个问题,我把每次视图裁剪时的视图矩形扩大了一些(描了一层厚厚的边),相当于多缓存一些当前视图周围的节点。
这下,当我进行较小的平移时(较小的平移预期也是最常见的实际应用场景),不会出现屏幕出现短暂但惹人注意的空白了。
不过,视图裁剪的范围变大后,视图裁剪运算性能也就受到一些影响,毕竟我给视图描的边有500px粗,几乎是我视图原本宽度的一半了!于是,平移画布又出现了卡顿。
这可怎么办,如果描边不够粗,那用户平移的稍微多一点,就很容易看到一瞬间的空白。
灵机一动。
当用户正在平移视图时,可以先保留之前的节点,也就是说,不是把刚刚视图裁剪好的节点直接替换掉当前可见的节点列表,而是取并集,而且这一个视图裁剪无需扩大视图(因为用户正在平移过程中)。当用户停止平移一段时间后(可以很短,比如半秒),缓存更大的视图裁剪结果,并直接替换掉当前可见的节点列表。
最终代码示例
最终设计中,除了新加入可见节点列表、当前可见节点列表外,还有选中节点列表、高亮节点列表(当边被选中时,相连的节点会被高亮)。
新加入可见节点和当前可见节点为什么要区分呢?因为我为新加入可见节点设计了渐显的动效,如果不加以区分,那么当一个节点从被选中节点列表加回正常的可见节点列表中时,他可能又会触发一次动效了。
// Update newly visible nodes once view bound is changed without timeout
useEffect(() => dispatch({ type: 'updateNewlyVisibleNodes' }), [viewBound, dispatch]);
// After 500ms without view bound changes,
// newly visible nodes will be transformed to confirmed visible nodes,
// which will disable the entering animation and also has a larger cached view.
useEffect(() => {
const timer = setTimeout(() => dispatch({ type: 'updateVisibleNodes', cacheExpandSize: 500 }), 300);
return () => clearTimeout(timer);
}, [viewBound, dispatch]);
const newlyVisibleNodes = useMemo(() =>
newlyVisibleNodeIds
.filter(i => !selectedNodeIds.has(i) && !highlightedNodeIds.has(i))
.map(i => [i, raw.nodes[i]] as [string, Node]),
[newlyVisibleNodeIds, highlightedNodeIds, selectedNodeIds, raw.nodes]);
const visibleNodes = useMemo(() =>
Array.from(visibleNodeIds.keys())
.filter(i => !selectedNodeIds.has(i) && !highlightedNodeIds.has(i))
.map(i => [i, raw.nodes[i]] as [string, Node]),
[visibleNodeIds, highlightedNodeIds, selectedNodeIds, raw.nodes]);
const highlightedNodes = useMemo(() =>
Array.from(highlightedNodeIds.keys())
.map(i => [i, raw.nodes[i]] as [string, Node]),
[highlightedNodeIds, raw.nodes]);
const selectedNodes = useMemo(() =>
Array.from(selectedNodeIds.keys())
.map(i => [i, raw.nodes[i]] as [string, Node]),
[selectedNodeIds, raw.nodes]);