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