Components

Diagram

Nodes joined by lines on a canvas: drawn, selected, moved to another port and removed, by any hand.

Markdown

A diagram is nodes joined by lines, arranged on a plane: a pipeline, a flow, a graph of dependencies, a node editor. It is a canvas whose things have ports, with lines between them that can be drawn, selected, moved and removed.

A pipeline

Drag from a port to another, or click one and then the other: the ports that can take the line light up, and one that cannot says why. Click a line and drag the dot near its end to another port. Let a line go over empty space to add a step there, already joined; drag a step onto a line to put it in. Add a step, lay the whole out, or keep it laid out as it changes; pin a step and the rest go round it. Delete removes what is selected, Command-Z takes anything back. Tab goes through nodes, ports and connections; Enter on a port begins a line.

Layout
Lines
Read orders
orders.csv
Read customers
customers.csv
Filter
date ≥ 1 July
Join
on customer
Summarise
total by region
Write table
regions.parquet
Notify
#sales
Read orders rows out to Filter rows in
Filter rows out to Join left rows in
Read customers rows out to Join right rows in
Join rows out to Summarise rows in
Summarise rows out to Write table rows in
Summarise summary out to Notify words in, daily

7 steps, 6 lines

Code
"use client";

import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import { rectUnion } from "@publictheta/ui/algorithms/geometry.ts";
import { Diagram, DiagramNode, DiagramPort } from "@publictheta/ui/components/diagram/diagram.tsx";
import type { DiagramRefusal } from "@publictheta/ui/components/diagram/diagram.tsx";
import {
  ContextMenu,
  ContextMenuTrigger,
  MenuContent,
  MenuItem,
} from "@publictheta/ui/components/menu/menu.tsx";
import type { Point } from "@publictheta/ui/algorithms/geometry.ts";
import { Button } from "@publictheta/ui/components/button/button.tsx";
import { isCommandKey } from "@publictheta/ui/lib/platform.ts";
import {
  applyGraphChanges,
  autoLayout,
  connection,
  findPort,
  insertion,
  invertGraphChanges,
  keepLaidOut,
  pinning,
} from "@publictheta/ui/primitives/graph.ts";
import type {
  GraphChange,
  GraphData,
  GraphEdge,
  GraphNode,
  GraphPort,
  LayoutOptions,
  PortRef,
} from "@publictheta/ui/primitives/graph.ts";
import { createHistory } from "@publictheta/ui/primitives/history.ts";
import { IDENTITY, fit } from "@publictheta/ui/primitives/viewport.ts";
import type { Viewport } from "@publictheta/ui/primitives/viewport.ts";
import { useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import type { KeyboardEvent } from "react";
import { Controls } from "../../ui/controls.tsx";

interface Step extends GraphNode {
  readonly title: string;
  readonly detail: string;
}

type Change = GraphChange<Step>;

const WIDTH = 128;
const HEIGHT = 52;
// The least zoom at which a step's words can still be read.
const READABLE = 0.7;
const LAYOUT: LayoutOptions = { direction: "right", layerGap: 56, nodeGap: 20 };

// Rows flow from step to step; a summary is words, and only a step that takes words takes it.
// Each side of a join takes one line; any other input takes as many as are given it.
const rowsIn = (id = "in", limit?: number): GraphPort => ({
  id,
  side: "left",
  direction: "in",
  type: "rows",
  limit,
});
const rowsOut: GraphPort = { id: "out", side: "right", direction: "out", type: "rows" };
const NAMES: Readonly<Record<string, string>> = {
  in: "rows in",
  left: "left rows in",
  right: "right rows in",
  out: "rows out",
  summary: "summary out",
  words: "words in",
};

const step = (
  id: string,
  title: string,
  detail: string,
  x: number,
  y: number,
  ports: readonly GraphPort[],
): Step => ({ id, title, detail, rect: { x, y, width: WIDTH, height: HEIGHT }, ports });

const STEPS: readonly Step[] = [
  step("orders", "Read orders", "orders.csv", 0, 0, [rowsOut]),
  step("customers", "Read customers", "customers.csv", 0, 104, [rowsOut]),
  step("recent", "Filter", "date ≥ 1 July", 184, 0, [rowsIn(), rowsOut]),
  step("join", "Join", "on customer", 368, 52, [rowsIn("left", 1), rowsIn("right", 1), rowsOut]),
  step("total", "Summarise", "total by region", 552, 52, [
    rowsIn(),
    rowsOut,
    { id: "summary", side: "bottom", direction: "out", type: "words" },
  ]),
  step("write", "Write table", "regions.parquet", 736, 0, [rowsIn()]),
  step("notify", "Notify", "#sales", 736, 156, [
    { id: "words", side: "left", direction: "in", type: "words" },
  ]),
];

const join = (id: string, from: string, out: string, to: string, into: string): GraphEdge => ({
  id,
  source: { node: from, port: out },
  target: { node: to, port: into },
});

const EDGES: readonly GraphEdge[] = [
  join("e1", "orders", "out", "recent", "in"),
  join("e2", "recent", "out", "join", "left"),
  join("e3", "customers", "out", "join", "right"),
  join("e4", "join", "out", "total", "in"),
  join("e5", "total", "out", "write", "in"),
  { ...join("e6", "total", "summary", "notify", "words"), label: "daily" },
];

/** What can be added where a line is let go over nothing: each made with its port at that place. */
const OFFERS: ReadonlyArray<{
  readonly name: string;
  readonly make: (id: string, x: number, y: number) => Step;
}> = [
  { name: "Filter", make: (id, x, y) => step(id, "Filter", "rows → rows", x, y, [rowsIn(), rowsOut]) },
  { name: "Summarise", make: (id, x, y) => step(id, "Summarise", "rows → rows", x, y, [rowsIn(), rowsOut]) },
  { name: "Write table", make: (id, x, y) => step(id, "Write table", "table.parquet", x, y, [rowsIn()]) },
  { name: "Read file", make: (id, x, y) => step(id, "Read file", "file.csv", x, y, [rowsOut]) },
];

const REASONS: Readonly<Record<DiagramRefusal, string>> = {
  nowhere: "Nothing here can take that.",
  missing: "That is no longer there.",
  "same-node": "A step does not feed itself.",
  direction: "An output goes to an input, not to another output.",
  type: "A summary is words: it does not go where rows are expected.",
  duplicate: "Those two are already joined.",
  full: "That side of the join already has its line. Move the line, or remove it first.",
  cycle: "That would feed a step with its own result.",
};

const RULES = { acyclic: true } as const;

function renderStep(node: Step) {
  return (
    <DiagramNode
      aria-label={node.title}
      className="group/step cursor-default rounded-lg border border-border bg-surface px-3 py-2 shadow-sm data-pinned:border-dashed"
      data-pinned={node.pinned === true ? "" : undefined}
    >
      <div className="truncate text-sm leading-tight font-medium text-fg-strong">{node.title}</div>
      <div className="truncate text-xs text-fg-muted">{node.detail}</div>
      {node.pinned === true && (
        <svg
          aria-hidden
          viewBox="0 0 16 16"
          className="absolute end-1.5 top-1.5 size-3 text-fg-muted"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
        >
          <path d="M6 2h4l-.5 4.5L12 9H4l2.5-2.5L6 2ZM8 9v5" />
        </svg>
      )}
      {node.ports.map((port) => (
        <DiagramPort
          key={port.id}
          port={port.id}
          // What carries words is square, what carries rows round: told apart without colour.
          className={port.type === "words" ? "rounded-xs" : undefined}
        />
      ))}
    </DiagramNode>
  );
}

const portName = (_: Step, port: GraphPort) => NAMES[port.id] ?? port.id;
const nodeName = (node: Step) => node.title;

export default function DiagramPipeline() {
  const [graph, setGraph] = useState<GraphData<Step>>({ nodes: STEPS, edges: EDGES });
  const [selected, setSelected] = useState<readonly string[]>([]);
  const [view, setView] = useState<Viewport>(IDENTITY);
  const [layout, setLayout] = useState<"hand" | "kept">("hand");
  const [line, setLine] = useState<"orthogonal" | "curve">("orthogonal");
  const [refused, setRefused] = useState<string | null>(null);
  const [history] = useState(() => createHistory<readonly Change[]>());
  const [, drawAgain] = useState(0);
  const canvas = useRef<HTMLDivElement>(null);
  const made = useRef({ steps: 0, edges: 0 });
  // A line let go over nothing: from where, and the place on the plane, while the menu of what to add there is open.
  const [offer, setOffer] = useState<{
    readonly from: PortRef;
    readonly point: Point;
    readonly id: string;
  } | null>(null);
  const offerAt = useRef<HTMLDivElement>(null);

  const size = () => ({ width: canvas.current?.clientWidth ?? 0, height: canvas.current?.clientHeight ?? 0 });
  const fitAll = (nodes: readonly Step[] = graph.nodes) => {
    const bounds = rectUnion(nodes.map((node) => node.rect));
    if (bounds === null) return;
    setView(fit(bounds, size(), { padding: 28, limits: { minimum: 0.1, maximum: 1 } }));
  };
  // Begin with the whole pipeline in view, once the canvas has a size — unless that would make its
  // words too small to read, as on a phone: then from its start, readable, with the rest a pan away.
  const fitFirst = () => {
    const bounds = rectUnion(STEPS.map((node) => node.rect))!;
    const whole = fit(bounds, size(), { padding: 28, limits: { minimum: 0.1, maximum: 1 } });
    if (whole.zoom >= READABLE) {
      // oxlint-disable-next-line react/set-state-in-effect
      setView(whole);
      return;
    }
    const { height } = size();
    setView({
      x: bounds.x - 16 / READABLE,
      y: bounds.y + bounds.height / 2 - height / READABLE / 2,
      zoom: READABLE,
    });
  };
  // oxlint-disable-next-line react/exhaustive-effect-dependencies -- once
  useEffect(fitFirst, []);

  /** Make changes: with the layout kept, everything not pinned takes its place again, in the same step of history. */
  function change(changes: readonly Change[], kept = layout === "kept"): GraphData<Step> {
    const all = kept ? keepLaidOut(graph, changes, LAYOUT) : changes;
    if (all.length === 0) return graph;
    const next = applyGraphChanges(graph, all);
    history.push(all);
    setGraph(next);
    setRefused(null);
    return next;
  }

  function travel(back: boolean): void {
    const entry = back ? history.undo() : history.redo();
    if (entry === undefined) return;
    setGraph(applyGraphChanges(graph, back ? invertGraphChanges(entry) : entry));
    setRefused(null);
    drawAgain((count) => count + 1);
  }

  const makeEdge = (source: PortRef, target: PortRef): GraphEdge => {
    made.current.edges += 1;
    return { id: `line-${made.current.edges}`, source, target };
  };

  function addStep(): void {
    made.current.steps += 1;
    const after = graph.nodes.find((node) => node.id === selected[0] && selected.length === 1);
    // Beside the step that is selected, or beneath them all: never on top of one.
    const all = rectUnion(graph.nodes.map((node) => node.rect)) ?? { x: 0, y: 0, width: 0, height: -20 };
    const added = newStep(
      `step-${made.current.steps}`,
      after === undefined ? all.x : after.rect.x + WIDTH + 56,
      after === undefined ? all.y + all.height + 20 : after.rect.y + HEIGHT + 20,
    );
    const changes: Change[] = [insertion(graph, [added])];
    // And joined to it, where it has an output free to give rows.
    const from = after?.ports.find((port) => port.direction === "out" && port.type === "rows");
    if (after !== undefined && from !== undefined) {
      const joined = connection(
        applyGraphChanges(graph, changes),
        { node: after.id, port: from.id },
        { node: added.id, port: "in" },
        makeEdge,
        RULES,
      );
      if (typeof joined !== "string") changes.push(joined);
    }
    const next = change(changes);
    setSelected([added.id]);
    fitAll(next.nodes);
  }

  /** The steps that could take a line from `from`, made at `point`, each with the change that adds and joins it. */
  function offered(from: PortRef, point: Point, id: string): Array<{ name: string; changes: Change[] }> {
    const port = findPort(graph, from)?.port;
    if (port === undefined) return [];
    return OFFERS.flatMap(({ name, make }) => {
      // Its port on the side that faces the line: an input's on the left, at the place; an output's on the right.
      const probe = make(id, 0, 0);
      const into = probe.ports.find((each) => each.direction !== port.direction);
      if (into === undefined) return [];
      const x = into.side === "left" ? point.x : point.x - WIDTH;
      const added = make(id, Math.round(x / 4) * 4, Math.round((point.y - HEIGHT / 2) / 4) * 4);
      const inserted = insertion(graph, [added]);
      const joined = connection(
        applyGraphChanges(graph, [inserted]),
        from,
        { node: id, port: into.id },
        (source, target) => ({ id: "", source, target }),
        RULES,
      );
      return typeof joined === "string" ? [] : [{ name, changes: [inserted] }];
    });
  }

  function add(from: PortRef, point: Point, id: string, name: string): void {
    const choice = offered(from, point, id).find((each) => each.name === name);
    if (choice === undefined) return;
    made.current.steps += 1;
    const [inserted] = choice.changes;
    const added = inserted!.kind === "insert" ? inserted!.nodes[0]!.item : undefined;
    if (added === undefined) return;
    const port = added.ports.find((each) => each.direction !== findPort(graph, from)?.port.direction)!;
    const joined = connection(
      applyGraphChanges(graph, [inserted!]),
      from,
      { node: added.id, port: port.id },
      makeEdge,
      RULES,
    );
    if (typeof joined === "string") return;
    change([inserted!, joined]);
    setSelected([added.id]);
  }

  function onKeyDown(event: KeyboardEvent): void {
    if (!isCommandKey(event) || event.key.toLowerCase() !== "z") return;
    event.preventDefault();
    travel(!event.shiftKey);
  }

  const chosen = graph.nodes.filter((node) => selected.includes(node.id));
  const pinned = chosen.length > 0 && chosen.every((node) => node.pinned === true);
  const lines = selected.length - chosen.length;

  return (
    <div className="flex w-full flex-col">
      <Controls>
        <RadioGroup
          label="Layout"
          size="small"
          value={layout}
          onValueChange={(next) => {
            setLayout(next);
            // Turned on, it begins at once.
            if (next === "kept") fitAll(change([], true).nodes);
          }}
        >
          <Radio value="hand">By hand</Radio>
          <Radio value="kept">Keep laid out</Radio>
        </RadioGroup>
        <RadioGroup label="Lines" size="small" value={line} onValueChange={setLine}>
          <Radio value="orthogonal">Square</Radio>
          <Radio value="curve">Curved</Radio>
        </RadioGroup>
      </Controls>
      <div className="mb-2 flex flex-wrap items-center gap-2">
        <Button onClick={addStep}>Add step</Button>
        <Button
          onClick={() => {
            const moves = autoLayout(graph, LAYOUT);
            fitAll(moves.length === 0 ? graph.nodes : change([{ kind: "move", moves }], false).nodes);
          }}
        >
          Lay out
        </Button>
        <Button
          pressed={pinned}
          disabled={chosen.length === 0}
          onClick={() => {
            const pin = pinning(
              graph,
              chosen.map((node) => node.id),
              !pinned,
            );
            if (pin !== null) change([pin]);
          }}
        >
          {pinned ? "Unpin" : "Pin"}
        </Button>
        <div className="ms-auto flex items-center gap-2">
          <Button focusableWhenDisabled disabled={!history.canUndo()} onClick={() => travel(true)}>
            Undo
          </Button>
          <Button focusableWhenDisabled disabled={!history.canRedo()} onClick={() => travel(false)}>
            Redo
          </Button>
          <Button onClick={() => fitAll()}>Fit</Button>
        </div>
      </div>
      <div className="relative">
        <Diagram<Step>
          ref={canvas}
          aria-label="Sales pipeline"
          graph={graph}
          onGraphChange={(changes) => change(changes)}
          renderNode={renderStep}
          nodeName={nodeName}
          portName={portName}
          createEdge={makeEdge}
          rules={RULES}
          line={line}
          onRefuse={(reason) => setRefused(REASONS[reason])}
          messages={{ refused: (reason) => REASONS[reason] }}
          value={selected}
          onValueChange={setSelected}
          viewport={view}
          onViewportChange={setView}
          grid={4}
          onKeyDown={onKeyDown}
          onDropOnEmpty={(from, point, client) => {
            const id = `step-${made.current.steps + 1}`;
            if (offered(from, point, id).length === 0) return;
            flushSync(() => setOffer({ from, point, id }));
            // Opened where the line was let go, as a menu asked for there.
            offerAt.current?.dispatchEvent(
              new MouseEvent("contextmenu", {
                bubbles: true,
                cancelable: true,
                clientX: client.x,
                clientY: client.y,
              }),
            );
          }}
          className="h-[420px] w-full rounded-lg border border-border-muted bg-surface-subtle max-sm:h-[360px]"
        />
        {/* The place the menu of what to add opens from. It takes no press of its own: it is only ever opened for a line let go. */}
        <ContextMenu
          onOpenChange={(open) => {
            if (!open) setOffer(null);
          }}
        >
          <ContextMenuTrigger ref={offerAt} aria-hidden className="pointer-events-none absolute inset-0" />
          <MenuContent aria-label="Add a step here">
            {offer !== null &&
              offered(offer.from, offer.point, offer.id).map(({ name }) => (
                <MenuItem key={name} onSelect={() => add(offer.from, offer.point, offer.id, name)}>
                  {`Add ${name}`}
                </MenuItem>
              ))}
          </MenuContent>
        </ContextMenu>
      </div>
      {/* The diagram says each of these itself, to those who cannot see it: this is for those who can. */}
      <p
        data-refused={refused === null ? undefined : ""}
        className="mt-6 text-center font-mono text-xs text-fg-muted data-refused:text-danger"
      >
        {refused !== null
          ? `Not joined. ${refused}`
          : selected.length === 0
            ? `${graph.nodes.length} steps, ${graph.edges.length} lines`
            : [
                chosen.length === 1 ? chosen[0]!.title : chosen.length > 1 ? `${chosen.length} steps` : "",
                lines === 1 ? "1 line" : lines > 1 ? `${lines} lines` : "",
              ]
                .filter(Boolean)
                .join(" and ") + " selected"}
      </p>
    </div>
  );
}

/** A step that takes rows and gives rows, to be made something of. */
function newStep(id: string, x: number, y: number): Step {
  return step(id, "New step", "rows → rows", Math.round(x / 4) * 4, Math.round(y / 4) * 4, [
    rowsIn(),
    rowsOut,
  ]);
}

When to use

Use a diagram when the connections are what people are making. When things only have places, a canvas is enough; when they only have an order, a listbox is.

Why it is a component of its own

A canvas does not know about lines, and should not: most canvases have none. But a line is not decoration. It can be selected, has a name, takes focus, answers a pointer, and is exposed to assistive technology — and each of those is a statement that has to be checked against something. Left to each application, the parts hardest to get right would be the ones written again every time: what a screen reader is told about a connection, how a line is drawn without a pointer, how an end is moved to another port.

So the diagram adds nothing of its own but the wiring. The plane, and all that is done to a node on it, is the canvas. The connections as a list are a listbox. The gesture is connecting. What may be joined, what a change is and where a layout puts things is the graph. Where a line runs, and what a pointer picks, is an algorithm.

Best practices

Draw lines that read. A line leaves its port squarely, goes round the two nodes it joins rather than through them — and round the other nodes in its way, even one that is being dragged there — turns half-way between them, and arrives squarely, with an arrowhead where it arrives. Rounded corners or curves: choose one and keep to it.

Keep lines with their nodes. While a node is dragged its lines follow at every moment, not when it is dropped.

Make a line easy to hit. A line is a hair wide and a pointer is not that exact: a click within a few pixels of a line is a click on it, and a finger is given more. A line under a resting mouse shows that it can be picked.

Select lines with nodes. A line is selected as a node is, and with nodes: click, Shift-click, tap. One Delete removes whatever is selected. A node's lines go with it, as one change to undo.

Join by dragging, by two presses, or by keys. See Connecting. Show which ports can take a line the moment it is begun, and refuse in the open, with the reason.

Move an end without redrawing the line. A selected line shows, a little way in from each end, a part to take that end by. Dragged to another port, the same line now ends there. Let go anywhere else, it stays where it was.

Let go over nothing, offer what could be there. A line let go over empty space is not simply given up: offer, at that place, the kinds of node that could take it, and add the chosen one already joined. Offer only what can take the line; with nothing to offer, let it go quietly.

Drop a node onto a line to put it in. A node dragged on its own until its middle lies on a line it can take and give on shows that line, and dropped there is put into it: the line becomes two, through the node. The move and the splice are one change, taken back by one undo. A node that cannot take what the line carries only moves — and the line goes round it.

Label a line where it says something. What a connection carries, or when, is written half-way along it, moves with it, and is read with the connection's name in the list.

Offer layout, and say whether it stays on. A button that lays the diagram out once, and a setting that keeps it laid out as it changes, are different promises. Let a node be pinned against both. See Graphs.

Stay immediate among thousands. Draw only the lines in and around the view; work out a line again only when one of its two nodes has moved.

Conventions

Everything a canvas does for a thing, it does for a node. A diagram adds:

IntentPointerKeyboardTouch
Reach a port—Tab from its node; Shift-Tab back—
Join two portsDrag from one to the other; or click eachEnter on a port, arrows, EnterDrag; or tap each
Give up a connectionEscape; release elsewhereEscapeLift elsewhere
Select a lineClick it; Shift-click to addSpace on it, in the list of connectionsTap it
Reach the connections—Tab past the last node; then arrow keys—
Move a line's endDrag the part near that endEnter on the connection, arrows, EnterDrag the part near that end
Add a node joinedLet a line go over empty space; choose—The same
Put a node into a lineDrag the node onto the line—The same
Remove what is selected—Delete, Backspace—

Platform considerations

Keyboard. Tab goes from a node through its ports and on to the next node, in the canvas's reading order; after the last node come the connections, as one stop, and then Tab leaves. In that list the arrow keys go from one connection to the next, and the line the keyboard is on is ringed and brought into view, since the list itself is not seen. Space selects it, Delete removes it, and Enter takes hold of the end it arrives at. While a connection is being made the arrow keys and Tab are its own, and Escape gives it up without clearing the selection.

Touch. A port is small on the screen and a fingertip is not: a port's target, and that of the part a line's end is taken by, is at least 44 pixels across, at any zoom. A tap on a port begins a connection and a tap on another ends it. A finger's aim at a line, and its reach for a port, are wider than a mouse's.

Pointer. A secondary press on a line selects it unless it is already selected, so that a menu opened there is about what it appears over.

Right to left. As on a canvas, the plane does not mirror. A layout's direction is the diagram's meaning — a pipeline that reads left to right — and is the application's to set.

Forced colours. Lines, the line being drawn, and the ring of a line the keyboard is on are drawn in the system's text and highlight colours.

Assistive technology. A line cannot be seen by someone who cannot see, so it is said three times over. Each node says what it sends to and what it receives from. Each port is a button, named for what it is, that says what is joined to it. And the connections are a list after the nodes, each named by its two ends, where one can be selected, removed, or have its end moved. What cannot be seen happening is said: that a connection was begun and how many ports can take it, that it was made, why it was not, what was removed.

Behaviour

Semantics

The diagram is an application with a name, and says how its keys work, ports and connections among them
const screen = await render(<Flow />);
const diagram = screen.getByRole("application", { name: "Flow" });
await expect.element(diagram).toHaveAccessibleDescription(/Enter on a port begins a connection/);
await expect.element(diagram).toHaveAccessibleDescription(/the connections are a list/);
Each node is a group with its name, and says what it sends to and what it receives from
const screen = await render(<Flow />);
await expect
  .element(screen.getByRole("group", { name: "A", exact: true }))
  .toHaveAccessibleDescription("Sends to B.");
await expect
  .element(screen.getByRole("group", { name: "B", exact: true }))
  .toHaveAccessibleDescription("Receives from A.");
expect(described(el(node("c")))).toBe("");
Each port is a button named for what it is, and says what it is connected to
await render(<Flow />);
const out = el(port("a", "out"));
expect(out.tagName).toBe("BUTTON");
expect(out.getAttribute("aria-label")).toBe("out, output");
expect(described(out)).toBe("Sends to B in.");
expect(described(el(port("b", "in")))).toBe("Receives from A out.");
expect(el(port("c", "in")).getAttribute("aria-label")).toBe("in, input");
expect(described(el(port("c", "in")))).toBe("Not connected.");
expect(el(port("a", "out")).dataset.connected).toBe("");
expect(el(port("c", "in")).dataset.connected).toBeUndefined();
The connections are a list after the nodes, each named by its two ends, and one that is selected says so
const screen = await render(<Flow defaultValue={["ab"]} />);
const list = screen.getByRole("listbox", { name: "Connections" });
await expect.element(list).toBeInTheDocument();
const option = screen.getByRole("option", { name: "A out to B in" });
await expect.element(option).toHaveAttribute("aria-selected", "true");
expect(
  el(node("d")).compareDocumentPosition(list.element()) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
A diagram with no connections has no list of them
const screen = await render(<Flow initial={{ ...flow(), edges: [] }} />);
expect(screen.getByRole("listbox").elements()).toEqual([]);
It has no accessibility violations, with a node and a connection selected
const screen = await render(<Flow defaultValue={["a", "ab"]} />);
await expectAccessible(screen.container);
Where the system forces its colours, lines and ports take the system's, and each state of a port is still told apartChromium, Chromium · touch

Not yet written.

Lines

A line runs from the port it leaves to the port it arrives at
await render(<Flow />);
// From A's right side to B's left, level with both.
expect(drawn("ab")).toBe("M80 40L140 40");
A line that cannot run straight goes round by level and upright runs, with its corners rounded
await render(<Flow initial={{ ...flow(), edges: [edge("a", "d")] }} />);
// Out of A, down between the two, and into D.
expect(drawn("ad")).toBe("M80 40L102 40Q110 40 110 48L110 172Q110 180 118 180L140 180");
A line goes round the other nodes in its way, not through them
await render(<Flow initial={{ ...flow(), edges: [edge("a", "c")] }} />);
// B stands between A and C, level with both.
expect(through("ac", [140, 20, 60, 40])).toBe(false);
expect(drawn("ac")).toMatch(/^M80 40/);
expect(drawn("ac")).toMatch(/L260 40$/);
A node dragged into a line's way is gone round while it is dragged, and where it is let go
// D only gives: it cannot be put into the line, only stand in its way.
const giver = step("d", 140, 160, { ports: [{ id: "out", side: "right", direction: "out" }] });
const initial = { nodes: [step("a", 20, 20), step("c", 260, 20), giver], edges: [edge("a", "c")] };
await render(<Flow initial={initial} snap={false} />);
expect(drawn("ac")).toBe("M80 40L260 40");
await commands.mouseDownAt(node("d"));
await commands.mouseMoveBy(0, -140);
const box = el(node("d")).getBoundingClientRect();
const corner = el(DIAGRAM).getBoundingClientRect();
const place = [box.left - corner.left, box.top - corner.top, box.width, box.height];
expect(Math.abs(place[1]! - 20)).toBeLessThan(2);
expect(through("ac", place)).toBe(false);
await commands.mouseUp();
expect(through("ac", place)).toBe(false);
// And it is not put into the line: it has nothing to take what the line carries.
expect(lines()).toEqual(["ac"]);
expect(el(line("ac")).dataset.splicing).toBeUndefined();
A line follows its nodes while they are dragged, and stays with them when the drag ends
await render(<Flow snap={false} />);
await commands.mouseDownAt(node("b"));
await commands.mouseMoveBy(0, 100);
// A phone places the pointer to within a pixel.
const end = () =>
  drawn("ab")!
    .match(/-?[\d.]+/g)!
    .slice(-2)
    .map(Number);
expect(Math.abs(end()[1]! - 140)).toBeLessThan(1.5);
expect(end()[0]).toBe(140);
await commands.mouseUp();
expect(Math.abs(end()[1]! - 140)).toBeLessThan(1.5);
Lines are drawn on the plane: they move and scale with it, beneath the nodes
await render(<Flow defaultViewport={{ x: 10, y: 0, zoom: 2 }} />);
const box = el(`${line("ab")} path:nth-of-type(2)`).getBoundingClientRect();
const corner = el(DIAGRAM).getBoundingClientRect();
expect(Math.round(box.left - corner.left)).toBe(140);
expect(Math.round(box.width)).toBe(120);
const layer = el("[data-diagram-edges]");
expect(layer.compareDocumentPosition(el(node("a"))) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
A hundred million units from zero, a line is drawn where its nodes are, in small numbers
const far = 100_000_000;
await render(
  <Flow
    initial={{
      nodes: [step("a", far + 20, far + 20), step("b", far + 140, far + 20)],
      edges: [edge("a", "b")],
    }}
    defaultViewport={{ x: far, y: far, zoom: 1 }}
  />,
);
const box = el(`${line("ab")} path:nth-of-type(2)`).getBoundingClientRect();
const corner = el(DIAGRAM).getBoundingClientRect();
expect(Math.round(box.left - corner.left)).toBe(80);
expect(Math.round(box.width)).toBe(60);
expect(
  Math.max(
    ...drawn("ab")!
      .match(/-?\d+(\.\d+)?/g)!
      .map(Number)
      .map(Math.abs),
  ),
).toBeLessThan(10_000);
Among a thousand connections, only those in and around the view are drawn, and those that are selected
const nodes = Array.from({ length: 1001 }, (_, index) => step(`n${index}`, 20 + index * 200, 20));
const edges = nodes.slice(1).map((each, index) => edge(`n${index}`, each.id));
await render(<Flow initial={{ nodes, edges }} defaultValue={["n900n901"]} />);
const shown = lines();
expect(shown.length).toBeLessThan(12);
expect(shown).toContain("n0n1");
expect(shown).toContain("n900n901");
expect(shown).not.toContain("n500n501");
A line whose node is not there is not drawn, and does no harm
await render(<Flow initial={{ ...flow(), edges: [edge("a", "b"), edge("a", "nowhere")] }} />);
expect(lines()).toEqual(["ab"]);
await clickAt(110, 40);
expect(selected()).toEqual(["ab"]);

Selecting lines

A click on a line selects it, and only it; a click on a node, or on empty space, lets it go
await render(<Flow defaultValue={["c"]} />);
await clickAt(110, 40);
expect(selected()).toEqual(["ab"]);
await commands.clickSelector(node("d"));
expect(selected()).toEqual(["d"]);
await clickAt(110, 40);
await clickAt(300, 270);
expect(selected()).toEqual([]);
A click a few pixels from a line still picks it; further off, it is a click on empty spaceChromium, Firefox, WebKit

Not yet written.

Shift-click adds a line to what is selected, or takes it out, nodes and lines together
const onValueChange = vi.fn();
await render(<Flow onValueChange={onValueChange} />);
await commands.clickSelector(node("c"));
await clickAt(110, 40, ["Shift"]);
expect(last(onValueChange)).toEqual(["c", "ab"]);
// Where the part its end is taken by now lies over it, a click there is still a click on the line.
await clickAt(110, 40, ["Shift"]);
expect(last(onValueChange)).toEqual(["c"]);
Command-click adds a line, or takes it out, as Shift-click doesChromium, Firefox, WebKit

Not yet written.

A tap on a line adds it to what is selected or takes it out, and a finger need not be as exactChromium · touch

Not yet written.

A line under the mouse shows that it can be pickedChromium, Firefox, WebKit

Not yet written.

A secondary press on a line selects it, unless it is selected alreadyChromium, Firefox, WebKit

Not yet written.

Removing

Delete removes what is selected and says so; a node's lines go with it, in the same change
const onChanges = vi.fn();
await render(<Flow onChanges={onChanges} />);
await commands.clickSelector(node("b"));
await press("{Delete}");
expect(onChanges).toHaveBeenCalledOnce();
expect(last<GraphChange[]>(onChanges)).toMatchObject([
  { kind: "remove", nodes: [{ at: 1, item: { id: "b" } }], edges: [{ at: 0, item: { id: "ab" } }] },
]);
expect(document.querySelector(node("b"))).toBeNull();
expect(lines()).toEqual([]);
await expect.poll(said).toBe("Removed 1 node and 1 connection.");
expect(selected()).toEqual([]);
Backspace removes a selected line alone, and its nodes stay
await render(<Flow />);
await clickAt(110, 40);
await press("{Backspace}");
expect(lines()).toEqual([]);
expect(all("[data-canvas-item]").length).toBe(4);
await expect.poll(said).toBe("Removed 1 connection.");
In a field inside a node, Delete and Backspace are the field's
const onChanges = vi.fn();
const withField = (each: Step) => (
  <DiagramNode aria-label={each.name}>
    <input aria-label={`${each.name} value`} defaultValue="ab" style={{ width: 40 }} />
  </DiagramNode>
);
await render(<Flow renderNode={withField} defaultValue={["a"]} onChanges={onChanges} />);
await commands.clickSelector('[aria-label="B value"]');
await press("{Backspace}");
expect((el('[aria-label="B value"]') as HTMLInputElement).value).toBe("a");
expect(onChanges).not.toHaveBeenCalled();
A locked node is not removed, though it is selected
const onChanges = vi.fn();
const initial = { ...flow(), nodes: [step("a", 20, 20, { locked: true }), ...flow().nodes.slice(1)] };
await render(<Flow initial={initial} defaultValue={["a", "c"]} onChanges={onChanges} />);
await commands.clickSelector(node("c"), { modifiers: ["Shift"] });
await commands.clickSelector(node("c"), { modifiers: ["Shift"] });
await press("{Delete}");
expect(all("[data-canvas-item]").map((each) => each.dataset.id)).toEqual(["a", "b", "d"]);

Connecting

A drag from a port to a port that takes it joins them with a line, and says so
const onChanges = vi.fn();
await render(<Flow onChanges={onChanges} />);
await commands.pressDragRelease(port("b", "out"), port("c", "in"));
expect(lines()).toEqual(["ab", "bc"]);
expect(onChanges).toHaveBeenCalledOnce();
expect(last<GraphChange[]>(onChanges)).toEqual([
  { kind: "insert", nodes: [], edges: [{ at: 1, item: edge("b", "c") }] },
]);
await expect.poll(said).toBe("Connected B out to C in.");
A line drawn from the port that takes is recorded running the right way
await render(<Flow />);
await commands.pressDragRelease(port("c", "in"), port("b", "out"));
expect(lines()).toEqual(["ab", "bc"]);
While it is drawn the line runs from its port to the pointer, and every port says whether it can take it
await render(<Flow />);
expect(document.querySelector(BAND)).toBeNull();
await commands.mouseDownAt(port("b", "out"));
await commands.mouseMoveBy(30, 60);
// Out of the port and along to the pointer, which a phone's screen places to within a pixel.
const band = el(BAND).getAttribute("d")!;
expect(band).toMatch(/^M200 40L/);
const [x, y] = band
  .match(/-?[\d.]+/g)!
  .slice(-2)
  .map(Number);
expect(Math.abs(x! - 230)).toBeLessThan(1.5);
expect(Math.abs(y! - 100)).toBeLessThan(1.5);
expect(el(DIAGRAM).dataset.connecting).toBe("drag");
expect(portStates()).toEqual({
  "a.in": "candidate",
  "a.out": "unavailable",
  "b.in": "unavailable",
  "b.out": "source",
  "c.in": "candidate",
  "c.out": "unavailable",
  "d.in": "candidate",
  "d.out": "unavailable",
});
// Onto C's input: the line ends on the port, and the port says so.
await commands.mouseMoveBy(30, -60);
expect(el(BAND).getAttribute("d")).toBe("M200 40L260 40");
expect(el(port("c", "in")).dataset.connectState).toBe("target");
await commands.mouseUp();
expect(document.querySelector(BAND)).toBeNull();
expect(el(DIAGRAM).dataset.connecting).toBeUndefined();
Released on a port that cannot take it, nothing is joined: the port and the line show it, and the reason is said and told
const onChanges = vi.fn();
const onRefuse = vi.fn();
await render(<Flow onChanges={onChanges} onRefuse={onRefuse} />);
await commands.mouseDownAt(port("b", "out"));
// Onto D's output.
await commands.mouseMoveBy(0, 140);
expect(el(port("d", "out")).dataset.connectState).toBe("refused");
expect(el(BAND).dataset.refused).toBe("");
expect(getComputedStyle(el(BAND)).stroke).toBe(getComputedStyle(el(port("d", "out"))).borderTopColor);
await commands.mouseUp();
expect(onChanges).not.toHaveBeenCalled();
expect(onRefuse).toHaveBeenCalledExactlyOnceWith(
  "direction",
  { node: "b", port: "out" },
  { node: "d", port: "out" },
);
await expect.poll(said).toMatch(/An output is connected to an input/);
A connection already made is not made twice, and where loops are refused, one that would close a loop is not made
const onRefuse = vi.fn();
await render(<Flow rules={{ acyclic: true }} onRefuse={onRefuse} />);
await commands.clickSelector(port("a", "out"));
await commands.clickSelector(port("b", "in"));
expect(last(onRefuse)).toBe("duplicate");
await press("{Escape}");
await commands.clickSelector(port("b", "out"));
expect(el(port("a", "in")).dataset.connectState).toBe("unavailable");
await commands.clickSelector(port("a", "in"));
expect(last(onRefuse)).toBe("cycle");
expect(lines()).toEqual(["ab"]);
await expect.poll(said).toBe("That would make a loop.");
Escape while a line is being drawn takes it back
const onChanges = vi.fn();
await render(<Flow onChanges={onChanges} defaultValue={["d"]} />);
await commands.mouseDownAt(port("b", "out"));
await commands.mouseMoveBy(60, 0);
await press("{Escape}");
expect(document.querySelector(BAND)).toBeNull();
await commands.mouseUp();
expect(onChanges).not.toHaveBeenCalled();
// And the selection is as it was: the Escape was the line's.
expect(selected()).toEqual(["d"]);
A press on a port does not move its node, select it, or begin a marquee
const onChanges = vi.fn();
const onValueChange = vi.fn();
await render(<Flow onChanges={onChanges} onValueChange={onValueChange} />);
await commands.mouseDownAt(port("d", "out"));
await commands.mouseMoveBy(40, 40);
expect(document.querySelector("[data-canvas-marquee]")).toBeNull();
await commands.mouseUp();
expect(onChanges).not.toHaveBeenCalled();
expect(onValueChange).not.toHaveBeenCalled();
A click on a port and then on another joins them: nothing need be held down
await render(<Flow />);
await commands.clickSelector(port("b", "out"));
expect(el(DIAGRAM).dataset.connecting).toBe("pick");
await commands.clickSelector(port("d", "in"));
expect(lines()).toEqual(["ab", "bd"]);
A tap on a port and then on another joins them, and a port's target is 44 pixels acrossChromium · touch, WebKit · touch

Not yet written.

A line drawn to the edge of the view moves the view along, and can end on a port that was out of view
const onViewportChange = vi.fn();
const initial = { ...flow(), nodes: [...flow().nodes, step("e", 460, 20)] };
await render(<Flow initial={initial} onViewportChange={onViewportChange} />);
await commands.mouseDownAt(port("b", "out"));
// Ten pixels from the right edge, level with the ports.
await commands.mouseMoveBy(130, 0);
// The view moves no further for a late frame than for a slow one: on a busy machine this takes a while.
await expect.poll(() => el(port("e", "in")).dataset.connectState, { timeout: 10_000 }).toBe("target");
// With a port that can take the line under the pointer, the view has stopped: it is there to be let go on.
const view = last<Viewport>(onViewportChange);
expect(view.x).toBeGreaterThan(100);
await new Promise((resolve) => setTimeout(resolve, 150));
expect(last<Viewport>(onViewportChange)).toEqual(view);
await commands.mouseUp();
expect(lines()).toEqual(["ab", "be"]);
However long a frame takes, the view stops with the port still under the pointer
const initial = { ...flow(), nodes: [...flow().nodes, step("e", 460, 20)] };
const onViewportChange = vi.fn();
await render(<Flow initial={initial} onViewportChange={onViewportChange} />);
// Every frame as late as a frame is let be: the view moves its furthest at each.
const frame = window.requestAnimationFrame.bind(window);
let time = 0;
const slow = vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) =>
  frame(() => {
    // Never behind the real clock, which the view's own start is read from.
    time = Math.max(time, performance.now()) + 64;
    callback(time);
  }),
);
try {
  await commands.mouseDownAt(port("b", "out"));
  await commands.mouseMoveBy(130, 0);
  await expect.poll(() => el(port("e", "in")).dataset.connectState).toBe("target");
  await commands.mouseUp();
} finally {
  slow.mockRestore();
}
expect(lines()).toEqual(["ab", "be"]);
// And at no frame did it move further than a port can be missed by: a mouse's reach.
const places = onViewportChange.mock.calls.map(([view]) => (view as Viewport).x);
const strides = places.slice(1).map((x, index) => Math.abs(x - places[index]!));
expect(Math.max(...strides)).toBeLessThanOrEqual(12);

Moving an end

A selected line shows, near each end, a part to take that end by
await render(<Flow initial={{ ...flow(), edges: [edge("a", "d")] }} />);
expect(all("[data-diagram-grip]")).toEqual([]);
await clickAt(110, 100);
const centre = (selector: string) => {
  const box = el(selector).getBoundingClientRect();
  const corner = el(DIAGRAM).getBoundingClientRect();
  return [
    Math.round(box.left + box.width / 2 - corner.left),
    Math.round(box.top + box.height / 2 - corner.top),
  ];
};
// Along the line from each port, clear of the port itself.
const inset = hasTouch ? 30 : 18;
expect(centre(grip("ad", "source"))).toEqual([80 + inset, 40]);
expect(centre(grip("ad", "target"))).toEqual([140 - inset, 180]);
On a line too short for both, the two parts keep apart: each three-tenths of the way from its end
await render(<Flow defaultValue={["ab"]} />);
const middle = (end: string) => {
  const box = el(grip("ab", end)).getBoundingClientRect();
  return Math.round(box.left + box.width / 2 - el(DIAGRAM).getBoundingClientRect().left);
};
// The line runs from 80 to 140.
expect([middle("source"), middle("target")]).toEqual([98, 122]);
Dragging that part to another port moves the end there, as one change that keeps the line
const onChanges = vi.fn();
await render(<Flow onChanges={onChanges} />);
await clickAt(110, 40);
await commands.pressDragRelease(grip("ab", "target"), port("d", "in"));
expect(last<GraphChange[]>(onChanges)).toEqual([
  {
    kind: "replace",
    nodes: [],
    edges: [
      { item: { ...edge("a", "b"), target: { node: "d", port: "in" } }, previous: edge("a", "b") },
    ],
  },
]);
expect(lines()).toEqual(["ab"]);
expect(drawn("ab")).toMatch(/L140 180$/);
await expect.poll(said).toBe("Connected A out to D in.");
While an end is moved the line is drawn from the end that stays, and the line as it was is faint
await render(<Flow />);
await clickAt(110, 40);
await commands.mouseDownAt(grip("ab", "target"));
await commands.mouseMoveBy(0, 60);
expect(el(BAND).getAttribute("d")).toMatch(/^M80 40/);
expect(el(line("ab")).dataset.reconnecting).toBe("");
expect(el(port("a", "out")).dataset.connectState).toBe("source");
// The port it is already on is among those that can take it: it is not counted against itself.
expect(el(port("b", "in")).dataset.connectState).toBe("candidate");
await commands.mouseUp();
expect(el(line("ab")).dataset.reconnecting).toBeUndefined();
An end let go over nothing, or over a port that cannot take it, stays where it was
const onChanges = vi.fn();
const onRefuse = vi.fn();
await render(<Flow onChanges={onChanges} onRefuse={onRefuse} />);
await clickAt(110, 40);
await commands.mouseDownAt(grip("ab", "target"));
await commands.mouseMoveBy(20, 90);
await commands.mouseUp();
await commands.pressDragRelease(grip("ab", "target"), port("d", "out"));
expect(onChanges).not.toHaveBeenCalled();
expect(last(onRefuse)).toBe("direction");
expect(drawn("ab")).toBe("M80 40L140 40");
Under a finger, the part an end is taken by has a target 44 pixels acrossChromium · touch, WebKit · touch

Not yet written.

Keyboard and focus

Tab goes from a node through its ports to the next node; past the last, to the connections, and then on out
await render(<Flow />);
await before();
const seen: Array<string | null | undefined> = [];
for (let stop = 0; stop < 14; stop += 1) {
  await press("{Tab}");
  await settled();
  seen.push(focused());
}
expect(seen).toEqual([
  "a",
  "a.in",
  "a.out",
  "b",
  "b.in",
  "b.out",
  "c",
  "c.in",
  "c.out",
  "d",
  "d.in",
  "d.out",
  "A out to B in",
  "After",
]);
Shift-Tab goes back through a node's ports to the node, and from a node to the node before
await render(<Flow />);
await before();
await press("{Tab}{Tab}{Tab}{Tab}{Tab}{Tab}");
expect(focused()).toBe("b.out");
const seen: Array<string | null | undefined> = [];
for (let stop = 0; stop < 4; stop += 1) {
  await press("{Shift>}{Tab}{/Shift}");
  await settled();
  seen.push(focused());
}
expect(seen).toEqual(["b.in", "b", "a", "Before"]);
Enter on a port begins a connection and says so; focus goes to the nearest port that can take it
await render(<Flow />);
await before();
await press("{Tab}{Tab}{Tab}{Tab}{Tab}{Tab}");
await press("{Enter}");
expect(focused()).toBe("c.in");
expect(el(DIAGRAM).dataset.connecting).toBe("pick");
await expect
  .poll(said)
  .toBe(
    "Connecting from B out. 3 ports can take it: arrow keys choose, Enter joins, Escape gives it up.",
  );
// The line is drawn to the port the keyboard is on.
expect(el(BAND).getAttribute("d")).toBe("M200 40L260 40");
The arrow keys choose among the ports that can take it, and move nothing; Enter joins
const onChanges = vi.fn();
await render(<Flow onChanges={onChanges} defaultValue={["a"]} />);
await before();
await press("{Tab}{Tab}{Tab}{Tab}{Tab}{Tab}{Enter}");
await press("{ArrowDown}");
expect(focused()).toBe("d.in");
expect(onChanges).not.toHaveBeenCalled();
await press("{Enter}");
expect(lines()).toEqual(["ab", "bd"]);
expect(onChanges).toHaveBeenCalledOnce();
expect(focused()).toBe("d.in");
await expect.poll(said).toBe("Connected B out to D in.");
Escape gives it up and returns to the port it began on; the selection is as it was
await render(<Flow defaultValue={["a"]} />);
await before();
await press("{Tab}{Tab}{Tab}{Tab}{Tab}{Tab}{Enter}{ArrowDown}{Escape}");
expect(focused()).toBe("b.out");
expect(el(DIAGRAM).dataset.connecting).toBeUndefined();
expect(selected()).toEqual(["a"]);
expect(lines()).toEqual(["ab"]);
From a port with nowhere to go, Enter begins nothing, and the reason is said
const single = (id: string, x: number) =>
  step(id, x, 20, { ports: [{ id: "out", side: "right", direction: "out" }] });
await render(<Flow initial={{ nodes: [single("a", 20), single("b", 140)], edges: [] }} />);
await before();
await press("{Tab}{Tab}{Enter}");
expect(focused()).toBe("a.out");
expect(el(DIAGRAM).dataset.connecting).toBeUndefined();
await expect.poll(said).toBe("There is nothing this can be connected to.");

The connections

In the list the arrow keys go from one connection to the next, and the line the keyboard is on is shown
await render(<Flow initial={two()} />);
await intoList();
expect(focused()).toBe("A out to B in");
expect(el(line("ab")).dataset.focused).toBe("");
expect(getComputedStyle(el(`${line("ab")} path`)).display).not.toBe("none");
await press("{ArrowDown}");
expect(focused()).toBe("B out to C in");
expect(el(line("ab")).dataset.focused).toBeUndefined();
expect(el(line("bc")).dataset.focused).toBe("");
// Shift-Tab from the list goes back to the nodes.
await press("{Shift>}{Tab}{/Shift}");
await settled();
expect(focused()).toBe("a");
expect(el(line("bc")).dataset.focused).toBeUndefined();
Space selects the connection the keyboard is on, or takes it out, and leaves the nodes that are selected
const onValueChange = vi.fn();
await render(<Flow initial={two()} defaultValue={["d"]} onValueChange={onValueChange} />);
await intoList();
await press(" ");
expect(last(onValueChange)).toEqual(["d", "ab"]);
expect(selected()).toContain("ab");
await press(" ");
expect(last(onValueChange)).toEqual(["d"]);
Delete removes the connection the keyboard is on, and focus stays in the list
const onChanges = vi.fn();
await render(<Flow initial={two()} onChanges={onChanges} />);
await intoList();
await press("{Delete}");
expect(lines()).toEqual(["bc"]);
expect(last<GraphChange[]>(onChanges)).toMatchObject([{ kind: "remove", nodes: [] }]);
await expect.poll(focused).toBe("B out to C in");
When the last connection is removed, focus goes to the nodes
await render(<Flow />);
await intoList();
await press("{Delete}");
expect(lines()).toEqual([]);
await expect.poll(focused).toBe("a");
Enter takes hold of the end a connection arrives at: the arrow keys choose another port, and Enter moves it there
const onChanges = vi.fn();
await render(<Flow onChanges={onChanges} />);
await intoList();
await press("{Enter}");
await expect.poll(focused).toBe("b.in");
expect(el(line("ab")).dataset.reconnecting).toBe("");
await press("{ArrowDown}");
expect(focused()).toBe("d.in");
await press("{Enter}");
expect(last<GraphChange[]>(onChanges)).toMatchObject([{ kind: "replace" }]);
expect(drawn("ab")).toMatch(/L140 180$/);
Escape while an end is held gives it up, and returns to the list
const onChanges = vi.fn();
await render(<Flow onChanges={onChanges} />);
await intoList();
await press("{Enter}{ArrowDown}{Escape}");
await expect.poll(focused).toBe("A out to B in");
expect(onChanges).not.toHaveBeenCalled();
A connection the keyboard reaches is brought into view
const onViewportChange = vi.fn();
const far: GraphData<Step> = {
  nodes: [step("z", 20, 20), step("a", 1000, 900), step("b", 1120, 900)],
  edges: [edge("a", "b")],
};
await render(<Flow initial={far} onViewportChange={onViewportChange} />);
await intoList();
const view = last<Viewport>(onViewportChange);
// The line runs from 1060 to 1120, at 920.
expect(view.x).toBeLessThan(1060);
expect(view.x + 340).toBeGreaterThan(1120);
expect(view.y + 300).toBeGreaterThan(920);
Among ten thousand connections, the list holds only those near the one the keyboard is on

Not yet written.

Putting a node into a line

A node dragged onto a line that it can take and give on shows the line it would go into; let go, it is put into it, as one change
const onChanges = vi.fn();
await render(<Flow initial={across()} onChanges={onChanges} snap={false} />);
await commands.mouseDownAt(node("d"));
await commands.mouseMoveBy(0, -140);
expect(el(line("ac")).dataset.splicing).toBe("");
await commands.mouseUp();
expect(onChanges).toHaveBeenCalledOnce();
const changes = last<GraphChange<Step>[]>(onChanges);
expect(changes.map((change) => change.kind)).toEqual(["move", "remove", "insert"]);
expect(lines()).toEqual(["ad", "dc"]);
await expect.poll(said).toBe("Put D into A out to C in.");
// One undo takes back the move and the splice together.
expect(applyGraphChanges(applyGraphChanges(across(), changes), invertGraphChanges(changes))).toEqual(
  across(),
);
A node dragged near a line but not onto it, or onto a line of its own, is only moved
const onChanges = vi.fn();
await render(<Flow initial={across()} onChanges={onChanges} snap={false} />);
// Its middle forty pixels below the line: further than half its height.
await commands.mouseDownAt(node("d"));
await commands.mouseMoveBy(0, -100);
expect(el(line("ac")).dataset.splicing).toBeUndefined();
await commands.mouseUp();
expect(last<GraphChange[]>(onChanges).map((change) => change.kind)).toEqual(["move"]);
expect(lines()).toEqual(["ac"]);
// A's own line, dragged along with A, is not one A is put into.
await commands.mouseDownAt(node("a"));
await commands.mouseMoveBy(60, 0);
expect(el(line("ac")).dataset.splicing).toBeUndefined();
await commands.mouseUp();
expect(lines()).toEqual(["ac"]);

Labels

A connection's label is shown half-way along its line
await render(<Flow initial={labelled()} />);
const box = el('[data-diagram-label][data-id="ab"]').getBoundingClientRect();
const corner = el(DIAGRAM).getBoundingClientRect();
// The line runs from 80 to 140, at 40.
expect(Math.abs(box.left + box.width / 2 - corner.left - 110)).toBeLessThan(1);
expect(Math.abs(box.top + box.height / 2 - corner.top - 40)).toBeLessThan(1);
expect(el('[data-diagram-label][data-id="ab"]').textContent).toBe("rows");
In the list of connections, a connection is read with its label
const screen = await render(<Flow initial={labelled()} />);
await expect.element(screen.getByRole("option", { name: "A out to B in, rows" })).toBeInTheDocument();
A label goes with its line while a node is dragged
await render(<Flow initial={labelled()} snap={false} />);
await commands.mouseDownAt(node("b"));
await commands.mouseMoveBy(60, 0);
const box = el('[data-diagram-label][data-id="ab"]').getBoundingClientRect();
const corner = el(DIAGRAM).getBoundingClientRect();
// Now from 80 to 200: half-way is 140, give or take where a phone puts the pointer.
expect(Math.abs(box.left + box.width / 2 - corner.left - 140)).toBeLessThan(2);
await commands.mouseUp();

API

onDropOnEmpty hears a line let go over empty space: from which port, and where on the plane; an end being moved, let go there, is not heard
const onDropOnEmpty = vi.fn();
await render(<Flow onDropOnEmpty={onDropOnEmpty} />);
await commands.mouseDownAt(port("b", "out"));
await commands.mouseMoveBy(40, 200);
await commands.mouseUp();
expect(onDropOnEmpty).toHaveBeenCalledOnce();
const [from, point, client] = onDropOnEmpty.mock.lastCall!;
expect(from).toEqual({ node: "b", port: "out" });
// At (240, 240), to within the pixel a phone puts the pointer on.
expect(Math.abs(point.x - 240)).toBeLessThan(1.5);
expect(Math.abs(point.y - 240)).toBeLessThan(1.5);
const corner = el(DIAGRAM).getBoundingClientRect();
expect(Math.abs(client.x - corner.left - 240)).toBeLessThan(1.5);
// Nothing is joined.
expect(lines()).toEqual(["ab"]);
onDropOnEmpty.mockClear();
await clickAt(110, 40);
await commands.mouseDownAt(grip("ab", "target"));
// To (222, 190): clear of D and its ports.
await commands.mouseMoveBy(100, 150);
await commands.mouseUp();
expect(onDropOnEmpty).not.toHaveBeenCalled();
expect(drawn("ab")).toBe("M80 40L140 40");
onGraphChange hears one gesture once, as changes that applyGraphChanges applies and invertGraphChanges takes back
const onChanges = vi.fn();
await render(<Flow onChanges={onChanges} snap={false} />);
await commands.mouseDownAt(node("c"));
await commands.mouseMoveBy(-20, 60);
await commands.mouseUp();
expect(onChanges).toHaveBeenCalledOnce();
const changes = last<GraphChange<Step>[]>(onChanges);
expect(changes).toMatchObject([{ kind: "move", moves: [{ id: "c", previous: { x: 260, y: 20 } }] }]);
const after = applyGraphChanges(flow(), changes);
expect(Math.abs(after.nodes[2]!.rect.y - 80)).toBeLessThan(2);
expect(applyGraphChanges(after, invertGraphChanges(changes))).toEqual(flow());
Without graph, the diagram keeps it itself from defaultGraph, and still reports changes
const onGraphChange = vi.fn();
await render(
  <Diagram<Step>
    aria-label="Flow"
    defaultGraph={flow()}
    onGraphChange={onGraphChange}
    renderNode={renderStep}
    nodeName={(each) => each.name}
    style={{ width: 340, height: 300 }}
  />,
);
await commands.pressDragRelease(port("b", "out"), port("c", "in"));
expect(lines().length).toBe(2);
expect(onGraphChange).toHaveBeenCalledOnce();
// An edge that the application does not make is named by its two ports.
expect(last<GraphChange[]>(onGraphChange)).toMatchObject([{ edges: [{ item: { id: "b/out→c/in" } }] }]);
value holds what is selected, nodes and lines together: with it, the diagram only asks
const onValueChange = vi.fn();
await render(<Flow value={["a", "ab"]} onValueChange={onValueChange} />);
expect(selected()).toEqual(["ab", "a"]);
await clickAt(300, 270);
expect(onValueChange).toHaveBeenCalledExactlyOnceWith([]);
expect(selected()).toEqual(["ab", "a"]);
With line set to curves, lines are curves that leave and arrive squarely
await render(<Flow line="curve" initial={{ ...flow(), edges: [edge("a", "d")] }} />);
expect(drawn("ad")).toMatch(/^M80 40C[\d.]+ 40 [\d.]+ 180 140 180$/);
await clickAt(110, 110);
expect(selected()).toEqual(["ad"]);
portName and messages say everything in other words
const screen = await render(
  <Flow
    portName={(_, each) => (each.id === "in" ? "entrée" : "sortie")}
    messages={{
      connections: "Liaisons",
      edge: (source, target) => `${source} vers ${target}`,
      port: (name) => name,
      sends: (to) => `Vers ${to.join(", ")}.`,
      removed: () => "Supprimé.",
      canvas: { selected: "choisi" },
    }}
    defaultValue={["a"]}
  />,
);
await expect.element(screen.getByRole("listbox", { name: "Liaisons" })).toBeInTheDocument();
await expect
  .element(screen.getByRole("option", { name: "A sortie vers B entrée" }))
  .toBeInTheDocument();
expect(el(port("a", "out")).getAttribute("aria-label")).toBe("sortie");
expect(described(el(node("a")))).toBe("Vers B. choisi");
await commands.clickSelector(node("a"));
await press("{Delete}");
await expect.poll(said).toBe("Supprimé.");
A handler given to the diagram runs first, and prevents the diagram's own by preventing the default
const onChanges = vi.fn();
await render(
  <Flow
    onChanges={onChanges}
    defaultValue={["a"]}
    onKeyDown={(event) => {
      if (event.key === "Delete") event.preventDefault();
    }}
  />,
);
await commands.clickSelector(node("a"));
await press("{Delete}");
expect(onChanges).not.toHaveBeenCalled();
A DiagramNode outside a diagram, and a port its node does not have, say what is wrong
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(render(<DiagramNode />)).rejects.toThrow("DiagramNode must be rendered by a Diagram");
await expect(render(<DiagramPort port="in" />)).rejects.toThrow("DiagramPort must be rendered inside");
const wrong = (each: Step) => (
  <DiagramNode aria-label={each.name}>
    <DiagramPort port="sideways" />
  </DiagramNode>
);
await expect(render(<Flow renderNode={wrong} />)).rejects.toThrow('has no port "sideways"');
errors.mockRestore();

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/diagram.json
Source

src/components/diagram/diagram.tsx

"use client";

/**
 * Nodes joined by lines: a flow, a pipeline, a graph. It is the wiring of what
 * it stands on, and has no behaviour that is not theirs: `Canvas` for the
 * plane and everything done to a node on it, `Listbox` for the connections as a
 * list, `connect` for the gesture that joins two ports, `graph` for what may be
 * joined and what a change is, and `edge-route` for where a line runs and what
 * a pointer picks.
 *
 * Parts: `Diagram`, which is given the graph and how to draw a node;
 * `DiagramNode`, what `renderNode` returns — a `CanvasItem` that says what it
 * is connected to; `DiagramPort`, a port of that node: a button on its edge.
 *
 * State, for styles: on a line `data-selected`, `data-focused`, `data-hovered`,
 * `data-reconnecting`; on a port `data-connect-state` (`source`, `candidate`,
 * `target`, `refused`, `unavailable`), `data-side`, `data-direction`,
 * `data-connected`; on the line being drawn `data-refused`. Nodes are canvas
 * items, and carry theirs.
 */
import { createContext, memo, use, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { ComponentProps, KeyboardEvent, MouseEvent, ReactElement, ReactNode } from "react";
import {
  MAX_OBSTACLES,
  ROUTE_POINTS,
  SIDE,
  curveDistance,
  curvePath,
  curvePoint,
  curveRoute,
  orthogonalRoute,
  pointsBounds,
  polylineDistance,
  polylineLength,
  polylinePath,
  polylinePoint,
} from "../../algorithms/edge-route.ts";
import type { Side } from "../../algorithms/edge-route.ts";
import { rectUnion, rectsIntersect } from "../../algorithms/geometry.ts";
import { spatialIndexFromRects, spatialSearch } from "../../algorithms/spatial-index.ts";
import type { SpatialIndex } from "../../algorithms/spatial-index.ts";
import type { Point, Rect } from "../../algorithms/geometry.ts";
import { mergeProps } from "../../lib/merge-props.ts";
import { mergeRefs } from "../../lib/merge-refs.ts";
import { isCommandKey } from "../../lib/platform.ts";
import { useCoarsePointer } from "../../lib/pointer.ts";
import { cn } from "../../lib/utils.ts";
import { connectReach, useConnect } from "../../primitives/connect.ts";
import type { PortProps } from "../../primitives/connect.ts";
import {
  applyGraphChanges,
  connectable,
  connection,
  portKey,
  portPoint,
  portRef,
  reconnection,
  refusal,
  removal,
  splice,
} from "../../primitives/graph.ts";
import type {
  ConnectRules,
  GraphChange,
  GraphData,
  GraphEdge,
  GraphNode,
  GraphPort,
  PortRef,
  PortSide,
  Refusal,
} from "../../primitives/graph.ts";
import { useControllableState } from "../../primitives/use-controllable-state.ts";
import { visibleRect } from "../../primitives/viewport.ts";
import { Canvas, CanvasItem } from "../canvas/canvas.tsx";
import type {
  CanvasApi,
  CanvasItemProps,
  CanvasLayer,
  CanvasMessages,
  CanvasProps,
} from "../canvas/canvas.tsx";
import { Listbox, ListboxItem } from "../listbox/listbox.tsx";

export type { GraphChange, GraphData, GraphEdge, GraphNode, GraphPort, PortRef };

/** Why a connection was not made: a rule of the graph's, or there being nowhere for it to go. */
export type DiagramRefusal = Refusal | "nowhere";

/** What the diagram says in words; pass `messages` to say it in another language. */
export interface DiagramMessages {
  /** How the keyboard works here: read with the diagram's name. */
  readonly instructions: string;
  /** The name of the list of connections. */
  readonly connections: string;
  /** A port, of a node: one end of a connection, in words. */
  readonly end: (node: string, port: string) => string;
  /** A connection, by its two ends, and its label where it has one. */
  readonly edge: (source: string, target: string, label?: string) => string;
  /** A port's name, with what it is for. */
  readonly port: (name: string, direction: "in" | "out" | undefined) => string;
  /** Said of a node or a port: what it leads to, and what leads to it. */
  readonly sends: (to: readonly string[]) => string;
  readonly receives: (from: readonly string[]) => string;
  /** Said of a port with nothing at it. */
  readonly unconnected: string;
  /** When the keyboard begins a connection: from where, and how many ports may take it. */
  readonly connecting: (from: string, count: number) => string;
  readonly connected: (source: string, target: string) => string;
  readonly refused: (reason: DiagramRefusal) => string;
  readonly removed: (nodes: number, edges: number) => string;
  /** A node dropped onto a connection and put into it. */
  readonly spliced: (node: string, edge: string) => string;
  /** The canvas's own words. */
  readonly canvas?: Partial<CanvasMessages> | undefined;
}

const count = (many: number, one: string, more: string) => (many === 1 ? `1 ${one}` : `${many} ${more}`);

const REFUSALS: Readonly<Record<DiagramRefusal, string>> = {
  nowhere: "There is nothing this can be connected to.",
  missing: "That is no longer there.",
  "same-node": "A node is not connected to itself.",
  direction: "An output is connected to an input, not to another output; an input to an output.",
  type: "These carry different things.",
  duplicate: "These are already connected.",
  full: "That takes no more connections.",
  cycle: "That would make a loop.",
};

const MESSAGES: DiagramMessages = {
  instructions:
    "Tab and Shift-Tab move between nodes, and through each node's ports; Alt with an arrow key moves to the nearest node that way. Enter on a port begins a connection: the arrow keys choose where it ends, Enter joins, Escape gives it up. After the nodes, the connections are a list: Space selects one, Enter moves its end, Delete removes it. Space selects a node, the arrow keys move what is selected, and Delete removes it.",
  connections: "Connections",
  end: (node, port) => `${node} ${port}`,
  edge: (source, target, label) => `${source} to ${target}${label === undefined ? "" : `, ${label}`}`,
  port: (name, direction) =>
    direction === "in" ? `${name}, input` : direction === "out" ? `${name}, output` : name,
  sends: (to) => `Sends to ${to.join(", ")}.`,
  receives: (from) => `Receives from ${from.join(", ")}.`,
  unconnected: "Not connected.",
  connecting: (from, ports) =>
    `Connecting from ${from}. ${count(ports, "port", "ports")} can take it: arrow keys choose, Enter joins, Escape gives it up.`,
  connected: (source, target) => `Connected ${source} to ${target}.`,
  refused: (reason) => REFUSALS[reason],
  removed: (nodes, edges) =>
    `Removed ${[
      nodes > 0 ? count(nodes, "node", "nodes") : "",
      edges > 0 ? count(edges, "connection", "connections") : "",
    ]
      .filter(Boolean)
      .join(" and ")}.`,
  spliced: (node, edge) => `Put ${node} into ${edge}.`,
};

export type DiagramProps<N extends GraphNode = GraphNode, E extends GraphEdge = GraphEdge> = Omit<
  CanvasProps<N>,
  "items" | "defaultItems" | "onItemsChange" | "renderItem" | "below" | "above" | "messages" | "children"
> & {
  /** The nodes and the edges. Given, the owner keeps them and applies `onGraphChange`; else they start at `defaultGraph`. */
  graph?: GraphData<N, E> | undefined;
  defaultGraph?: GraphData<N, E> | undefined;
  /** One gesture's changes, once: to apply (`applyGraphChanges`), and to record as one thing to undo. */
  onGraphChange?: ((changes: ReadonlyArray<GraphChange<N, E>>) => void) | undefined;
  /** What a node looks like: a `DiagramNode` around its content and its `DiagramPort`s. Keep it the same function between renders. */
  renderNode: (node: N) => ReactNode;
  /** What a node is called, where its connections are put into words. */
  nodeName: (node: N) => string;
  /** What a port is called; its id, unless this says otherwise. */
  portName?: ((node: N, port: GraphPort) => string) | undefined;
  /** The edge to make between two ports. Its id must be its own among the nodes and edges. */
  createEdge?: ((source: PortRef, target: PortRef) => E) | undefined;
  /** What may be connected, beyond what the ports themselves say. */
  rules?: ConnectRules | undefined;
  /** A connection that was not made, and why: to show it, however the application shows such things. */
  onRefuse?: ((reason: DiagramRefusal, from: PortRef, to: PortRef | null) => void) | undefined;
  /**
   * A line let go over empty space, begun at a port: where it was let go, on the
   * plane and in the browser's viewport — to offer there what could take it.
   */
  onDropOnEmpty?: ((from: PortRef, point: Point, client: Point) => void) | undefined;
  /** How lines run: level and upright with rounded corners, or as curves. */
  line?: "orthogonal" | "curve" | undefined;
  messages?: Partial<DiagramMessages> | undefined;
};

// Past this many edges, only those in and around the view are drawn.
const CULL_ABOVE = 500;
const CULL_MARGIN = 256;
// On the plane: how far an end runs straight out of its port, and how round a corner is.
const STUB = 16;
const CORNER = 8;
// A curve's handles reach no further than this beyond its ends.
const CURVE_REACH = 240;
// On the plane: how far beyond the box around its two nodes a line looks for other nodes to go round.
const AROUND = STUB * 4;
// On the screen: how near a line a press must be to pick it.
const PICK = { mouse: 6, pen: 8, touch: 16 } as const;
// On the screen: how far along a selected line, from its end, the part that end is moved by sits.
const GRIP_INSET = { fine: 18, coarse: 30 } as const;

const SIDES: Readonly<Record<PortSide, Side>> = {
  right: SIDE.right,
  bottom: SIDE.bottom,
  left: SIDE.left,
  top: SIDE.top,
};
const OPPOSITE: Readonly<Record<PortSide, PortSide>> = {
  left: "right",
  right: "left",
  top: "bottom",
  bottom: "top",
};
// The way an arrowhead points as it arrives at a port on each side.
const ARRIVING: Readonly<Record<PortSide, number>> = { left: 0, top: 90, right: 180, bottom: 270 };

const NO_GRAPH: GraphData<never, never> = { nodes: [], edges: [] };
const NO_IDS: readonly string[] = [];
const NOT_PLANE =
  "[data-canvas-item], [data-canvas-handle], [data-canvas-interactive], button, input, textarea, select, a[href]";
const TYPED = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';

/** An end of an edge being moved to another port. */
interface Moving {
  readonly edge: string;
  readonly end: "source" | "target";
}

/** A node about to be put into a line, and the changes that would do it. */
interface Splice<N extends GraphNode, E extends GraphEdge> {
  readonly edge: string;
  readonly node: string;
  /** The graph the changes were made for. */
  readonly graph: GraphData<N, E>;
  readonly changes: ReadonlyArray<GraphChange<N, E>>;
}

/** A line as it is drawn: where it runs, and all that is asked of it afterwards. */
interface Route {
  readonly edge: GraphEdge;
  readonly from: Rect;
  readonly to: Rect;
  readonly originX: number;
  readonly originY: number;
  readonly curve: boolean;
  readonly points: Float64Array;
  readonly count: number;
  /** Left, top, right, bottom. */
  readonly bounds: Float64Array;
  readonly data: string;
  readonly arrow: { readonly x: number; readonly y: number; readonly turn: number };
  /** The other nodes it was routed round, as they were then. */
  readonly around: readonly Rect[];
  /** Half-way along it, where its label is. */
  readonly middle: Point;
}

const scratch = new Float64Array(ROUTE_POINTS * 2);
const boxes = new Float64Array(8);
const hits: number[] = [];
const NONE: readonly Rect[] = [];

/**
 * The other nodes near a line between two nodes, for it to go round: those the
 * index finds about the two, where each is shown now — the nearest few, where
 * there are more.
 */
function nodesAround(
  index: SpatialIndex,
  nodes: readonly GraphNode[],
  source: string,
  target: string,
  from: Rect,
  to: Rect,
  moving: CanvasLayer["moving"],
  skip: string | null = null,
): readonly Rect[] {
  const left = Math.min(from.x, to.x) - AROUND;
  const top = Math.min(from.y, to.y) - AROUND;
  const right = Math.max(from.x + from.width, to.x + to.width) + AROUND;
  const bottom = Math.max(from.y + from.height, to.y + to.height) + AROUND;
  hits.length = 0;
  spatialSearch(index, left, top, right, bottom, hits);
  const near: Rect[] = [];
  for (const at of hits) {
    const node = nodes[at];
    if (node === undefined || node.id === source || node.id === target || node.id === skip) continue;
    near.push(rectOf(node, moving));
  }
  // A node being dragged is where it is shown, which the index, made from where nodes are, does not know.
  if (moving !== null) {
    for (const [id, rect] of moving) {
      if (id === source || id === target || id === skip || near.includes(rect)) continue;
      if (rect.x < right && rect.x + rect.width > left && rect.y < bottom && rect.y + rect.height > top) {
        near.push(rect);
      }
    }
  }
  if (near.length === 0) return NONE;
  if (near.length > MAX_OBSTACLES) {
    const cx = (left + right) / 2;
    const cy = (top + bottom) / 2;
    const distance = (rect: Rect) =>
      Math.abs(rect.x + rect.width / 2 - cx) + Math.abs(rect.y + rect.height / 2 - cy);
    // In place: the list was made here.
    // oxlint-disable-next-line unicorn/no-array-sort
    near.sort((a, b) => distance(a) - distance(b));
    near.length = MAX_OBSTACLES;
  }
  return near;
}

const sameRects = (a: readonly Rect[], b: readonly Rect[]) =>
  a.length === b.length && a.every((rect, at) => rect === b[at]);

function flatten(rects: readonly Rect[]): Float64Array | null {
  if (rects.length === 0) return null;
  const flat = new Float64Array(rects.length * 4);
  for (const [at, rect] of rects.entries()) flat.set([rect.x, rect.y, rect.width, rect.height], at * 4);
  return flat;
}
const place = new Float64Array(2);

function drawRoute(
  edge: GraphEdge,
  source: GraphNode,
  target: GraphNode,
  from: Rect,
  to: Rect,
  curve: boolean,
  origin: Point,
  around: readonly Rect[],
): Route | null {
  const a = portPoint(source, edge.source.port, from);
  const b = portPoint(target, edge.target.port, to);
  if (a === null || b === null) return null;
  let points: Float64Array;
  let length: number;
  if (curve) {
    points = new Float64Array(8);
    curveRoute(a.x, a.y, SIDES[a.side], b.x, b.y, SIDES[b.side], points, STUB * 1.5, CURVE_REACH);
    length = 4;
  } else {
    boxes.set([from.x, from.y, from.width, from.height, to.x, to.y, to.width, to.height]);
    length = orthogonalRoute(
      a.x,
      a.y,
      SIDES[a.side],
      b.x,
      b.y,
      SIDES[b.side],
      boxes,
      scratch,
      STUB,
      flatten(around),
    );
    points = scratch.slice(0, length * 2);
  }
  const bounds = new Float64Array(4);
  pointsBounds(points, length, bounds);
  return {
    edge,
    from,
    to,
    originX: origin.x,
    originY: origin.y,
    curve,
    points,
    count: length,
    bounds,
    data: curve
      ? curvePath(points, origin.x, origin.y)
      : polylinePath(points, length, CORNER, origin.x, origin.y),
    arrow: { x: b.x - origin.x, y: b.y - origin.y, turn: ARRIVING[b.side] },
    around,
    middle: halfway(points, length, curve),
  };
}

function halfway(points: Float64Array, length: number, curve: boolean): Point {
  if (curve) curvePoint(points, 0.5, place);
  else polylinePoint(points, length, polylineLength(points, length) / 2, place);
  return { x: place[0]!, y: place[1]! };
}

/** The point a distance along a line from one of its ends. */
function along(route: Route, distance: number, end: "source" | "target"): Point {
  if (route.curve) {
    // A curve's length is near enough the length of the line through its four points.
    const part = Math.min(distance / Math.max(polylineLength(route.points, 4), 1), 0.3);
    curvePoint(route.points, end === "source" ? part : 1 - part, place);
  } else {
    const whole = polylineLength(route.points, route.count);
    // On a short line, three-tenths of the way: the two ends' parts stay apart.
    const part = Math.min(distance, whole * 0.3);
    polylinePoint(route.points, route.count, end === "source" ? part : whole - part, place);
  }
  return { x: place[0]!, y: place[1]! };
}

/** Where a node is shown now: where it is being dragged to, or where it is. */
const rectOf = (node: GraphNode, moving: CanvasLayer["moving"]) => moving?.get(node.id) ?? node.rect;

// Sized on the screen whatever the zoom: the plane is scaled, and these would be too.
const onScreen = (pixels: number) => `calc(${pixels}px / var(--canvas-zoom))`;

interface WireProps {
  id: string;
  data: string;
  x: number;
  y: number;
  turn: number;
  selected: boolean;
  focused: boolean;
  hovered: boolean;
  reconnecting: boolean;
  splicing: boolean;
}

/** One line: drawn again only when something of its own changes. */
const Wire = memo(function Wire({
  id,
  data,
  x,
  y,
  turn,
  selected,
  focused,
  hovered,
  reconnecting,
  splicing,
}: WireProps) {
  return (
    <g
      data-diagram-edge=""
      data-id={id}
      data-selected={selected ? "" : undefined}
      data-focused={focused ? "" : undefined}
      data-hovered={hovered ? "" : undefined}
      data-reconnecting={reconnecting ? "" : undefined}
      data-splicing={splicing ? "" : undefined}
      className={cn(
        "group/edge fill-none stroke-fg-muted text-fg-muted data-hovered:stroke-fg-strong data-hovered:text-fg-strong",
        "data-selected:stroke-selected data-selected:text-selected data-reconnecting:opacity-30",
        "data-splicing:stroke-selected data-splicing:text-selected forced-colors:data-splicing:stroke-[Highlight] forced-colors:data-splicing:text-[Highlight]",
        "forced-colors:stroke-[CanvasText] forced-colors:text-[CanvasText] forced-colors:data-selected:stroke-[Highlight] forced-colors:data-selected:text-[Highlight]",
      )}
    >
      {/* The ring of a line that the keyboard is on. */}
      <path
        d={data}
        className="hidden stroke-focus-ring opacity-60 group-data-focused/edge:block forced-colors:stroke-[Highlight] forced-colors:opacity-100"
        style={{ strokeWidth: onScreen(6) }}
        strokeLinecap="round"
      />
      <path
        d={data}
        style={{ strokeWidth: onScreen(selected || splicing ? 2 : 1.5) }}
        strokeLinejoin="round"
      />
      <path
        d="M-7 -4L0 0L-7 4Z"
        fill="currentColor"
        stroke="none"
        style={{
          transform: `translate(${x}px, ${y}px) rotate(${turn}deg) scale(calc(1 / var(--canvas-zoom)))`,
        }}
      />
    </g>
  );
});

interface DiagramContextValue {
  readonly portProps: (key: string) => PortProps;
  readonly portLabel: (node: GraphNode, port: GraphPort) => string;
  readonly describeNode: (node: string) => string;
  readonly describePort: (key: string) => string;
  readonly connected: (key: string) => boolean;
}

const DiagramContext = createContext<DiagramContextValue | null>(null);
const NodeContext = createContext<GraphNode | null>(null);

export function Diagram<N extends GraphNode = GraphNode, E extends GraphEdge = GraphEdge>({
  graph: graphProp,
  defaultGraph,
  onGraphChange,
  renderNode,
  nodeName,
  portName,
  createEdge,
  rules,
  onRefuse,
  onDropOnEmpty,
  line = "orthogonal",
  messages: messagesProp,
  value: valueProp,
  defaultValue,
  onValueChange,
  api,
  ref,
  className,
  onKeyDown,
  onClick,
  onPointerDown,
  onPointerMove,
  onPointerLeave,
  ...props
}: DiagramProps<N, E>): ReactElement {
  const rootRef = useRef<HTMLDivElement>(null);
  const setRoot = useCallback((element: HTMLDivElement | null) => {
    rootRef.current = element;
  }, []);
  const mergedRef = useMemo(() => mergeRefs<HTMLDivElement>(setRoot, ref), [setRoot, ref]);
  // What is asked of the canvas, once it is there: kept as state, since what is drawn asks it too.
  const [canvasApi, setCanvasApi] = useState<CanvasApi | null>(null);
  const mergedApi = useMemo(() => mergeRefs<CanvasApi>(setCanvasApi, api), [api]);
  const messages = useMemo(() => ({ ...MESSAGES, ...messagesProp }), [messagesProp]);
  const coarse = useCoarsePointer();
  const curve = line === "curve";

  const [ownGraph, setOwnGraph] = useState<GraphData<N, E>>(defaultGraph ?? NO_GRAPH);
  const graph = graphProp ?? ownGraph;
  const controlled = graphProp !== undefined;
  const [value, setValue] = useControllableState<readonly string[]>(
    valueProp,
    defaultValue ?? NO_IDS,
    onValueChange as ((value: readonly string[]) => void) | undefined,
  );
  const selected = useMemo(() => new Set(value), [value]);

  const nodes = useMemo(() => new Map(graph.nodes.map((node) => [node.id, node])), [graph.nodes]);
  const edges = useMemo(() => new Map(graph.edges.map((edge) => [edge.id, edge])), [graph.edges]);
  // Where the nodes are, for a line to find those it should go round.
  const index = useMemo(() => spatialIndexFromRects(graph.nodes.map((node) => node.rect)), [graph.nodes]);
  const selectedEdges = useMemo(() => value.filter((id) => edges.has(id)), [value, edges]);

  const endName = useCallback(
    (end: PortRef): string => {
      const node = nodes.get(end.node);
      const port = node?.ports.find((each) => each.id === end.port);
      if (node === undefined || port === undefined) return end.node;
      return messages.end(nodeName(node), portName?.(node, port) ?? port.id);
    },
    [nodes, nodeName, portName, messages],
  );
  const edgeName = useCallback(
    (edge: GraphEdge) => messages.edge(endName(edge.source), endName(edge.target), edge.label),
    [messages, endName],
  );

  // What each node and each port is joined to, gathered once for them all.
  const links = useMemo(() => {
    const byNode = new Map<string, { to: Set<string>; from: Set<string> }>();
    const byPort = new Map<string, { to: string[]; from: string[] }>();
    const node = (id: string) => {
      let found = byNode.get(id);
      if (found === undefined) byNode.set(id, (found = { to: new Set(), from: new Set() }));
      return found;
    };
    const port = (key: string) => {
      let found = byPort.get(key);
      if (found === undefined) byPort.set(key, (found = { to: [], from: [] }));
      return found;
    };
    for (const edge of graph.edges) {
      const source = nodes.get(edge.source.node);
      const target = nodes.get(edge.target.node);
      if (source === undefined || target === undefined) continue;
      node(source.id).to.add(nodeName(target));
      node(target.id).from.add(nodeName(source));
      port(portKey(edge.source)).to.push(endName(edge.target));
      port(portKey(edge.target)).from.push(endName(edge.source));
    }
    return { byNode, byPort };
  }, [graph.edges, nodes, nodeName, endName]);

  const emit = useCallback(
    (changes: ReadonlyArray<GraphChange<N, E>>) => {
      if (changes.length === 0) return;
      if (!controlled) setOwnGraph((current) => applyGraphChanges(current, changes));
      onGraphChange?.(changes);
    },
    [controlled, onGraphChange],
  );

  const makeEdge =
    createEdge ??
    ((source: PortRef, target: PortRef) =>
      ({ id: `${portKey(source)}→${portKey(target)}`, source, target }) as E);

  const refuse = (reason: DiagramRefusal, from: PortRef, to: PortRef | null) => {
    canvasApi?.announce(messages.refused(reason));
    onRefuse?.(reason, from, to);
  };

  // Where the free end of the line being drawn is, on the plane.
  const [free, setFree] = useState<Point | null>(null);
  const aimAgain = useRef<() => string | null>(() => null);
  const connect = useConnect<Moving>({
    targets: (from, moving) =>
      connectable(graph, portRef(from), { ...rules, replacing: moving?.edge }).map(portKey),
    onConnect(from, to, moving) {
      const made =
        moving === undefined
          ? connection(graph, portRef(from), portRef(to), makeEdge, rules)
          : reconnection(graph, moving.edge, moving.end, portRef(to), rules);
      if (made === null) return;
      if (typeof made === "string") {
        refuse(made, portRef(from), portRef(to));
        return;
      }
      emit([made]);
      const joined =
        made.kind === "insert"
          ? made.edges[0]?.item
          : made.kind === "replace"
            ? made.edges[0]?.item
            : undefined;
      if (joined !== undefined) {
        canvasApi?.announce(messages.connected(endName(joined.source), endName(joined.target)));
      }
    },
    // A connection begun without a pointer is said: where from, and how many ports can take it.
    onBegin(from, how, targets) {
      if (how === "pick") {
        canvasApi?.announce(messages.connecting(endName(portRef(from)), targets.length));
      }
    },
    // Let go over nothing: the application may offer to put something there. An end being moved stays where it was.
    onMiss(from, at, moving) {
      if (moving !== undefined || canvasApi === null) return;
      onDropOnEmpty?.(portRef(from), canvasApi.toPlane(at.x, at.y), at);
    },
    onRefuse(from, to, moving) {
      const reason =
        to === null
          ? "nowhere"
          : refusal(graph, portRef(from), portRef(to), { ...rules, replacing: moving?.edge });
      if (reason !== null) refuse(reason, portRef(from), to === null ? null : portRef(to));
    },
    onDrag(drag, target) {
      const plane = canvasApi;
      if (plane === null) return;
      if (drag === null) {
        plane.dragAt(null);
        setFree(null);
        return;
      }
      setFree(plane.toPlane(drag.x, drag.y));
      // Held near an edge the view moves along, and what is under the resting pointer changes —
      // until a port that can take the line comes under it: there the view stops, to be let go on.
      if (target !== null) plane.dragAt(null);
      else {
        plane.dragAt(
          drag,
          () => {
            setFree(plane.toPlane(drag.x, drag.y));
            if (aimAgain.current() !== null) plane.dragAt(null);
          },
          // No further at a time than a port can be missed by, however late the frame.
          connectReach(drag.pointerType),
        );
      }
    },
  });
  useEffect(() => {
    aimAgain.current = connect.aimAgain;
  });
  const { connecting } = connect;

  // Ports draw again when what they are to the connection changes, not at every move of it:
  // `portProps` is the same function from one move to the next.
  const { portProps } = connect;
  const context = useMemo<DiagramContextValue>(
    () => ({
      portProps,
      portLabel: (node, port) => messages.port(portName?.(node as N, port) ?? port.id, port.direction),
      describeNode(id) {
        const found = links.byNode.get(id);
        return [
          found !== undefined && found.to.size > 0 ? messages.sends([...found.to]) : "",
          found !== undefined && found.from.size > 0 ? messages.receives([...found.from]) : "",
        ]
          .filter(Boolean)
          .join(" ");
      },
      describePort(key) {
        const found = links.byPort.get(key);
        if (found === undefined) return messages.unconnected;
        return [
          found.to.length > 0 ? messages.sends(found.to) : "",
          found.from.length > 0 ? messages.receives(found.from) : "",
        ]
          .filter(Boolean)
          .join(" ");
      },
      connected: (key) => links.byPort.has(key),
    }),
    [portProps, links, messages, portName],
  );

  const renderItem = useCallback(
    (node: N) => <NodeContext value={node}>{renderNode(node)}</NodeContext>,
    [renderNode],
  );

  const [hovered, setHovered] = useState<string | null>(null);
  const [focusedEdge, setFocusedEdge] = useState<string | null>(null);
  const routes = useRef<Map<string, Route>>(new Map());
  const settled = useRef<ReadonlyMap<string, Route>>(new Map());
  const spliced = useRef<Splice<N, E> | null>(null);
  const zoom = useRef(1);
  const pointerType = useRef("mouse");

  /** The line at a place in the browser's viewport: the nearest within a pointer's aim, the topmost of equals. */
  function edgeAt(clientX: number, clientY: number, kind: string): string | null {
    const plane = canvasApi;
    if (plane === null) return null;
    const { x, y } = plane.toPlane(clientX, clientY);
    let least = (kind === "mouse" || kind === "pen" ? PICK[kind] : PICK.touch) / zoom.current;
    let found: string | null = null;
    for (const [id, route] of routes.current) {
      const { bounds } = route;
      if (
        x < bounds[0]! - least ||
        x > bounds[2]! + least ||
        y < bounds[1]! - least ||
        y > bounds[3]! + least
      ) {
        continue;
      }
      const distance = route.curve
        ? curveDistance(route.points, x, y)
        : polylineDistance(route.points, route.count, x, y);
      if (distance <= least) {
        least = distance;
        found = id;
      }
    }
    return found;
  }

  const onPlane = (target: EventTarget | null) =>
    target instanceof Element &&
    rootRef.current?.contains(target) === true &&
    target.closest(NOT_PLANE) === null;

  function below(layer: CanvasLayer): ReactNode {
    zoom.current = layer.viewport.zoom;
    const { origin, moving } = layer;
    const many = graph.edges.length > CULL_ABOVE;
    const shown = visibleRect(layer.viewport, layer.size);
    const margin = CULL_MARGIN / layer.viewport.zoom + (curve ? CURVE_REACH : STUB);
    const view: Rect = {
      x: shown.x - margin,
      y: shown.y - margin,
      width: shown.width + margin * 2,
      height: shown.height + margin * 2,
    };
    const next = new Map<string, Route>();
    const wires: ReactNode[] = [];
    const labels: ReactNode[] = [];
    const into = spliceTarget(moving);
    spliced.current = into;
    for (const edge of graph.edges) {
      const source = nodes.get(edge.source.node);
      const target = nodes.get(edge.target.node);
      if (source === undefined || target === undefined) continue;
      const from = rectOf(source, moving);
      const to = rectOf(target, moving);
      const kept = selected.has(edge.id) || edge.id === focusedEdge || connecting?.payload?.edge === edge.id;
      // A line stays within reach of the box around its two nodes: one out of view need not be routed to know it.
      if (many && !kept && !rectsIntersect(rectUnion([from, to])!, view)) continue;
      const last = routes.current.get(edge.id);
      // A curve goes round nothing; a square line, round the nodes near it — but for one about to be put into it.
      const around = curve
        ? NONE
        : nodesAround(
            index,
            graph.nodes,
            source.id,
            target.id,
            from,
            to,
            moving,
            into?.edge === edge.id ? into.node : null,
          );
      const route =
        last !== undefined &&
        last.edge === edge &&
        last.from === from &&
        last.to === to &&
        last.curve === curve &&
        last.originX === origin.x &&
        last.originY === origin.y &&
        sameRects(last.around, around)
          ? last
          : drawRoute(edge, source, target, from, to, curve, origin, around);
      if (route === null) continue;
      next.set(edge.id, route);
      wires.push(
        <Wire
          key={edge.id}
          id={edge.id}
          data={route.data}
          x={route.arrow.x}
          y={route.arrow.y}
          turn={route.arrow.turn}
          selected={selected.has(edge.id)}
          focused={edge.id === focusedEdge}
          hovered={edge.id === hovered}
          reconnecting={connecting?.payload?.edge === edge.id}
          splicing={into?.edge === edge.id}
        />,
      );
      if (edge.label !== undefined && edge.label !== "") {
        labels.push(
          <span
            key={edge.id}
            data-diagram-label=""
            data-id={edge.id}
            style={{ left: route.middle.x - origin.x, top: route.middle.y - origin.y }}
            className="absolute -translate-1/2 rounded-sm bg-surface px-1 text-[11px] leading-4 whitespace-nowrap text-fg-muted forced-colors:text-[CanvasText]"
          >
            {edge.label}
          </span>,
        );
      }
    }
    routes.current = next;
    // Where the lines are when nothing is being dragged: what a dragged node is dropped onto.
    if (moving === null) settled.current = next;
    return (
      <>
        <svg
          aria-hidden
          data-diagram-edges=""
          width="1"
          height="1"
          className="pointer-events-none absolute top-0 left-0 overflow-visible"
        >
          {wires}
        </svg>
        {/* Read in the list of connections, with the connection's name. */}
        {labels.length > 0 && (
          <div aria-hidden data-diagram-labels="" className="pointer-events-none absolute top-0 left-0">
            {labels}
          </div>
        )}
      </>
    );
  }

  /**
   * The line a node being dragged on its own would be put into, were it let go
   * now: the nearest whose line, as it lay before the drag, runs within half the
   * node's breadth of its middle — and that its ports can take and give.
   */
  function spliceTarget(moving: CanvasLayer["moving"]): Splice<N, E> | null {
    if (moving === null || moving.size !== 1) return null;
    const [id, rect] = [...moving][0]!;
    const node = nodes.get(id);
    if (node === undefined || node.locked === true) return null;
    const x = rect.x + rect.width / 2;
    const y = rect.y + rect.height / 2;
    let least = Math.min(rect.width, rect.height) / 2;
    let nearest: string | null = null;
    for (const [edgeId, route] of settled.current) {
      const { edge, bounds } = route;
      if (edge.source.node === id || edge.target.node === id) continue;
      if (
        x < bounds[0]! - least ||
        x > bounds[2]! + least ||
        y < bounds[1]! - least ||
        y > bounds[3]! + least
      ) {
        continue;
      }
      const distance = route.curve
        ? curveDistance(route.points, x, y)
        : polylineDistance(route.points, route.count, x, y);
      if (distance <= least) {
        least = distance;
        nearest = edgeId;
      }
    }
    if (nearest === null) return null;
    const last = spliced.current;
    if (last !== null && last.edge === nearest && last.node === id && last.graph === graph) return last;
    const changes = splice(graph, nearest, id, makeEdge, rules);
    return changes === null ? null : { edge: nearest, node: id, graph, changes };
  }

  function above(layer: CanvasLayer): ReactNode {
    const { origin } = layer;
    const inset = (coarse ? GRIP_INSET.coarse : GRIP_INSET.fine) / layer.viewport.zoom;
    let band: ReactNode = null;
    if (connecting !== null) {
      const from = portRef(connecting.from);
      const source = nodes.get(from.node);
      const start = source === undefined ? null : portPoint(source, from.port, rectOf(source, layer.moving));
      const to = connecting.target === null ? null : portRef(connecting.target);
      const target = to === null ? undefined : nodes.get(to.node);
      const end =
        to !== null && target !== undefined ? portPoint(target, to.port, rectOf(target, layer.moving)) : null;
      const stop = end ?? free;
      if (source !== undefined && start !== null && stop !== null) {
        // A free end is come to from the side it is on of where it began.
        const facing = end?.side ?? OPPOSITE[start.side];
        let data: string;
        if (curve) {
          const points = new Float64Array(8);
          curveRoute(
            start.x,
            start.y,
            SIDES[start.side],
            stop.x,
            stop.y,
            SIDES[facing],
            points,
            STUB * 1.5,
            CURVE_REACH,
          );
          data = curvePath(points, origin.x, origin.y);
        } else if (end === null || target === undefined) {
          // To a pointer there is no box to go round and no side to arrive by: out of the port,
          // across half-way — or a stub out, where the pointer is behind — and along to it.
          const level = start.side === "left" || start.side === "right";
          const sign = start.side === "left" || start.side === "top" ? -1 : 1;
          const out = level ? start.x : start.y;
          const far = level ? stop.x : stop.y;
          const turn = (far - out) * sign > STUB * 2 ? (out + far) / 2 : out + STUB * sign;
          scratch.set(
            level
              ? [start.x, start.y, turn, start.y, turn, stop.y, stop.x, stop.y]
              : [start.x, start.y, start.x, turn, stop.x, turn, stop.x, stop.y],
          );
          data = polylinePath(scratch, 4, CORNER, origin.x, origin.y);
        } else {
          const rect = rectOf(source, layer.moving);
          const ends = rectOf(target, layer.moving);
          boxes.set([rect.x, rect.y, rect.width, rect.height, ends.x, ends.y, ends.width, ends.height]);
          const length = orthogonalRoute(
            start.x,
            start.y,
            SIDES[start.side],
            stop.x,
            stop.y,
            SIDES[facing],
            boxes,
            scratch,
            STUB,
            flatten(nodesAround(index, graph.nodes, source.id, target.id, rect, ends, layer.moving)),
          );
          data = polylinePath(scratch, length, CORNER, origin.x, origin.y);
        }
        band = (
          <svg
            aria-hidden
            width="1"
            height="1"
            className="pointer-events-none absolute top-0 left-0 overflow-visible"
          >
            <path
              data-diagram-band=""
              data-refused={connecting.refused === null ? undefined : ""}
              d={data}
              className="fill-none stroke-selected data-refused:stroke-danger forced-colors:stroke-[Highlight]"
              style={{ strokeWidth: onScreen(2), strokeDasharray: `${onScreen(6)} ${onScreen(4)}` }}
              strokeLinejoin="round"
            />
          </svg>
        );
      }
    }
    // A selected line shows, near each end, the part that end is taken hold of by.
    const grips: ReactNode[] = [];
    if (connecting === null || connecting.payload !== undefined) {
      for (const id of selectedEdges) {
        const route = routes.current.get(id);
        if (route === undefined || (connecting !== null && connecting.payload?.edge !== id)) continue;
        for (const end of ["source", "target"] as const) {
          const at = along(route, inset, end);
          const kept = end === "source" ? route.edge.target : route.edge.source;
          const grip = connect.gripProps(portKey(kept), { edge: id, end });
          grips.push(
            <div
              key={`${id} ${end}`}
              data-diagram-grip={end}
              data-drag-handle=""
              data-id={id}
              data-canvas-interactive=""
              {...grip}
              className={cn(
                "absolute -translate-1/2 cursor-grab rounded-full border border-selected bg-surface",
                // The target is wider than what is seen, and wider again under a finger.
                "before:absolute before:top-1/2 before:left-1/2 before:size-[calc(24px/var(--canvas-zoom))] before:-translate-1/2 before:rounded-full pointer-coarse:before:size-[calc(44px/var(--canvas-zoom))]",
                "forced-colors:border-[Highlight] forced-colors:bg-[Canvas]",
              )}
              style={{
                ...grip.style,
                left: at.x - origin.x,
                top: at.y - origin.y,
                width: onScreen(coarse ? 16 : 10),
                height: onScreen(coarse ? 16 : 10),
                borderWidth: onScreen(1.5),
              }}
            />,
          );
        }
      }
    }
    return (
      <>
        {band}
        {grips}
      </>
    );
  }

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>): void {
    const target = event.target as HTMLElement;
    if (event.key === "Tab" && !event.altKey && !event.ctrlKey && !event.metaKey) {
      // A node's ports come after it and before the next node: the same in every browser, button or not.
      const item = target.closest<HTMLElement>("[data-canvas-item]");
      if (item === null || !event.currentTarget.contains(item)) return;
      const ports = [...item.querySelectorAll<HTMLElement>("[data-connect-port]")];
      const at = ports.indexOf(target.closest<HTMLElement>("[data-connect-port]")!);
      const next =
        target === item
          ? event.shiftKey
            ? undefined
            : ports[0]
          : at === -1
            ? undefined
            : (ports[at + (event.shiftKey ? -1 : 1)] ?? (event.shiftKey ? item : undefined));
      if (next === undefined) return;
      event.preventDefault();
      next.focus({ preventScroll: true });
      return;
    }
    if ((event.key === "Delete" || event.key === "Backspace") && target.closest(TYPED) === null) {
      // In the list, the connection the keyboard is on goes, unless it is one of several selected.
      const listed = target.closest("[data-diagram-connections]") !== null ? focusedEdge : null;
      const going = listed !== null && !selected.has(listed) ? [listed] : value;
      const change = removal(
        graph,
        going.filter((id) => nodes.get(id)?.locked !== true),
      );
      if (change === null || change.kind !== "remove") return;
      event.preventDefault();
      // Focus on something that is going is given to the canvas, which finds it a place.
      const active = document.activeElement;
      const lost =
        active instanceof HTMLElement &&
        (active.closest("[data-diagram-connections]") !== null
          ? change.edges.length === graph.edges.length
          : false);
      emit([change]);
      const gone = new Set([...change.nodes, ...change.edges].map((each) => each.item.id));
      setValue(value.filter((id) => !gone.has(id)));
      canvasApi?.announce(messages.removed(change.nodes.length, change.edges.length));
      if (lost) {
        (
          event.currentTarget.querySelector<HTMLElement>('[data-canvas-item][tabindex="0"]') ??
          event.currentTarget
        ).focus({ preventScroll: true });
      }
    }
  }

  function handleClick(event: MouseEvent<HTMLDivElement>): void {
    if (event.detail === 0) return;
    // The part a line's end is taken by is part of the line: a press on it that drags nothing is a press on the line.
    const held =
      event.target instanceof Element ? event.target.closest<HTMLElement>("[data-diagram-grip]") : null;
    const id =
      held !== null
        ? (held.dataset.id ?? null)
        : onPlane(event.target)
          ? edgeAt(event.clientX, event.clientY, pointerType.current)
          : null;
    if (id === null) return;
    // The click was the line's: the canvas does not also take it for one on empty space.
    event.preventDefault();
    // A mouse replaces, or with Shift or the command key adds and takes out; a finger adds and takes out.
    const toggles = pointerType.current !== "mouse" || event.shiftKey || isCommandKey(event);
    if (!toggles) setValue([id]);
    else setValue(selected.has(id) ? value.filter((each) => each !== id) : [...value, id]);
  }

  return (
    <DiagramContext value={context}>
      <Canvas<N>
        ref={mergedRef}
        api={mergedApi}
        items={graph.nodes}
        onItemsChange={(changes) => {
          const move: GraphChange<N, E> = { kind: "move", moves: changes };
          const into = spliced.current;
          spliced.current = null;
          // Dropped onto a line on its own, a node goes into it: the move and the splice are one change.
          if (into === null || changes.length !== 1 || changes[0]!.id !== into.node) {
            emit([move]);
            return;
          }
          emit([move, ...into.changes]);
          const node = nodes.get(into.node);
          const edge = edges.get(into.edge);
          if (node !== undefined && edge !== undefined) {
            // After the canvas has said where it moved to: what was done is the last word.
            const words = messages.spliced(nodeName(node), edgeName(edge));
            queueMicrotask(() => canvasApi?.announce(words));
          }
        }}
        renderItem={renderItem}
        below={below}
        above={above}
        value={value}
        onValueChange={setValue}
        messages={{ instructions: messages.instructions, ...messages.canvas }}
        data-diagram=""
        data-connecting={connecting === null ? undefined : connecting.how}
        {...props}
        className={cn(hovered !== null && "cursor-pointer", className)}
        onKeyDown={(event) => {
          onKeyDown?.(event);
          if (!event.defaultPrevented) handleKeyDown(event);
        }}
        onClick={(event) => {
          onClick?.(event);
          if (!event.defaultPrevented) handleClick(event);
        }}
        onPointerDown={(event) => {
          onPointerDown?.(event);
          pointerType.current = event.pointerType;
          // A secondary press on a line selects it, unless it is selected already: a menu opened there is about it.
          if (event.defaultPrevented || event.button !== 2 || !onPlane(event.target)) return;
          const id = edgeAt(event.clientX, event.clientY, event.pointerType);
          if (id !== null && !selected.has(id)) setValue([id]);
        }}
        onPointerMove={(event) => {
          onPointerMove?.(event);
          // A line under a resting mouse shows that it can be picked; a finger has no resting.
          if (event.pointerType !== "mouse") return;
          const over =
            event.buttons === 0 && connecting === null && onPlane(event.target)
              ? edgeAt(event.clientX, event.clientY, "mouse")
              : null;
          if (over !== hovered) setHovered(over);
        }}
        onPointerLeave={(event) => {
          onPointerLeave?.(event);
          if (hovered !== null) setHovered(null);
        }}
      >
        {graph.edges.length > 0 && (
          <Listbox
            data-diagram-connections=""
            data-canvas-interactive=""
            aria-label={messages.connections}
            selectionMode="multiple"
            selectionBehavior="toggle"
            value={selectedEdges}
            onValueChange={(chosen) => setValue([...value.filter((id) => !edges.has(id)), ...chosen])}
            // Enter takes hold of the end a connection arrives at, to move it to another port.
            onAction={(id) => {
              const edge = edges.get(id);
              if (edge !== undefined) connect.begin(portKey(edge.source), { edge: id, end: "target" });
            }}
            onBlur={(event) => {
              if (!event.currentTarget.contains(event.relatedTarget)) setFocusedEdge(null);
            }}
            className="sr-only"
          >
            {graph.edges.map((edge) => (
              <ListboxItem
                key={edge.id}
                value={edge.id}
                // The line the keyboard is on is shown, and brought into view.
                onFocus={() => {
                  setFocusedEdge(edge.id);
                  const bounds = routes.current.get(edge.id)?.bounds;
                  if (bounds !== undefined) {
                    canvasApi?.reveal({
                      x: bounds[0]!,
                      y: bounds[1]!,
                      width: bounds[2]! - bounds[0]!,
                      height: bounds[3]! - bounds[1]!,
                    });
                  }
                }}
              >
                {edgeName(edge)}
              </ListboxItem>
            ))}
          </Listbox>
        )}
      </Canvas>
    </DiagramContext>
  );
}

export type DiagramNodeProps = CanvasItemProps;

/** A node: a canvas item that also says what it sends to and receives from. */
export function DiagramNode({
  children,
  "aria-describedby": describedBy,
  ...props
}: DiagramNodeProps): ReactElement {
  const diagram = use(DiagramContext);
  const node = use(NodeContext);
  if (diagram === null || node === null) {
    throw new Error("DiagramNode must be rendered by a Diagram's `renderNode`.");
  }
  const id = useId();
  const said = diagram.describeNode(node.id);
  return (
    <CanvasItem
      aria-describedby={[describedBy, said === "" ? null : id].filter(Boolean).join(" ") || undefined}
      {...props}
    >
      {children}
      <span id={id} hidden>
        {said}
      </span>
    </CanvasItem>
  );
}

export type DiagramPortProps = Omit<ComponentProps<"button">, "id" | "type"> & {
  /** The port's id among its node's. */
  port: string;
};

/**
 * A port of the node it is in: a button on the node's edge, where the node's
 * data says it is. A connection is dragged from it, or begun on it with a
 * press or Enter.
 */
export function DiagramPort({ port: portId, className, style, ...props }: DiagramPortProps): ReactElement {
  const diagram = use(DiagramContext);
  const node = use(NodeContext);
  if (diagram === null || node === null) {
    throw new Error("DiagramPort must be rendered inside a DiagramNode.");
  }
  const port = node.ports.find((each) => each.id === portId);
  const at = portPoint(node, portId);
  if (port === undefined || at === null) {
    throw new Error(`DiagramPort: the node "${node.id}" has no port "${portId}".`);
  }
  const id = useId();
  const key = portKey({ node: node.id, port: portId });
  const own = diagram.portProps(key);
  const { rect } = node;
  return (
    <>
      <button
        type="button"
        // Reached from its node by Tab, which the diagram moves itself: a browser that leaves buttons out of the Tab order would pass it by.
        tabIndex={-1}
        aria-label={diagram.portLabel(node, port)}
        aria-describedby={id}
        data-diagram-port={portId}
        data-side={port.side}
        data-direction={port.direction}
        data-connected={diagram.connected(key) ? "" : undefined}
        {...mergeProps(own, props)}
        style={{
          // In parts of the node, so that it keeps its place while the node is resized.
          left: `${rect.width === 0 ? 0 : ((at.x - rect.x) / rect.width) * 100}%`,
          top: `${rect.height === 0 ? 0 : ((at.y - rect.y) / rect.height) * 100}%`,
          ...own.style,
          ...style,
        }}
        className={cn(
          "absolute size-3 -translate-1/2 cursor-crosshair rounded-full border-[1.5px] border-border bg-surface p-0 outline-none",
          "[-webkit-tap-highlight-color:transparent]",
          // The target is wider than what is seen, on the screen whatever the zoom, and wider again under a finger.
          "before:absolute before:top-1/2 before:left-1/2 before:size-[calc(28px/var(--canvas-zoom))] before:-translate-1/2 before:rounded-full pointer-coarse:before:size-[calc(44px/var(--canvas-zoom))]",
          "hover:border-selected data-connected:bg-fg-muted",
          "data-[connect-state=source]:border-selected data-[connect-state=source]:bg-selected",
          "data-[connect-state=candidate]:border-selected data-[connect-state=candidate]:bg-surface",
          "data-[connect-state=target]:scale-150 data-[connect-state=target]:border-selected data-[connect-state=target]:bg-selected",
          "data-[connect-state=refused]:cursor-not-allowed data-[connect-state=refused]:border-danger data-[connect-state=refused]:bg-danger",
          "data-[connect-state=unavailable]:opacity-40",
          "focus-visible:outline-solid focus-visible:outline-focus-ring focus-visible:outline-[length:calc(2px/var(--canvas-zoom))] focus-visible:outline-offset-[calc(2px/var(--canvas-zoom))]",
          // The system's colours, each state told from the others as it is by the palette's.
          "forced-colors:border-[ButtonText] forced-colors:bg-[Canvas] forced-colors:data-connected:bg-[ButtonText]",
          "forced-colors:data-[connect-state=source]:border-[Highlight] forced-colors:data-[connect-state=source]:bg-[Highlight]",
          "forced-colors:data-[connect-state=candidate]:border-[Highlight] forced-colors:data-[connect-state=candidate]:bg-[Canvas]",
          "forced-colors:data-[connect-state=target]:border-[Highlight] forced-colors:data-[connect-state=target]:bg-[Highlight]",
          "forced-colors:data-[connect-state=refused]:border-[GrayText] forced-colors:data-[connect-state=refused]:bg-[GrayText]",
          className,
        )}
      />
      <span id={id} hidden>
        {diagram.describePort(key)}
      </span>
    </>
  );
}