Skip to content

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.

React Flow complete
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>
  );
}