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