Foundations

Graphs

Nodes, ports and edges as data: what may be joined, changes that undo, and a layout that follows or is pinned against.

Markdown

A graph is things joined to things: the steps of a pipeline, the nodes of a flow, the boxes and arrows of a diagram. The things are nodes; where a line may be attached to one is a port; a line from one port to another is an edge. What may be joined, what a change to the graph is, and where its nodes are put are questions about the graph itself, whatever draws it.

Laid out, live, and pinned

Turn on Keep laid out and add a step: it takes its place and the rest make room. Drag a step somewhere else: it is pinned where you put it, and the others arrange themselves about it. Unpin it and it goes back. Every one of these is one undo.

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

Best practices

Let ports say what they accept. A port that only gives is not joined to another that only gives; a port that carries one kind of thing is not joined to one that carries another; a port that takes a single edge does not take a second. Decide these from the ports, before anything is drawn, so that the same answer is given to a drag, a key and a program.

Run every edge the way it means. An edge runs from the port that gives to the port that takes, whichever of the two it was drawn from. Between ports that do not say, it runs from where it was begun.

Refuse with a reason. A connection that is not made says why: the same two are already joined, it would make a loop, the port is full. "Nothing happened" teaches nobody anything.

Refuse a loop only where a loop means nothing. A pipeline cannot feed itself; a state machine returns to where it was all the time. Make it a rule of the graph, not of graphs.

Take a node's edges with it. An edge with nothing at one end is not an edge. Removing a node removes its edges in the same action, so that one undo brings back the node and every line that was attached to it.

Make every change a thing that can be taken back. Adding, removing, moving, joining, and moving one end of an edge to another port are each one change, with what was there before. Several that people think of as one โ€” a node dropped and everything laid out again around it โ€” are recorded as one. See Undo and redo.

Lay out on request, or keep laid out โ€” and say which. A layout may be something people ask for, once, and then arrange by hand; or it may be live, following the graph as it changes, so that a node added takes its place and the rest make room. Both are right for different work. Never lay out without being asked in the first mode, and never leave things where they fell in the second.

Let a node be pinned. A pinned node stays exactly where it is, whatever the layout does: the layout is placed about it, and whatever would land on it goes past it instead. Nothing a layout places ever overlaps another node.

Believe the hand. While the layout is live, a node that someone moves stays where they put it: moving it pins it. The alternative โ€” the node springing back โ€” tells people their own gesture did not count.

Keep the whole where it was. Laying out does not send the graph off to another part of the plane: with nothing pinned, its top-left corner stays where it was. Laying out twice changes nothing the second time.

Conventions

RuleWhat is refused
DirectionTwo outputs joined; two inputs joined
TypePorts that each say what they carry, and say differently
OnceThe same two ports joined a second time
LimitOne more edge than a port holds
ItselfA port to itself; a node to itself, unless that is allowed
No loops, where heldAn edge whose target already leads back to its source
StateMoved by handMoved by a layoutRemoved
FreeYesYesYes
PinnedYesNoYes
LockedNoNoNo

See also Connecting, Diagram and Layered layout.

Behaviour

Ports

