Skip to content

Instantly share code, notes, and snippets.

@basicserge
Last active January 19, 2026 13:25
Show Gist options
  • Select an option

  • Save basicserge/2b468baa57c01fc09213ef0199c591ef to your computer and use it in GitHub Desktop.

Select an option

Save basicserge/2b468baa57c01fc09213ef0199c591ef to your computer and use it in GitHub Desktop.
d3-plus
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