diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/README.md b/libs/@hashintel/petrinaut/src/ui/views/Notebook/README.md index 8c1e166df4d..6db8f6024f9 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Notebook/README.md +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/README.md @@ -4,7 +4,8 @@ role: Notebook view — the net as expandable cells with editable code, dependen --- The notebook renders the net as a flat list of cells, one per entity, so a -model reads like a program: declarations and the flow that uses them. It replaces the canvas and its +model reads like a program: declarations, the flow that uses them, and the +analyses that fall out of the structure. It replaces the canvas and its panels wholesale, which is what lets its Monaco editors reuse the LSP document URIs — a model is never mounted twice. @@ -21,3 +22,9 @@ from the editor, expansion and search live here). The graph explorer draws the whole net from the arc structure alone, ignoring canvas positions, so the diagram answers "what feeds what" rather than "where did the author drag things". + +Every analysis is structural: it reads places, transitions and arcs, never +markings or scenario state. The reasoning behind each algorithm — why +cycles are SCCs, why "needs seeding" means a minimal siphon, how the +layout and its animation stay cheap — is in the deep-dive: +[Notebook graph analyses](doc:notebook/graph-analyses). diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-cycles.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-cycles.test.ts new file mode 100644 index 00000000000..3e9a4066e8c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-cycles.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { buildCycleMembership, findCycleGroups } from "./net-cycles"; + +import type { NetGraph, NetGraphNode } from "./notebook-model"; + +const place = (id: string): NetGraphNode => ({ id, name: id, kind: "place" }); +const transition = (id: string): NetGraphNode => ({ + id, + name: id, + kind: "transition", +}); + +describe("findCycleGroups", () => { + it("finds no cycles in a chain", () => { + const graph: NetGraph = { + nodes: [place("Source"), transition("Move"), place("Sink")], + edges: [ + { from: "Source", to: "Move" }, + { from: "Move", to: "Sink" }, + ], + }; + + expect(findCycleGroups(graph)).toEqual([]); + }); + + it("groups the members of a two-node loop", () => { + const graph: NetGraph = { + nodes: [place("Pool"), transition("Churn")], + edges: [ + { from: "Pool", to: "Churn" }, + { from: "Churn", to: "Pool" }, + ], + }; + + const groups = findCycleGroups(graph); + + expect(groups).toHaveLength(1); + expect(groups[0]!.memberIds).toEqual(["Pool", "Churn"]); + expect(groups[0]!.label).toBe(1); + }); + + it("keeps separate loops in separate groups, numbered in document order", () => { + const graph: NetGraph = { + nodes: [ + place("A1"), + transition("A2"), + place("B1"), + transition("B2"), + place("Free"), + ], + edges: [ + { from: "A1", to: "A2" }, + { from: "A2", to: "A1" }, + { from: "B1", to: "B2" }, + { from: "B2", to: "B1" }, + { from: "A1", to: "Free" }, + ], + }; + + const groups = findCycleGroups(graph); + + expect(groups.map(({ memberIds }) => memberIds)).toEqual([ + ["A1", "A2"], + ["B1", "B2"], + ]); + expect(groups.map(({ label }) => label)).toEqual([1, 2]); + }); + + it("treats a longer loop as one group", () => { + const graph: NetGraph = { + nodes: [place("P1"), transition("T1"), place("P2"), transition("T2")], + edges: [ + { from: "P1", to: "T1" }, + { from: "T1", to: "P2" }, + { from: "P2", to: "T2" }, + { from: "T2", to: "P1" }, + ], + }; + + const groups = findCycleGroups(graph); + + expect(groups).toHaveLength(1); + expect(groups[0]!.memberIds).toEqual(["P1", "T1", "P2", "T2"]); + }); + + it("maps every member to its group", () => { + const graph: NetGraph = { + nodes: [place("Pool"), transition("Churn"), place("Outside")], + edges: [ + { from: "Pool", to: "Churn" }, + { from: "Churn", to: "Pool" }, + { from: "Churn", to: "Outside" }, + ], + }; + + const membership = buildCycleMembership(findCycleGroups(graph)); + + expect(membership.get("Pool")?.label).toBe(1); + expect(membership.get("Churn")?.label).toBe(1); + expect(membership.has("Outside")).toBe(false); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-cycles.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-cycles.ts new file mode 100644 index 00000000000..ed8fea94b3c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-cycles.ts @@ -0,0 +1,150 @@ +/** + * Cycle detection for the net graph: Tarjan's strongly-connected components, + * run iteratively so deep nets can't blow the call stack. + * + * Only places and transitions can take part in a cycle — types, parameters + * and differential equations are pure declarations — so this works on the + * {@link NetGraph} rather than the full dependency graph. + */ + +import type { NetGraph } from "./notebook-model"; + +/** A set of nodes that are all reachable from each other. */ +export type CycleGroup = { + /** Stable key derived from the members, safe to use as a React key. */ + key: string; + /** 1-based number shown to the user, in document order of the first member. */ + label: number; + memberIds: string[]; +}; + +/** + * Every cycle in the net, ordered by where its earliest member appears in the + * document. Nodes not in any cycle are absent. + */ +export function findCycleGroups(graph: NetGraph): CycleGroup[] { + const targetsByNode = new Map(); + for (const edge of graph.edges) { + const existing = targetsByNode.get(edge.from); + if (existing === undefined) { + targetsByNode.set(edge.from, [edge.to]); + } else { + existing.push(edge.to); + } + } + + const documentOrder = new Map( + graph.nodes.map((node, position) => [node.id, position]), + ); + + const depthIndex = new Map(); + const lowLink = new Map(); + const onStack = new Set(); + const componentStack: string[] = []; + const components: string[][] = []; + let nextIndex = 0; + + const open = (id: string) => { + depthIndex.set(id, nextIndex); + lowLink.set(id, nextIndex); + nextIndex += 1; + componentStack.push(id); + onStack.add(id); + }; + + for (const root of graph.nodes) { + if (depthIndex.has(root.id)) { + continue; + } + open(root.id); + const callStack: { id: string; nextTarget: number }[] = [ + { id: root.id, nextTarget: 0 }, + ]; + + while (callStack.length > 0) { + const frame = callStack[callStack.length - 1]!; + const targets = targetsByNode.get(frame.id) ?? []; + + if (frame.nextTarget < targets.length) { + const target = targets[frame.nextTarget]!; + frame.nextTarget += 1; + + if (!depthIndex.has(target)) { + open(target); + callStack.push({ id: target, nextTarget: 0 }); + } else if (onStack.has(target)) { + lowLink.set( + frame.id, + Math.min(lowLink.get(frame.id)!, depthIndex.get(target)!), + ); + } + continue; + } + + callStack.pop(); + const parent = callStack[callStack.length - 1]; + if (parent !== undefined) { + lowLink.set( + parent.id, + Math.min(lowLink.get(parent.id)!, lowLink.get(frame.id)!), + ); + } + + if (lowLink.get(frame.id) === depthIndex.get(frame.id)) { + const members: string[] = []; + let member: string; + do { + member = componentStack.pop()!; + onStack.delete(member); + members.push(member); + } while (member !== frame.id); + + if (members.length > 1) { + components.push( + members.sort( + (left, right) => + (documentOrder.get(left) ?? 0) - + (documentOrder.get(right) ?? 0), + ), + ); + } + } + } + } + + return components + .sort( + (left, right) => + (documentOrder.get(left[0]!) ?? 0) - + (documentOrder.get(right[0]!) ?? 0), + ) + .map((memberIds, position) => ({ + key: memberIds.join("+"), + label: position + 1, + memberIds, + })); +} + +/** Lookup from node id to the cycle it belongs to, for rows and diagram nodes. */ +export function buildCycleMembership( + groups: CycleGroup[], +): Map { + const membership = new Map(); + for (const group of groups) { + for (const id of group.memberIds) { + membership.set(id, group); + } + } + return membership; +} + +/** + * Distinct tints for cycle badges and rings, cycled through by group number. + * Deliberately avoids the blue/orange/purple used for selection roles. + */ +export const CYCLE_TINTS = ["pink", "green", "yellow"] as const; + +export type CycleTint = (typeof CYCLE_TINTS)[number]; + +export const cycleTint = (group: CycleGroup): CycleTint => + CYCLE_TINTS[(group.label - 1) % CYCLE_TINTS.length]!; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-siphons.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-siphons.test.ts new file mode 100644 index 00000000000..4e732eba05e --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-siphons.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; + +import { + probabilisticSatellitesSDCPN, + productionMachines, + sirModel, +} from "@hashintel/petrinaut-core/examples"; + +import { + buildInitialPlaceMembership, + findInitialPlaceGroups, +} from "./net-siphons"; + +import type { ActiveNetDefinition } from "../../../react/state/active-net-context"; + +const emptyNet: ActiveNetDefinition = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + componentInstances: [], +}; + +const place = (id: string): ActiveNetDefinition["places"][number] => ({ + id, + name: id, + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, +}); + +const transition = ( + id: string, + inputs: string[], + outputs: string[], +): ActiveNetDefinition["transitions"][number] => ({ + id, + name: id, + inputArcs: inputs.map((placeId) => ({ + placeId, + weight: 1, + type: "standard" as const, + })), + outputArcs: outputs.map((placeId) => ({ placeId, weight: 1 })), + lambdaType: "stochastic", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, +}); + +/** Group members by place name, for readable assertions. */ +const groupNames = (net: ActiveNetDefinition): string[][] => { + const nameOf = new Map(net.places.map((p) => [p.id, p.name || p.id])); + return findInitialPlaceGroups(net).map((group) => + group.placeIds.map((id) => nameOf.get(id) ?? id).sort(), + ); +}; + +const exampleNet = (example: { + petriNetDefinition: { places: unknown; transitions: unknown }; +}): ActiveNetDefinition => + ({ + ...emptyNet, + ...example.petriNetDefinition, + }) as ActiveNetDefinition; + +describe("findInitialPlaceGroups", () => { + it("reports a source place as its own group", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Source"), place("Sink")], + transitions: [transition("Move", ["Source"], ["Sink"])], + }; + + expect(groupNames(net)).toEqual([["Source"]]); + }); + + it("reports a resource pool that circulates inside a cycle", () => { + // Machines are borrowed and returned, never manufactured. + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Idle"), place("Busy"), place("Output")], + transitions: [ + transition("Start", ["Idle"], ["Busy"]), + transition("Finish", ["Busy"], ["Idle", "Output"]), + ], + }; + + expect(groupNames(net)).toEqual([["Busy", "Idle"]]); + }); + + it("does not report places fed by a source transition", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Arrivals")], + transitions: [transition("Spawn", [], ["Arrivals"])], + }; + + expect(groupNames(net)).toEqual([]); + }); + + it("excludes a group that merely contains a smaller one", () => { + // Output is only reachable through the seeded pool, so it is not itself + // something the initial state has to mark. + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Idle"), place("Busy"), place("Output")], + transitions: [ + transition("Start", ["Idle"], ["Busy"]), + transition("Finish", ["Busy"], ["Idle", "Output"]), + ], + }; + + const membership = buildInitialPlaceMembership(findInitialPlaceGroups(net)); + + expect(membership.has("Output")).toBe(false); + expect(membership.get("Idle")).toBe(membership.get("Busy")); + }); + + it("ignores arcs naming a place that no longer exists", () => { + // "Feed"'s only input names a deleted place, so it must not read as a + // source transition — "Arrivals" still needs seeding. + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Arrivals")], + transitions: [transition("Feed", ["deleted-place"], ["Arrivals"])], + }; + + expect(groupNames(net)).toEqual([["Arrivals"]]); + }); + + it("handles an empty net", () => { + expect(findInitialPlaceGroups(emptyNet)).toEqual([]); + }); +}); + +describe("findInitialPlaceGroups on the shipped examples", () => { + it("finds the raw material and the machine pool, without the technicians", () => { + // Technicians are created by "Call Technician" from a broken machine, so + // they are fed from outside and must not be reported. + expect(groupNames(exampleNet(productionMachines))).toEqual([ + ["RawMaterial"], + [ + "AvailableMachines", + "BrokenMachines", + "MachinesBeingRepaired", + "MachinesProducing", + "MachinesToRepair", + ], + ]); + }); + + it("finds nothing to seed when source transitions manufacture tokens", () => { + expect(groupNames(exampleNet(probabilisticSatellitesSDCPN))).toEqual([]); + }); + + it("finds both SIR compartments that the epidemic needs", () => { + // Two independent groups: without susceptibles nothing can be infected, + // and without a patient zero the infection can never start. + expect(groupNames(exampleNet(sirModel))).toEqual([ + ["Susceptible"], + ["Infected"], + ]); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-siphons.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-siphons.ts new file mode 100644 index 00000000000..271b3a2c5e5 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-siphons.ts @@ -0,0 +1,217 @@ +/** + * Which places the initial state has to seed. + * + * The underlying notion is a **siphon**: a set of places S where every + * transition that produces into S also consumes from S. Nothing in the net can + * put the first token into such a set, so an empty siphon stays empty forever + * and every transition drawing on it is dead. A net therefore has to seed + * every *minimal* siphon to come alive. + * + * This unifies the two cases that look different on the canvas: + * + * - a source place with no producers at all (its own singleton siphon), and + * - a resource pool circulating inside a cycle — machines that are borrowed + * and returned but never manufactured. + * + * Places fed from outside are excluded, including places downstream of a + * source transition (`∅ → p`), which manufactures tokens from nothing. + */ + +import { + transitionInputPlaceIds, + transitionOutputPlaceIds, +} from "./notebook-model"; + +import type { ActiveNetDefinition } from "../../../react/state/active-net-context"; + +/** + * A minimal set of places that the initial state must mark. Marking any one + * member keeps the group from starting empty — the necessary condition this + * analysis checks — which is why they are shown together. + */ +export type InitialPlaceGroup = { + /** Stable key derived from the members, safe to use as a React key. */ + key: string; + /** 1-based number, ordered by the document position of the first member. */ + label: number; + placeIds: string[]; +}; + +/** + * Enumerating minimal siphons is exponential in the worst case. The shrink + * below is polynomial but still cubic in the place count, so very large nets + * skip the analysis rather than stalling a render (a dense 100-place net + * computes in roughly 100ms). + */ +const MAX_ANALYSED_PLACES = 100; + +type TransitionArcs = { inputs: string[]; outputs: string[] }; + +/** + * The largest siphon contained in `candidate`. + * + * Any transition that produces into the set without consuming from it breaks + * the siphon property, so its output places are dropped; repeating that to a + * fixed point leaves the maximal siphon (possibly empty). + */ +function maximalSiphonWithin( + candidate: ReadonlySet, + transitions: TransitionArcs[], +): Set { + const siphon = new Set(candidate); + let changed = true; + + while (changed) { + changed = false; + for (const { inputs, outputs } of transitions) { + if (!outputs.some((placeId) => siphon.has(placeId))) { + continue; + } + if (inputs.some((placeId) => siphon.has(placeId))) { + continue; + } + for (const placeId of outputs) { + if (siphon.delete(placeId)) { + changed = true; + } + } + } + } + + return siphon; +} + +/** + * The smallest siphon that still contains `placeId`, found by greedily + * dropping other places and keeping each reduction that `placeId` survives. + * + * The result is minimal: for every remaining member, removing it leaves no + * siphon containing `placeId` at all. + */ +function minimalSiphonContaining( + placeId: string, + allPlaceIds: string[], + transitions: TransitionArcs[], +): Set | null { + let siphon = maximalSiphonWithin(new Set(allPlaceIds), transitions); + if (!siphon.has(placeId)) { + return null; + } + + for (const candidate of allPlaceIds) { + if (candidate === placeId || !siphon.has(candidate)) { + continue; + } + const reduced = new Set(siphon); + reduced.delete(candidate); + const shrunk = maximalSiphonWithin(reduced, transitions); + if (shrunk.has(placeId)) { + siphon = shrunk; + } + } + + return siphon; +} + +/** + * Every minimal siphon in the net — the groups of places the initial state has + * to mark. Groups that merely contain another group are discarded, so a place + * downstream of a seeded pool isn't reported as needing seeding itself. + */ +export function findInitialPlaceGroups( + net: ActiveNetDefinition, +): InitialPlaceGroup[] { + if (net.places.length === 0 || net.places.length > MAX_ANALYSED_PLACES) { + return []; + } + + const allPlaceIds = net.places.map(({ id }) => id); + const existingPlaceIds = new Set(allPlaceIds); + // A transition whose input arc names a place that no longer exists can + // never fire, so it produces nothing: it is excluded outright rather than + // read as a source (`∅ → p`), which would suppress a genuine seeding + // requirement on its output places. Stale output ids are merely dropped. + const transitions: TransitionArcs[] = net.transitions + .map((transition) => ({ + inputs: transitionInputPlaceIds(transition), + outputs: transitionOutputPlaceIds(transition).filter((placeId) => + existingPlaceIds.has(placeId), + ), + })) + .filter(({ inputs }) => + inputs.every((placeId) => existingPlaceIds.has(placeId)), + ); + const documentOrder = new Map( + allPlaceIds.map((id, position) => [id, position]), + ); + + const found: Set[] = []; + const seenKeys = new Set(); + + for (const placeId of allPlaceIds) { + const siphon = minimalSiphonContaining(placeId, allPlaceIds, transitions); + if (siphon === null || siphon.size === 0) { + continue; + } + const key = [...siphon] + .sort( + (left, right) => + (documentOrder.get(left) ?? 0) - (documentOrder.get(right) ?? 0), + ) + .join("+"); + if (!seenKeys.has(key)) { + seenKeys.add(key); + found.push(siphon); + } + } + + // Siphons overlap: the machine pool and "machines plus technicians minus one + // place" can both be minimal. Reporting every one of them buries the answer, + // so the smallest groups are kept and any later group sharing a place with an + // accepted one is dropped — seeding the shared member covers both. Groups + // that are genuinely independent never overlap, so they all survive. + const claimed = new Set(); + const representative: Set[] = []; + for (const siphon of [...found].sort( + (left, right) => left.size - right.size, + )) { + if ([...siphon].some((placeId) => claimed.has(placeId))) { + continue; + } + for (const placeId of siphon) { + claimed.add(placeId); + } + representative.push(siphon); + } + + return representative + .map((siphon) => + [...siphon].sort( + (left, right) => + (documentOrder.get(left) ?? 0) - (documentOrder.get(right) ?? 0), + ), + ) + .sort( + (left, right) => + (documentOrder.get(left[0]!) ?? 0) - + (documentOrder.get(right[0]!) ?? 0), + ) + .map((placeIds, position) => ({ + key: placeIds.join("+"), + label: position + 1, + placeIds, + })); +} + +/** Lookup from place id to the group it seeds, for badges and ordering. */ +export function buildInitialPlaceMembership( + groups: InitialPlaceGroup[], +): Map { + const membership = new Map(); + for (const group of groups) { + for (const placeId of group.placeIds) { + membership.set(placeId, group); + } + } + return membership; +} diff --git a/libs/@local/petrinaut-arch-docs/content/notebook/graph-analyses.mdx b/libs/@local/petrinaut-arch-docs/content/notebook/graph-analyses.mdx new file mode 100644 index 00000000000..e50dad1026d --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/notebook/graph-analyses.mdx @@ -0,0 +1,114 @@ +--- +title: Notebook graph analyses +description: Why the notebook's cycle, siphon, layout and animation algorithms look the way they do. +attachTo: ui.views.notebook +--- + +The notebook's right-hand explorer and its cell badges are driven by four +structural analyses. Each is a pure function over places, transitions and +arcs — no markings, no scenario state — so they can be unit-tested as plain +data transforms and recomputed on every render without an invalidation +story. This page records why each algorithm was chosen, not what it does; +the doc comments in [the layer itself](layer:ui.views.notebook) cover that. + +## One edge list, two directions + +Every dependency the view draws — gutter lines, explorer lists, dependent +counts — derives from a single edge list built in `notebook-model`. The +upstream and downstream indices are two projections of the same edges, so +the left and right gutters can never disagree about a relationship. The +alternative, computing each direction from the net separately, invites +exactly that disagreement. + +Parameter usage is the one best-effort edge: a word-boundary text match +against transition and equation code. A parameter mentioned only in a +comment still counts as used. Resolving that properly means asking the +LSP, which is asynchronous and per-model; a structural view that is wrong +about comments beats one that is right but flickers. + +## Cycles are SCCs + +A "cycle" badge marks a strongly connected component of size above one, +found with an iterative Tarjan pass. SCCs rather than elementary cycles +because the number of elementary cycles can be exponential and the answer +the reader needs is "these nodes feed back into each other", not an +enumeration of every loop. The known cost: a densely-connected net can be +one giant SCC, and then the badge marks most of the net. If that proves +unhelpful in practice, the upgrade path is Johnson's algorithm with a +size threshold. + +Tarjan is iterative, not recursive, because nets are user data and a long +chain would overflow the call stack. + +## "Needs seeding" means minimal siphon + +The `initial` badge answers: which places must the initial marking cover +for the net to run at all? The structural notion is a **siphon** — a place +set where every transition producing into the set also consumes from it. +An empty siphon stays empty forever. This one definition unifies the two +cases that look different on the canvas: a source place with no producers +(a singleton siphon) and a resource pool circulating in a cycle (borrowed +and returned, never manufactured). + +Minimal siphons are found per place by a greedy shrink from the maximal +siphon: drop a member, re-run the fixpoint, keep the reduction if the +place survives. The result is provably minimal and the whole pass stays +polynomial — no linear algebra, no exponential enumeration. Two pragmatic +filters keep the output readable: + +- Groups that merely contain a smaller reported group are dropped, so a + place downstream of a seeded pool is not itself reported. +- Groups overlapping an already-accepted one are dropped. Real nets have + legitimate overlapping minimal siphons (production-machines has three), + but reporting them reads as noise and wrongly implies more places need + seeding than do. This is a display decision, not a soundness one. + +A transition that references a place which no longer exists is excluded +from the analysis entirely: it can never fire, so treating its remaining +arcs as a live source would suppress genuine seeding requirements. + +The pass is cubic in the place count, so nets above the cap in +`net-siphons` skip the analysis rather than stall a render. The next +precision upgrade, if ever needed, is P-invariants via the Farkas +algorithm, which would also name conserved token counts. + +## Layout ignores the canvas + +The explorer diagram is a cut-down Sugiyama pipeline: break cycles with a +depth-first sweep, layer by longest path, order within layers by two +barycentre passes, then assign coordinates. Canvas x/y positions are +ignored on purpose — the diagram exists to answer "what feeds what", and +inheriting hand-drag positions would make it a worse copy of the canvas. + +D2 and other rendering engines were rejected for this: the Go-to-WASM +bundle is multi-megabyte, initialises asynchronously, and is hard to +theme or wire click handlers into. Hand-computed positions plus inline +SVG keep the whole thing synchronous, testable and a few hundred lines. +`elkjs` already ships in [core.layout](layer:core.layout) if a full +engine is ever wanted. + +Focus mode re-layers by signed BFS distance from the selected node — +dependencies above, dependents below. Nodes unreachable from the focus +keep their own longest-path sub-layering in a band underneath, because +collapsing a disconnected component into one row leaves its edges as +degenerate same-row curves. Edges are classified as "return" edges by +geometry (target layer at or above source layer) rather than by the +cycle-breaking pass, so the same rule holds in both layouts. + +## Animation writes, never reads + +Re-layouts animate FLIP-style. React renders the target layout once; a +single `requestAnimationFrame` loop then writes each node's remaining +offset as a `transform` and recomputes each edge path from interpolated +positions. The loop only writes — it never calls `getBoundingClientRect` +or reads computed styles — so there is no forced synchronous layout, and +zero React re-renders happen per frame. Offsets decay to zero, leaving +the DOM exactly where React thinks it is, with nothing to unwind. + +Each node gets an outer animation-owned `` around the inner +React-owned ``, so the two transforms never fight over one attribute. +The subtle case is an interrupted animation: any React re-render re-runs +the effect, whose cleanup cancels the frame loop. Bailing out on an +unchanged layout signature alone would freeze nodes mid-flight, so the +effect also checks that every node has settled before declining to +animate.