A port sits on its side of its node; the ports of a side share it evenly
const two = node("a", 10, 20, {
  ports: [input, output, { id: "more", side: "right" }, { id: "top", side: "top" }],
});
expect(portPoint(two, "in")).toEqual({ x: 10, y: 40, side: "left" });
expect(portPoint(two, "out")!.y).toBeCloseTo(20 + 40 / 3, 9);
expect(portPoint(two, "more")!.y).toBeCloseTo(20 + 80 / 3, 9);
expect(portPoint(two, "top")).toEqual({ x: 60, y: 20, side: "top" });
expect(portPoint(two, "none")).toBe(null);
A port that says where along its side it is sits there
const one = node("a", 0, 0, { ports: [{ id: "low", side: "bottom", at: 0.25 }] });
expect(portPoint(one, "low")).toEqual({ x: 25, y: 40, side: "bottom" });
A port's key reads back as the port, whatever its ids hold
fc.assert(
  fc.property(fc.string(), fc.string(), (a, b) => {
    const key = portKey({ node: a, port: b });
    return portRef(key).node === a && portRef(key).port === b && !/["\s\\]/.test(key);
  }),
);
The edges at a node are those that arrive and those that leave; at a port, those at that port
const graph = chain();
expect(edgesAt(graph, "b").incoming.map((each) => each.id)).toEqual(["a-b"]);
expect(edgesAt(graph, "b").outgoing.map((each) => each.id)).toEqual(["b-c"]);
expect(edgesAt(graph, "b", "in").outgoing).toEqual([]);

What may be connected

An edge runs from the port that gives to the port that takes, whichever it was drawn from
const graph: GraphData = { nodes: [node("a"), node("b")], edges: [] };
const forward = connection(graph, at("a", "out"), at("b", "in"), make);
const backward = connection(graph, at("b", "in"), at("a", "out"), make);
expect(forward).toEqual(backward);
expect(forward).toMatchObject({ kind: "insert", edges: [{ at: 0, item: edge("a", "b") }] });
Between ports that do not say, an edge runs from the one it was drawn from
const open = (id: string) => node(id, 0, 0, { ports: [{ id: "p", side: "top" }] });
const graph: GraphData = { nodes: [open("a"), open("b")], edges: [] };
expect(connection(graph, at("b", "p"), at("a", "p"), make)).toMatchObject({
  edges: [{ item: { source: at("b", "p"), target: at("a", "p") } }],
});
Two ports that both only give, or both only take, are not connected
const graph: GraphData = { nodes: [node("a"), node("b")], edges: [] };
expect(refusal(graph, at("a", "out"), at("b", "out"))).toBe("direction");
expect(refusal(graph, at("a", "in"), at("b", "in"))).toBe("direction");
expect(refusal(graph, at("a", "out"), at("b", "in"))).toBe(null);
Ports that carry different types are not connected; a port that does not say takes any
const typed = (id: string, type?: string) =>
  node(id, 0, 0, {
    ports: [
      { ...input, type },
      { ...output, type },
    ],
  });
const graph: GraphData = {
  nodes: [typed("rows", "rows"), typed("text", "text"), typed("any")],
  edges: [],
};
expect(refusal(graph, at("rows"), at("text", "in"))).toBe("type");
expect(refusal(graph, at("rows"), at("any", "in"))).toBe(null);
expect(refusal(graph, at("any"), at("text", "in"))).toBe(null);
The same two ports are not connected twice
expect(refusal(chain(), at("a"), at("b", "in"))).toBe("duplicate");
expect(refusal(chain(), at("b", "in"), at("a"))).toBe("duplicate");
A port that holds as many edges as it may takes no more
const single = (id: string) => node(id, 0, 0, { ports: [{ ...input, limit: 1 }, output] });
const graph: GraphData = { nodes: [single("a"), single("b"), single("c")], edges: [edge("a", "c")] };
expect(refusal(graph, at("b"), at("c", "in"))).toBe("full");
expect(refusal(graph, at("a"), at("b", "in"))).toBe(null);
A node is not connected to itself unless that is allowed, and a port never to itself
const graph = chain();
expect(refusal(graph, at("a"), at("a", "in"))).toBe("same-node");
expect(refusal(graph, at("a"), at("a", "in"), { selfLoops: true })).toBe(null);
expect(refusal(graph, at("a"), at("a"), { selfLoops: true })).toBe("same-node");
Where cycles are refused, an edge that would close one is, whichever end it is drawn from
const graph = chain();
expect(refusal(graph, at("c"), at("a", "in"), { acyclic: true })).toBe("cycle");
expect(refusal(graph, at("a", "in"), at("c"), { acyclic: true })).toBe("cycle");
expect(refusal(graph, at("a"), at("c", "in"), { acyclic: true })).toBe(null);
expect(refusal(graph, at("c"), at("a", "in"))).toBe(null);
expect(refusal(graph, at("a"), at("a", "in"), { acyclic: true, selfLoops: true })).toBe("cycle");
An edge is refused as closing a cycle exactly when, with it, no order puts every node after those that lead to it
fc.assert(
  fc.property(anyGraph, fc.nat(8), fc.nat(8), (graph, a, b) => {
    const from = a % graph.nodes.length;
    const to = b % graph.nodes.length;
    if (from === to) return true;
    const pairs = graph.edges.flatMap((each) => [
      Number(each.source.node.slice(1)),
      Number(each.target.node.slice(1)),
    ]);
    const cyclic = topologicalOrder(graphFrom(graph.nodes.length, [...pairs, from, to])) === null;
    const refused = refusal(graph, at(`n${from}`), at(`n${to}`, "in"), { acyclic: true });
    return cyclic ? refused === "cycle" : refused !== "cycle";
  }),
);
A port that is not there is not connected
expect(refusal(chain(), at("a"), at("z", "in"))).toBe("missing");
expect(refusal(chain(), at("a", "nowhere"), at("b", "in"))).toBe("missing");
An application's own rule is asked of the port that gives and the port that takes
const asked: string[] = [];
const accepts = (source: GraphPort, target: GraphPort) => {
  asked.push(`${source.id} ${target.id}`);
  return false;
};
expect(refusal(chain(), at("c", "in"), at("a"), { accepts })).toBe("type");
expect(asked).toEqual(["out in"]);
The ports an edge may be taken to are exactly those that do not refuse it, in the order of the nodes
fc.assert(
  fc.property(anyGraph, fc.nat(8), fc.boolean(), (graph, pick, acyclic) => {
    const from = at(`n${pick % graph.nodes.length}`);
    const every = graph.nodes.flatMap((each) => each.ports.map((port) => at(each.id, port.id)));
    const open = every.filter((to) => refusal(graph, from, to, { acyclic }) === null);
    return JSON.stringify(connectable(graph, from, { acyclic })) === JSON.stringify(open);
  }),
);
expect(connectable(chain(), at("a"))).toEqual([at("c", "in")]);
expect(connectable(chain(), at("nowhere"))).toEqual([]);

Changes

Taking a node out takes out every edge at it in the same change; undone, both are back where they were
const graph = chain();
const change = removal(graph, ["b"])!;
expect(change.kind === "remove" && change.edges.map((each) => each.item.id)).toEqual(["a-b", "b-c"]);
const after = applyGraphChange(graph, change);
expect(after.nodes.map((each) => each.id)).toEqual(["a", "c"]);
expect(after.edges).toEqual([]);
expect(applyGraphChange(after, invertGraphChange(change))).toEqual(graph);
Taking out edges and nodes together takes each once; taking out what is not there is no change
const change = removal(chain(), ["a-b", "b", "a-b"])!;
expect(change.kind === "remove" && change.edges.map((each) => each.at)).toEqual([0, 1]);
expect(removal(chain(), ["nowhere"])).toBe(null);
Any changes, then their inverses, give back the graph as it was
fc.assert(
  fc.property(
    anyGraph,
    fc.array(fc.tuple(fc.nat(3), fc.nat(8), fc.nat(8)), { maxLength: 8 }),
    (graph, steps) => {
      const changes: GraphChange[] = [];
      let now = graph;
      for (const [kind, a, b] of steps) {
        const one = now.nodes[a % Math.max(now.nodes.length, 1)];
        const other = now.nodes[b % Math.max(now.nodes.length, 1)];
        let change: GraphChange | null | string = null;
        if (kind === 0 && one !== undefined) change = removal(now, [one.id, now.edges[b]?.id ?? ""]);
        else if (kind === 1) change = insertion(now, [node(`new${changes.length}`, a, b)]);
        else if (kind === 2 && one !== undefined) {
          change = {
            kind: "move",
            moves: [{ id: one.id, rect: { ...one.rect, x: a }, previous: one.rect }],
          };
        } else if (one !== undefined && other !== undefined) {
          change = pinning(now, [one.id], one.pinned !== true);
          const first = now.edges[0];
          if (first !== undefined) {
            changes.push(change!);
            now = applyGraphChange(now, change!);
            change = reconnection(now, first.id, "target", at(other.id, "in"));
          }
        }
        if (change === null || typeof change === "string") continue;
        changes.push(change);
        now = applyGraphChange(now, change);
      }
      expect(applyGraphChanges(now, invertGraphChanges(changes))).toEqual(graph);
    },
  ),
);
A change shares what it does not touch, and one that changes nothing gives the same graph
const graph = chain();
const rect = { x: 5, y: 5, width: 100, height: 40 };
const moved = applyGraphChange(graph, {
  kind: "move",
  moves: [{ id: "a", rect, previous: graph.nodes[0]!.rect }],
});
expect(moved.nodes[1]).toBe(graph.nodes[1]);
expect(moved.edges).toBe(graph.edges);
expect(moved.nodes[0]!.rect).toBe(rect);
const still = { id: "a", rect: graph.nodes[0]!.rect, previous: graph.nodes[0]!.rect };
expect(applyGraphChange(graph, { kind: "move", moves: [still] })).toBe(graph);
expect(applyGraphChange(graph, { kind: "remove", nodes: [], edges: [] })).toBe(graph);
What is added goes after what is there
const graph = chain();
const after = applyGraphChange(graph, insertion(graph, [node("d")], [edge("c", "d")]));
expect(after.nodes.map((each) => each.id)).toEqual(["a", "b", "c", "d"]);
expect(after.edges.at(-1)!.id).toBe("c-d");
An end of an edge is moved to another port as one change, which keeps the edge and its way
const graph: GraphData = { ...chain(), nodes: [...chain().nodes, node("d")] };
const change = reconnection(graph, "a-b", "target", at("d", "in"));
expect(change).toEqual({
  kind: "replace",
  nodes: [],
  edges: [{ item: { ...edge("a", "b"), target: at("d", "in") }, previous: edge("a", "b") }],
});
An end is not moved to a port that refuses it, and the edge itself does not stand in its own way
const single = (id: string) =>
  node(id, 0, 0, {
    ports: [
      { ...input, limit: 1 },
      { ...output, limit: 1 },
    ],
  });
const graph: GraphData = {
  nodes: [single("a"), single("b"), single("c")],
  edges: [edge("a", "b"), edge("b", "c")],
};
// The source is full only of the edge being moved.
expect(reconnection(graph, "a-b", "target", at("c", "in"))).toBe("full");
const graph2 = { ...graph, edges: [edge("a", "b")] };
expect(reconnection(graph2, "a-b", "target", at("c", "in"))).toMatchObject({ kind: "replace" });
expect(reconnection(graph2, "a-b", "target", at("c", "out"))).toBe("direction");
expect(reconnection(graph2, "a-b", "target", at("b", "in"))).toBe(null);
expect(reconnection(graph2, "nowhere", "target", at("c", "in"))).toBe(null);
An edge that is refused is no change, but the reason
expect(connection(chain(), at("a"), at("b", "in"), make)).toBe("duplicate");
A node is put into an edge as changes made together: the edge out, and two in its place, through the node's first ports that take and give
const graph: GraphData = { nodes: [...chain().nodes, node("d", 100, 100)], edges: [edge("a", "b")] };
const changes = splice(graph, "a-b", "d", make)!;
expect(changes).toEqual([
  { kind: "remove", nodes: [], edges: [{ at: 0, item: edge("a", "b") }] },
  {
    kind: "insert",
    nodes: [],
    edges: [
      { at: 0, item: edge("a", "d") },
      { at: 1, item: edge("d", "b") },
    ],
  },
]);
const after = applyGraphChanges(graph, changes);
expect(after.edges.map((each) => each.id)).toEqual(["a-d", "d-b"]);
// And undone as one.
expect(applyGraphChanges(after, invertGraphChanges(changes))).toEqual(graph);
A node with no port to take what the edge carries, or none to give it on, or one of the edge's own ends, is not put into it
const graph: GraphData = {
  nodes: [
    ...chain().nodes,
    node("only-in", 0, 0, { ports: [input] }),
    node("only-out", 0, 0, { ports: [output] }),
    node("other", 0, 0, { ports: [{ ...input, type: "words" }, output] }),
  ],
  edges: [{ ...edge("a", "b"), source: at("a"), target: at("b", "in") }],
};
const typed: GraphData = {
  ...graph,
  nodes: graph.nodes.map((each) =>
    each.id === "a" ? { ...each, ports: [input, { ...output, type: "rows" }] } : each,
  ),
};
expect(splice(graph, "a-b", "only-in", make)).toBe(null);
expect(splice(graph, "a-b", "only-out", make)).toBe(null);
expect(splice(typed, "a-b", "other", make)).toBe(null);
expect(splice(graph, "a-b", "a", make, { selfLoops: true })).toBe(null);
expect(splice(graph, "nowhere", "c", make)).toBe(null);
Where loops are refused, a node that the edge's target already leads to is not put into it
// c follows b: a โ†’ c โ†’ b would close a loop through b โ†’ c.
const graph = chain();
expect(splice(graph, "a-b", "c", make, { acyclic: true })).toBe(null);
expect(splice(graph, "a-b", "c", make)).not.toBe(null);

Layout

A graph is laid out in layers, its edges reading onward, a gap between one layer and the next
const scattered: GraphData = {
  nodes: [node("a", 300, 90), node("b", 10, 200), node("c", 50, 10)],
  edges: [edge("a", "b"), edge("b", "c")],
};
const after = laidOut(scattered);
const [a, b, c] = after.nodes.map((each) => each.rect);
expect(b!.x - (a!.x + a!.width)).toBe(64);
expect(c!.x - (b!.x + b!.width)).toBe(64);
expect(new Set([a!.y, b!.y, c!.y]).size).toBe(1);
const down = applyGraphChange(scattered, {
  kind: "move",
  moves: autoLayout(scattered, { direction: "down" }),
});
expect(down.nodes[1]!.rect.y).toBeGreaterThan(down.nodes[0]!.rect.y);
With nothing held, the layout begins where the nodes did: the corner of the whole does not move
fc.assert(
  fc.property(anyGraph, (graph) => {
    const free = { ...graph, nodes: graph.nodes.map(({ id, rect, ports }) => ({ id, rect, ports })) };
    const corner = (nodes: readonly GraphNode[]) => [
      Math.min(...nodes.map((each) => each.rect.x)),
      Math.min(...nodes.map((each) => each.rect.y)),
    ];
    expect(corner(laidOut(free).nodes)).toEqual(corner(free.nodes));
  }),
);
A node that is pinned is not moved, nor one that is locked
fc.assert(
  fc.property(anyGraph, (graph) => {
    const ids = new Set(autoLayout(graph).map((move) => move.id));
    return graph.nodes.every((each) => !held(each) || !ids.has(each.id));
  }),
);
No node that the layout places overlaps another, held or not
fc.assert(
  fc.property(anyGraph, (graph) => {
    const { nodes } = laidOut(graph);
    for (let a = 0; a < nodes.length; a += 1) {
      for (let b = a + 1; b < nodes.length; b += 1) {
        if (held(nodes[a]!) && held(nodes[b]!)) continue;
        if (overlap(nodes[a]!, nodes[b]!)) return false;
      }
    }
    return true;
  }),
  { numRuns: 300 },
);
Laying out what has just been laid out moves nothing
fc.assert(fc.property(anyGraph, (graph) => autoLayout(laidOut(graph)).length === 0));
With a node held, the layout is placed about it: it is exactly where the layout wants it, and the rest follow
const graph: GraphData = {
  nodes: [node("a", 0, 0), node("b", 500, 300, { pinned: true }), node("c", 0, 0)],
  edges: [edge("a", "b"), edge("b", "c")],
};
const after = laidOut(graph);
expect(after.nodes.map((each) => each.rect)).toMatchObject([
  { x: 336, y: 300 },
  { x: 500, y: 300 },
  { x: 664, y: 300 },
]);
A held node standing where another would go: that one, and those after it in its layer, are pushed past it
// b and c both follow a. The stray is pinned where b would go.
const graph: GraphData = {
  nodes: [
    node("a", 0, 0, { pinned: true }),
    node("b"),
    node("c"),
    node("stray", 164, -40, { pinned: true }),
  ],
  edges: [edge("a", "b"), edge("a", "c")],
};
const [, b, c, stray] = laidOut(graph).nodes.map((each) => each.rect);
const [first, second] = [b!, c!].toSorted((one, other) => one.y - other.y);
expect(first!.x).toBe(164);
expect(first!.y).toBe(stray!.y + stray!.height + 24);
expect(second!.y).toBe(first!.y + first!.height + 24);
A graph with nothing in it has nothing to move
expect(autoLayout({ nodes: [], edges: [] })).toEqual([]);

Kept laid out

A change is followed by the layout in the same list: one undo takes both back
const graph = laidOut(chain());
const adding = insertion(graph, [node("d", 999, 999)], [edge("c", "d")]);
const changes = keepLaidOut(graph, [adding]);
expect(changes.map((change) => change.kind)).toEqual(["insert", "move"]);
const after = applyGraphChanges(graph, changes);
expect(after.nodes[3]!.rect).toMatchObject({ x: 492, y: 0 });
expect(applyGraphChanges(after, invertGraphChanges(changes))).toEqual(graph);
A node moved by hand is pinned where it was put, and the rest laid out about it
const graph = laidOut(chain());
const rect = { ...graph.nodes[1]!.rect, y: 200 };
const changes = keepLaidOut(graph, [
  { kind: "move", moves: [{ id: "b", rect, previous: graph.nodes[1]!.rect }] },
]);
const after = applyGraphChanges(graph, changes);
expect(after.nodes[1]).toMatchObject({ pinned: true, rect });
expect(after.nodes[0]!.rect.y).toBe(200);
expect(after.nodes[2]!.rect.y).toBe(200);
A change that leaves everything in its place is only itself
const graph = laidOut(chain());
const pin = pinning(graph, ["a"], true)!;
expect(keepLaidOut(graph, [pin])).toEqual([pin]);
expect(pinning(applyGraphChange(graph, pin), ["a"], true)).toBe(null);

React

Install

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

src/primitives/graph.ts

/**
 * A graph as data: nodes that have ports, and edges that join one port to
 * another โ€” a flow, a pipeline, a diagram. Everything here works on ids and
 * values, never on elements: what may be connected, what a change is and how it
 * is undone, and where a layout would put things.
 *
 * - **Ports say what they accept.** A port may only give (`out`) or only take
 *   (`in`), carry a `type` that must match the other end's, and hold at most so
 *   many edges. A connection that breaks a rule is refused, with the reason.
 *   An edge always runs from a port that gives to one that takes, whichever it
 *   was drawn from.
 * - **Changes are values.** Putting in, taking out, moving and replacing are
 *   each a value that can be applied, and turned about to undo it. Taking a
 *   node out takes its edges out in the same change, so that one undo brings
 *   both back.
 * - **Layout is a change too.** `autoLayout` gives the moves that would lay
 *   the graph out in layers; applying them is the owner's choice. Run after
 *   every change (`keepLaidOut`), placement is live: it follows the graph.
 * - **A pinned node stays where it is.** No layout moves a node that is pinned
 *   or locked, and what a layout does move is put clear of it. A node moved by
 *   hand while the layout is live becomes pinned: the hand is believed.
 */
import type { Rect } from "../algorithms/geometry.ts";
import { rectsEqual } from "../algorithms/geometry.ts";
import { layeredLayout } from "../algorithms/layered-layout.ts";
import type { LayeredOptions } from "../algorithms/layered-layout.ts";
import { affectedBy, graphFrom } from "../algorithms/topological.ts";

export type PortSide = "left" | "right" | "top" | "bottom";

export interface GraphPort {
  readonly id: string;
  /** The side of its node it sits on, and so the way an edge leaves it. */
  readonly side: PortSide;
  /** Whether edges only arrive here, or only leave. Left out, either. */
  readonly direction?: "in" | "out" | undefined;
  /** What it carries. Two ports that each say, and say differently, are not connected. */
  readonly type?: string | undefined;
  /** The most edges it holds. */
  readonly limit?: number | undefined;
  /** Where along its side it sits, from 0 to 1. Left out, the ports of a side share it evenly. */
  readonly at?: number | undefined;
}

export interface GraphNode {
  readonly id: string;
  readonly rect: Rect;
  readonly ports: readonly GraphPort[];
  /** Held where it is against any layout; still moved by hand. */
  readonly pinned?: boolean | undefined;
  /** Held against everything. */
  readonly locked?: boolean | undefined;
}

/** A port, named by its node and itself. */
export interface PortRef {
  readonly node: string;
  readonly port: string;
}

export interface GraphEdge {
  readonly id: string;
  /** The port it leaves, and the port it arrives at. */
  readonly source: PortRef;
  readonly target: PortRef;
  /** What it says of itself โ€” what it carries, when it runs โ€” shown along it. */
  readonly label?: string | undefined;
}

export interface GraphData<N extends GraphNode = GraphNode, E extends GraphEdge = GraphEdge> {
  readonly nodes: readonly N[];
  readonly edges: readonly E[];
}

/** A port as one string, for an attribute or a key; `portRef` reads it back. */
export function portKey(ref: PortRef): string {
  return `${encodeURIComponent(ref.node)}/${encodeURIComponent(ref.port)}`;
}

export function portRef(key: string): PortRef {
  const slash = key.indexOf("/");
  return { node: decodeURIComponent(key.slice(0, slash)), port: decodeURIComponent(key.slice(slash + 1)) };
}

export function samePort(a: PortRef, b: PortRef): boolean {
  return a.node === b.node && a.port === b.port;
}

/** The port a reference names, with its node; `null` when either is not there. */
export function findPort<N extends GraphNode>(
  graph: GraphData<N>,
  ref: PortRef,
): { readonly node: N; readonly port: GraphPort } | null {
  const node = graph.nodes.find((each) => each.id === ref.node);
  const port = node?.ports.find((each) => each.id === ref.port);
  return node === undefined || port === undefined ? null : { node, port };
}

/**
 * Where a port is on the plane โ€” or on `rect`, where its node is shown elsewhere
 * for a while โ€” on its side of its node, at its own place along
 * it, or sharing the side evenly with the others there.
 */
export function portPoint(
  node: GraphNode,
  portId: string,
  rect: Rect = node.rect,
): { x: number; y: number; side: PortSide } | null {
  const port = node.ports.find((each) => each.id === portId);
  if (port === undefined) return null;
  let along = port.at;
  if (along === undefined) {
    const beside = node.ports.filter((each) => each.side === port.side);
    along = (beside.indexOf(port) + 1) / (beside.length + 1);
  }
  const { x, y, width, height } = rect;
  switch (port.side) {
    case "left":
      return { x, y: y + height * along, side: port.side };
    case "right":
      return { x: x + width, y: y + height * along, side: port.side };
    case "top":
      return { x: x + width * along, y, side: port.side };
    case "bottom":
      return { x: x + width * along, y: y + height, side: port.side };
  }
}

/** The edges at a node, or at one of its ports: those that arrive, and those that leave. */
export function edgesAt<E extends GraphEdge>(
  graph: GraphData<GraphNode, E>,
  node: string,
  port?: string,
): { readonly incoming: E[]; readonly outgoing: E[] } {
  const at = (ref: PortRef) => ref.node === node && (port === undefined || ref.port === port);
  return {
    incoming: graph.edges.filter((edge) => at(edge.target)),
    outgoing: graph.edges.filter((edge) => at(edge.source)),
  };
}

/** Why two ports may not be connected. */
export type Refusal = "missing" | "same-node" | "direction" | "type" | "duplicate" | "full" | "cycle";

export interface ConnectRules {
  /** Refuse an edge that would close a cycle. */
  readonly acyclic?: boolean | undefined;
  /** Allow an edge from a node to itself. */
  readonly selfLoops?: boolean | undefined;
  /** An edge being moved to another port: it is not counted against a limit, nor as a duplicate. */
  readonly replacing?: string | undefined;
  /** A further rule of the application's own, asked last: whether this source may feed this target. */
  readonly accepts?: ((source: GraphPort, target: GraphPort) => boolean) | undefined;
}

/**
 * Which of two ports an edge between them leaves: the one that only gives, or
 * the other of one that only takes; where neither says, the first. `null` when
 * both only give, or both only take.
 */
export function orient(
  from: PortRef,
  fromPort: GraphPort,
  to: PortRef,
  toPort: GraphPort,
): { readonly source: PortRef; readonly target: PortRef } | null {
  const a = fromPort.direction;
  const b = toPort.direction;
  if (a !== undefined && a === b) return null;
  return a === "in" || b === "out" ? { source: to, target: from } : { source: from, target: to };
}

/** What a question about connecting from one port is asked against, worked out once. */
interface Asking {
  readonly from: PortRef;
  readonly fromPort: GraphPort;
  readonly held: ReadonlyMap<string, number>;
  readonly joined: ReadonlySet<string>;
  /** The nodes that lead to the one asked from, and those it leads to; `null` where cycles are allowed. */
  readonly before: ReadonlySet<string> | null;
  readonly after: ReadonlySet<string> | null;
}

function reach(nodes: readonly GraphNode[], pairs: number[], from: number): Set<string> {
  const graph = graphFrom(nodes.length, pairs);
  // Any order serves: only which nodes are reached is read.
  const order = Int32Array.from({ length: nodes.length }, (_, index) => index);
  return new Set(Array.from(affectedBy(graph, order, [from]), (index) => nodes[index]!.id));
}

function ask(graph: GraphData, from: PortRef, rules: ConnectRules): Asking | null {
  const found = findPort(graph, from);
  if (found === null) return null;
  const held = new Map<string, number>();
  const joined = new Set<string>();
  for (const edge of graph.edges) {
    if (edge.id === rules.replacing) continue;
    for (const end of [edge.source, edge.target]) {
      const key = portKey(end);
      held.set(key, (held.get(key) ?? 0) + 1);
    }
    joined.add(`${portKey(edge.source)} ${portKey(edge.target)}`);
  }
  let before: Set<string> | null = null;
  let after: Set<string> | null = null;
  if (rules.acyclic === true) {
    const index = new Map(graph.nodes.map((node, at) => [node.id, at]));
    const onward: number[] = [];
    const back: number[] = [];
    for (const edge of graph.edges) {
      const source = index.get(edge.source.node);
      const target = index.get(edge.target.node);
      if (edge.id === rules.replacing || source === undefined || target === undefined) continue;
      onward.push(source, target);
      back.push(target, source);
    }
    const start = index.get(from.node)!;
    after = reach(graph.nodes, onward, start);
    before = reach(graph.nodes, back, start);
  }
  return { from, fromPort: found.port, held, joined, before, after };
}

function judge(graph: GraphData, asking: Asking, to: PortRef, rules: ConnectRules): Refusal | null {
  const found = findPort(graph, to);
  if (found === null) return "missing";
  const { from, fromPort } = asking;
  if (from.node === to.node && (rules.selfLoops !== true || from.port === to.port)) return "same-node";
  const way = orient(from, fromPort, to, found.port);
  if (way === null) return "direction";
  const outward = way.source === from;
  const source = outward ? fromPort : found.port;
  const target = outward ? found.port : fromPort;
  if (source.type !== undefined && target.type !== undefined && source.type !== target.type) return "type";
  if (asking.joined.has(`${portKey(way.source)} ${portKey(way.target)}`)) return "duplicate";
  for (const [ref, port] of [
    [from, fromPort],
    [to, found.port],
  ] as const) {
    if (port.limit !== undefined && (asking.held.get(portKey(ref)) ?? 0) >= port.limit) return "full";
  }
  // An edge onward closes a cycle when its target already leads back; an edge back, when its source is already led to.
  const closes = outward ? asking.before : asking.after;
  if (closes !== null && closes.has(to.node)) return "cycle";
  if (rules.accepts !== undefined && !rules.accepts(source, target)) return "type";
  return null;
}

/** Why an edge between two ports is refused, or `null` when it may be made. */
export function refusal(
  graph: GraphData,
  from: PortRef,
  to: PortRef,
  rules: ConnectRules = {},
): Refusal | null {
  const asking = ask(graph, from, rules);
  return asking === null ? "missing" : judge(graph, asking, to, rules);
}

/** Every port an edge from `from` may be taken to, in the order of the nodes and of their ports. */
export function connectable(graph: GraphData, from: PortRef, rules: ConnectRules = {}): PortRef[] {
  const asking = ask(graph, from, rules);
  if (asking === null) return [];
  const found: PortRef[] = [];
  for (const node of graph.nodes) {
    for (const port of node.ports) {
      const to = { node: node.id, port: port.id };
      if (judge(graph, asking, to, rules) === null) found.push(to);
    }
  }
  return found;
}

/** Something at its place in a list: the place it was taken from, or is put back at. */
export interface Placed<T> {
  readonly at: number;
  readonly item: T;
}

/** Something that took another's place: the same id, another value. */
export interface Replaced<T> {
  readonly item: T;
  readonly previous: T;
}

/** A node's move: which, where it is now, and where it was. */
export interface GraphMove {
  readonly id: string;
  readonly rect: Rect;
  readonly previous: Rect;
}

export type GraphChange<N extends GraphNode = GraphNode, E extends GraphEdge = GraphEdge> =
  | {
      readonly kind: "insert" | "remove";
      /** In the order of their places: for a removal, each place as it was before any was taken. */
      readonly nodes: ReadonlyArray<Placed<N>>;
      readonly edges: ReadonlyArray<Placed<E>>;
    }
  | { readonly kind: "move"; readonly moves: readonly GraphMove[] }
  | {
      readonly kind: "replace";
      readonly nodes: ReadonlyArray<Replaced<N>>;
      readonly edges: ReadonlyArray<Replaced<E>>;
    };

function putIn<T>(list: readonly T[], placed: ReadonlyArray<Placed<T>>): readonly T[] {
  if (placed.length === 0) return list;
  const next = list.slice();
  for (const { at, item } of placed) next.splice(Math.min(Math.max(at, 0), next.length), 0, item);
  return next;
}

function takeOut<T extends { readonly id: string }>(
  list: readonly T[],
  placed: ReadonlyArray<Placed<T>>,
): readonly T[] {
  if (placed.length === 0) return list;
  const going = new Set(placed.map((each) => each.item.id));
  const next = list.filter((each) => !going.has(each.id));
  return next.length === list.length ? list : next;
}

function swap<T extends { readonly id: string }>(
  list: readonly T[],
  replaced: ReadonlyArray<Replaced<T>>,
): readonly T[] {
  if (replaced.length === 0) return list;
  const by = new Map(replaced.map(({ item }) => [item.id, item]));
  let changed = false;
  const next = list.map((each) => {
    const item = by.get(each.id);
    if (item === undefined || item === each) return each;
    changed = true;
    return item;
  });
  return changed ? next : list;
}

/** The graph after a change. What the change does not touch is the same objects; a change that changes nothing gives the same graph. */
export function applyGraphChange<N extends GraphNode, E extends GraphEdge>(
  graph: GraphData<N, E>,
  change: GraphChange<N, E>,
): GraphData<N, E> {
  let { nodes, edges } = graph;
  switch (change.kind) {
    case "insert":
      nodes = putIn(nodes, change.nodes);
      edges = putIn(edges, change.edges);
      break;
    case "remove":
      nodes = takeOut(nodes, change.nodes);
      edges = takeOut(edges, change.edges);
      break;
    case "replace":
      nodes = swap(nodes, change.nodes);
      edges = swap(edges, change.edges);
      break;
    case "move": {
      const rects = new Map(change.moves.map((move) => [move.id, move.rect]));
      let changed = false;
      const next = nodes.map((node) => {
        const rect = rects.get(node.id);
        if (rect === undefined || rectsEqual(rect, node.rect)) return node;
        changed = true;
        return { ...node, rect };
      });
      if (changed) nodes = next;
      break;
    }
  }
  return nodes === graph.nodes && edges === graph.edges ? graph : { ...graph, nodes, edges };
}

export function applyGraphChanges<N extends GraphNode, E extends GraphEdge>(
  graph: GraphData<N, E>,
  changes: ReadonlyArray<GraphChange<N, E>>,
): GraphData<N, E> {
  let next = graph;
  for (const change of changes) next = applyGraphChange(next, change);
  return next;
}

/** The change that puts back what a change did. */
export function invertGraphChange<N extends GraphNode, E extends GraphEdge>(
  change: GraphChange<N, E>,
): GraphChange<N, E> {
  switch (change.kind) {
    case "insert":
      return { ...change, kind: "remove" };
    case "remove":
      return { ...change, kind: "insert" };
    case "move":
      return {
        kind: "move",
        moves: change.moves.map(({ id, rect, previous }) => ({ id, rect: previous, previous: rect })),
      };
    case "replace": {
      const turn = <T>({ item, previous }: Replaced<T>): Replaced<T> => ({ item: previous, previous: item });
      return { kind: "replace", nodes: change.nodes.map(turn), edges: change.edges.map(turn) };
    }
  }
}

/** The changes that undo several, in the order to apply them: all that undo needs. */
export function invertGraphChanges<N extends GraphNode, E extends GraphEdge>(
  changes: ReadonlyArray<GraphChange<N, E>>,
): Array<GraphChange<N, E>> {
  return changes.map(invertGraphChange).toReversed();
}

/** The change that adds nodes and edges, after those there are. */
export function insertion<N extends GraphNode, E extends GraphEdge>(
  graph: GraphData<N, E>,
  nodes: readonly N[],
  edges: readonly E[] = [],
): GraphChange<N, E> {
  return {
    kind: "insert",
    nodes: nodes.map((item, index) => ({ at: graph.nodes.length + index, item })),
    edges: edges.map((item, index) => ({ at: graph.edges.length + index, item })),
  };
}

/**
 * The change that takes out the nodes and edges with the given ids โ€” and, with
 * each node, every edge at it. `null` when none of the ids is in the graph.
 */
export function removal<N extends GraphNode, E extends GraphEdge>(
  graph: GraphData<N, E>,
  ids: Iterable<string>,
): GraphChange<N, E> | null {
  const going = new Set(ids);
  const nodes = graph.nodes.flatMap((item, at) => (going.has(item.id) ? [{ at, item }] : []));
  const gone = new Set(nodes.map((each) => each.item.id));
  const edges = graph.edges.flatMap((item, at) =>
    going.has(item.id) || gone.has(item.source.node) || gone.has(item.target.node) ? [{ at, item }] : [],
  );
  return nodes.length === 0 && edges.length === 0 ? null : { kind: "remove", nodes, edges };
}

/**
 * The change that joins two ports, as the edge `make` gives for its two ends โ€”
 * or the reason it is refused.
 */
export function connection<N extends GraphNode, E extends GraphEdge>(
  graph: GraphData<N, E>,
  from: PortRef,
  to: PortRef,
  make: (source: PortRef, target: PortRef) => E,
  rules: ConnectRules = {},
): GraphChange<N, E> | Refusal {
  const refused = refusal(graph, from, to, rules);
  if (refused !== null) return refused;
  const way = orient(from, findPort(graph, from)!.port, to, findPort(graph, to)!.port)!;
  return insertion(graph, [], [make(way.source, way.target)]);
}

/**
 * The change that moves one end of an edge to another port โ€” or the reason it
 * is refused. `null` when the edge is not there, or already ends there.
 */
export function reconnection<N extends GraphNode, E extends GraphEdge>(
  graph: GraphData<N, E>,
  edgeId: string,
  end: "source" | "target",
  to: PortRef,
  rules: ConnectRules = {},
): GraphChange<N, E> | Refusal | null {
  const previous = graph.edges.find((edge) => edge.id === edgeId);
  if (previous === undefined || samePort(previous[end], to)) return null;
  const kept = end === "source" ? previous.target : previous.source;
  const refused = refusal(graph, kept, to, { ...rules, replacing: edgeId });
  if (refused !== null) return refused;
  const way = orient(kept, findPort(graph, kept)!.port, to, findPort(graph, to)!.port)!;
  return { kind: "replace", nodes: [], edges: [{ item: { ...previous, ...way }, previous }] };
}

/**
 * The changes that put a node into an edge: the edge taken out, and in its place
 * one from the edge's source to a port of the node that takes it, and one from a
 * port of the node that gives to the edge's target โ€” the first of each, in the
 * order of the node's ports. `null` when the node has no such pair, or is at
 * either end of the edge already.
 */
export function splice<N extends GraphNode, E extends GraphEdge>(
  graph: GraphData<N, E>,
  edgeId: string,
  nodeId: string,
  make: (source: PortRef, target: PortRef) => E,
  rules: ConnectRules = {},
): Array<GraphChange<N, E>> | null {
  const at = graph.edges.findIndex((edge) => edge.id === edgeId);
  const edge = graph.edges[at];
  const node = graph.nodes.find((each) => each.id === nodeId);
  if (edge === undefined || node === undefined) return null;
  if (edge.source.node === nodeId || edge.target.node === nodeId) return null;
  const taken: GraphChange<N, E> = { kind: "remove", nodes: [], edges: [{ at, item: edge }] };
  const without = applyGraphChange(graph, taken);
  for (const into of node.ports) {
    const inward = { node: nodeId, port: into.id };
    // Into the node, the way the edge ran: the node's port must take what the source gives.
    if (into.direction === "out" || refusal(without, edge.source, inward, rules) !== null) continue;
    for (const out of node.ports) {
      const outward = { node: nodeId, port: out.id };
      if (out === into || out.direction === "in" || refusal(without, outward, edge.target, rules) !== null) {
        continue;
      }
      return [taken, insertion(without, [], [make(edge.source, inward), make(outward, edge.target)])];
    }
  }
  return null;
}

/** The change that pins nodes where they are, or lets them go. `null` when it would change nothing. */
export function pinning<N extends GraphNode, E extends GraphEdge>(
  graph: GraphData<N, E>,
  ids: Iterable<string>,
  pinned: boolean,
): GraphChange<N, E> | null {
  const which = new Set(ids);
  const nodes = graph.nodes.flatMap((previous) =>
    which.has(previous.id) && (previous.pinned === true) !== pinned
      ? [{ item: { ...previous, pinned }, previous }]
      : [],
  );
  return nodes.length === 0 ? null : { kind: "replace", nodes, edges: [] };
}

export type LayoutOptions = LayeredOptions;

/** Whether a layout leaves a node where it is. */
export const isHeld = (node: GraphNode): boolean => node.pinned === true || node.locked === true;

const SLACK = 1e-6;

/**
 * The moves that lay a graph out in layers (`algorithms/layered-layout.ts`),
 * edges reading one way.
 *
 * - A node that is pinned or locked is not moved.
 * - With none held, the layout's top-left corner is the top-left corner of
 *   where the nodes were: the whole does not jump. With some held, the layout
 *   is placed so that the first of them is exactly where the layout wants it.
 * - A node that is moved is put clear of every held node: where one stands in
 *   its layer's way, it and those after it in the layer are pushed along past
 *   it. So no two nodes overlap, unless both are held.
 * - Laying out what has just been laid out moves nothing.
 */
export function autoLayout(graph: GraphData, options: LayoutOptions = {}): GraphMove[] {
  const { nodes } = graph;
  const count = nodes.length;
  if (count === 0) return [];
  const { layerGap = 64, nodeGap = 24, direction = "right" } = options;
  const index = new Map(nodes.map((node, at) => [node.id, at]));
  const pairs: number[] = [];
  for (const edge of graph.edges) {
    const source = index.get(edge.source.node);
    const target = index.get(edge.target.node);
    if (source !== undefined && target !== undefined) pairs.push(source, target);
  }
  const sizes = new Float64Array(count * 2);
  for (let at = 0; at < count; at += 1) {
    sizes[at * 2] = nodes[at]!.rect.width;
    sizes[at * 2 + 1] = nodes[at]!.rect.height;
  }
  const layout = layeredLayout(graphFrom(count, pairs), sizes, { ...options, layerGap, nodeGap, direction });

  const anchor = nodes.findIndex(isHeld);
  let shiftX = Infinity;
  let shiftY = Infinity;
  if (anchor === -1) {
    for (const node of nodes) {
      shiftX = Math.min(shiftX, node.rect.x);
      shiftY = Math.min(shiftY, node.rect.y);
    }
  } else {
    shiftX = nodes[anchor]!.rect.x - layout.x[anchor]!;
    shiftY = nodes[anchor]!.rect.y - layout.y[anchor]!;
  }

  const down = direction === "down";
  // Along the layers' direction, and across it.
  const alongOf = (rect: Rect) => (down ? rect.y : rect.x);
  const acrossOf = (rect: Rect) => (down ? rect.x : rect.y);
  const lengthOf = (rect: Rect) => (down ? rect.height : rect.width);
  const breadthOf = (rect: Rect) => (down ? rect.width : rect.height);
  const along = down ? layout.y : layout.x;
  const across = down ? layout.x : layout.y;
  const shiftAlong = down ? shiftY : shiftX;
  const shiftAcross = down ? shiftX : shiftY;

  const held: number[] = [];
  const free: number[] = [];
  for (let at = 0; at < count; at += 1) (isHeld(nodes[at]!) ? held : free).push(at);
  // Layer by layer, and within each in the order the layout gave.
  // In place: the array was made here.
  // oxlint-disable-next-line unicorn/no-array-sort
  free.sort((a, b) => layout.layer[a]! - layout.layer[b]! || across[a]! - across[b]! || a - b);

  const moves: GraphMove[] = [];
  let layer = -1;
  let next = -Infinity;
  for (const at of free) {
    const { rect } = nodes[at]!;
    if (layout.layer[at] !== layer) {
      layer = layout.layer[at]!;
      next = -Infinity;
    }
    const from = along[at]! + shiftAlong;
    const length = lengthOf(rect);
    const breadth = breadthOf(rect);
    let place = Math.max(across[at]! + shiftAcross, next);
    // Past every held node in the way, one after another until none is.
    for (let moved = true; moved;) {
      moved = false;
      for (const other of held) {
        const fixed = nodes[other]!.rect;
        if (
          alongOf(fixed) < from + length + layerGap - SLACK &&
          alongOf(fixed) + lengthOf(fixed) > from - layerGap + SLACK &&
          acrossOf(fixed) - nodeGap < place + breadth - SLACK &&
          acrossOf(fixed) + breadthOf(fixed) + nodeGap > place + SLACK
        ) {
          place = acrossOf(fixed) + breadthOf(fixed) + nodeGap;
          moved = true;
        }
      }
    }
    next = place + breadth + nodeGap;
    const placed: Rect = down
      ? { x: place, y: from, width: rect.width, height: rect.height }
      : { x: from, y: place, width: rect.width, height: rect.height };
    if (!rectsEqual(placed, rect)) moves.push({ id: nodes[at]!.id, rect: placed, previous: rect });
  }
  return moves;
}

/**
 * Changes, with the layout kept live after them: what the changes moved by
 * hand is pinned where it was put, and everything not held is laid out again.
 * The result is the changes to make โ€” and to record โ€” as one.
 */
export function keepLaidOut<N extends GraphNode, E extends GraphEdge>(
  graph: GraphData<N, E>,
  changes: ReadonlyArray<GraphChange<N, E>>,
  options: LayoutOptions = {},
): Array<GraphChange<N, E>> {
  const all = [...changes];
  let next = applyGraphChanges(graph, changes);
  const moved = changes.flatMap((change) =>
    change.kind === "move" ? change.moves.map((move) => move.id) : [],
  );
  const pin = pinning(next, moved, true);
  if (pin !== null) {
    all.push(pin);
    next = applyGraphChange(next, pin);
  }
  const moves = autoLayout(next, options);
  if (moves.length > 0) all.push({ kind: "move", moves });
  return all;
}