tierLayout.ts4.3 KBView on GitHub /**
* The fallback shape for a graph whose edges do not give it one.
*
* ── The failure this exists for, measured ──
*
* `autoLayout` is a dagre pass over the layout edges. Give it a graph with NO edges and dagre
* has nothing to rank by, so it emits every node in ONE ROW: a real 37-person chart came out
* 9,380px wide, which `fitView` then renders at about 8% zoom — a line of ~16px specks where
* two adjacent people read as a single node. That is not a degraded chart, it is an unreadable
* one, and it is the COMMON case for a buying committee: nobody has stated who reports to whom,
* and the agent that builds the chart is forbidden from inventing it.
*
* ── Why a tier and not a guessed edge ──
*
* The obvious fix — infer `reports_to` from seniority — would write relations nobody asserted
* into the document, which is exactly what the evidence-only rule exists to prevent. A tier
* asserts nothing. It is a VIEW decision, computed at render time, stored only as coordinates,
* and it disappears the moment real edges arrive.
*
* ── Where the order comes from ──
*
* `view.tierField` names a field; the tiers are that field's own `options`, IN ORDER. So a
* `seniority` field declaring `c_level, vp, director, manager, ic` produces the family-tree
* shape a reader expects, with no new vocabulary and nothing for an author to keep in sync — the
* order they wrote the options in IS the order of the chart.
*/
import { CARD_HEIGHT, CARD_WIDTH, GAP_X, GAP_Y, MARGIN } from './layout-metrics';
import type { GraphNode, GraphNodeField, GraphPosition } from '@zero/server/graph';
export interface TierLayoutInput {
nodes: readonly Pick<GraphNode, 'id' | 'fields' | 'derived'>[];
/** The field named by `view.tierField`, when the schema declares one. */
tierField?: GraphNodeField;
/** Ties within a tier break on this, so the order is stable across renders. */
labelOf: (nodeId: string) => string;
}
/**
* Which tier a node sits in: the index of its value in the field's `options`.
*
* A node with no value, or a value the field never declared, goes in a tier BELOW every declared
* one rather than being dropped or floated to the top. "We have not recorded this person's
* seniority" is a real and common state, and it must not read as "this person is the CEO".
*/
export function tierOf(
node: Pick<GraphNode, 'fields' | 'derived'>,
tierField: GraphNodeField | undefined,
): number {
if (!tierField) return 0;
const options = (Array.isArray(tierField.options) ? tierField.options : []).map(String);
if (options.length === 0) return 0;
// A bound field resolves into `derived`; an authored one lives in `fields`. Read both, in the
// same order the node face does, so the tier and the label can never disagree.
const raw = tierField.from ? node.derived?.[tierField.key] : node.fields[tierField.key];
const value = Array.isArray(raw) ? raw[0] : raw;
if (value === undefined || value === null || value === '') return options.length;
const index = options.indexOf(String(value));
return index === -1 ? options.length : index;
}
/**
* Stack the nodes in tiers, centred, most senior at the top.
*
* Each tier is centred against the widest one so the result reads as a tree rather than as a
* left-aligned table — the centring is what makes a 2-over-5 chart look like a hierarchy.
*/
export function tierLayout(input: TierLayoutInput): Record<string, GraphPosition> {
const byTier = new Map<number, string[]>();
for (const node of input.nodes) {
const tier = tierOf(node, input.tierField);
const bucket = byTier.get(tier) ?? [];
bucket.push(node.id);
byTier.set(tier, bucket);
}
const widest = Math.max(1, ...[...byTier.values()].map((ids) => ids.length));
const fullWidth = widest * CARD_WIDTH + (widest - 1) * GAP_X;
const positions: Record<string, GraphPosition> = {};
const tiers = [...byTier.keys()].sort((a, b) => a - b);
tiers.forEach((tier, row) => {
const ids = (byTier.get(tier) ?? []).sort((a, b) => input.labelOf(a).localeCompare(input.labelOf(b)));
const rowWidth = ids.length * CARD_WIDTH + (ids.length - 1) * GAP_X;
const offset = MARGIN + (fullWidth - rowWidth) / 2;
ids.forEach((id, column) => {
positions[id] = {
x: offset + column * (CARD_WIDTH + GAP_X),
y: MARGIN + row * (CARD_HEIGHT + GAP_Y),
};
});
});
return positions;
}