hierarchyLayout.ts5.4 KBView on GitHub /**
* The canvas's one layout: the reporting TREE on top, everyone else below it.
*
* ── What was wrong with handing the whole graph to dagre ──
*
* `autoLayout` ranks by edges, and a buying committee has reporting lines for SOME of its
* people and none for the rest. Given 7 nodes and 2 `reports_to` edges, dagre ranks the 3
* connected ones correctly and then scatters the other 4 across those same ranks to balance the
* drawing — so on a real chart, two people nobody had placed rendered as peers of the VP who
* decides, and two more as peers of the champion three levels down. Dagre was not wrong; it was
* asked the wrong question. A node with no reporting line has no rank, and putting it on one
* asserts a hierarchy nobody stated — which is the same failure as inventing an edge, arrived at
* through layout instead of through data.
*
* ── And what was wrong with tiers alone ──
*
* The fallback tiered everything by seniority, which is right for a graph with no edges and
* destructive for one with them: it discards the real reporting structure — the thing the chart
* is actually for — in favour of an inference from job titles.
*
* ── So: two blocks, and each node is in exactly one ──
*
* TOP nodes touched by a layout edge, laid out by dagre as the tree they form
* BOTTOM everyone else, tiered by `view.tierField`, centred under the tree
*
* A node moves between blocks only when a real edge is drawn or removed, so the layout says
* exactly as much as the data does and no more. With no edges at all this is pure tiers; with
* every node connected it is pure dagre; the mixed case — which is the normal one — gets both.
*/
import { autoLayout } from './autoLayout';
import { CARD_HEIGHT, CARD_WIDTH, GAP_X, GAP_Y, MARGIN, SECTION_GAP } from './layout-metrics';
import { tierLayout } from './tierLayout';
import type { GraphNode, GraphNodeField, GraphPosition } from '@zero/server/graph';
export interface HierarchyLayoutInput {
nodes: readonly GraphNode[];
/** Only the edges whose kind is the layout axis. */
layoutEdges: readonly { source: string; target: string }[];
tierField?: GraphNodeField;
labelOf: (nodeId: string) => string;
rankdir?: 'TB' | 'LR';
}
export function hierarchyLayout(input: HierarchyLayoutInput): Record<string, GraphPosition> {
const ids = new Set(input.nodes.map((n) => n.id));
// Only edges whose BOTH ends are here put a node in the tree — a dangling edge is a lint
// finding, not a reporting line, and it must not drag its live end into the hierarchy.
const edges = input.layoutEdges.filter((e) => ids.has(e.source) && ids.has(e.target));
const connected = new Set<string>();
for (const e of edges) {
connected.add(e.source);
connected.add(e.target);
}
const tree = input.nodes.filter((n) => connected.has(n.id));
const loose = input.nodes.filter((n) => !connected.has(n.id));
const top =
tree.length > 0
? autoLayout(
tree.map((n) => ({ id: n.id })),
// ── REVERSED, and this is the whole of "seniors at the top" ──
//
// `autoLayout` puts an edge's SOURCE above its TARGET, which is right for the outbound
// flow canvas it is shared with: a flow runs downward, so the step that comes first is
// drawn first. A hierarchy runs the other way. `reports_to` is written junior→senior —
// `nim reports_to brian` — so feeding it unreversed drew the most junior person at the
// top of the chart and the VP at the bottom. It was an org chart upside down.
//
// Reversed here rather than in `autoLayout`, because the flow canvas is not wrong and
// there is no neutral direction: whether an edge means "above" or "below" is a
// property of the RELATION, and only the graph layer knows the relation.
edges.map((e) => ({ source: e.target, target: e.source })),
{
rankdir: input.rankdir ?? 'TB',
// The card's REAL size and the graph's own gaps — see `layout-metrics.ts`. Dagre
// spaces ranks by `ranksep + height`, so the flow canvas's 72px left a 150px card's
// rows almost touching.
nodeWidth: CARD_WIDTH,
nodeHeight: CARD_HEIGHT,
nodesep: GAP_X,
ranksep: GAP_Y,
},
)
: {};
if (loose.length === 0) return top;
const bottom = tierLayout({
nodes: loose,
...(input.tierField ? { tierField: input.tierField } : {}),
labelOf: input.labelOf,
});
if (tree.length === 0) return bottom;
// Drop the loose block below the tree, and centre the two against each other so the chart
// reads as one object rather than as two drawings that happen to share a canvas.
const topValues = Object.values(top);
const treeBottom = Math.max(...topValues.map((p) => p.y)) + CARD_HEIGHT;
const treeCentre =
(Math.min(...topValues.map((p) => p.x)) +
Math.max(...topValues.map((p) => p.x)) +
CARD_WIDTH) /
2;
const bottomValues = Object.values(bottom);
const looseCentre =
(Math.min(...bottomValues.map((p) => p.x)) +
Math.max(...bottomValues.map((p) => p.x)) +
CARD_WIDTH) /
2;
const dx = treeCentre - looseCentre;
const dy = treeBottom + SECTION_GAP - MARGIN;
const shifted: Record<string, GraphPosition> = {};
for (const [id, p] of Object.entries(bottom)) shifted[id] = { x: p.x + dx, y: p.y + dy };
return { ...top, ...shifted };
}