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