GraphEdgeLine.tsx5.0 KBView on GitHub 'use client';
/**
* ONE edge component, styled from the KIND.
*
* Style, arrowhead and label all come from the graph's `edgeKinds` declaration rather than from a
* second React Flow edge type per relation — so a graph that invents a sixth relation gets a
* drawn edge without a deploy, which is the whole of "the vocabulary is the author's".
*
* An unrecognised `style` draws the plain default and an undeclared KIND draws plain with its key
* as the label. Never a broken line and never nothing: an edge the author asserted has to be
* visible even when the graph has not been told what to call it, or the only way to discover a
* typo'd kind is to read the JSON.
*
* ── Two path shapes, and the difference is structural not decorative ──
*
* The AXIS relation — whatever `view.layoutEdgeKind` names — draws ORTHOGONALLY: down out of
* the child, across, up into the parent. Siblings therefore share one horizontal run at a fixed
* distance above their parent, which is what makes a tree read as a tree instead of as a fan of
* curves that happen to converge. Every other relation stays a bezier, so an `influences`
* overlay can never be mistaken for the reporting line it crosses.
*
* `getTreeEdgePath` rather than React Flow's `getSmoothStepPath`: smoothstep turns at the
* midpoint between ITS OWN two endpoints, so four children of one manager — whose cards differ
* in height — get four horizontal runs a few pixels apart. See `tree-edge.ts` for the shape and
* for why the turn is anchored to the parent instead.
*/
import {
BaseEdge,
EdgeLabelRenderer,
getBezierPath,
useInternalNode,
type EdgeProps,
} from '@xyflow/react';
import { getFloatingEdgeParams } from './floating-edge';
import { getTreeEdgePath } from './tree-edge';
import { GAP_Y } from './layout-metrics';
import { cn } from '@/lib/utils';
import type { GraphFlowEdge } from './graph-flow-types';
/**
* How far BELOW the parent's edge the children's shared bus sits.
*
* `GAP_Y` is the gap the layout leaves between ranks, so half of it puts the bus midway — far
* enough from the parent to read as its own line, far enough from the children not to crowd
* their tops. Measured from the parent rather than from the midpoint of each pair, which is the
* whole point: every sibling turns on the same y however tall their own cards are.
*/
const TREE_OFFSET = GAP_Y / 2;
/** The closed, shared vocabulary. Anything else falls through to solid. */
const DASH: Record<string, string | undefined> = {
solid: undefined,
dashed: '6 4',
dotted: '2 4',
};
export function GraphEdgeLine({
id,
source,
target,
markerEnd,
label,
data,
selected,
}: EdgeProps<GraphFlowEdge>) {
// Read the LIVE node boxes rather than the `sourceX`/`sourceY` React Flow hands us: those are
// resolved against the node's fixed handles, which is the thing this edge exists to stop
// depending on. `useInternalNode` re-renders the edge on every drag, so the sides re-pick
// while you are still holding the card.
const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target);
// One frame on mount, before either node is measured. Drawing nothing beats drawing a line
// from the origin.
if (!sourceNode || !targetNode) return null;
const { sx, sy, tx, ty, sourcePosition, targetPosition } = getFloatingEdgeParams(
sourceNode,
targetNode,
);
const geometry = {
sourceX: sx,
sourceY: sy,
sourcePosition,
targetX: tx,
targetY: ty,
targetPosition,
};
const [path, labelX, labelY] = data?.hierarchy
? getTreeEdgePath(geometry, { offset: TREE_OFFSET })
: getBezierPath(geometry);
const kind = data?.edgeKind;
const dash = typeof kind?.style === 'string' ? DASH[kind.style] : undefined;
// `directed: false` means a SYMMETRIC relation — `worked_with` says the same thing in both
// directions, and an arrowhead on it asserts a direction the author did not.
const directed = kind?.directed !== false;
return (
<>
<BaseEdge
id={id}
path={path}
markerEnd={directed ? markerEnd : undefined}
style={{
strokeWidth: selected ? 2 : 1.5,
...(dash ? { strokeDasharray: dash } : {}),
}}
className={cn(selected ? 'stroke-primary' : 'stroke-muted-foreground/50')}
/>
{label && (
<EdgeLabelRenderer>
<div
style={{ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)` }}
className={cn(
// `pointer-events-auto` because React Flow's label layer disables them by default,
// and this label has to be clickable to open the edge's properties.
'pointer-events-auto absolute cursor-pointer rounded bg-raised px-1.5 py-0.5 text-xs',
'border border-surface-border text-muted-foreground',
selected && 'border-primary text-foreground',
)}
>
{label}
</div>
</EdgeLabelRenderer>
)}
</>
);
}
export const GRAPH_EDGE_TYPES = { graphEdge: GraphEdgeLine };