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