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.
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.