React Flow complete
This example wires ego-graph to React Flow: generate a labelled graph, tune layout and fold settings, then render with the ego-graph/react-flow adapters. Structural nodes are blue; satellites are orange. Fold stand-ins expand and collapse via expandedRoots. The layout dropdown is driven by LAYOUT_NAMES; the pipeline maps each LayoutName to its function.
All source files for the demo are editable in the playground tabs below.
import { useCallback, useEffect, useMemo, useState, type MouseEvent } from 'react'; import { ReactFlow, ReactFlowProvider, useNodesInitialized, useReactFlow, type Edge, type Node, } from '@xyflow/react'; import { DEFAULT_SPACING, LAYOUT_NAMES, type LayoutName } from 'ego-graph'; import '@xyflow/react/dist/style.css'; import './styles.css'; import { generateGraph, type DemoGraph } from './generateGraph'; import { layoutFlow, type EdgePathType, type FlowEdge, type FlowNode, type PipelineOptions, } from './layoutPipeline'; import { nodeTypes } from './nodes'; const STRUCTURAL_COUNTS = [10, 50, 100, 200] as const; const RING_SIZES = [5, 10, 20, 50] as const; const THRESHOLDS = [0, 5, 10, 20, 50] as const; const EDGE_TYPES: EdgePathType[] = ['default', 'straight', 'step', 'smoothstep']; function deg(rad: number): number { return Math.round((rad * 180) / Math.PI); } function rad(degValue: number): number { return (degValue * Math.PI) / 180; } function neighbourIds(nodeId: string, edges: FlowEdge[]): Set<string> { const ids = new Set<string>([nodeId]); for (const edge of edges) { if (edge.source === nodeId) ids.add(edge.target); if (edge.target === nodeId) ids.add(edge.source); } return ids; } function withHoverClasses(nodes: FlowNode[], edges: FlowEdge[], hovered: string | null): FlowNode[] { if (!hovered) { return nodes.map((node) => ({ ...node, className: undefined })); } const hot = neighbourIds(hovered, edges); return nodes.map((node) => ({ ...node, className: hot.has(node.id) ? 'is-highlighted' : 'is-dimmed', })); } function AutoLayout({ graph, pipeline, onLaidOut, }: { graph: DemoGraph; pipeline: PipelineOptions; onLaidOut: (nodes: FlowNode[], edges: FlowEdge[]) => void; }) { const { getNodes, fitView } = useReactFlow(); const ready = useNodesInitialized(); const [measuredOnce, setMeasuredOnce] = useState(false); useEffect(() => { setMeasuredOnce(false); }, [graph, pipeline]); useEffect(() => { if (!ready || measuredOnce) return; const measured = new Map<string, { width: number; height: number }>(); for (const node of getNodes()) { const width = node.measured?.width ?? node.width; const height = node.measured?.height ?? node.height; if (typeof width === 'number' && typeof height === 'number') { measured.set(node.id, { width, height }); } } if (measured.size === 0) return; const next = layoutFlow(graph, pipeline, measured); onLaidOut(next.nodes, next.edges); setMeasuredOnce(true); requestAnimationFrame(() => fitView({ padding: 0.15 })); }, [ready, measuredOnce, graph, pipeline, getNodes, onLaidOut, fitView]); useEffect(() => { const onResize = () => fitView({ padding: 0.15 }); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, [fitView]); return null; } function FlowCanvas({ graph, pipeline, onExpand, onCollapse, }: { graph: DemoGraph; pipeline: PipelineOptions; onExpand: (rootId: string) => void; onCollapse: (rootId: string) => void; }) { const initial = useMemo(() => layoutFlow(graph, pipeline), [graph, pipeline]); const [nodes, setNodes] = useState<FlowNode[]>(initial.nodes); const [edges, setEdges] = useState<FlowEdge[]>(initial.edges); const [hovered, setHovered] = useState<string | null>(null); useEffect(() => { const next = layoutFlow(graph, pipeline); setNodes(next.nodes); setEdges(next.edges); setHovered(null); }, [graph, pipeline]); const onLaidOut = useCallback((nextNodes: FlowNode[], nextEdges: FlowEdge[]) => { setNodes(nextNodes); setEdges(nextEdges); }, []); const displayNodes = useMemo( () => withHoverClasses(nodes, edges, hovered) as Node[], [nodes, edges, hovered], ); const onCanvasClick = (event: MouseEvent) => { const target = (event.target as HTMLElement).closest<HTMLElement>('[data-fold-action]'); if (!target) return; const rootId = target.dataset.foldRoot; if (!rootId) return; if (target.dataset.foldAction === 'expand') onExpand(rootId); if (target.dataset.foldAction === 'collapse') onCollapse(rootId); }; return ( <div className="canvas" onClick={onCanvasClick}> <ReactFlow nodes={displayNodes} edges={edges as Edge[]} nodeTypes={nodeTypes} onNodeMouseEnter={(_, node) => setHovered(node.id)} onNodeMouseLeave={() => setHovered(null)} fitView minZoom={0.05} proOptions={{ hideAttribution: true }} > <AutoLayout graph={graph} pipeline={pipeline} onLaidOut={onLaidOut} /> </ReactFlow> </div> ); } export default function App() { const [structuralCount, setStructuralCount] = useState<(typeof STRUCTURAL_COUNTS)[number]>(50); const [satelliteCount, setSatelliteCount] = useState(2); const [edgeDensity, setEdgeDensity] = useState(0.5); const [ringSize, setRingSize] = useState<(typeof RING_SIZES)[number]>(10); const [layout, setLayout] = useState<LayoutName>('polarPetal'); const [threshold, setThreshold] = useState<(typeof THRESHOLDS)[number]>(10); const [edgeType, setEdgeType] = useState<EdgePathType>('default'); const [spacing, setSpacing] = useState({ ...DEFAULT_SPACING }); const [expandedRoots, setExpandedRoots] = useState<Set<string>>(() => new Set()); const [graph, setGraph] = useState<DemoGraph>(() => generateGraph({ structuralCount: 50, satelliteCount: 2, edgeDensity: 0.5, ringSize: 10 }), ); const regenerate = useCallback(() => { setExpandedRoots(new Set()); setGraph( generateGraph({ structuralCount, satelliteCount, edgeDensity, ringSize: Math.min(ringSize, structuralCount - 1) as (typeof RING_SIZES)[number], }), ); }, [structuralCount, satelliteCount, edgeDensity, ringSize]); useEffect(() => { if (ringSize >= structuralCount) { const next = [...RING_SIZES].reverse().find((n) => n < structuralCount) ?? 5; setRingSize(next); return; } regenerate(); }, [structuralCount, satelliteCount, edgeDensity, ringSize, regenerate]); const pipeline = useMemo<PipelineOptions>( () => ({ layout, threshold, expandedRoots, spacing, edgeType }), [layout, threshold, expandedRoots, spacing, edgeType], ); const setSpacingField = <K extends keyof typeof spacing>(key: K, value: (typeof spacing)[K]) => { setSpacing((prev) => ({ ...prev, [key]: value })); }; return ( <div className="app"> <aside className="panel"> <h2>Graph generation</h2> <div className="field"> <label htmlFor="structuralCount">Structural nodes</label> <select id="structuralCount" value={structuralCount} onChange={(e) => setStructuralCount(Number(e.target.value) as (typeof STRUCTURAL_COUNTS)[number])} > {STRUCTURAL_COUNTS.map((n) => ( <option key={n} value={n}> {n} </option> ))} </select> </div> <div className="field"> <label htmlFor="satelliteCount"> Satellites <span className="value">{satelliteCount}</span> </label> <input id="satelliteCount" type="range" min={0} max={5} step={1} value={satelliteCount} onChange={(e) => setSatelliteCount(Number(e.target.value))} /> </div> <div className="field"> <label htmlFor="edgeDensity"> Edge density <span className="value">{edgeDensity.toFixed(1)}</span> </label> <input id="edgeDensity" type="range" min={0.1} max={2} step={0.1} value={edgeDensity} onChange={(e) => setEdgeDensity(Number(e.target.value))} /> </div> <div className="field"> <label htmlFor="ringSize">First ring (Root neighbours)</label> <select id="ringSize" value={ringSize} onChange={(e) => setRingSize(Number(e.target.value) as (typeof RING_SIZES)[number])} > {RING_SIZES.map((n) => ( <option key={n} value={n} disabled={n >= structuralCount}> {n} </option> ))} </select> </div> <button type="button" onClick={regenerate}> Regenerate </button> <h2>Ego-graph</h2> <div className="field"> <label htmlFor="layout">Layout</label> <select id="layout" value={layout} onChange={(e) => setLayout(e.target.value as LayoutName)}> {LAYOUT_NAMES.map((name) => ( <option key={name} value={name}> {name} </option> ))} </select> </div> <div className="field"> <label htmlFor="threshold">Fold threshold</label> <select id="threshold" value={threshold} onChange={(e) => setThreshold(Number(e.target.value) as (typeof THRESHOLDS)[number])} > {THRESHOLDS.map((n) => ( <option key={n} value={n}> {n} </option> ))} </select> </div> <div className="field"> <label htmlFor="edgeType">Edge path</label> <select id="edgeType" value={edgeType} onChange={(e) => setEdgeType(e.target.value as EdgePathType)}> {EDGE_TYPES.map((t) => ( <option key={t} value={t}> {t} </option> ))} </select> </div> <button type="button" onClick={() => setExpandedRoots(new Set())}> Regroup all </button> <h2>Spacing</h2> {( [ ['nodeSep', spacing.nodeSep, 20, 200, 1], ['rankSep', spacing.rankSep, 20, 240, 1], ['minRingRadius', spacing.minRingRadius, 80, 480, 1], ['ringPadding', spacing.ringPadding, 10, 160, 1], ] as const ).map(([key, value, min, max, step]) => ( <div className="field" key={key}> <label htmlFor={key}> {key} <span className="value">{value}</span> </label> <input id={key} type="range" min={min} max={max} step={step} value={value} onChange={(e) => setSpacingField(key, Number(e.target.value))} /> </div> ))} <div className="field"> <label htmlFor="satelliteGap"> satelliteGap <span className="value">{deg(spacing.satelliteGap)}°</span> </label> <input id="satelliteGap" type="range" min={10} max={180} step={1} value={deg(spacing.satelliteGap)} onChange={(e) => setSpacingField('satelliteGap', rad(Number(e.target.value)))} /> </div> <div className="field"> <label htmlFor="minSatelliteGap"> minSatelliteGap <span className="value">{deg(spacing.minSatelliteGap)}°</span> </label> <input id="minSatelliteGap" type="range" min={2} max={90} step={1} value={deg(spacing.minSatelliteGap)} onChange={(e) => setSpacingField('minSatelliteGap', rad(Number(e.target.value)))} /> </div> </aside> <ReactFlowProvider> <FlowCanvas graph={graph} pipeline={pipeline} onExpand={(rootId) => setExpandedRoots((prev) => new Set(prev).add(rootId))} onCollapse={(rootId) => setExpandedRoots((prev) => { const next = new Set(prev); next.delete(rootId); return next; }) } /> </ReactFlowProvider> </div> ); }