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