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;
}