import { useState, useRef, useCallback, useEffect, useContext } from "react"; import { useJsonForms } from "@jsonforms/react"; import { Group } from "@visx/group"; import { hierarchy, Tree } from "@visx/hierarchy"; import { Zoom } from "@visx/zoom"; import { localPoint } from "@visx/event"; import { DataContext } from "../../context/Context"; import { Toolbar } from "./Toolbar"; import { NodeContainer } from "./NodeContainer"; import { ChartProps, TreeNode, DEFAULT_MARGIN, TOOLBAR_HEIGHT, MIN_ZOOM, MAX_ZOOM, } from "./Interface"; import { mergeChildren, getLinkComponent, getLevelStyle, extractLevelStyles, countVisibleLeaves, computeDynamicHeight, collectExpandedNodeIds, } from "./Utils"; function SvgDefs() { return ( ); } const ANIMATION_STYLES = ` @keyframes nodeSlideIn { 0% { opacity: 0; transform: translateY(-20px); } 100% { opacity: 1; transform: translateY(0px); } } @keyframes linkDraw { 0% { stroke-dashoffset: 1; } 100% { stroke-dashoffset: 0; } } @keyframes toggleSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } `; export function DrawHierarchyChart({ path, width: totalWidth, height: totalHeight, data, linkType, stepPercent, nodeWidth, nodeHeight, styleConfig, margin = DEFAULT_MARGIN, renderers, cells, enabled, nodeElements, islazyLoad, lazyLoadFunction }: ChartProps) { const ctx = useJsonForms(); const canvasBgColor: string = styleConfig.canvasBgColor ?? "#ffffff"; const linkColor: string = styleConfig?.linkColor; const { serviceProvider } = useContext(DataContext); const [localTree, setLocalTree] = useState(data); const [expandedIds, setExpandedIds] = useState>( () => collectExpandedNodeIds(data) ); const [nodeHeights, setNodeHeights] = useState>({}); const [loadingNodeIds, setLoadingNodeIds] = useState>(new Set()); const [recentlyExpandedId, setRecentlyExpandedId] = useState(null); const expandAnimTimerRef = useRef | null>(null); const isDraggingRef = useRef(false); const dragStartPosRef = useRef({ x: 0, y: 0 }); const lastPinchDistRef = useRef(null); const levelStyles = extractLevelStyles(styleConfig); useEffect(() => { setLocalTree(data); setExpandedIds( collectExpandedNodeIds(data) ); setNodeHeights({}); }, [data, islazyLoad]); const handleHeightChange = useCallback((id: number | string, height: number) => { setNodeHeights((prev) => { if (prev[id] === height) return prev; return { ...prev, [id]: height }; }); }, []); const triggerExpandAnimation = useCallback((targetId: number | string) => { if (expandAnimTimerRef.current) clearTimeout(expandAnimTimerRef.current); setRecentlyExpandedId(targetId); expandAnimTimerRef.current = setTimeout(() => setRecentlyExpandedId(null), 700); }, []); const handleToggle = useCallback( async (targetId: number | string, nodeData: TreeNode) => { if (isDraggingRef.current) return; const isCurrentlyExpanded = expandedIds.has(targetId); const childrenAlreadyLoaded = Array.isArray(nodeData.children) && nodeData.children.length > 0; const shouldFetch = islazyLoad && !isCurrentlyExpanded && !childrenAlreadyLoaded; if (shouldFetch) { setLoadingNodeIds((prev) => new Set(prev).add(targetId)); try { const fetchedChildren: TreeNode[] = await serviceProvider( ctx, { onClick: lazyLoadFunction || "onNodeExpandChange" }, { event: { _reactName: "onClick" }, path, paramValue: { path, expandedNodeId: targetId }, } ); if (Array.isArray(fetchedChildren)) { setLocalTree((prev) => mergeChildren(prev, targetId, fetchedChildren)); setExpandedIds((prev) => { const next = new Set(prev); next.add(targetId); return next; }); triggerExpandAnimation(targetId); } } catch (err) { console.error("HierarchyChart lazy load error:", err); } finally { setLoadingNodeIds((prev) => { const next = new Set(prev); next.delete(targetId); return next; }); } return; } setExpandedIds((prev) => { const next = new Set(prev); if (next.has(targetId)) { next.delete(targetId); } else { next.add(targetId); triggerExpandAnimation(targetId); } return next; }); }, [expandedIds, islazyLoad, serviceProvider, ctx, lazyLoadFunction, triggerExpandAnimation, path] ); const innerWidth = totalWidth - margin.left - margin.right; const canvasHeight = totalHeight - TOOLBAR_HEIGHT; const NODE_MIN_PADDING = 24 + nodeWidth * 0.25; const origin = { x: 0, y: 60 }; const sizeWidth = Math.max( innerWidth, countVisibleLeaves(localTree, expandedIds) * (nodeWidth + NODE_MIN_PADDING) ); const sizeHeight = computeDynamicHeight(localTree, expandedIds, nodeHeight, nodeHeights); const LinkComponent = getLinkComponent(linkType); const hasElements = nodeElements.length > 0; if (totalWidth < 10) return null; return ( <> width={totalWidth} height={canvasHeight} scaleXMin={MIN_ZOOM} scaleXMax={MAX_ZOOM} scaleYMin={MIN_ZOOM} scaleYMax={MAX_ZOOM} > {(zoom) => { return (
{ if (zoom.isDragging) { const dx = Math.abs(e.clientX - dragStartPosRef.current.x); const dy = Math.abs(e.clientY - dragStartPosRef.current.y); if (dx > 3 || dy > 3) isDraggingRef.current = true; zoom.dragMove(e); } }} onMouseUp={() => { zoom.dragEnd(); setTimeout(() => { isDraggingRef.current = false; }, 0); }} onMouseLeave={() => { if (zoom.isDragging) zoom.dragEnd(); }} onTouchStart={(e) => { if (e.touches.length === 2) { lastPinchDistRef.current = Math.hypot( e.touches[1].clientX - e.touches[0].clientX, e.touches[1].clientY - e.touches[0].clientY ); } else { lastPinchDistRef.current = null; zoom.dragStart(e); } }} onTouchMove={(e) => { if (e.touches.length === 2) { const dist = Math.hypot( e.touches[1].clientX - e.touches[0].clientX, e.touches[1].clientY - e.touches[0].clientY ); if (lastPinchDistRef.current !== null) { const scaleFactor = dist / lastPinchDistRef.current; const svgRect = zoom.containerRef.current?.getBoundingClientRect(); const midClientX = (e.touches[0].clientX + e.touches[1].clientX) / 2; const midClientY = (e.touches[0].clientY + e.touches[1].clientY) / 2; const point = { x: svgRect ? midClientX - svgRect.left : midClientX, y: svgRect ? midClientY - svgRect.top : midClientY, }; zoom.scale({ scaleX: scaleFactor, scaleY: scaleFactor, point }); } lastPinchDistRef.current = dist; } else { lastPinchDistRef.current = null; zoom.dragMove(e); } }} onTouchEnd={() => { lastPinchDistRef.current = null; zoom.dragEnd(); }} onWheel={(e) => { const point = localPoint(e) || { x: 0, y: 0 }; const sf = e.deltaY < 0 ? 1.1 : 0.9; zoom.scale({ scaleX: sf, scaleY: sf, point }); }} > { dragStartPosRef.current = { x: e.clientX, y: e.clientY }; isDraggingRef.current = false; zoom.dragStart(e); }} /> expandedIds.has(d.id) ? d.children : null )} size={[sizeWidth, sizeHeight]} separation={(a, b) => { return a.parent === b.parent ? 1 : 1.5; }} > {(tree) => { const links = tree.links(); return ( {links.map((link, i) => { const gradientId = `gradient-${i}`; return ( ); })} {links.map((link, i) => { const gradientId = `gradient-${i}`; return ( ); })} {tree.descendants().map((node) => { const top = node.y; const left = node.x; const nodeId = node.data.id; const storedH = nodeHeights[nodeId]; const currentHeight = storedH && storedH > 0 ? storedH : nodeHeight; const hasChildren = node.data.hasChildren === true || (Array.isArray(node.data.children) && node.data.children.length > 0); const isCollapsed = !expandedIds.has(nodeId); const depth = node.depth; const levelStyle = getLevelStyle(levelStyles, depth); const isHighlighted = node.data.isHighlighted === true; const isNodeLoading = loadingNodeIds.has(nodeId); const isChildOfExpanded = recentlyExpandedId !== null && node.parent?.data?.id === recentlyExpandedId; const childIndex = isChildOfExpanded ? (node.parent?.children?.findIndex( (c) => c.data.id === nodeId ) ?? 0) : 0; return ( {hasElements ? ( handleToggle(nodeId, node.data) } onHeightChange={(h) => handleHeightChange(nodeId, h) } currentHeight={currentHeight} levelStyle={levelStyle} depth={depth} isHighlighted={isHighlighted} isNodeLoading={isNodeLoading} styleConfig={styleConfig} /> ) : ( hasChildren && handleToggle(nodeId, node.data) } /> )} ); })} ); }}
); }} ); }