floating-edge.ts4.0 KBView on GitHub
/**
 * Which side of each card an edge should leave from and arrive at.
 *
 * ── The problem ──
 *
 * A node's handles are fixed: an edge leaves the SOURCE's bottom and enters the TARGET's top.
 * That is right exactly while the source sits above the target, which is true on a freshly laid
 * out chart and false the moment anybody drags. Drag Peter above the AEs who point at him and
 * every one of those edges still has to leave its AE's bottom, turn around, travel back up past
 * the whole card, and come into Peter's top — a long loop around two nodes to express a
 * relationship that is now a short straight line.
 *
 * ── The rule ──
 *
 * Pick the pair of sides that makes the line shortest: if the source is ABOVE the target, leave
 * the bottom and enter the top; if it is BELOW, leave the top and enter the bottom. Anchored at
 * the horizontal centre of the chosen side, so the line meets the card square rather than at a
 * corner.
 *
 * Deliberately only ever TOP or BOTTOM — never left or right. This is a hierarchy: vertical
 * position carries meaning (who is above whom), and horizontal position does not. An edge that
 * sometimes ran sideways would read as a different KIND of relationship, and the two extra dots
 * on every card would be clutter for a direction the chart does not use.
 */

import { Position } from '@xyflow/react';

/**
 * The only two things this module reads off a node.
 *
 * Declared structurally rather than taking `InternalNode<Node>` whole, because the whole type
 * is ~20 fields of React Flow internals and a test would have to fabricate all of them — which
 * in practice means a cast, and a cast is the thing that stops the compiler noticing the day
 * React Flow renames `positionAbsolute`. A real `InternalNode` satisfies this by structure.
 */
export interface NodeBox {
  internals: { positionAbsolute: { x: number; y: number } };
  measured?: { width?: number | null; height?: number | null } | undefined;
}

export interface FloatingEdgeParams {
  sx: number;
  sy: number;
  tx: number;
  ty: number;
  sourcePosition: Position;
  targetPosition: Position;
}

/** The midpoint of a node's top and bottom edges, in flow coordinates. */
export function anchors(node: NodeBox): { cx: number; top: number; bottom: number } {
  const pos = node.internals.positionAbsolute;
  // `measured` is the rendered DOM box and is undefined for one frame after mount; the fallback
  // keeps the first paint on the card rather than at its top-left corner.
  const width = node.measured?.width ?? 200;
  const height = node.measured?.height ?? 72;
  return { cx: pos.x + width / 2, top: pos.y, bottom: pos.y + height };
}

export function getFloatingEdgeParams(
  source: NodeBox,
  target: NodeBox,
): FloatingEdgeParams {
  const s = anchors(source);
  const t = anchors(target);

  // Compared on TOP EDGES, which is the rank line — NOT on centres.
  //
  // This was centres, and centres are what made siblings disagree. Cards differ in height (a
  // node with three displayed fields is taller than one with none), so two people on the same
  // row of the chart have DIFFERENT centres; when their shared boss's centre falls between the
  // two, one child connects upward and the other downward, and the chart grows a line that
  // loops around a card for no reason a reader can see. Two nodes laid out on one rank share a
  // top y EXACTLY, so comparing tops cannot separate them however tall either card grows.
  //
  // `<=` rather than `<` so an exact tie resolves the same way every render — and a tie is the
  // common case here, not the rare one: it is what every pair of siblings is.
  const sourceIsAbove = s.top <= t.top;

  return sourceIsAbove
    ? {
        sx: s.cx,
        sy: s.bottom,
        tx: t.cx,
        ty: t.top,
        sourcePosition: Position.Bottom,
        targetPosition: Position.Top,
      }
    : {
        sx: s.cx,
        sy: s.top,
        tx: t.cx,
        ty: t.bottom,
        sourcePosition: Position.Top,
        targetPosition: Position.Bottom,
      };
}