-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathuseTreeCollapse.js
More file actions
292 lines (248 loc) · 10.3 KB
/
useTreeCollapse.js
File metadata and controls
292 lines (248 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
import { getSubtreeSize } from '@barso/helpers';
import { useEffect, useReducer, useRef } from 'react';
/**
* @typedef {Object} TreeNode
* @property {string} id - Unique identifier for the node.
* @property {TreeNode[]} [children] - Child nodes of the current node.
* @property {number} [children_deep_count] - Total number of nested children.
* @property {number} [collapsedSize] - Render size when the node is collapsed.
* @property {number} [expandedSize] - Render size when the node is expanded.
*/
/**
* @typedef {Object} useTreeCollapseReturn
* @property {TreeNode[]} nodeStates - The current computed state of the first-level children of the tree.
* @property {(id: string) => void} handleExpand - Expands a node and adjusts visibility of related nodes based on budget constraints.
* @property {(id: string) => void} handleCollapse - Collapses a node's immediate children by its ID.
*/
/**
* @typedef {Object} useTreeCollapseParams
* @property {TreeNode[]} [nodes=[]] - The initial list of tree nodes.
* @property {number} [minimalSubTree=3] - The minimum size of a subtree to be expanded.
* @property {number} [totalBudget=20] - The total node rendering budget available.
* @property {number} [additionalBudget=10] - The additional budget allocated when expanding a node.
* @property {string|null} [defaultExpandedId=null] - The ID of the node to expand by default.
*/
/**
* Hook to manage collapse/expand logic for the first children level of a tree structure,
* using rendering budget constraints. It returns all tree nodes annotated with their current
* expansion state, along with handlers to toggle visibility.
*
* @param {useTreeCollapseParams} [params] - Parameters to configure the tree collapse behavior.
* @returns {useTreeCollapseReturn} - All nodes with computed expansion metadata and controls to modify their state.
*/
export function useTreeCollapse({
nodes = [],
minimalSubTree = 3,
totalBudget = 20,
additionalBudget = 10,
defaultExpandedId = null,
} = {}) {
const [nodeStates, dispatch] = useReducer(
(previousState, action = {}) => {
switch (action.type) {
case 'EXPAND_NODE':
return expandChildren({ additionalBudget, minimalSubTree, previousState, ...action });
case 'COLLAPSE_NODE':
return collapseChildren({ previousState, ...action });
case 'UPDATE_STATE':
return computeNodeStates({ defaultExpandedId, minimalSubTree, nodes, totalBudget, previousState, ...action });
case 'RESET_STATE':
default:
return computeNodeStates({ defaultExpandedId, minimalSubTree, nodes, totalBudget, ...action });
}
},
{ minimalSubTree, nodes, totalBudget, defaultExpandedId },
computeNodeStates,
);
const lastParamsRef = useRef();
useEffect(() => {
if (!lastParamsRef.current) {
lastParamsRef.current = { totalBudget, minimalSubTree, defaultExpandedId };
return;
}
const shouldUsePrevious =
lastParamsRef.current.totalBudget === totalBudget &&
lastParamsRef.current.minimalSubTree === minimalSubTree &&
lastParamsRef.current.defaultExpandedId === defaultExpandedId;
if (shouldUsePrevious) {
dispatch({ type: 'UPDATE_STATE' });
} else {
lastParamsRef.current = { totalBudget, minimalSubTree, defaultExpandedId };
dispatch({ type: 'RESET_STATE' });
}
}, [defaultExpandedId, nodes, minimalSubTree, totalBudget]);
const handleExpand = (targetId) => dispatch({ type: 'EXPAND_NODE', targetId });
const handleCollapse = (targetId) => dispatch({ type: 'COLLAPSE_NODE', targetId });
return {
handleCollapse,
handleExpand,
nodeStates,
};
}
/**
* Computes the initial state of tree nodes based on budget constraints and previous state.
*
* @param {Object} params
* @param {number} [params.minimalSubTree=1] - Minimum size of a subtree to be expanded
* @param {TreeNode[]} params.nodes - Array of tree nodes to process
* @param {TreeNode[]} [params.previousState=[]] - Previous state to maintain expanded sizes
* @param {number} params.totalBudget - Total budget available for node expansion
* @param {string|null} [params.defaultExpandedId=null] - ID of the node to expand by default
* @returns {TreeNode[]} Array of nodes with computed expansion states
*/
export function computeNodeStates({
minimalSubTree = 1,
nodes,
previousState = [],
totalBudget,
defaultExpandedId = null,
}) {
if (!nodes || !Array.isArray(nodes) || !nodes.length) return nodes;
let remainingBudget = totalBudget;
const previousExpandedSizes = new Map(
previousState
.filter((node) => typeof node?.id === 'string' && typeof node.expandedSize === 'number')
.map((node) => [node.id, node.expandedSize]),
);
const initialPass = nodes.map((node) => {
if (typeof node?.id !== 'string') return node;
const cachedExpandedSize = previousExpandedSizes.get(node.id);
if (cachedExpandedSize >= 0) {
remainingBudget -= cachedExpandedSize;
return { ...node, expandedSize: cachedExpandedSize };
}
if (remainingBudget > 0 || node.id === defaultExpandedId) {
const maxFullDepth = getSubtreeSize(node);
const allocated = Math.max(1, Math.min(minimalSubTree, remainingBudget, maxFullDepth));
remainingBudget -= allocated;
return { ...node, expandedSize: allocated };
}
return { ...node, expandedSize: 0 };
});
const grouped = groupCollapsed(initialPass);
return distributeRemainingBudget(grouped, remainingBudget);
}
/**
* Groups consecutive collapsed nodes and calculates their combined collapsed size.
*
* @param {TreeNode[]} nodes - Array of nodes to process
* @returns {TreeNode[]} Array with collapsed nodes grouped together
*/
function groupCollapsed(nodes) {
const result = [];
let i = 0;
while (i < nodes?.length) {
const node = nodes[i];
if (node?.expandedSize === 0) {
let collapsedSize = getSubtreeSize(node);
let j = i + 1;
// Find consecutive collapsed nodes
while (j < nodes.length && nodes[j]?.expandedSize === 0) {
collapsedSize += getSubtreeSize(nodes[j]);
j++;
}
// Add the first node with combined collapsed size
result.push({ ...node, collapsedSize });
// Add remaining nodes in the group without collapsedSize
for (let k = i + 1; k < j; k++) {
result.push({ ...nodes[k] });
}
i = j;
} else {
result.push(node);
i++;
}
}
return result;
}
/**
* Distributes any remaining budget across nodes that can still be expanded.
*
* @param {TreeNode[]} nodes - Array of nodes to distribute budget to
* @param {number} remainingBudget - Amount of budget still available
* @returns {TreeNode[]} Array of nodes with updated expanded sizes
*/
function distributeRemainingBudget(nodes, remainingBudget) {
if (remainingBudget <= 0) return nodes;
return nodes.map((node) => {
if (remainingBudget <= 0 || !node?.expandedSize) return node;
const maxDepth = node.collapsedSize || getSubtreeSize(node);
if (node.expandedSize >= maxDepth) return node;
const extra = Math.min(remainingBudget, maxDepth - node.expandedSize);
remainingBudget -= extra;
return {
...node,
expandedSize: node.expandedSize + extra,
};
});
}
/**
* Expands collapsed children of a target node by allocating additional budget.
* This function finds consecutive collapsed nodes starting from the target and expands them.
*
* @param {Object} params - Parameters for expansion
* @param {number} params.additionalBudget - Additional budget to allocate for expansion
* @param {number} params.minimalSubTree - Minimum size for subtree expansion
* @param {TreeNode[]} params.previousState - Current state of all nodes
* @param {string} params.targetId - ID of the target node to expand
* @returns {TreeNode[]} Updated array with expanded nodes
*/
function expandChildren({ additionalBudget, minimalSubTree, previousState, targetId }) {
const startIndex = previousState.findIndex((node) => node?.id === targetId);
if (startIndex < 0) return previousState;
const nodes = [];
let endIndex = startIndex;
// Collect consecutive collapsed nodes
while (previousState[endIndex]?.expandedSize === 0) {
const { collapsedSize, ...nodeWithoutCollapsedSize } = previousState[endIndex];
nodes.push(nodeWithoutCollapsedSize);
endIndex++;
}
const expanded = computeNodeStates({
minimalSubTree,
nodes,
totalBudget: additionalBudget,
});
return [...previousState.slice(0, startIndex), ...expanded, ...previousState.slice(endIndex)];
}
/**
* Collapses a node and updates the collapsed size information for adjacent collapsed nodes.
*
* @param {Object} params - Parameters for collapsing
* @param {TreeNode[]} params.previousState - Current state of all nodes
* @param {string} params.targetId - ID of the target node to collapse
* @returns {TreeNode[]} Updated array with collapsed node
*/
function collapseChildren({ previousState, targetId }) {
const targetNodeIndex = previousState.findIndex((node) => node?.id === targetId);
if (targetNodeIndex < 0 || previousState[targetNodeIndex].expandedSize === 0) return previousState;
const result = [...previousState];
const originalTargetNode = result[targetNodeIndex];
const targetNode = {
...originalTargetNode,
// Collapse the target node
expandedSize: 0,
collapsedSize: getSubtreeSize(originalTargetNode),
};
result[targetNodeIndex] = targetNode;
// Update collapsed size if next node is also collapsed
if (result[targetNodeIndex + 1]?.collapsedSize) {
targetNode.collapsedSize += result[targetNodeIndex + 1].collapsedSize;
const nextNode = { ...result[targetNodeIndex + 1] };
delete nextNode.collapsedSize;
result[targetNodeIndex + 1] = nextNode;
}
// Find the first collapsed node in the sequence (going backwards)
let firstCollapsedIndex = targetNodeIndex;
while (result[firstCollapsedIndex - 1]?.expandedSize === 0) {
firstCollapsedIndex--;
}
// If target is not the first collapsed node, move collapsedSize to the first one
if (firstCollapsedIndex < targetNodeIndex) {
const totalCollapsedSize = result[firstCollapsedIndex].collapsedSize + targetNode.collapsedSize;
const firstNode = { ...result[firstCollapsedIndex], collapsedSize: totalCollapsedSize };
result[firstCollapsedIndex] = firstNode;
delete targetNode.collapsedSize;
}
return result;
}