Skip to content

Layer bands

firstLayer and nextLayers can each be one spec or a list of bands: each band’s mode applies while a parent has at most upTo children.

First-layer modes are radial, dagre, bubble, and dagreSubtree. Next layers may also be cloud. cloud is invalid on the first layer: the root has no outbound ray.

Kai’s four neighbours sit on a radial first ring. Each neighbour then uses a different rule, because each has a different number of children.

Parent Children Band
Ada 3 up to 4 → dagre
Lin 7 up to 10 → radial
Maria 14 up to 16 → bubble
Kofi 22 remaining → cloud (threshold 8)

Omit upTo on the last band so anything larger still matches.

This sample is examples/next-layer-bands.tsx. Dagre bands need ego-graph/dagre. The preview here uses the library source next to this docs tree.

Next-layer bands
KaiAda 3Lin 7Maria 14Kofi 22A1A2A3L1L2L3L4L5L6L7M1M2M3M4M5M6M7M8M9M10M11M12M13M14K1K2K3K4K5K6K7K8K9K10K11K12K13K14K15K16K17K18K19K20K21K22
import { customLayout } from 'ego-graph/dagre';

const SIZE = { width: 96, height: 36 };

function node(id: string, label: string) {
  return { id, label, ...SIZE };
}

function childrenOf(parent: string, count: number, prefix: string) {
  const nodes = Array.from({ length: count }, (_, i) => node(`${prefix}-${i + 1}`, `${prefix}${i + 1}`));
  const edges = nodes.map((item) => ({ source: parent, target: item.id }));
  return { nodes, edges };
}

const ada = childrenOf('ada', 3, 'A');
const lin = childrenOf('lin', 7, 'L');
const maria = childrenOf('maria', 14, 'M');
const kofi = childrenOf('kofi', 22, 'K');

export const nodes = [
  node('kai', 'Kai'),
  node('ada', 'Ada 3'),
  node('lin', 'Lin 7'),
  node('maria', 'Maria 14'),
  node('kofi', 'Kofi 22'),
  ...ada.nodes,
  ...lin.nodes,
  ...maria.nodes,
  ...kofi.nodes,
];

export const edges = [
  { source: 'kai', target: 'ada' },
  { source: 'kai', target: 'lin' },
  { source: 'kai', target: 'maria' },
  { source: 'kai', target: 'kofi' },
  ...ada.edges,
  ...lin.edges,
  ...maria.edges,
  ...kofi.edges,
];

export const layoutOptions = {
  firstLayer: { mode: 'radial' as const },
  nextLayers: [
    { upTo: 4, mode: 'dagre' as const },
    { upTo: 10, mode: 'radial' as const },
    { upTo: 16, mode: 'bubble' as const },
    { mode: 'cloud' as const, threshold: 8 as const },
  ],
};

const graph = { nodes, edges, root: 'kai' };
const positions = customLayout(graph, layoutOptions);
const byId = new Map(nodes.map((item) => [item.id, item]));

const PAD = 28;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const item of nodes) {
  const position = positions.get(item.id)!;
  minX = Math.min(minX, position.x);
  minY = Math.min(minY, position.y);
  maxX = Math.max(maxX, position.x + item.width);
  maxY = Math.max(maxY, position.y + item.height);
}
const width = maxX - minX + PAD * 2;
const height = maxY - minY + PAD * 2;
const ox = PAD - minX;
const oy = PAD - minY;

const center = (id: string) => {
  const position = positions.get(id)!;
  const item = byId.get(id)!;
  return { x: position.x + item.width / 2 + ox, y: position.y + item.height / 2 + oy };
};

type Swatch = { fill: string; stroke: string; text: string };

const BRANCH: Record<string, Swatch> = {
  kai: { fill: '#3157b7', stroke: '#3157b7', text: 'white' },
  ada: { fill: '#1e3a8a', stroke: '#1e3a8a', text: 'white' },
  lin: { fill: '#115e59', stroke: '#115e59', text: 'white' },
  maria: { fill: '#5b21b6', stroke: '#5b21b6', text: 'white' },
  kofi: { fill: '#9a3412', stroke: '#9a3412', text: 'white' },
};

const KIDS: Record<string, Swatch> = {
  A: { fill: '#dbeafe', stroke: '#1e3a8a', text: '#172554' },
  L: { fill: '#ccfbf1', stroke: '#115e59', text: '#134e4a' },
  M: { fill: '#ede9fe', stroke: '#5b21b6', text: '#4c1d95' },
  K: { fill: '#ffedd5', stroke: '#9a3412', text: '#7c2d12' },
};

function swatchFor(id: string): Swatch {
  if (id in BRANCH) return BRANCH[id]!;
  return KIDS[id[0]!] ?? { fill: 'white', stroke: '#3157b7', text: '#172554' };
}

export default function App() {
  return (
    <svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Next-layer bands on an ego graph">
      <rect width={width} height={height} fill="#f8fafc" />
      {edges.map((edge) => {
        const from = center(edge.source);
        const to = center(edge.target);
        return (
          <line
            key={`${edge.source}-${edge.target}`}
            x1={from.x}
            y1={from.y}
            x2={to.x}
            y2={to.y}
            stroke="#94a3b8"
            strokeWidth="2"
          />
        );
      })}
      {nodes.map((item) => {
        const position = positions.get(item.id)!;
        const swatch = swatchFor(item.id);
        return (
          <g key={item.id} transform={`translate(${position.x + ox}, ${position.y + oy})`}>
            <rect width={item.width} height={item.height} rx="10" fill={swatch.fill} stroke={swatch.stroke} />
            <text x={item.width / 2} y="24" textAnchor="middle" fill={swatch.text} fontSize="12">
              {item.label}
            </text>
          </g>
        );
      })}
    </svg>
  );
}

A single nextLayers spec still works when every deeper parent should use the same mode. dagre may set sectorThreshold to split a bushy parent into several shallow pockets. dagreSubtree lays the remaining descendants as one Dagre tree and does not recurse. Bubble ring size is innerRingCapacity on spacing (T, then 2T, 3T, …), not a band. Cloud’s cap is threshold on that spec. Bands only pick which layout to run.