tree-edge.ts4.3 KBView on GitHub
/**
 * The family-tree line: one drop, one shared bus, one riser per child.
 *
 * ── What `getSmoothStepPath` does instead, and why it is wrong here ──
 *
 * React Flow's smoothstep turns at the MIDPOINT BETWEEN ITS OWN TWO ENDPOINTS. That is right
 * for a flow diagram, where each edge is its own object, and wrong for a tree, where the four
 * edges leaving one manager are one drawing. Cards differ in height, so four children produce
 * four different midpoints — four horizontal runs a few pixels apart, which is the "zigzag".
 * And when a child sits directly under its parent, floating-point drift between the two card
 * centres is enough to make it jog sideways and back for no reason a reader can see.
 *
 * So the turn is anchored to the PARENT instead: the bus sits a fixed distance below the upper
 * card's edge. Every child of one parent therefore shares one horizontal line whatever their
 * own cards do, which is the shape every org chart on paper has ever had.
 *
 *        ┌────────┐
 *        │ parent │
 *        └───┬────┘
 *            │            ← one drop
 *     ┌──────┴──────┐     ← one bus, shared by every child
 *     │             │
 *  ┌──┴──┐       ┌──┴──┐
 *  │child│       │child│
 *  └─────┘       └─────┘
 *
 * ── It works in both directions, by construction ──
 *
 * `reports_to` is authored junior→senior, so the edge's SOURCE is usually the lower card — but
 * a dragged node can put either end on top, and `getFloatingEdgeParams` already flips the
 * anchors when it does. The upper anchor is then simply whichever has the smaller `y`, which is
 * why `busY` needs no flag: `min(sy, ty) + offset` is the parent's side in both cases.
 */

/** A rounded corner, in px. Clamped per corner to whatever room the run actually has. */
const CORNER = 8;

export interface TreeEdgeGeometry {
  sourceX: number;
  sourceY: number;
  targetX: number;
  targetY: number;
}

export interface TreeEdgeOptions {
  /** How far below the upper card's edge the shared bus sits. */
  offset: number;
  borderRadius?: number;
}

/** `[path, labelX, labelY]` — the same tuple React Flow's own path helpers return. */
export type TreeEdgePath = [string, number, number];

/** -1, 0 or 1. `Math.sign` returns `-0` for `-0`, which poisons the arithmetic below. */
function direction(delta: number): number {
  if (delta > 0) return 1;
  if (delta < 0) return -1;
  return 0;
}

export function getTreeEdgePath(
  { sourceX, sourceY, targetX, targetY }: TreeEdgeGeometry,
  { offset, borderRadius = CORNER }: TreeEdgeOptions,
): TreeEdgePath {
  const topY = Math.min(sourceY, targetY);
  const bottomY = Math.max(sourceY, targetY);
  // Clamped so a cramped pair — two cards dragged almost on top of each other — collapses to an
  // L rather than routing the bus THROUGH the lower card.
  const busY = Math.min(topY + offset, bottomY);

  // Directly in line: one straight segment. The whole reason this branch exists is that a
  // rounded step path between two points 0.4px apart horizontally still draws both corners,
  // and that tiny kink is the most visible defect on an otherwise tidy chart. A pixel of
  // tolerance, because card centres are computed from measured DOM boxes and land on fractions.
  if (Math.abs(targetX - sourceX) < 1) {
    return [`M ${sourceX},${sourceY} L ${targetX},${targetY}`, targetX, busY];
  }

  const toBus = direction(busY - sourceY);
  const across = direction(targetX - sourceX);
  const toTarget = direction(targetY - busY);

  const halfRun = Math.abs(targetX - sourceX) / 2;
  const enter = Math.min(borderRadius, Math.abs(busY - sourceY), halfRun);
  const leave = Math.min(borderRadius, Math.abs(targetY - busY), halfRun);

  const path = [
    `M ${sourceX},${sourceY}`,
    `L ${sourceX},${busY - toBus * enter}`,
    `Q ${sourceX},${busY} ${sourceX + across * enter},${busY}`,
    `L ${targetX - across * leave},${busY}`,
    `Q ${targetX},${busY} ${targetX},${busY + toTarget * leave}`,
    `L ${targetX},${targetY}`,
  ].join(' ');

  // The label rides the bus, centred on the run — never on a corner, where it would cover the
  // turn that tells you which parent the line belongs to.
  return [path, (sourceX + targetX) / 2, busY];
}