Last active
January 19, 2026 13:25
-
-
Save basicserge/2b468baa57c01fc09213ef0199c591ef to your computer and use it in GitHub Desktop.
d3-plus
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useMemo, useState, useCallback } from 'react'; | |
| import ReactFlow, { | |
| Background, | |
| Controls, | |
| MiniMap, | |
| Handle, | |
| Position, | |
| type Node, | |
| type Edge, | |
| type NodeMouseHandler | |
| } from 'reactflow'; | |
| import { hierarchy, tree } from 'd3-hierarchy'; | |
| import 'reactflow/dist/style.css'; | |
| interface TreeNode { | |
| id: string; | |
| name: string; | |
| children?: TreeNode[]; | |
| } | |
| // Seeded random for reproducible results | |
| function seededRandom(seed: number) { | |
| return () => { | |
| seed = (seed * 1103515245 + 12345) & 0x7fffffff; | |
| return seed / 0x7fffffff; | |
| }; | |
| } | |
| function generateTreeData(targetNodes = 300, seed = 42): TreeNode { | |
| const random = seededRandom(seed); | |
| let id = 0; | |
| let totalNodes = 1; | |
| // First pass: create tree structure, collect all nodes | |
| const allNodes: { node: TreeNode; level: number }[] = []; | |
| function createNode(level: number): TreeNode { | |
| const nodeId = id++; | |
| const node: TreeNode = { | |
| id: String(nodeId), | |
| name: level === 0 ? 'Root' : `L${level}-${nodeId}` | |
| }; | |
| allNodes.push({ node, level }); | |
| return node; | |
| } | |
| const root = createNode(0); | |
| // Keep adding children until we reach target | |
| while (totalNodes < targetNodes) { | |
| // Pick a random existing node to add children to | |
| const candidates = allNodes.filter(n => | |
| n.level < 5 && // Max depth | |
| (!n.node.children || n.node.children.length < 8) // Max children per node | |
| ); | |
| if (candidates.length === 0) break; | |
| // Prefer nodes with fewer children and shallower depth | |
| const weights = candidates.map(c => { | |
| const depthWeight = Math.max(1, 5 - c.level); | |
| const childWeight = c.node.children ? Math.max(1, 6 - c.node.children.length) : 6; | |
| return depthWeight * childWeight; | |
| }); | |
| const totalWeight = weights.reduce((a, b) => a + b, 0); | |
| let pick = random() * totalWeight; | |
| let selected = candidates[0]; | |
| for (let i = 0; i < candidates.length; i++) { | |
| pick -= weights[i]; | |
| if (pick <= 0) { | |
| selected = candidates[i]; | |
| break; | |
| } | |
| } | |
| // Add 1-4 children (uneven) | |
| const childCount = Math.min( | |
| 1 + Math.floor(random() * 4), | |
| targetNodes - totalNodes, | |
| 8 - (selected.node.children?.length || 0) | |
| ); | |
| if (childCount > 0) { | |
| if (!selected.node.children) selected.node.children = []; | |
| for (let i = 0; i < childCount; i++) { | |
| selected.node.children.push(createNode(selected.level + 1)); | |
| totalNodes++; | |
| } | |
| } | |
| } | |
| return root; | |
| } | |
| const CompactNode = ({ data }: { data: { label: string } }) => ( | |
| <div style={{ | |
| padding: '8px 12px', | |
| borderRadius: '4px', | |
| background: '#fff', | |
| border: '1px solid #ddd', | |
| fontSize: '12px', | |
| minWidth: '80px', | |
| textAlign: 'center', | |
| }}> | |
| <Handle type="target" position={Position.Top} /> | |
| {data.label} | |
| <Handle type="source" position={Position.Bottom} /> | |
| </div> | |
| ); | |
| const nodeTypes = { | |
| compact: CompactNode, | |
| }; | |
| function useStandardLayout(data: TreeNode) { | |
| return useMemo(() => { | |
| const root = hierarchy<TreeNode>(data); | |
| const treeLayout = tree<TreeNode>().nodeSize([100, 120]); | |
| treeLayout(root); | |
| const nodes: Node[] = []; | |
| const edges: Edge[] = []; | |
| root.descendants().forEach(d => { | |
| nodes.push({ | |
| id: d.data.id, | |
| data: { label: d.data.name }, | |
| position: { x: d.x ?? 0, y: d.y ?? 0 }, | |
| type: 'compact', | |
| }); | |
| if (d.parent) { | |
| edges.push({ | |
| id: `${d.parent.data.id}-${d.data.id}`, | |
| source: d.parent.data.id, | |
| target: d.data.id, | |
| }); | |
| } | |
| }); | |
| return { nodes, edges }; | |
| }, [data]); | |
| } | |
| function useGridLayout(data: TreeNode, viewportWidth: number, viewportHeight: number) { | |
| return useMemo(() => { | |
| const root = hierarchy<TreeNode>(data); | |
| const allNodes = root.descendants(); | |
| const nodeCount = allNodes.length; | |
| const nodeWidth = 100; | |
| const nodeHeight = 40; | |
| const gapX = 15; | |
| const gapY = 15; | |
| // Calculate optimal cols to match viewport aspect ratio | |
| // Grid aspect ratio: (cols * cellWidth) / (rows * cellHeight) = viewportWidth / viewportHeight | |
| // With rows = ceil(nodeCount / cols), solve for cols | |
| const viewportAspect = viewportWidth / viewportHeight; | |
| const cellWidth = nodeWidth + gapX; | |
| const cellHeight = nodeHeight + gapY; | |
| // cols ≈ sqrt(nodeCount * viewportAspect * cellHeight / cellWidth) | |
| let cols = Math.round(Math.sqrt(nodeCount * viewportAspect * cellHeight / cellWidth)); | |
| cols = Math.max(1, Math.min(cols, nodeCount)); // clamp | |
| // Position from origin, fitView will scale to fit | |
| const offsetX = 0; | |
| const offsetY = 0; | |
| const nodes: Node[] = []; | |
| const edges: Edge[] = []; | |
| allNodes.forEach((d, index) => { | |
| const col = index % cols; | |
| const row = Math.floor(index / cols); | |
| nodes.push({ | |
| id: d.data.id, | |
| data: { label: d.data.name }, | |
| position: { | |
| x: offsetX + col * cellWidth, | |
| y: offsetY + row * cellHeight | |
| }, | |
| type: 'compact', | |
| }); | |
| if (d.parent) { | |
| edges.push({ | |
| id: `${d.parent.data.id}-${d.data.id}`, | |
| source: d.parent.data.id, | |
| target: d.data.id, | |
| }); | |
| } | |
| }); | |
| return { nodes, edges }; | |
| }, [data, viewportWidth, viewportHeight]); | |
| } | |
| type LayoutMethod = 'standard' | 'grid'; | |
| const DATASET_SEEDS = [ | |
| { seed: 42, nodes: 100, label: '100 nodes' }, | |
| { seed: 123, nodes: 200, label: '200 nodes' }, | |
| { seed: 777, nodes: 300, label: '300 nodes' }, | |
| { seed: 999, nodes: 500, label: '500 nodes' }, | |
| { seed: 2024, nodes: 1000, label: '1000 nodes' }, | |
| ]; | |
| export default function TreeLayoutComparison() { | |
| const [layoutMethod, setLayoutMethod] = useState<LayoutMethod>('grid'); | |
| const [viewportSize, setViewportSize] = useState({ width: 1200, height: 800 }); | |
| const [selectedNodes, setSelectedNodes] = useState<Set<string>>(new Set()); | |
| const [datasetIndex, setDatasetIndex] = useState(0); | |
| const currentDataset = DATASET_SEEDS[datasetIndex]; | |
| const treeData = useMemo( | |
| () => generateTreeData(currentDataset.nodes, currentDataset.seed), | |
| [currentDataset.nodes, currentDataset.seed] | |
| ); | |
| React.useEffect(() => { | |
| const updateSize = () => { | |
| setViewportSize({ | |
| width: window.innerWidth, | |
| height: window.innerHeight - 60 | |
| }); | |
| }; | |
| updateSize(); | |
| window.addEventListener('resize', updateSize); | |
| return () => window.removeEventListener('resize', updateSize); | |
| }, []); | |
| const standardLayout = useStandardLayout(treeData); | |
| const gridLayout = useGridLayout(treeData, viewportSize.width, viewportSize.height); | |
| const currentLayout = layoutMethod === 'standard' ? standardLayout : gridLayout; | |
| const relatedNodes = useMemo(() => { | |
| if (selectedNodes.size === 0) return new Set<string>(); | |
| const related = new Set<string>(selectedNodes); | |
| currentLayout.edges.forEach(edge => { | |
| if (selectedNodes.has(edge.source)) { | |
| related.add(edge.target); | |
| } | |
| if (selectedNodes.has(edge.target)) { | |
| related.add(edge.source); | |
| } | |
| }); | |
| return related; | |
| }, [selectedNodes, currentLayout.edges]); | |
| const highlightedNodes = useMemo(() => { | |
| return currentLayout.nodes.map(node => { | |
| const isSelected = selectedNodes.has(node.id); | |
| const isRelated = relatedNodes.has(node.id) && !isSelected; | |
| const shouldDim = selectedNodes.size > 0 && !relatedNodes.has(node.id); | |
| return { | |
| ...node, | |
| style: { | |
| opacity: shouldDim ? 0.2 : 1, | |
| backgroundColor: isSelected ? '#ff6b6b' : | |
| isRelated ? '#4ecdc4' : | |
| '#fff', | |
| border: isSelected ? '2px solid #e55555' : '1px solid #ddd', | |
| } | |
| }; | |
| }); | |
| }, [currentLayout.nodes, selectedNodes, relatedNodes]); | |
| const highlightedEdges = useMemo(() => { | |
| return currentLayout.edges.map(edge => { | |
| const connectsSelected = selectedNodes.has(edge.source) || selectedNodes.has(edge.target); | |
| const shouldDim = selectedNodes.size > 0 && | |
| !relatedNodes.has(edge.source) && | |
| !relatedNodes.has(edge.target); | |
| return { | |
| ...edge, | |
| style: { | |
| opacity: shouldDim ? 0.1 : 1, | |
| stroke: connectsSelected ? '#ff6b6b' : '#b1b1b7', | |
| strokeWidth: connectsSelected ? 2 : 1, | |
| } | |
| }; | |
| }); | |
| }, [currentLayout.edges, selectedNodes, relatedNodes]); | |
| const onNodeClick: NodeMouseHandler = useCallback((event, node) => { | |
| setSelectedNodes(prev => { | |
| const next = new Set(prev); | |
| if (event.shiftKey || event.metaKey) { | |
| // Multi-select: toggle node in selection | |
| if (next.has(node.id)) { | |
| next.delete(node.id); | |
| } else { | |
| next.add(node.id); | |
| } | |
| } else { | |
| // Single click: replace selection or deselect if already sole selection | |
| if (prev.has(node.id) && prev.size === 1) { | |
| return new Set(); | |
| } | |
| return new Set([node.id]); | |
| } | |
| return next; | |
| }); | |
| }, []); | |
| const clearSelection = useCallback(() => { | |
| setSelectedNodes(new Set()); | |
| }, []); | |
| const handleAction = useCallback((action: string) => { | |
| console.log(`Action "${action}" on nodes:`, Array.from(selectedNodes)); | |
| alert(`${action}: ${Array.from(selectedNodes).join(', ')}`); | |
| }, [selectedNodes]); | |
| return ( | |
| <div style={{ width: '100vw', height: '100vh', display: 'flex', flexDirection: 'column' }}> | |
| <div style={{ | |
| padding: '10px', | |
| background: '#f5f5f5', | |
| borderBottom: '1px solid #ddd', | |
| display: 'flex', | |
| gap: '20px', | |
| alignItems: 'center' | |
| }}> | |
| <label> | |
| <input | |
| type="radio" | |
| value="standard" | |
| checked={layoutMethod === 'standard'} | |
| onChange={(e) => setLayoutMethod(e.target.value as LayoutMethod)} | |
| /> | |
| {' '}Tree | |
| </label> | |
| <label> | |
| <input | |
| type="radio" | |
| value="grid" | |
| checked={layoutMethod === 'grid'} | |
| onChange={(e) => setLayoutMethod(e.target.value as LayoutMethod)} | |
| /> | |
| {' '}Grid | |
| </label> | |
| <span style={{ borderLeft: '1px solid #ccc', paddingLeft: '20px' }}> | |
| <select | |
| value={datasetIndex} | |
| onChange={(e) => { | |
| setDatasetIndex(Number(e.target.value)); | |
| setSelectedNodes(new Set()); | |
| }} | |
| style={{ padding: '4px 8px' }} | |
| > | |
| {DATASET_SEEDS.map((d, i) => ( | |
| <option key={d.seed} value={i}>{d.label}</option> | |
| ))} | |
| </select> | |
| </span> | |
| <span style={{ color: '#666' }}> | |
| Узлов: {currentLayout.nodes.length} | Связей: {currentLayout.edges.length} | |
| </span> | |
| <span style={{ color: '#999', fontSize: '11px' }}> | |
| (Shift/⌘+клик для множественного выбора) | |
| </span> | |
| {selectedNodes.size > 0 && ( | |
| <> | |
| <span style={{ color: '#ff6b6b', fontWeight: 'bold' }}> | |
| Выбрано: {selectedNodes.size} ({relatedNodes.size - selectedNodes.size} связей) | |
| </span> | |
| <div style={{ display: 'flex', gap: '8px', marginLeft: '10px' }}> | |
| <button | |
| onClick={() => handleAction('Delete')} | |
| style={{ | |
| padding: '4px 12px', | |
| background: '#ff6b6b', | |
| color: 'white', | |
| border: 'none', | |
| borderRadius: '4px', | |
| cursor: 'pointer', | |
| }} | |
| > | |
| Delete | |
| </button> | |
| <button | |
| onClick={() => handleAction('Group')} | |
| style={{ | |
| padding: '4px 12px', | |
| background: '#4ecdc4', | |
| color: 'white', | |
| border: 'none', | |
| borderRadius: '4px', | |
| cursor: 'pointer', | |
| }} | |
| > | |
| Group | |
| </button> | |
| <button | |
| onClick={() => handleAction('Export')} | |
| style={{ | |
| padding: '4px 12px', | |
| background: '#95a5a6', | |
| color: 'white', | |
| border: 'none', | |
| borderRadius: '4px', | |
| cursor: 'pointer', | |
| }} | |
| > | |
| Export | |
| </button> | |
| <button | |
| onClick={clearSelection} | |
| style={{ | |
| padding: '4px 12px', | |
| background: '#ecf0f1', | |
| color: '#333', | |
| border: '1px solid #bdc3c7', | |
| borderRadius: '4px', | |
| cursor: 'pointer', | |
| }} | |
| > | |
| Clear | |
| </button> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| <div style={{ flex: 1 }}> | |
| <ReactFlow | |
| nodes={highlightedNodes} | |
| edges={highlightedEdges} | |
| nodeTypes={nodeTypes} | |
| onNodeClick={onNodeClick} | |
| fitView | |
| // fitViewOptions={{ padding: layoutMethod === 'grid' ? 0 : 0.1 }} | |
| minZoom={0.1} | |
| attributionPosition="bottom-left" | |
| > | |
| <Background /> | |
| <Controls /> | |
| <MiniMap /> | |
| </ReactFlow> | |
| </div> | |
| </div> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment