From 636f0d023fe8683160428fefe1ac713ebb550c0a Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 29 Aug 2026 04:08:00 +0200 Subject: [PATCH 1/3] FE-1509: Add the notebook view's data model and layout foundations Pure, unit-tested projections of an SDCPN for the notebook view built on top of them: the cell list and its dependency-edge index covering all five entity kinds, topological cell ordering with declarations inlined before their first user, and a cut-down Sugiyama layered layout with a focus mode that re-layers by hop distance. --- .../petrinaut/src/ui/views/Notebook/README.md | 23 + .../views/Notebook/net-graph-layout.test.ts | 232 ++++++++ .../src/ui/views/Notebook/net-graph-layout.ts | 444 +++++++++++++++ .../ui/views/Notebook/notebook-model.test.ts | 401 +++++++++++++ .../src/ui/views/Notebook/notebook-model.ts | 535 ++++++++++++++++++ .../ui/views/Notebook/notebook-order.test.ts | 171 ++++++ .../src/ui/views/Notebook/notebook-order.ts | 83 +++ 7 files changed, 1889 insertions(+) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/README.md create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.ts diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/README.md b/libs/@hashintel/petrinaut/src/ui/views/Notebook/README.md new file mode 100644 index 00000000000..8c1e166df4d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/README.md @@ -0,0 +1,23 @@ +--- +layer: ui.views.notebook +role: Notebook view — the net as expandable cells with editable code, dependency analysis, and a whole-net graph explorer +--- + +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 +panels wholesale, which is what lets its Monaco editors reuse the LSP +document URIs — a model is never mounted twice. + +Everything an expanded cell shows edits in place — names, fields, arc +weights, type assignments, and code — through the same guarded mutations as +the properties panel; only adding and removing nodes, arcs, and fields +stays in Edit mode. + +The folder splits into a pure core and thin views. `notebook-model`, +`notebook-order`, `net-cycles`, `net-siphons` and `net-graph-layout` are +plain functions over the net definition, unit-tested without the DOM; the +`.tsx` files render their output and own only view state (selection comes +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". diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.test.ts new file mode 100644 index 00000000000..824ba6db385 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from "vitest"; + +import { edgePath, layoutNetGraph, NET_NODE_HEIGHT } from "./net-graph-layout"; + +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", +}); + +const layerOf = (graph: NetGraph, id: string): number => { + const node = layoutNetGraph(graph).nodes.find((entry) => entry.id === id); + if (node === undefined) { + throw new Error(`${id} should be laid out`); + } + return node.layer; +}; + +describe("layoutNetGraph", () => { + it("returns an empty layout for an empty graph", () => { + expect(layoutNetGraph({ nodes: [], edges: [] })).toEqual({ + width: 0, + height: 0, + nodes: [], + edges: [], + }); + }); + + it("stacks a chain into one node per layer, in flow order", () => { + const graph: NetGraph = { + nodes: [place("Source"), transition("Move"), place("Sink")], + edges: [ + { from: "Source", to: "Move" }, + { from: "Move", to: "Sink" }, + ], + }; + + expect(layerOf(graph, "Source")).toBe(0); + expect(layerOf(graph, "Move")).toBe(1); + expect(layerOf(graph, "Sink")).toBe(2); + + const layout = layoutNetGraph(graph); + expect(layout.edges.every(({ isBackEdge }) => !isBackEdge)).toBe(true); + expect(layout.height).toBeGreaterThan(NET_NODE_HEIGHT * 3); + }); + + it("puts a node after its deepest dependency, not its shallowest", () => { + // Direct : Source -> Join + // Indirect: Source -> Detour -> Join + const graph: NetGraph = { + nodes: [place("Source"), transition("Detour"), transition("Join")], + edges: [ + { from: "Source", to: "Join" }, + { from: "Source", to: "Detour" }, + { from: "Detour", to: "Join" }, + ], + }; + + expect(layerOf(graph, "Detour")).toBe(1); + expect(layerOf(graph, "Join")).toBe(2); + }); + + it("breaks a cycle so every node still gets a layer", () => { + const graph: NetGraph = { + nodes: [place("Pool"), transition("Churn")], + edges: [ + { from: "Pool", to: "Churn" }, + { from: "Churn", to: "Pool" }, + ], + }; + + const layout = layoutNetGraph(graph); + + expect(layout.nodes).toHaveLength(2); + expect( + layout.nodes.map(({ layer }) => layer).sort((a, b) => a - b), + ).toEqual([0, 1]); + // Exactly one of the two arcs closes the cycle and is drawn as a return. + expect(layout.edges.filter(({ isBackEdge }) => isBackEdge)).toHaveLength(1); + }); + + it("lays out a graph with no roots at all", () => { + const graph: NetGraph = { + nodes: [place("A"), transition("B"), place("C")], + edges: [ + { from: "A", to: "B" }, + { from: "B", to: "C" }, + { from: "C", to: "A" }, + ], + }; + + const layout = layoutNetGraph(graph); + + expect(layout.nodes).toHaveLength(3); + expect(layout.edges.filter(({ isBackEdge }) => isBackEdge)).toHaveLength(1); + }); + + it("is deterministic for the same input", () => { + const graph: NetGraph = { + nodes: [place("P1"), place("P2"), transition("T"), place("P3")], + edges: [ + { from: "P1", to: "T" }, + { from: "P2", to: "T" }, + { from: "T", to: "P3" }, + ], + }; + + expect(layoutNetGraph(graph)).toEqual(layoutNetGraph(graph)); + }); + + it("keeps disconnected nodes in the graph", () => { + const graph: NetGraph = { + nodes: [place("Lonely"), place("Source"), transition("Move")], + edges: [{ from: "Source", to: "Move" }], + }; + + const layout = layoutNetGraph(graph); + + expect(layout.nodes.map(({ id }) => id).sort()).toEqual([ + "Lonely", + "Move", + "Source", + ]); + expect(layerOf(graph, "Lonely")).toBe(0); + }); +}); + +describe("layoutNetGraph with a focus node", () => { + const chain: NetGraph = { + nodes: [ + place("Source"), + transition("Move"), + place("Middle"), + transition("Ship"), + place("Sink"), + place("Detached"), + ], + edges: [ + { from: "Source", to: "Move" }, + { from: "Move", to: "Middle" }, + { from: "Middle", to: "Ship" }, + { from: "Ship", to: "Sink" }, + ], + }; + + const layerFor = (focusId: string, id: string): number => { + const node = layoutNetGraph(chain, { focusId }).nodes.find( + (entry) => entry.id === id, + ); + if (node === undefined) { + throw new Error(`${id} should be laid out`); + } + return node.layer; + }; + + it("stacks dependencies above and dependents below the focus", () => { + const focus = layerFor("Middle", "Middle"); + + expect(layerFor("Middle", "Move")).toBe(focus - 1); + expect(layerFor("Middle", "Source")).toBe(focus - 2); + expect(layerFor("Middle", "Ship")).toBe(focus + 1); + expect(layerFor("Middle", "Sink")).toBe(focus + 2); + }); + + it("keeps unreachable nodes in the layout, below everything else", () => { + const layout = layoutNetGraph(chain, { focusId: "Middle" }); + + expect(layout.nodes).toHaveLength(chain.nodes.length); + expect(layerFor("Middle", "Detached")).toBeGreaterThan( + layerFor("Middle", "Sink"), + ); + }); + + it("ignores a focus id that is not in the graph", () => { + expect(layoutNetGraph(chain, { focusId: "nope" })).toEqual( + layoutNetGraph(chain), + ); + }); + + it("layers an unreachable component instead of collapsing it to one row", () => { + const graph: NetGraph = { + nodes: [ + place("Focus"), + transition("Feed"), + // A separate flow the focus can't reach either way. + place("OtherSource"), + transition("OtherMove"), + place("OtherSink"), + ], + edges: [ + { from: "Focus", to: "Feed" }, + { from: "OtherSource", to: "OtherMove" }, + { from: "OtherMove", to: "OtherSink" }, + ], + }; + const layout = layoutNetGraph(graph, { focusId: "Focus" }); + const layer = (id: string) => + layout.nodes.find((entry) => entry.id === id)!.layer; + + // The component keeps its own flow order below the reachable band… + expect(layer("OtherSource")).toBeGreaterThan(layer("Feed")); + expect(layer("OtherMove")).toBe(layer("OtherSource") + 1); + expect(layer("OtherSink")).toBe(layer("OtherMove") + 1); + // …so its edges still travel downwards rather than being classified as + // degenerate same-row returns. + for (const edge of layout.edges) { + expect(edge.isBackEdge).toBe(false); + } + }); +}); + +describe("edgePath", () => { + it("keeps a same-row return edge visible by bowing below the row", () => { + const path = edgePath({ x: 0, y: 50 }, { x: 200, y: 50 }, true); + // A flat bow would put every coordinate on y = centre; the dip moves the + // control points off the row so the curve has visible area. + const centreY = 50 + NET_NODE_HEIGHT / 2; + expect(path).toContain(`${centreY + NET_NODE_HEIGHT}`); + }); + + it("does not dip a return edge that spans rows", () => { + const from = { x: 0, y: 100 }; + const to = { x: 0, y: 0 }; + const path = edgePath(from, to, true); + const fromCentreY = 100 + NET_NODE_HEIGHT / 2; + const toCentreY = NET_NODE_HEIGHT / 2; + expect(path).toContain(`C 104 ${fromCentreY}, 104 ${toCentreY}`); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.ts new file mode 100644 index 00000000000..949b1c962cb --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.ts @@ -0,0 +1,444 @@ +/** + * Layered layout for the whole-net graph, computed from the arc structure + * alone — the net's stored x/y positions are deliberately ignored. + * + * A cut-down Sugiyama pipeline: break cycles with a depth-first sweep, assign + * layers by longest path, reduce crossings with barycentre ordering, then + * place coordinates. + */ + +import type { NetGraph, NetGraphEdge, NetGraphNode } from "./notebook-model"; + +export const NET_NODE_WIDTH = 86; +export const NET_NODE_HEIGHT = 22; + +const PADDING = 10; +const COLUMN_GAP = 10; +const ROW_GAP = 34; +/** How far a return edge bows clear of the nodes it connects. */ +const BACK_EDGE_BOW = 18; +/** Barycentre sweeps; two is plenty for nets of this size. */ +const ORDERING_PASSES = 2; + +export type PositionedNetNode = NetGraphNode & { + x: number; + y: number; + layer: number; +}; + +export type LaidOutEdge = NetGraphEdge & { + key: string; + /** + * True when the edge does not travel downwards through the layers; drawn + * as a bowed return path instead of a straight drop. + */ + isBackEdge: boolean; +}; + +export type NetGraphLayout = { + width: number; + height: number; + nodes: PositionedNetNode[]; + edges: LaidOutEdge[]; +}; + +const edgeKey = (edge: NetGraphEdge) => `${edge.from} ${edge.to}`; + +const groupTargets = (edges: NetGraphEdge[]): Map => { + const byFrom = new Map(); + for (const edge of edges) { + const existing = byFrom.get(edge.from); + if (existing === undefined) { + byFrom.set(edge.from, [edge.to]); + } else { + existing.push(edge.to); + } + } + return byFrom; +}; + +const groupSources = (edges: NetGraphEdge[]): Map => { + const byTo = new Map(); + for (const edge of edges) { + const existing = byTo.get(edge.to); + if (existing === undefined) { + byTo.set(edge.to, [edge.from]); + } else { + existing.push(edge.from); + } + } + return byTo; +}; + +/** + * Find the edges that close a cycle, using an iterative depth-first search. + * Removing them leaves a DAG, which is what the layering step needs. + */ +function findBackEdges(graph: NetGraph): Set { + const targetsByNode = groupTargets(graph.edges); + const backEdges = new Set(); + /** 0 = unvisited, 1 = on the current path, 2 = finished. */ + const state = new Map(); + + for (const root of graph.nodes) { + if ((state.get(root.id) ?? 0) !== 0) { + continue; + } + state.set(root.id, 1); + const stack: { id: string; nextTarget: number }[] = [ + { id: root.id, nextTarget: 0 }, + ]; + + while (stack.length > 0) { + const frame = stack[stack.length - 1]!; + const targets = targetsByNode.get(frame.id) ?? []; + + if (frame.nextTarget >= targets.length) { + state.set(frame.id, 2); + stack.pop(); + continue; + } + + const target = targets[frame.nextTarget]!; + frame.nextTarget += 1; + const targetState = state.get(target) ?? 0; + + if (targetState === 1) { + backEdges.add(edgeKey({ from: frame.id, to: target })); + } else if (targetState === 0) { + state.set(target, 1); + stack.push({ id: target, nextTarget: 0 }); + } + } + } + + return backEdges; +} + +/** Longest-path layering over the acyclic edge set. */ +function assignLayers( + nodes: NetGraphNode[], + dagEdges: NetGraphEdge[], +): Map { + const layer = new Map(nodes.map(({ id }) => [id, 0])); + const remainingInDegree = new Map( + nodes.map(({ id }) => [id, 0]), + ); + for (const edge of dagEdges) { + remainingInDegree.set(edge.to, (remainingInDegree.get(edge.to) ?? 0) + 1); + } + + const targetsByNode = groupTargets(dagEdges); + const queue = nodes + .filter(({ id }) => remainingInDegree.get(id) === 0) + .map(({ id }) => id); + + for (let head = 0; head < queue.length; head++) { + const id = queue[head]!; + for (const target of targetsByNode.get(id) ?? []) { + layer.set(target, Math.max(layer.get(target)!, layer.get(id)! + 1)); + const remaining = (remainingInDegree.get(target) ?? 0) - 1; + remainingInDegree.set(target, remaining); + if (remaining === 0) { + queue.push(target); + } + } + } + + return layer; +} + +/** Mean position of a node's neighbours in the adjacent layer. */ +const barycentre = ( + neighbours: string[], + positions: Map, + fallback: number, +): number => { + const known = neighbours + .map((id) => positions.get(id)) + .filter((position): position is number => position !== undefined); + return known.length === 0 + ? fallback + : known.reduce((total, position) => total + position, 0) / known.length; +}; + +const positionsOf = (layerIds: string[]): Map => + new Map(layerIds.map((id, index) => [id, index])); + +/** + * Layer nodes by hop distance from one node: the focus sits in the middle, + * everything upstream of it above (negative distance), everything downstream + * below. Nodes the focus can't reach either way keep the graph's context by + * being parked in a band underneath. + * + * A node reachable both ways — a cycle through the focus — takes whichever + * side is nearer, and the cycle badge and return edge carry the loop. + */ +function assignFocusLayers( + graph: NetGraph, + focusId: string, + dagEdges: NetGraphEdge[], +): Map { + const targetsByNode = groupTargets(graph.edges); + const sourcesByNode = groupSources(graph.edges); + + const walk = ( + adjacency: Map, + sign: 1 | -1, + ): Map => { + const distances = new Map(); + let frontier = [focusId]; + let distance = 0; + const seen = new Set([focusId]); + + while (frontier.length > 0) { + distance += 1; + const next: string[] = []; + for (const id of frontier) { + for (const neighbour of adjacency.get(id) ?? []) { + if (seen.has(neighbour)) { + continue; + } + seen.add(neighbour); + distances.set(neighbour, sign * distance); + next.push(neighbour); + } + } + frontier = next; + } + + return distances; + }; + + const downstream = walk(targetsByNode, 1); + const upstream = walk(sourcesByNode, -1); + + const distances = new Map([[focusId, 0]]); + for (const node of graph.nodes) { + if (node.id === focusId) { + continue; + } + const below = downstream.get(node.id); + const above = upstream.get(node.id); + if (below !== undefined && above !== undefined) { + distances.set(node.id, Math.abs(above) <= below ? above : below); + } else if (below !== undefined) { + distances.set(node.id, below); + } else if (above !== undefined) { + distances.set(node.id, above); + } + } + + // Unreachable nodes sit below everything else rather than vanishing. They + // keep their own longest-path layering — collapsing them into a single band + // would put whole disconnected components on one row, leaving their edges + // as degenerate same-row curves. + const unreachable = graph.nodes.filter((node) => !distances.has(node.id)); + if (unreachable.length > 0) { + const unreachableIds = new Set(unreachable.map(({ id }) => id)); + const unreachableEdges = dagEdges.filter( + (edge) => unreachableIds.has(edge.from) && unreachableIds.has(edge.to), + ); + const subLayers = assignLayers(unreachable, unreachableEdges); + const deepest = Math.max(0, ...distances.values()); + for (const [id, subLayer] of subLayers) { + distances.set(id, deepest + 2 + subLayer); + } + } + + // Collapse the signed distances to dense layer indices. + const usedDistances = [...new Set(distances.values())].sort( + (left, right) => left - right, + ); + const layerByDistance = new Map( + usedDistances.map((value, index) => [value, index]), + ); + + return new Map( + [...distances].map(([id, value]) => [id, layerByDistance.get(value)!]), + ); +} + +/** + * Order nodes within each layer to reduce edge crossings, sweeping down then + * up. `Array.prototype.sort` is stable, so equal barycentres keep their + * relative order and the result is deterministic. + */ +function orderLayers( + initialLayers: string[][], + dagEdges: NetGraphEdge[], +): string[][] { + const sourcesByNode = groupSources(dagEdges); + const targetsByNode = groupTargets(dagEdges); + + const sortByAdjacent = ( + layers: string[][], + index: number, + adjacentLayer: string[], + neighboursByNode: Map, + ): string[] => { + const adjacentPositions = positionsOf(adjacentLayer); + const current = layers[index]!; + const scores = new Map( + current.map((id, position) => [ + id, + barycentre(neighboursByNode.get(id) ?? [], adjacentPositions, position), + ]), + ); + return [...current].sort( + (left, right) => scores.get(left)! - scores.get(right)!, + ); + }; + + let layers = initialLayers.map((ids) => [...ids]); + + for (let pass = 0; pass < ORDERING_PASSES; pass++) { + const downward = layers.map((ids) => [...ids]); + for (let index = 1; index < downward.length; index++) { + downward[index] = sortByAdjacent( + downward, + index, + downward[index - 1]!, + sourcesByNode, + ); + } + + const upward = downward.map((ids) => [...ids]); + for (let index = upward.length - 2; index >= 0; index--) { + upward[index] = sortByAdjacent( + upward, + index, + upward[index + 1]!, + targetsByNode, + ); + } + + layers = upward; + } + + return layers; +} + +export interface LayoutOptions { + /** + * Re-layer around this node instead of by longest path: it takes the middle + * row, its dependencies stack above and its dependents below. + */ + focusId?: string | null; +} + +export function layoutNetGraph( + graph: NetGraph, + options: LayoutOptions = {}, +): NetGraphLayout { + if (graph.nodes.length === 0) { + return { width: 0, height: 0, nodes: [], edges: [] }; + } + + const backEdges = findBackEdges(graph); + const dagEdges = graph.edges.filter((edge) => !backEdges.has(edgeKey(edge))); + + const focusId = + options.focusId != null && + graph.nodes.some((node) => node.id === options.focusId) + ? options.focusId + : null; + + const layerByNode = + focusId === null + ? assignLayers(graph.nodes, dagEdges) + : assignFocusLayers(graph, focusId, dagEdges); + + const layers: string[][] = []; + for (const node of graph.nodes) { + const index = layerByNode.get(node.id)!; + while (layers.length <= index) { + layers.push([]); + } + layers[index]!.push(node.id); + } + + const orderedLayers = orderLayers(layers, dagEdges); + + const layerWidths = orderedLayers.map( + (ids) => ids.length * NET_NODE_WIDTH + (ids.length - 1) * COLUMN_GAP, + ); + const contentWidth = Math.max(...layerWidths); + // Return edges bow out past the rightmost node, so they get their own lane; + // without it the SVG viewport clips the curve's peak. + const hasBackEdge = graph.edges.some( + (edge) => + (layerByNode.get(edge.to) ?? 0) <= (layerByNode.get(edge.from) ?? 0), + ); + const width = contentWidth + PADDING * 2 + (hasBackEdge ? BACK_EDGE_BOW : 0); + const height = + PADDING * 2 + + orderedLayers.length * NET_NODE_HEIGHT + + (orderedLayers.length - 1) * ROW_GAP; + + const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); + const nodes: PositionedNetNode[] = []; + + orderedLayers.forEach((ids, layer) => { + const startX = PADDING + (contentWidth - layerWidths[layer]!) / 2; + ids.forEach((id, position) => { + const node = nodesById.get(id)!; + nodes.push({ + ...node, + layer, + x: startX + position * (NET_NODE_WIDTH + COLUMN_GAP), + y: PADDING + layer * (NET_NODE_HEIGHT + ROW_GAP), + }); + }); + }); + + // Classified by geometry rather than by the layering pass, so an edge is + // drawn as a return path exactly when it doesn't travel downwards — true in + // both the default and the focused layering. + const edges: LaidOutEdge[] = graph.edges.map((edge) => ({ + ...edge, + key: edgeKey(edge), + isBackEdge: + (layerByNode.get(edge.to) ?? 0) <= (layerByNode.get(edge.from) ?? 0), + })); + + return { width, height, nodes, edges }; +} + +/** A node's top-left corner, which is all the edge geometry needs. */ +export type Point = { x: number; y: number }; + +const anchorsOf = (point: Point) => ({ + centreX: point.x + NET_NODE_WIDTH / 2, + centreY: point.y + NET_NODE_HEIGHT / 2, + top: point.y, + bottom: point.y + NET_NODE_HEIGHT, + right: point.x + NET_NODE_WIDTH, +}); + +/** + * Forward edges drop from one layer to the next; a cycle-closing edge bows out + * to the right of both endpoints so it never runs through the layers. + * + * Takes plain points rather than laid-out nodes so the animation can call it + * with interpolated positions mid-flight. + */ +export function edgePath(from: Point, to: Point, isBackEdge: boolean): string { + const source = anchorsOf(from); + const target = anchorsOf(to); + + if (isBackEdge) { + const bowX = Math.max(source.right, target.right) + BACK_EDGE_BOW; + // A same-row return (both ends focused to the same layer) has no vertical + // span to curve through, so dip the control points below the row — a flat + // bow would degenerate into an invisible horizontal line. + const dip = + Math.abs(source.centreY - target.centreY) < NET_NODE_HEIGHT + ? NET_NODE_HEIGHT + : 0; + return `M ${source.right} ${source.centreY} C ${bowX} ${source.centreY + dip}, ${bowX} ${target.centreY + dip}, ${target.right} ${target.centreY}`; + } + + const midY = (source.bottom + target.top) / 2; + return `M ${source.centreX} ${source.bottom} C ${source.centreX} ${midY}, ${target.centreX} ${midY}, ${target.centreX} ${target.top}`; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.test.ts new file mode 100644 index 00000000000..1320bfd93ad --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.test.ts @@ -0,0 +1,401 @@ +import { describe, expect, it } from "vitest"; + +import { + buildConnectionIndex, + buildDependentCounts, + buildNetGraph, + buildNodeNeighbourhood, + fuzzyMatchName, +} from "./notebook-model"; + +import type { ActiveNetDefinition } from "../../../react/state/active-net-context"; +import type { NodeRef } from "./notebook-model"; + +const emptyNet: ActiveNetDefinition = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + componentInstances: [], +}; + +const place = ( + id: string, + overrides: Partial = {}, +): ActiveNetDefinition["places"][number] => ({ + id, + name: id, + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + ...overrides, +}); + +const transition = ( + id: string, + overrides: Partial = {}, +): ActiveNetDefinition["transitions"][number] => ({ + id, + name: id, + inputArcs: [], + outputArcs: [], + lambdaType: "stochastic", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + ...overrides, +}); + +const parameter = ( + id: string, + variableName: string, +): ActiveNetDefinition["parameters"][number] => ({ + id, + name: id, + variableName, + type: "real", + defaultValue: "1", +}); + +const names = (refs: { name: string }[]) => refs.map(({ name }) => name).sort(); + +describe("buildConnectionIndex", () => { + it("links a transition to its input places upstream and output places downstream", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Source"), place("Sink")], + transitions: [ + transition("Move", { + inputArcs: [{ placeId: "Source", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "Sink", weight: 1 }], + }), + ], + }; + + const index = buildConnectionIndex(net); + + expect(names(index.get("Move")!.upstream)).toEqual(["Source"]); + expect(names(index.get("Move")!.downstream)).toEqual(["Sink"]); + // The reverse direction is derived from the same edges. + expect(names(index.get("Source")!.downstream)).toEqual(["Move"]); + expect(names(index.get("Sink")!.upstream)).toEqual(["Move"]); + }); + + it("links a place to its token type and differential equation", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [ + place("Stock", { + colorId: "colour", + dynamicsEnabled: true, + differentialEquationId: "decay", + }), + ], + types: [ + { + id: "colour", + name: "Widget", + iconSlug: "circle", + displayColor: "#000000", + elements: [], + }, + ], + differentialEquations: [ + { id: "decay", name: "Decay", colorId: "colour", code: "" }, + ], + }; + + const index = buildConnectionIndex(net); + + expect(names(index.get("Stock")!.upstream)).toEqual(["Decay", "Widget"]); + expect(names(index.get("colour")!.downstream)).toEqual(["Decay", "Stock"]); + expect(names(index.get("decay")!.upstream)).toEqual(["Widget"]); + expect(names(index.get("decay")!.downstream)).toEqual(["Stock"]); + }); + + it("links parameters to the transitions and equations whose code reads them", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + parameters: [parameter("rate", "growth_rate")], + transitions: [ + transition("Grow", { lambdaCode: "return parameters.growth_rate;" }), + transition("Idle", { lambdaCode: "return 1;" }), + ], + differentialEquations: [ + { + id: "growth", + name: "Growth", + colorId: null, + code: "return growth_rate * x;", + }, + ], + }; + + const index = buildConnectionIndex(net); + + expect(names(index.get("rate")!.downstream)).toEqual(["Grow", "Growth"]); + expect(names(index.get("Grow")!.upstream)).toEqual(["rate"]); + expect(index.get("Idle")).toBeUndefined(); + }); + + it("only counts parameter references bounded by non-identifier characters", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + parameters: [parameter("rate", "growth_rate")], + transitions: [ + transition("Longer", { lambdaCode: "return growth_rate2;" }), + transition("Prefixed", { lambdaCode: "return my_growth_rate;" }), + transition("Dollar", { lambdaCode: "return $growth_rate;" }), + transition("Exact", { + lambdaCode: "return growth_rate2 + growth_rate;", + }), + ], + }; + + const index = buildConnectionIndex(net); + + expect(names(index.get("rate")!.downstream)).toEqual(["Exact"]); + }); + + it("records both directions when a place is an input and an output of one transition", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Pool")], + transitions: [ + transition("Churn", { + inputArcs: [{ placeId: "Pool", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "Pool", weight: 1 }], + }), + ], + }; + + const index = buildConnectionIndex(net); + + expect(names(index.get("Churn")!.upstream)).toEqual(["Pool"]); + expect(names(index.get("Churn")!.downstream)).toEqual(["Pool"]); + }); + + it("ignores component port arc endpoints", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Local")], + transitions: [ + transition("Bridge", { + inputArcs: [ + { + endpoint: { + kind: "componentPort", + componentInstanceId: "instance", + portPlaceId: "port", + }, + weight: 1, + type: "standard", + }, + ], + outputArcs: [{ placeId: "Local", weight: 1 }], + }), + ], + }; + + const index = buildConnectionIndex(net); + + expect(index.get("Bridge")!.upstream).toEqual([]); + expect(names(index.get("Bridge")!.downstream)).toEqual(["Local"]); + }); +}); + +describe("buildNodeNeighbourhood", () => { + const ref = ( + type: "place" | "transition" | "parameter", + id: string, + ): NodeRef => ({ type, id, name: id }); + + it("keeps only places and transitions", () => { + const neighbourhood = buildNodeNeighbourhood({ + upstream: [ref("place", "Source"), ref("parameter", "rate")], + downstream: [ref("transition", "Move")], + }); + + expect(names(neighbourhood.dependencies)).toEqual(["Source"]); + expect(names(neighbourhood.dependents)).toEqual(["Move"]); + expect(neighbourhood.bidirectional).toEqual([]); + }); + + it("moves a neighbour reachable both ways into the bidirectional bucket", () => { + const neighbourhood = buildNodeNeighbourhood({ + upstream: [ref("place", "Pool"), ref("place", "Source")], + downstream: [ref("place", "Pool"), ref("place", "Sink")], + }); + + expect(names(neighbourhood.bidirectional)).toEqual(["Pool"]); + expect(names(neighbourhood.dependencies)).toEqual(["Source"]); + expect(names(neighbourhood.dependents)).toEqual(["Sink"]); + }); + + it("deduplicates repeated refs", () => { + const neighbourhood = buildNodeNeighbourhood({ + upstream: [ref("place", "Source"), ref("place", "Source")], + downstream: [], + }); + + expect(neighbourhood.dependencies).toHaveLength(1); + }); +}); + +describe("buildNetGraph", () => { + it("turns input arcs into place -> transition and output arcs into transition -> place", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Source"), place("Sink")], + transitions: [ + transition("Move", { + inputArcs: [{ placeId: "Source", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "Sink", weight: 1 }], + }), + ], + }; + + const graph = buildNetGraph(net); + + expect(graph.nodes.map(({ id }) => id)).toEqual(["Source", "Sink", "Move"]); + expect(graph.edges).toEqual([ + { from: "Source", to: "Move" }, + { from: "Move", to: "Sink" }, + ]); + }); + + it("excludes types, parameters and equations", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Stock", { colorId: "colour" })], + types: [ + { + id: "colour", + name: "Widget", + iconSlug: "circle", + displayColor: "#000000", + elements: [], + }, + ], + parameters: [parameter("rate", "growth_rate")], + differentialEquations: [ + { id: "decay", name: "Decay", colorId: null, code: "" }, + ], + }; + + expect(buildNetGraph(net).nodes.map(({ id }) => id)).toEqual(["Stock"]); + }); + + it("skips arcs naming a place that no longer exists", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + transitions: [ + transition("Orphan", { + inputArcs: [{ placeId: "Missing", weight: 1, type: "standard" }], + }), + ], + }; + + expect(buildNetGraph(net).edges).toEqual([]); + }); +}); + +describe("buildDependentCounts", () => { + it("counts direct and transitive dependents down a chain", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Source"), place("Middle"), place("Sink")], + transitions: [ + transition("First", { + inputArcs: [{ placeId: "Source", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "Middle", weight: 1 }], + }), + transition("Second", { + inputArcs: [{ placeId: "Middle", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "Sink", weight: 1 }], + }), + ], + }; + + const counts = buildDependentCounts(buildConnectionIndex(net)); + + // Source -> First -> Middle -> Second -> Sink + expect(counts.get("Source")).toEqual({ direct: 1, transitive: 4 }); + expect(counts.get("Middle")).toEqual({ direct: 1, transitive: 2 }); + expect(counts.get("Sink")).toEqual({ direct: 0, transitive: 0 }); + }); + + it("excludes the cell itself when it sits in a cycle", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Pool")], + transitions: [ + transition("Churn", { + inputArcs: [{ placeId: "Pool", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "Pool", weight: 1 }], + }), + ], + }; + + const counts = buildDependentCounts(buildConnectionIndex(net)); + + expect(counts.get("Pool")).toEqual({ direct: 1, transitive: 1 }); + expect(counts.get("Churn")).toEqual({ direct: 1, transitive: 1 }); + }); + + it("counts a declaration's users across kinds", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Stock", { colorId: "colour" })], + types: [ + { + id: "colour", + name: "Widget", + iconSlug: "circle", + displayColor: "#000000", + elements: [], + }, + ], + differentialEquations: [ + { id: "decay", name: "Decay", colorId: "colour", code: "" }, + ], + }; + + const counts = buildDependentCounts(buildConnectionIndex(net)); + + // The type is used by the place and the equation directly. + expect(counts.get("colour")).toEqual({ direct: 2, transitive: 2 }); + }); +}); + +describe("fuzzyMatchName", () => { + it("matches a subsequence case-insensitively and returns its indices", () => { + expect(fuzzyMatchName("rwm", "RawMaterial")).toEqual([0, 2, 3]); + }); + + it("ignores whitespace in the query", () => { + expect(fuzzyMatchName("raw mat", "RawMaterial")).toEqual([ + 0, 1, 2, 3, 4, 5, + ]); + }); + + it("returns null when a character has no match after the previous one", () => { + expect(fuzzyMatchName("lam", "Material")).toBeNull(); + }); + + it("returns indices into the original string when lowercasing changes its length", () => { + // "İ".toLowerCase() is two code units; the indices must still point into + // the original string. + expect(fuzzyMatchName("stan", "İstanbul")).toEqual([1, 2, 3, 4]); + }); + + it("matches a query character whose lowercase form expands", () => { + // Lowercasing the whole query up front would split "İ" into "i" + a + // combining dot and fail on the second step. + expect(fuzzyMatchName("İst", "İstanbul")).toEqual([0, 1, 2]); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.ts new file mode 100644 index 00000000000..f42d15a92b4 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.ts @@ -0,0 +1,535 @@ +/** + * Pure projections of an SDCPN into the notebook view's data: the flat, + * document-ordered list of cells, and the dependency graph the gutter lines + * and graph explorer are drawn from. + */ + +import type { ActiveNetDefinition } from "../../../react/state/active-net-context"; +import type { + Color, + DifferentialEquation, + InputArc, + OutputArc, + Parameter, + Place, + SelectionItem, + Transition, +} from "@hashintel/petrinaut-core"; + +export type NotebookCell = + | { kind: "place"; id: string; place: Place } + | { kind: "transition"; id: string; transition: Transition } + | { kind: "type"; id: string; color: Color } + | { kind: "differentialEquation"; id: string; equation: DifferentialEquation } + | { kind: "parameter"; id: string; parameter: Parameter }; + +export type NotebookCellKind = NotebookCell["kind"]; + +/** + * Cells in document order — the order entities are stored in the net + * definition. + */ +export function buildNotebookCells(net: ActiveNetDefinition): NotebookCell[] { + return [ + ...net.places.map( + (place): NotebookCell => ({ kind: "place", id: place.id, place }), + ), + ...net.transitions.map( + (transition): NotebookCell => ({ + kind: "transition", + id: transition.id, + transition, + }), + ), + ...net.types.map( + (color): NotebookCell => ({ kind: "type", id: color.id, color }), + ), + ...net.differentialEquations.map( + (equation): NotebookCell => ({ + kind: "differentialEquation", + id: equation.id, + equation, + }), + ), + ...net.parameters.map( + (parameter): NotebookCell => ({ + kind: "parameter", + id: parameter.id, + parameter, + }), + ), + ]; +} + +export function cellToSelectionItem(cell: NotebookCell): SelectionItem { + return { type: cell.kind, id: cell.id }; +} + +export function cellName(cell: NotebookCell): string { + switch (cell.kind) { + case "place": + return cell.place.name || cell.id; + case "transition": + return cell.transition.name || cell.id; + case "type": + return cell.color.name || cell.id; + case "differentialEquation": + return cell.equation.name || cell.id; + case "parameter": + return cell.parameter.name || cell.id; + } +} + +/** + * Case-insensitive fuzzy subsequence match of `query` against `name` + * (whitespace in the query is ignored). Returns the indices of the matched + * characters in `name`, or `null` when the query doesn't match. + */ +export function fuzzyMatchName(query: string, name: string): number[] | null { + const indices: number[] = []; + let searchFrom = 0; + // Lowercase one character at a time, on both sides: lowercasing a whole + // string can change its length (e.g. "İ" → "i̇"), which would split one + // typed query character into two match steps and leave the returned + // indices misaligned with the original name. + for (const char of query) { + if (char.trim() === "") { + continue; + } + const needle = char.toLowerCase(); + let index = -1; + for (let at = searchFrom; at < name.length; at += 1) { + if ( + name + .slice(at, at + char.length) + .toLowerCase() + .startsWith(needle) + ) { + index = at; + break; + } + } + if (index === -1) { + return null; + } + indices.push(index); + searchFrom = index + 1; + } + return indices; +} + +/** + * Resolve an arc to the plain place it connects to, or `null` for component + * port endpoints. + */ +export function arcPlaceId(arc: InputArc | OutputArc): string | null { + if (arc.endpoint) { + return arc.endpoint.kind === "place" ? arc.endpoint.placeId : null; + } + return arc.placeId ?? null; +} + +function dedupe(ids: string[]): string[] { + return [...new Set(ids)]; +} + +export function transitionInputPlaceIds(transition: Transition): string[] { + return dedupe( + transition.inputArcs + .map(arcPlaceId) + .filter((id): id is string => id !== null), + ); +} + +export function transitionOutputPlaceIds(transition: Transition): string[] { + return dedupe( + transition.outputArcs + .map(arcPlaceId) + .filter((id): id is string => id !== null), + ); +} + +// Keyed weakly by the net object: edits produce a new definition object, so +// each cache entry stays valid for as long as its net is reachable. Without +// this, every arc of every rendered summary rescans the place array. +const placeNamesByNet = new WeakMap>(); + +export function placeName(net: ActiveNetDefinition, placeId: string): string { + let names = placeNamesByNet.get(net); + if (names === undefined) { + names = new Map( + net.places.map((place) => [place.id, place.name || place.id]), + ); + placeNamesByNet.set(net, names); + } + return names.get(placeId) ?? placeId; +} + +/** A cell referenced as one end of a dependency edge. */ +export type NodeRef = { + type: NotebookCellKind; + id: string; + name: string; +}; + +/** + * What a cell depends on and what depends on it. Derived from a single edge + * list, so the two directions are always consistent with each other. + */ +export type CellConnections = { + upstream: NodeRef[]; + downstream: NodeRef[]; +}; + +export const noConnections = (): CellConnections => ({ + upstream: [], + downstream: [], +}); + +/** `dependent` needs `dependency` in order to be understood. */ +type DependencyEdge = { dependency: NodeRef; dependent: NodeRef }; + +function isIdentifierChar(char: string | undefined): boolean { + if (char === undefined) { + return false; + } + return ( + (char >= "a" && char <= "z") || + (char >= "A" && char <= "Z") || + (char >= "0" && char <= "9") || + char === "_" || + char === "$" + ); +} + +/** + * Best-effort textual check for a parameter reference in user code: the + * variable name must appear with no identifier character on either side, so + * `rate` matches in `rate * 2` but not in `growth_rate` or `$rate`. + */ +function codeReferences( + code: string | undefined, + variableName: string, +): boolean { + if (!code || !variableName) { + return false; + } + for ( + let at = code.indexOf(variableName); + at !== -1; + at = code.indexOf(variableName, at + 1) + ) { + if ( + !isIdentifierChar(code[at - 1]) && + !isIdentifierChar(code[at + variableName.length]) + ) { + return true; + } + } + return false; +} + +/** + * Every dependency relationship the notebook knows how to draw: + * + * - a transition depends on its input places, and its output places depend + * on it (so token flow reads left-to-right through the gutters); + * - a place depends on its token type, its differential equation, and any + * parameter its visualizer references; + * - a differential equation depends on its token type and the parameters its + * code references; + * - a transition depends on the parameters its lambda or kernel references. + * + * Types and parameters are pure declarations, so they never have upstream + * edges of their own. + */ +function buildDependencyEdges(net: ActiveNetDefinition): DependencyEdge[] { + const placeRefs = new Map( + net.places.map((place) => [ + place.id, + { type: "place", id: place.id, name: place.name || place.id }, + ]), + ); + const typeRefs = new Map( + net.types.map((color) => [ + color.id, + { type: "type", id: color.id, name: color.name || color.id }, + ]), + ); + const equationRefs = new Map( + net.differentialEquations.map((equation) => [ + equation.id, + { + type: "differentialEquation", + id: equation.id, + name: equation.name || equation.id, + }, + ]), + ); + + const edges: DependencyEdge[] = []; + const seen = new Set(); + + const add = ( + dependency: NodeRef | undefined, + dependent: NodeRef | undefined, + ) => { + if ( + dependency === undefined || + dependent === undefined || + dependency.id === dependent.id + ) { + return; + } + // Direction matters: a place that is both input and output of the same + // transition legitimately produces one edge each way. + const key = `${dependency.id} ${dependent.id}`; + if (seen.has(key)) { + return; + } + seen.add(key); + edges.push({ dependency, dependent }); + }; + + const parameterRefs = net.parameters.map((parameter) => ({ + parameter, + ref: { + type: "parameter" as const, + id: parameter.id, + name: parameter.name || parameter.id, + }, + })); + + const addParameterEdges = ( + dependent: NodeRef, + ...code: (string | undefined)[] + ) => { + for (const { parameter, ref } of parameterRefs) { + if ( + code.some((snippet) => codeReferences(snippet, parameter.variableName)) + ) { + add(ref, dependent); + } + } + }; + + for (const place of net.places) { + const dependent = placeRefs.get(place.id)!; + if (place.colorId !== null) { + add(typeRefs.get(place.colorId), dependent); + } + if (place.differentialEquationId !== null) { + add(equationRefs.get(place.differentialEquationId), dependent); + } + addParameterEdges(dependent, place.visualizerCode); + } + + for (const transition of net.transitions) { + const transitionRef: NodeRef = { + type: "transition", + id: transition.id, + name: transition.name || transition.id, + }; + for (const placeId of transitionInputPlaceIds(transition)) { + add(placeRefs.get(placeId), transitionRef); + } + for (const placeId of transitionOutputPlaceIds(transition)) { + add(transitionRef, placeRefs.get(placeId)); + } + addParameterEdges( + transitionRef, + transition.lambdaCode, + transition.transitionKernelCode, + ); + } + + for (const equation of net.differentialEquations) { + const dependent = equationRefs.get(equation.id)!; + if (equation.colorId !== null) { + add(typeRefs.get(equation.colorId), dependent); + } + addParameterEdges(dependent, equation.code); + } + + return edges; +} + +/** + * Upstream/downstream connections for every cell in the net, keyed by cell + * id. Built once per net so the gutter lines, the explorer and keyboard + * navigation all read the same graph. + */ +export function buildConnectionIndex( + net: ActiveNetDefinition, +): Map { + const index = new Map(); + + const entryFor = (id: string): CellConnections => { + const existing = index.get(id); + if (existing !== undefined) { + return existing; + } + const created = noConnections(); + index.set(id, created); + return created; + }; + + for (const { dependency, dependent } of buildDependencyEdges(net)) { + entryFor(dependent.id).upstream.push(dependency); + entryFor(dependency.id).downstream.push(dependent); + } + + return index; +} + +/** + * The selected node's immediate neighbourhood, restricted to the net's own + * nodes (places and transitions) and split by direction. + * + * A neighbour that is reachable both ways — a place that is an input *and* an + * output of the same transition, for instance — is a cycle through the centre + * and lands in `bidirectional` rather than being drawn twice. + */ +export type NodeNeighbourhood = { + dependencies: NodeRef[]; + dependents: NodeRef[]; + bidirectional: NodeRef[]; +}; + +const isNetNode = (ref: NodeRef): boolean => + ref.type === "place" || ref.type === "transition"; + +/** Deduplicate refs by id, keeping first occurrence order. */ +const uniqueRefs = (refs: NodeRef[]): NodeRef[] => { + const byId = new Map(); + for (const ref of refs) { + if (!byId.has(ref.id)) { + byId.set(ref.id, ref); + } + } + return [...byId.values()]; +}; + +export function buildNodeNeighbourhood( + connections: CellConnections, +): NodeNeighbourhood { + const upstream = uniqueRefs(connections.upstream.filter(isNetNode)); + const downstream = uniqueRefs(connections.downstream.filter(isNetNode)); + + const upstreamIds = new Set(upstream.map(({ id }) => id)); + const downstreamIds = new Set(downstream.map(({ id }) => id)); + + return { + dependencies: upstream.filter(({ id }) => !downstreamIds.has(id)), + dependents: downstream.filter(({ id }) => !upstreamIds.has(id)), + bidirectional: upstream.filter(({ id }) => downstreamIds.has(id)), + }; +} + +export type NetGraphNodeKind = "place" | "transition"; + +export type NetGraphNode = { + id: string; + name: string; + kind: NetGraphNodeKind; +}; + +/** A directed arc between two net nodes, in token-flow direction. */ +export type NetGraphEdge = { from: string; to: string }; + +export type NetGraph = { nodes: NetGraphNode[]; edges: NetGraphEdge[] }; + +/** + * The whole net as a directed graph of places and transitions: an input arc + * becomes place → transition, an output arc transition → place. Arcs to + * component ports, and arcs naming a place that no longer exists, are + * skipped. Only places and transitions appear — this graph describes token + * flow. + */ +export function buildNetGraph(net: ActiveNetDefinition): NetGraph { + const nodes: NetGraphNode[] = [ + ...net.places.map( + (place): NetGraphNode => ({ + id: place.id, + name: place.name || place.id, + kind: "place", + }), + ), + ...net.transitions.map( + (transition): NetGraphNode => ({ + id: transition.id, + name: transition.name || transition.id, + kind: "transition", + }), + ), + ]; + + const placeIds = new Set(net.places.map(({ id }) => id)); + const edges: NetGraphEdge[] = []; + const seen = new Set(); + + const add = (from: string, to: string) => { + const key = `${from} ${to}`; + if (seen.has(key)) { + return; + } + seen.add(key); + edges.push({ from, to }); + }; + + for (const transition of net.transitions) { + for (const placeId of transitionInputPlaceIds(transition)) { + if (placeIds.has(placeId)) { + add(placeId, transition.id); + } + } + for (const placeId of transitionOutputPlaceIds(transition)) { + if (placeIds.has(placeId)) { + add(transition.id, placeId); + } + } + } + + return { nodes, edges }; +} + +/** + * How much depends on a cell: `direct` counts its immediate dependents, + * `transitive` counts everything reachable downstream from it (itself + * excluded, so a cell inside a cycle doesn't count itself). + */ +export type DependentCount = { direct: number; transitive: number }; + +/** + * Dependent counts for every cell in the index. Cycles are handled by visiting + * each node at most once per traversal, so a loop contributes its members + * rather than looping forever. + */ +export function buildDependentCounts( + index: Map, +): Map { + const counts = new Map(); + + for (const [id, connections] of index) { + const reached = new Set(); + const queue = connections.downstream.map((ref) => ref.id); + + for (let head = 0; head < queue.length; head++) { + const next = queue[head]!; + if (next === id || reached.has(next)) { + continue; + } + reached.add(next); + for (const ref of index.get(next)?.downstream ?? []) { + queue.push(ref.id); + } + } + + counts.set(id, { + direct: connections.downstream.length, + transitive: reached.size, + }); + } + + return counts; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.test.ts new file mode 100644 index 00000000000..2014ed2dfda --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; + +import { orderCellsTopologically } from "./notebook-order"; + +import type { CellConnections, NodeRef, NotebookCell } from "./notebook-model"; + +const placeCell = (id: string): NotebookCell => ({ + kind: "place", + id, + place: { + id, + name: id, + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, +}); + +const transitionCell = (id: string): NotebookCell => ({ + kind: "transition", + id, + transition: { + id, + name: id, + inputArcs: [], + outputArcs: [], + lambdaType: "stochastic", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, +}); + +const parameterCell = (id: string): NotebookCell => ({ + kind: "parameter", + id, + parameter: { + id, + name: id, + variableName: id, + type: "real", + defaultValue: "1", + }, +}); + +const typeCell = (id: string): NotebookCell => ({ + kind: "type", + id, + color: { + id, + name: id, + iconSlug: "circle", + displayColor: "#000000", + elements: [], + }, +}); + +const ref = (type: NodeRef["type"], id: string): NodeRef => ({ + type, + id, + name: id, +}); + +const connections = ( + entries: Record, +): Map => + new Map( + Object.entries(entries).map(([id, upstream]) => [ + id, + { upstream, downstream: [] }, + ]), + ); + +const ids = (cells: NotebookCell[]) => cells.map(({ id }) => id); + +describe("orderCellsTopologically", () => { + it("follows the supplied flow order for places and transitions", () => { + const cells = [ + transitionCell("Move"), + placeCell("Sink"), + placeCell("Source"), + ]; + + expect( + ids( + orderCellsTopologically(cells, ["Source", "Move", "Sink"], new Map()), + ), + ).toEqual(["Source", "Move", "Sink"]); + }); + + it("inlines a parameter immediately before its first user", () => { + const cells = [ + parameterCell("rate"), + placeCell("Source"), + transitionCell("Move"), + ]; + + const ordered = orderCellsTopologically( + cells, + ["Source", "Move"], + connections({ Move: [ref("parameter", "rate")] }), + ); + + expect(ids(ordered)).toEqual(["Source", "rate", "Move"]); + }); + + it("emits a declaration only once, before its earliest user", () => { + const cells = [ + parameterCell("rate"), + transitionCell("First"), + transitionCell("Second"), + ]; + + const ordered = orderCellsTopologically( + cells, + ["First", "Second"], + connections({ + First: [ref("parameter", "rate")], + Second: [ref("parameter", "rate")], + }), + ); + + expect(ids(ordered)).toEqual(["rate", "First", "Second"]); + }); + + it("emits a declaration's own dependencies before it", () => { + // Equation depends on a type and a parameter; a place uses the equation. + const cells = [ + placeCell("Stock"), + typeCell("Widget"), + parameterCell("decayRate"), + { + kind: "differentialEquation" as const, + id: "Decay", + equation: { id: "Decay", name: "Decay", colorId: null, code: "" }, + }, + ]; + + const ordered = orderCellsTopologically( + cells, + ["Stock"], + connections({ + Stock: [ref("differentialEquation", "Decay")], + Decay: [ref("type", "Widget"), ref("parameter", "decayRate")], + }), + ); + + expect(ids(ordered)).toEqual(["Widget", "decayRate", "Decay", "Stock"]); + }); + + it("keeps unused declarations, appended at the end", () => { + const cells = [parameterCell("unused"), placeCell("Source")]; + + expect(ids(orderCellsTopologically(cells, ["Source"], new Map()))).toEqual([ + "Source", + "unused", + ]); + }); + + it("never drops a cell missing from the flow order", () => { + const cells = [placeCell("Source"), placeCell("Detached")]; + + expect(ids(orderCellsTopologically(cells, ["Source"], new Map()))).toEqual([ + "Source", + "Detached", + ]); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.ts new file mode 100644 index 00000000000..4ddf2de537f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.ts @@ -0,0 +1,83 @@ +/** + * Cell ordering for the notebook list. + * + * `document` keeps the net's own storage order. `topological` reads like a + * program: places and transitions follow token flow, and every declaration a + * cell needs — its token type, differential equation, parameters — is emitted + * immediately before the first cell that uses it. + */ + +import type { CellConnections, NotebookCell } from "./notebook-model"; + +export type CellOrder = "document" | "topological"; + +const DECLARATION_KINDS = new Set([ + "type", + "differentialEquation", + "parameter", +]); + +/** + * Order cells so nothing is referenced before it is declared. + * + * `flowOrder` supplies the place/transition sequence — pass the diagram's + * layer order so the list and the graph tell the same story. Cells missing + * from `flowOrder`, and declarations nothing uses, are appended in document + * order so no cell is ever dropped. + */ +export function orderCellsTopologically( + cells: NotebookCell[], + flowOrder: string[], + connections: Map, +): NotebookCell[] { + const cellById = new Map(cells.map((cell) => [cell.id, cell])); + const emitted = new Set(); + const emitting = new Set(); + const ordered: NotebookCell[] = []; + + const emit = (id: string) => { + const cell = cellById.get(id); + if (cell === undefined || emitted.has(id)) { + return; + } + emitted.add(id); + ordered.push(cell); + }; + + /** + * Emit the declarations a cell depends on, deepest first — an equation's own + * token type and parameters come before the equation itself. `emitting` + * guards against a malformed net looping back on itself. + */ + const emitDeclarations = (id: string) => { + if (emitting.has(id)) { + return; + } + emitting.add(id); + for (const dependency of connections.get(id)?.upstream ?? []) { + if ( + !DECLARATION_KINDS.has(dependency.type) || + emitted.has(dependency.id) + ) { + continue; + } + emitDeclarations(dependency.id); + emit(dependency.id); + } + emitting.delete(id); + }; + + for (const id of flowOrder) { + emitDeclarations(id); + emit(id); + } + + // Anything the flow order didn't reach: unused declarations, and nodes that + // aren't part of the graph at all. + for (const cell of cells) { + emitDeclarations(cell.id); + emit(cell.id); + } + + return ordered; +} From 219faf0719ce4bf3ebcce30f12d41fb675d62334 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 29 Aug 2026 04:09:12 +0200 Subject: [PATCH 2/3] FE-1509: Add the notebook cell list as an experimental mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new global mode behind an enableNotebookView user setting, toggled in the viewport settings dialog and placed directly after Edit. The net renders as a list of one-line expandable cells with keyboard navigation on the worksheet focus flow (one roving tab stop, no edge wrap), fuzzy name search, kind filters, document/topological ordering, per-row dependent counts, and gutter connector lines from the selected cell to its dependencies and dependents. Everything an expanded cell shows edits in place through the same guarded mutations as the properties panel: names, fields, arc weights, type assignments, and code. Body parts join the focus flow — Enter engages a part's widgets or code editor, Tab cycles them, Escape reverts drafts and steps back out. Code edits only commit while the editor has focus, so programmatic model resets can never write back. The effective global mode is derived in one shared hook used by both the editor view and useReadOnlyReason, so the rendered view and the mutation rules never disagree when the flag is off. --- .../src/react/state/editor-context.ts | 2 +- .../react/state/use-effective-global-mode.ts | 20 + .../src/react/state/use-read-only-reason.ts | 7 +- .../react/state/use-undo-redo-shortcuts.ts | 45 + .../src/react/state/user-settings-context.ts | 4 + .../react/state/user-settings-provider.tsx | 2 + .../components/TopBar/mode-selector.tsx | 15 +- .../Editor/components/TopBar/top-bar.tsx | 3 + .../src/ui/views/Editor/editor-view.tsx | 30 +- .../src/ui/views/Notebook/cell-kinds.ts | 52 + .../ui/views/Notebook/connection-lines.tsx | 196 +++ .../src/ui/views/Notebook/notebook-cell.tsx | 1458 +++++++++++++++++ .../src/ui/views/Notebook/notebook-view.tsx | 482 ++++++ .../petrinaut/src/ui/views/README.md | 2 +- .../components/viewport-settings-dialog.tsx | 19 + 15 files changed, 2325 insertions(+), 12 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/react/state/use-effective-global-mode.ts create mode 100644 libs/@hashintel/petrinaut/src/react/state/use-undo-redo-shortcuts.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/cell-kinds.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/connection-lines.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-cell.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-view.tsx diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts index 7778c21bab4..4d1ecf0dad7 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts @@ -13,7 +13,7 @@ export type DraggingStateByNodeId = Record< { dragging: boolean; position: { x: number; y: number } } >; -export type EditorGlobalMode = "edit" | "simulate" | "actual"; +export type EditorGlobalMode = "edit" | "simulate" | "actual" | "notebook"; type EditorEditionMode = | "cursor" | "add-place" diff --git a/libs/@hashintel/petrinaut/src/react/state/use-effective-global-mode.ts b/libs/@hashintel/petrinaut/src/react/state/use-effective-global-mode.ts new file mode 100644 index 00000000000..5c21f1bc014 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/use-effective-global-mode.ts @@ -0,0 +1,20 @@ +import { use } from "react"; + +import { EditorContext } from "./editor-context"; +import { UserSettingsContext } from "./user-settings-context"; + +import type { EditorGlobalMode } from "./editor-context"; + +/** + * The global mode the editor actually renders. The stored mode can say + * "notebook" while the experimental notebook flag is off (e.g. the flag was + * disabled while the view was active); every consumer must agree that this + * falls back to "edit" — deriving it in one consumer only would render the + * edit canvas while mutations are still refused with a notebook explanation. + */ +export const useEffectiveGlobalMode = (): EditorGlobalMode => { + const { globalMode } = use(EditorContext); + const { enableNotebookView } = use(UserSettingsContext); + + return globalMode === "notebook" && !enableNotebookView ? "edit" : globalMode; +}; diff --git a/libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts b/libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts index f944517d199..c9caf5f6344 100644 --- a/libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts +++ b/libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts @@ -1,8 +1,8 @@ import { use } from "react"; import { SimulationContext } from "../simulation/context"; -import { EditorContext } from "./editor-context"; import { SDCPNContext } from "./sdcpn-context"; +import { useEffectiveGlobalMode } from "./use-effective-global-mode"; /** * Why the editor currently disallows mutations, or `null` when mutations @@ -26,7 +26,10 @@ export type ReadOnlyReason = */ export const useReadOnlyReason = (): ReadOnlyReason | null => { const { readonly } = use(SDCPNContext); - const { globalMode } = use(EditorContext); + // The effective mode, not the stored one — the stored mode can say + // "notebook" while the flag is off, in which case the edit canvas renders + // and mutations must be allowed. + const globalMode = useEffectiveGlobalMode(); const { state: simulationState } = use(SimulationContext); if (readonly) { diff --git a/libs/@hashintel/petrinaut/src/react/state/use-undo-redo-shortcuts.ts b/libs/@hashintel/petrinaut/src/react/state/use-undo-redo-shortcuts.ts new file mode 100644 index 00000000000..0f754f3625a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/use-undo-redo-shortcuts.ts @@ -0,0 +1,45 @@ +import { use, useEffect, useEffectEvent } from "react"; + +import { UndoRedoContext } from "./undo-redo-context"; + +/** + * Binds Cmd/Ctrl+Z (undo) and Cmd/Ctrl+Shift+Z (redo) for views that don't + * mount the canvas BottomBar, which owns the full editor shortcut set. + * Inputs, textareas and code editors are left alone so their native undo + * stacks keep working. + */ +export function useUndoRedoShortcuts() { + const undoRedo = use(UndoRedoContext); + + const handleKeyDown = useEffectEvent((event: KeyboardEvent) => { + if ( + !undoRedo || + !(event.metaKey || event.ctrlKey) || + event.key.toLowerCase() !== "z" + ) { + return; + } + const target = event.target as HTMLElement; + const isInputFocused = + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable || + target.closest(".monaco-editor") !== null; + if (isInputFocused) { + return; + } + event.preventDefault(); + if (event.shiftKey) { + undoRedo.redo(); + } else { + undoRedo.undo(); + } + }); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); +} diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts index 64c97de3035..c6a48266dd2 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts @@ -44,6 +44,7 @@ export type UserSettings = { partialSelection: boolean; useEntitiesTreeView: boolean; enableNetComponents: boolean; + enableNotebookView: boolean; /** * Persisted preference controlling whether the product walkthrough opens * automatically the next time the app initializes. The live open state is @@ -72,6 +73,7 @@ export type UserSettingsActions = { setPartialSelection: (value: boolean) => void; setUseEntitiesTreeView: (value: boolean) => void; setEnableNetComponents: (value: boolean) => void; + setEnableNotebookView: (value: boolean) => void; setShowWalkthroughOnInit: (value: boolean) => void; updateSubViewSection: ( containerName: string, @@ -100,6 +102,7 @@ export const defaultUserSettings: UserSettings = { partialSelection: true, useEntitiesTreeView: false, enableNetComponents: false, + enableNotebookView: false, showWalkthroughOnInit: true, subViewPanels: {}, }; @@ -123,6 +126,7 @@ const DEFAULT_CONTEXT_VALUE: UserSettingsContextValue = { setPartialSelection: () => {}, setUseEntitiesTreeView: () => {}, setEnableNetComponents: () => {}, + setEnableNotebookView: () => {}, setShowWalkthroughOnInit: () => {}, updateSubViewSection: () => {}, }; diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx index 4cd78390f29..f89d9bdffc8 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx @@ -80,6 +80,8 @@ export const UserSettingsProvider: React.FC = ({ setState((prev) => ({ ...prev, useEntitiesTreeView: value })), setEnableNetComponents: (value: boolean) => setState((prev) => ({ ...prev, enableNetComponents: value })), + setEnableNotebookView: (value: boolean) => + setState((prev) => ({ ...prev, enableNotebookView: value })), setShowWalkthroughOnInit: (value: boolean) => setState((prev) => ({ ...prev, showWalkthroughOnInit: value })), updateSubViewSection: ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/mode-selector.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/mode-selector.tsx index 916db1e0ff7..bbd0ffdb6e9 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/mode-selector.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/mode-selector.tsx @@ -5,18 +5,30 @@ import type { SegmentedControlItem } from "@hashintel/ds-components"; export interface ModeSelectorProps { actualModeAvailable: boolean; + notebookViewAvailable: boolean; mode: EditorGlobalMode; onChange: (mode: EditorGlobalMode) => void; } const getOptions = ( actualModeAvailable: boolean, + notebookViewAvailable: boolean, ): SegmentedControlItem[] => [ { label: "Edit", value: "edit", iconName: "shapes", }, + ...(notebookViewAvailable + ? [ + { + label: "Notebook", + value: "notebook", + iconName: "fileLines", + tooltip: "Read the net as a list of cells.", + } satisfies SegmentedControlItem, + ] + : []), { label: "Simulate", value: "simulate", @@ -35,6 +47,7 @@ const getOptions = ( export const ModeSelector: React.FC = ({ actualModeAvailable, + notebookViewAvailable, mode, onChange, }) => { @@ -42,7 +55,7 @@ export const ModeSelector: React.FC = ({ ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx index 40993fb5136..ef64cba7999 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx @@ -59,6 +59,7 @@ const titleStyles = css({ interface TopBarProps { actualModeAvailable: boolean; + notebookViewAvailable: boolean; menuItems: MenuItem[]; title: string; onTitleChange: (value: string) => void; @@ -71,6 +72,7 @@ interface TopBarProps { export const TopBar: React.FC = ({ actualModeAvailable, + notebookViewAvailable, menuItems, title, onTitleChange, @@ -132,6 +134,7 @@ export const TopBar: React.FC = ({ {/* Center section - mode switcher */} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index 9f00baa5f6c..02d0fcfe030 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -27,6 +27,7 @@ import { ActualModeContext } from "../../../react/actual-mode-context"; import { ExperimentsContext } from "../../../react/experiments/context"; import { EditorContext } from "../../../react/state/editor-context"; import { SDCPNContext } from "../../../react/state/sdcpn-context"; +import { useEffectiveGlobalMode } from "../../../react/state/use-effective-global-mode"; import { useSelectionCleanup } from "../../../react/state/use-selection-cleanup"; import { UserSettingsContext } from "../../../react/state/user-settings-context"; import { Box } from "../../components/box"; @@ -39,6 +40,7 @@ import { WalkthroughDialog } from "../../components/walkthrough/walkthrough-dial import { exportSDCPN } from "../../file-io/export-sdcpn"; import { exportTikZ } from "../../file-io/export-tikz"; import { importSDCPN } from "../../file-io/import-sdcpn"; +import { NotebookView } from "../Notebook/notebook-view"; import { SDCPNView } from "../SDCPN/sdcpn-view"; import { AiCtaModal } from "./components/ai-cta-modal"; import { BottomBar } from "./components/BottomBar/bottom-bar"; @@ -80,8 +82,13 @@ const formatRelativeTime = (isoTimestamp: string): string => { }).format(new Date(isoTimestamp)); }; +// The remaining space under the TopBar, never 100% of the root: a full-height +// row overflows the root by the TopBar's height, and although the root hides +// overflow, scrollIntoView can still scroll it programmatically — pushing the +// TopBar out of view. const rowContainerStyle = css({ - height: "full", + flex: "[1]", + minHeight: "[0]", userSelect: "none", }); @@ -131,7 +138,6 @@ export const EditorView = ({ // Get editor context const { - globalMode: mode, isAiAssistantOpen, setGlobalMode, editionMode, @@ -152,10 +158,17 @@ export const EditorView = ({ >(null); const [isAiCtaDismissed, setIsAiCtaDismissed] = useState(false); - const { showWalkthroughOnInit, setShowWalkthroughOnInit } = - use(UserSettingsContext); + const { + enableNotebookView, + showWalkthroughOnInit, + setShowWalkthroughOnInit, + } = use(UserSettingsContext); const walkthrough = use(WalkthroughContext); + // Shared with useReadOnlyReason so the rendered view and the mutation + // rules never disagree. + const effectiveMode = useEffectiveGlobalMode(); + // Live open state for the walkthrough. Seeded once from the persisted // "show on init" preference, so toggling that preference only takes effect // on the next init rather than reopening the walkthrough mid-session. @@ -433,11 +446,12 @@ export const EditorView = ({ {/* Top Bar - always visible */} handleRunningExperimentClick(experiment.id) @@ -446,8 +460,10 @@ export const EditorView = ({ /> - {mode === "simulate" ? ( + {effectiveMode === "simulate" ? ( + ) : effectiveMode === "notebook" ? ( + ) : ( {/* Left Sidebar - Tools and content panels */} @@ -474,7 +490,7 @@ export const EditorView = ({ +> = { + place: PlaceFilledIcon, + transition: TransitionFilledIcon, + type: TokenTypeIcon, + differentialEquation: DifferentialEquationIcon, + parameter: ParameterIcon, +}; + +/** Keyword shown before a cell's name, as a declaration would read. */ +export const CELL_KIND_LABELS: Record = { + place: "Place", + transition: "Transition", + type: "Type", + differentialEquation: "Equation", + parameter: "Parameter", +}; + +/** Kinds in the order the filter row lists them. */ +export const CELL_KINDS: NotebookCellKind[] = [ + "place", + "transition", + "type", + "differentialEquation", + "parameter", +]; + +export const CELL_KIND_PLURAL_LABELS: Record = { + place: "Places", + transition: "Transitions", + type: "Types", + differentialEquation: "Equations", + parameter: "Parameters", +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/connection-lines.tsx b/libs/@hashintel/petrinaut/src/ui/views/Notebook/connection-lines.tsx new file mode 100644 index 00000000000..e10a593607d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/connection-lines.tsx @@ -0,0 +1,196 @@ +import { useEffect, useState } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; + +/** + * Width reserved on each side of the cell list for connection lines. The cell + * list content wrapper must pad its rows by this amount so the lines have + * empty space to run through. + */ +export const CONNECTION_GUTTER_WIDTH = 32; + +/** Horizontal distance between the row edge and where a line attaches. */ +const EDGE_INSET = 4; +/** Preferred offset between parallel trunks, narrowed when lines are many. */ +const TRUNK_STEP = 3; +/** Innermost trunk position, measured inwards from the gutter's outer edge. */ +const FIRST_TRUNK_OFFSET = 14; +/** Space kept clear at the gutter's outer edge. */ +const OUTER_MARGIN = 3; + +/** + * Distance from the gutter's outer edge to the vertical trunk of line + * `index` of `count`. Lines fan outwards from the rows, and the step narrows + * so that a cell with many connections still shows distinct trunks instead of + * a single overlapping bundle. + */ +const trunkOffset = (index: number, count: number): number => { + if (count <= 1) { + return FIRST_TRUNK_OFFSET; + } + const span = FIRST_TRUNK_OFFSET - OUTER_MARGIN; + const step = Math.min(TRUNK_STEP, span / (count - 1)); + return FIRST_TRUNK_OFFSET - index * step; +}; + +const overlayStyle = css({ + position: "absolute", + top: "[0]", + left: "[0]", + pointerEvents: "none", +}); + +const lineStyle = css({ + stroke: "blue.s90", + strokeWidth: "[1.5]", + fill: "[none]", + strokeLinejoin: "round", + strokeLinecap: "round", + opacity: "[0.8]", +}); + +type Line = { fromY: number; toY: number }; + +type Geometry = { + width: number; + height: number; + leftLines: Line[]; + rightLines: Line[]; +}; + +export interface ConnectionLinesProps { + /** + * The positioned (`position: relative`) element containing the cell rows; + * row positions are measured against it and the overlay fills it. + */ + containerRef: React.RefObject; + selectedId: string | null; + /** Cell ids the left-gutter lines connect the selected cell to. */ + upstreamIds: string[]; + /** Cell ids the right-gutter lines connect the selected cell to. */ + downstreamIds: string[]; + /** + * Every visible row in list order. Reordering or filtering moves rows + * without resizing the container, which the ResizeObserver cannot see, so + * this is what tells the overlay to re-measure. + */ + visibleCellIds: string[]; +} + +/** + * Draws Observable-style angled connector lines in the cell list gutters: + * from the selected cell's row to each of its dependencies (left gutter) and + * dependents (right gutter). Positions are measured from the DOM and follow + * layout changes (cells expanding, editors loading) via a ResizeObserver. + */ +export const ConnectionLines: React.FC = ({ + containerRef, + selectedId, + upstreamIds, + downstreamIds, + visibleCellIds, +}) => { + const [geometry, setGeometry] = useState(null); + + // Serialized so the effect's dependencies stay primitive while the arrays + // are rebuilt each render; ids are arbitrary imported strings, so JSON is + // the only safe delimiter. + const upstreamKey = JSON.stringify(upstreamIds); + const downstreamKey = JSON.stringify(downstreamIds); + const rowOrderKey = JSON.stringify(visibleCellIds); + + useEffect(() => { + const container = containerRef.current; + + const measure = () => { + if (container === null || selectedId === null) { + setGeometry(null); + return; + } + + const centerY = (id: string): number | null => { + const row = container.querySelector( + `[data-cell-row="${CSS.escape(id)}"]`, + ); + return row === null ? null : row.offsetTop + row.offsetHeight / 2; + }; + + const fromY = centerY(selectedId); + if (fromY === null) { + setGeometry(null); + return; + } + + const toLines = (key: string): Line[] => + (JSON.parse(key) as string[]) + .map(centerY) + .filter((y): y is number => y !== null) + .map((toY) => ({ fromY, toY })); + + setGeometry({ + width: container.clientWidth, + height: container.clientHeight, + leftLines: toLines(upstreamKey), + rightLines: toLines(downstreamKey), + }); + }; + + const frame = requestAnimationFrame(measure); + + if (container === null) { + return () => cancelAnimationFrame(frame); + } + + // Re-measure when the content resizes — cells expanding/collapsing and + // code editors finishing loading both change row positions. + const observer = new ResizeObserver(measure); + observer.observe(container); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, [containerRef, selectedId, upstreamKey, downstreamKey, rowOrderKey]); + + if ( + geometry === null || + (geometry.leftLines.length === 0 && geometry.rightLines.length === 0) + ) { + return null; + } + + const leftPath = ({ fromY, toY }: Line, index: number, count: number) => { + const edgeX = CONNECTION_GUTTER_WIDTH - EDGE_INSET; + const trunkX = trunkOffset(index, count); + return `M ${edgeX} ${fromY} H ${trunkX} V ${toY} H ${edgeX}`; + }; + + const rightPath = ({ fromY, toY }: Line, index: number, count: number) => { + const edgeX = geometry.width - CONNECTION_GUTTER_WIDTH + EDGE_INSET; + const trunkX = geometry.width - trunkOffset(index, count); + return `M ${edgeX} ${fromY} H ${trunkX} V ${toY} H ${edgeX}`; + }; + + return ( + + {geometry.leftLines.map((line, index) => ( + + ))} + {geometry.rightLines.map((line, index) => ( + + ))} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-cell.tsx b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-cell.tsx new file mode 100644 index 00000000000..b8a01fa3564 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-cell.tsx @@ -0,0 +1,1458 @@ +import { Fragment, useRef, useState } from "react"; + +import { css, cva, cx } from "@hashintel/ds-helpers/css"; +import { getDocumentUri } from "@hashintel/petrinaut-core"; + +import { usePetrinautMutations } from "../../../react/hooks/use-petrinaut-mutations"; +import { useIsReadOnly } from "../../../react/state/use-is-read-only"; +import { useDraftField } from "../../hooks/use-draft-field"; +import { CodeEditor } from "../../monaco/code-editor"; +import { focusLands } from "../../worksheet/focus-flow"; +import { CELL_KIND_ICONS, CELL_KIND_LABELS } from "./cell-kinds"; +import { + arcPlaceId, + placeName, + transitionInputPlaceIds, + transitionOutputPlaceIds, +} from "./notebook-model"; + +import type { ActiveNetDefinition } from "../../../react/state/active-net-context"; +import type { CodeEditorProps } from "../../monaco/code-editor"; +import type { + DependentCount, + NotebookCell as NotebookCellModel, +} from "./notebook-model"; +import type { + Color, + DifferentialEquation, + Parameter, + Place, + Transition, +} from "@hashintel/petrinaut-core"; + +const cellStyle = cva({ + base: { + borderBottomWidth: "[1px]", + borderBottomStyle: "solid", + borderBottomColor: "neutral.s30", + borderRadius: "sm", + transition: "[background-color 100ms ease-out, opacity 100ms ease-out]", + }, + variants: { + isSelected: { + true: { backgroundColor: "blue.s20" }, + false: { + _hover: { backgroundColor: "neutral.bg.surface.hover" }, + }, + }, + isDimmed: { + true: { opacity: "[0.4]" }, + false: {}, + }, + }, +}); + +const rowStyle = css({ + display: "flex", + alignItems: "center", + gap: "1.5", + minHeight: "8", + paddingX: "1", + cursor: "pointer", + userSelect: "none", +}); + +const caretButtonStyle = cva({ + base: { + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "4", + height: "6", + padding: "[0]", + backgroundColor: "[transparent]", + borderWidth: "[0]", + color: "neutral.s80", + cursor: "pointer", + transition: "[transform 150ms ease-out]", + _hover: { color: "neutral.s115" }, + }, + variants: { + expanded: { + true: { transform: "rotate(90deg)" }, + false: { transform: "rotate(0deg)" }, + }, + }, +}); + +const iconStyle = css({ + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", +}); + +const kindStyle = css({ + flexShrink: 0, + fontSize: "xs", + fontFamily: "mono", + fontWeight: "medium", + color: "purple.s100", + whiteSpace: "nowrap", +}); + +const nameStyle = css({ + flexShrink: 0, + fontSize: "sm", + fontWeight: "medium", + color: "neutral.s115", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + maxWidth: "[40%]", +}); + +const nameMarkStyle = css({ + backgroundColor: "yellow.s40", + color: "[inherit]", + borderRadius: "xs", +}); + +const countStyle = css({ + flexShrink: 0, + fontSize: "[10px]", + fontFamily: "mono", + color: "neutral.s90", + cursor: "default", + paddingLeft: "1", +}); + +const summaryStyle = css({ + flex: "[1]", + minWidth: "[0]", + fontSize: "xs", + color: "neutral.fg.subtle", + fontFamily: "mono", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", +}); + +const bodyStyle = css({ + display: "flex", + flexDirection: "column", + gap: "2", + paddingLeft: "7", + paddingRight: "2", + paddingBottom: "3", +}); + +const fieldRowStyle = css({ + display: "flex", + alignItems: "baseline", + gap: "2", + fontSize: "xs", +}); + +const fieldLabelStyle = css({ + flexShrink: 0, + width: "[110px]", + color: "neutral.fg.subtle", + fontWeight: "medium", +}); + +const fieldValueStyle = css({ + color: "neutral.s115", + fontFamily: "mono", + minWidth: "[0]", + overflowWrap: "anywhere", +}); + +const sectionLabelStyle = css({ + fontSize: "xs", + fontWeight: "semibold", + color: "neutral.fg.subtle", + textTransform: "uppercase", + letterSpacing: "wide", + marginTop: "1", +}); + +const arcListStyle = css({ + display: "flex", + flexDirection: "column", + gap: "0.5", + fontSize: "xs", + fontFamily: "mono", + color: "neutral.s115", +}); + +const mutedStyle = css({ + fontSize: "xs", + color: "neutral.fg.subtle", +}); + +const CODE_LINE_HEIGHT = 18; + +const CellCodeBlock: React.FC<{ + code: string; + path: string; + onChange: (code: string) => void; +}> = ({ code, path, onChange }) => { + const isReadOnly = useIsReadOnly(); + const editorRef = useRef< + Parameters>[0] | null + >(null); + const lineCount = code.split("\n").length; + const height = Math.min(Math.max(lineCount, 3), 24) * CODE_LINE_HEIGHT + 16; + + return ( + { + editorRef.current = editorInstance; + }} + onChange={(value) => { + // Only user edits commit: models are shared by URI and reset + // programmatically around mount and mode switches, and writing those + // resets back would wipe the code they carry. + if (value !== undefined && editorRef.current?.hasTextFocus()) { + onChange(value); + } + }} + options={{ + readOnly: isReadOnly, + lineNumbers: "on", + scrollbar: { alwaysConsumeMouseWheel: false }, + }} + /> + ); +}; + +/** One focusable line inside an expanded cell body, in visual order. */ +export type CellBodyPart = { + id: string; + /** + * Focusable cells on this line, left to right. 1 means the whole line is + * one full-width focus target; more means horizontal arrows walk the + * line's cells individually (an arc's place, weight and type). + */ + columns: number; +}; + +const bodyPart = (id: string, columns = 1): CellBodyPart => ({ + id, + columns, +}); + +/** + * The focusable parts an expanded cell contributes to the list's focus flow, + * in the order the body renders them, with the cells each line exposes. Must + * stay in step with the body components below: a declared part with no + * matching `data-cell-part` element is a stop arrows can never land on, and + * a declared column with no matching cell falls back to the line's first. + */ +export function cellBodyParts( + cell: NotebookCellModel, + net: ActiveNetDefinition, +): CellBodyPart[] { + switch (cell.kind) { + case "transition": { + const { transition } = cell; + const arcParts = ( + prefix: string, + targets: (string | null)[], + columns: number, + ): CellBodyPart[] => + targets.flatMap((target, index) => + target === null ? [] : [bodyPart(`${prefix}-${index}`, columns)], + ); + return [ + bodyPart("name"), + // Input arcs expose place, weight and type; outputs have no type. + ...arcParts("in", transition.inputArcs.map(arcPlaceId), 3), + ...arcParts("out", transition.outputArcs.map(arcPlaceId), 2), + bodyPart("code-lambda"), + bodyPart("code-kernel"), + ]; + } + case "place": { + const { place } = cell; + const hasEquationCell = + place.dynamicsEnabled && net.differentialEquations.length > 0; + return [ + bodyPart("name"), + bodyPart("type"), + bodyPart("dynamics", hasEquationCell ? 2 : 1), + ...(place.isPort ? [bodyPart("port")] : []), + ...(place.visualizerCode?.trim() ? [bodyPart("code-visualizer")] : []), + ]; + } + case "type": + return [ + bodyPart("name"), + bodyPart("display-color"), + ...cell.color.elements.map((element) => + bodyPart(`field-${element.elementId}`, 2), + ), + ]; + case "differentialEquation": + return [bodyPart("name"), bodyPart("type"), bodyPart("code")]; + case "parameter": + return [ + bodyPart("name"), + bodyPart("variable-name"), + bodyPart("type"), + bodyPart("default-value"), + ]; + } +} + +/** The stop id a body part contributes to the list's focus flow. */ +export const partStopId = (cellId: string, partId: string): string => + `${cellId} ${partId}`; + +/** What an expanded body needs to enrol its parts in the focus flow. */ +export type BodyPartContext = { + /** Accessors for the part's stop; `column` defaults to the line's first. */ + focusFor: (partId: string, column?: number) => CellRowFocus; + stopIdFor: (partId: string) => string; + navigateToCell: (cellId: string) => void; +}; + +const partLineStyle = css({ + width: "fit", + paddingX: "1", + borderRadius: "sm", +}); + +/** A multi-cell line: a plain layout row whose cells are the focus targets. */ +const cellLineStyle = css({ + display: "flex", + alignItems: "center", + gap: "1", +}); + +const widgetCellStyle = css({ + display: "inline-flex", + alignItems: "center", + gap: "1", + paddingX: "0.5", + borderRadius: "sm", +}); + +/** + * Focusable widget surfaces, in engage order. Monaco's own surface varies by + * version: a textarea in older builds, an EditContext div in newer ones. + */ +const WIDGET_SELECTOR = + '.native-edit-context, textarea, input, select, [contenteditable="true"], [tabindex="0"]'; + +/** + * Enter on a part either activates or engages its widget: one-shot controls + * (buttons, checkboxes) act immediately, editable ones (inputs, selects, + * code editors) take focus so every key belongs to them until Escape. + */ +const activateOrEngage = (container: HTMLElement | null) => { + for (const candidate of container?.querySelectorAll( + `${WIDGET_SELECTOR}, button`, + ) ?? []) { + if ( + candidate instanceof HTMLButtonElement || + (candidate instanceof HTMLInputElement && candidate.type === "checkbox") + ) { + candidate.click(); + return; + } + if (focusLands(candidate)) { + return; + } + } +}; + +/** + * A part with widgets inside — a code editor, inputs, selects. The widgets + * are their own keyboard world: Enter on the part engages the first one, + * every key then belongs to the widget, Tab cycles the part's widgets, and + * Escape hands focus back to the part — the grid "interaction mode" pattern + * from the ARIA Authoring Practices. Widgets carry `tabIndex={-1}` so each + * cell list stays a single roving tab stop. + * + * With a `column`, the part is one cell of a multi-cell line rather than a + * full-width line: horizontal arrows walk the line's cells too, and the + * focus flow targets the cell by its `data-part-column`. + */ +const WidgetPart: React.FC<{ + stopId: string; + focus: CellRowFocus; + column?: number; + className?: string; + children: React.ReactNode; +}> = ({ stopId, focus, column, className, children }) => { + const [element, setElement] = useState(null); + + return ( +
{ + if (event.target === event.currentTarget) { + focus.onFocus(); + } + }} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) { + // Keys bubbling out of an engaged widget: Escape disengages, Tab + // cycles the part's widgets, everything else stays the widget's. + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + element?.focus(); + } else if (event.key === "Tab" && element) { + const widgets = [ + ...element.querySelectorAll(WIDGET_SELECTOR), + ]; + const at = widgets.indexOf(event.target as HTMLElement); + const next = widgets[at + (event.shiftKey ? -1 : 1)]; + if (at !== -1 && next) { + event.preventDefault(); + event.stopPropagation(); + next.focus(); + } + } + return; + } + if (event.key === "Enter") { + event.preventDefault(); + activateOrEngage(element); + } else if ( + event.key === "ArrowDown" || + event.key === "ArrowUp" || + (column !== undefined && + (event.key === "ArrowLeft" || event.key === "ArrowRight")) + ) { + focus.onNavigate(event); + } + }} + > + {children} +
+ ); +}; + +/** One cell of a multi-cell body line, as a WidgetPart pinned to a column. */ +const WidgetCell: React.FC<{ + parts: BodyPartContext; + partId: string; + column: number; + children: React.ReactNode; +}> = ({ parts, partId, column, children }) => ( + + {children} + +); + +const inlineInputStyle = css({ + fontFamily: "mono", + fontSize: "xs", + color: "neutral.s115", + backgroundColor: "[transparent]", + borderWidth: "[1px]", + borderStyle: "solid", + borderColor: "[transparent]", + borderRadius: "sm", + paddingX: "1", + _hover: { borderColor: "neutral.s40" }, + _focus: { + borderColor: "blue.s70", + outline: "[none]", + backgroundColor: "neutral.s00", + }, +}); + +/** + * Inline draft text input for one field of a cell: edits stay local until + * blur or Enter commits them, Escape reverts to the net's value, and drafts + * failing `isValid` revert instead of committing. + */ +const PartTextField: React.FC<{ + sourceId: string; + value: string; + onCommit: (value: string) => void; + isValid?: (draft: string) => boolean; + "aria-label": string; +}> = ({ sourceId, value, onCommit, isValid, "aria-label": ariaLabel }) => { + const isReadOnly = useIsReadOnly(); + const draft = useDraftField({ sourceId, sourceValue: value }); + // Enter and Escape hand focus back to the part, which blurs the input + // before their own state settles — the blur must not commit again (Enter) + // or commit the draft the user just discarded (Escape). + const skipBlurCommitRef = useRef(false); + + const commit = () => { + if (isReadOnly || draft.value === value) { + return; + } + if (isValid?.(draft.value) ?? true) { + onCommit(draft.value); + } else { + draft.setValue(value); + } + }; + + return ( + draft.setValue(event.target.value)} + onBlur={() => { + if (skipBlurCommitRef.current) { + skipBlurCommitRef.current = false; + return; + } + commit(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commit(); + skipBlurCommitRef.current = true; + event.currentTarget.closest("[data-cell-part]")?.focus(); + } else if (event.key === "Escape") { + draft.setValue(value); + skipBlurCommitRef.current = true; + } + }} + /> + ); +}; + +const inlineSelectStyle = css({ + fontFamily: "mono", + fontSize: "xs", + color: "neutral.s115", + backgroundColor: "[transparent]", + borderWidth: "[1px]", + borderStyle: "solid", + borderColor: "neutral.s40", + borderRadius: "sm", + paddingX: "0.5", + _focus: { borderColor: "blue.s70", outline: "[none]" }, +}); + +/** Inline enum/reference select for one field of a cell. */ +const PartSelect: React.FC<{ + value: string; + onChange: (value: string) => void; + options: { value: string; label: string }[]; + "aria-label": string; +}> = ({ value, onChange, options, "aria-label": ariaLabel }) => { + const isReadOnly = useIsReadOnly(); + return ( + + ); +}; + +/** A code block as a focus-flow part; edits commit through `onChange`. */ +const BodyCodeBlock: React.FC<{ + parts: BodyPartContext; + partId: string; + code: string; + path: string; + onChange: (code: string) => void; +}> = ({ parts, partId, code, path, onChange }) => ( + + + +); + +const typeName = (net: ActiveNetDefinition, colorId: string | null): string => { + if (colorId === null) { + return "untyped"; + } + const color = net.types.find(({ id }) => id === colorId); + return color?.name ?? colorId; +}; + +const placeSummary = (net: ActiveNetDefinition, place: Place): string => { + const parts = [typeName(net, place.colorId)]; + if (place.dynamicsEnabled && place.differentialEquationId !== null) { + const equation = net.differentialEquations.find( + ({ id }) => id === place.differentialEquationId, + ); + parts.push(`d/dt: ${equation?.name ?? place.differentialEquationId}`); + } + if (place.isPort) { + parts.push("port"); + } + return parts.join(" · "); +}; + +const transitionSummary = ( + net: ActiveNetDefinition, + transition: Transition, +): string => { + const inputs = transitionInputPlaceIds(transition).map((id) => + placeName(net, id), + ); + const outputs = transitionOutputPlaceIds(transition).map((id) => + placeName(net, id), + ); + return `${inputs.join(", ") || "∅"} → ${outputs.join(", ") || "∅"}`; +}; + +const colorSummary = (color: Color): string => + color.elements.length === 0 + ? "no fields" + : color.elements + .map((element) => `${element.name}: ${element.type}`) + .join(", "); + +const parameterSummary = (parameter: Parameter): string => + `${parameter.variableName} = ${parameter.defaultValue} (${parameter.type})`; + +const equationSummary = ( + net: ActiveNetDefinition, + equation: DifferentialEquation, +): string => { + const places = net.places.filter( + ({ differentialEquationId }) => differentialEquationId === equation.id, + ); + return `${typeName(net, equation.colorId)} · ${places.length} place${places.length === 1 ? "" : "s"}`; +}; + +const FieldRow: React.FC<{ + label: string; + part?: { stopId: string; focus: CellRowFocus }; + children: React.ReactNode; +}> = ({ label, part, children }) => { + const content = ( + <> + {label} + {children} + + ); + return part === undefined ? ( +
{content}
+ ) : ( + + {content} + + ); +}; + +const UNTYPED = "__untyped__"; + +const PlaceBody: React.FC<{ + net: ActiveNetDefinition; + place: Place; + parts: BodyPartContext; +}> = ({ net, place, parts }) => { + const { updatePlace } = usePetrinautMutations(); + const isReadOnly = useIsReadOnly(); + const partFor = (partId: string) => ({ + stopId: parts.stopIdFor(partId), + focus: parts.focusFor(partId), + }); + const typeOptions = [ + { value: UNTYPED, label: "untyped" }, + ...net.types.map((color) => ({ + value: color.id, + label: color.name || color.id, + })), + ]; + const equationOptions = net.differentialEquations.map((equation) => ({ + value: equation.id, + label: equation.name || equation.id, + })); + + return ( + <> + + + updatePlace({ placeId: place.id, update: { name } }) + } + /> + + + + updatePlace({ + placeId: place.id, + update: { colorId: value === UNTYPED ? null : value }, + }) + } + /> + + + + + + updatePlace({ + placeId: place.id, + update: { dynamicsEnabled: event.target.checked }, + }) + } + /> + + {place.dynamicsEnabled ? ( + equationOptions.length > 0 ? ( + + + updatePlace({ + placeId: place.id, + update: { + differentialEquationId: value === "" ? null : value, + }, + }) + } + /> + + ) : ( + no equations in the net + ) + ) : ( + disabled + )} + + + {place.isPort ? ( + + exposed as component port + + ) : null} + {place.visualizerCode?.trim() ? ( + <> + Visualizer + + updatePlace({ + placeId: place.id, + update: { visualizerCode: code }, + }) + } + /> + + ) : null} + + ); +}; + +const arcJumpStyle = css({ + cursor: "pointer", + backgroundColor: "[transparent]", + borderWidth: "[0]", + padding: "[0]", + fontFamily: "mono", + fontSize: "xs", + color: "neutral.s115", + _hover: { textDecoration: "underline" }, +}); + +const INPUT_ARC_TYPES: { value: string; label: string }[] = [ + { value: "standard", label: "standard" }, + { value: "read", label: "read" }, + { value: "inhibitor", label: "inhibitor" }, +]; + +const ArcLine: React.FC<{ + net: ActiveNetDefinition; + transitionId: string; + direction: "input" | "output"; + placeId: string; + weight: number; + arcType: string | null; + index: number; + partId: string; + parts: BodyPartContext; +}> = ({ + net, + transitionId, + direction, + placeId, + weight, + arcType, + index, + partId, + parts, +}) => { + const { updateArcWeight, updateArcType } = usePetrinautMutations(); + + return ( +
+ {index + 1}. + + + + × + + + Number(draft) > 0 && Number.isFinite(Number(draft)) + } + onCommit={(draft) => + updateArcWeight({ + transitionId, + arcDirection: direction, + placeId, + weight: Number(draft), + }) + } + /> + + {arcType !== null && ( + + + updateArcType({ + transitionId, + placeId, + type: value as "standard" | "read" | "inhibitor", + }) + } + /> + + )} +
+ ); +}; + +const ArcLines: React.FC<{ + net: ActiveNetDefinition; + transitionId: string; + direction: "input" | "output"; + arcs: { placeId: string | null; weight: number; arcType: string | null }[]; + partPrefix: string; + parts: BodyPartContext; +}> = ({ net, transitionId, direction, arcs, partPrefix, parts }) => + arcs.length > 0 ? ( +
+ {arcs.map(({ placeId, weight, arcType }, index) => + placeId === null ? ( + + {index + 1}. component port ×{weight} + + ) : ( + + ), + )} +
+ ) : ( + None + ); + +const TransitionBody: React.FC<{ + net: ActiveNetDefinition; + transition: Transition; + parts: BodyPartContext; +}> = ({ net, transition, parts }) => { + const { updateTransition } = usePetrinautMutations(); + return ( + <> + + + updateTransition({ transitionId: transition.id, update: { name } }) + } + /> + + + Inputs + ({ + placeId: arcPlaceId(arc), + weight: arc.weight, + arcType: arc.type, + }))} + partPrefix="in" + parts={parts} + /> + + Outputs + ({ + placeId: arcPlaceId(arc), + weight: arc.weight, + arcType: null, + }))} + partPrefix="out" + parts={parts} + /> + + + Firing time —{" "} + {transition.lambdaType === "predicate" ? "predicate" : "stochastic"} + + + updateTransition({ + transitionId: transition.id, + update: { lambdaCode: code }, + }) + } + /> + + Transition kernel + + updateTransition({ + transitionId: transition.id, + update: { transitionKernelCode: code }, + }) + } + /> + + ); +}; + +const TYPE_ELEMENT_TYPES: { value: string; label: string }[] = [ + { value: "real", label: "real" }, + { value: "integer", label: "integer" }, + { value: "boolean", label: "boolean" }, +]; + +const TypeBody: React.FC<{ color: Color; parts: BodyPartContext }> = ({ + color, + parts, +}) => { + const { updateType, updateTypeElement } = usePetrinautMutations(); + const partFor = (partId: string) => ({ + stopId: parts.stopIdFor(partId), + focus: parts.focusFor(partId), + }); + + return ( + <> + + + updateType({ typeId: color.id, update: { name } }) + } + /> + + + + updateType({ typeId: color.id, update: { displayColor } }) + } + /> + + Fields + {color.elements.length > 0 ? ( +
+ {color.elements.map((element) => ( +
+ + + updateTypeElement({ + typeId: color.id, + elementId: element.elementId, + update: { name }, + }) + } + /> + + : + + + updateTypeElement({ + typeId: color.id, + elementId: element.elementId, + update: { type: value as "real" | "integer" | "boolean" }, + }) + } + /> + +
+ ))} +
+ ) : ( + No fields + )} + + ); +}; + +const PARAMETER_TYPES: { value: string; label: string }[] = [ + { value: "real", label: "real" }, + { value: "integer", label: "integer" }, + { value: "boolean", label: "boolean" }, +]; + +const ParameterBody: React.FC<{ + parameter: Parameter; + parts: BodyPartContext; +}> = ({ parameter, parts }) => { + const { updateParameter } = usePetrinautMutations(); + const fieldPart = (partId: string) => ({ + stopId: parts.stopIdFor(partId), + focus: parts.focusFor(partId), + }); + return ( + <> + + + updateParameter({ parameterId: parameter.id, update: { name } }) + } + /> + + + /^[A-Za-z_$][\w$]*$/.test(draft)} + onCommit={(variableName) => + updateParameter({ + parameterId: parameter.id, + update: { variableName }, + }) + } + /> + + + + updateParameter({ + parameterId: parameter.id, + update: { type: value as "real" | "integer" | "boolean" }, + }) + } + /> + + + + updateParameter({ + parameterId: parameter.id, + update: { defaultValue }, + }) + } + /> + + + ); +}; + +const EquationBody: React.FC<{ + net: ActiveNetDefinition; + equation: DifferentialEquation; + parts: BodyPartContext; +}> = ({ net, equation, parts }) => { + const { updateDifferentialEquation } = usePetrinautMutations(); + const partFor = (partId: string) => ({ + stopId: parts.stopIdFor(partId), + focus: parts.focusFor(partId), + }); + return ( + <> + + + updateDifferentialEquation({ + equationId: equation.id, + update: { name }, + }) + } + /> + + + ({ + value: color.id, + label: color.name || color.id, + })), + ]} + onChange={(value) => + updateDifferentialEquation({ + equationId: equation.id, + update: { colorId: value === UNTYPED ? null : value }, + }) + } + /> + + Equation + + updateDifferentialEquation({ + equationId: equation.id, + update: { code }, + }) + } + /> + + ); +}; + +const HighlightedName: React.FC<{ + name: string; + matchIndices: number[] | null; +}> = ({ name, matchIndices }) => { + if (matchIndices === null || matchIndices.length === 0) { + return name; + } + const matched = new Set(matchIndices); + + const segments: { start: number; text: string; isMatch: boolean }[] = []; + let runStart = 0; + for (let index = 1; index <= name.length; index++) { + if (index === name.length || matched.has(index) !== matched.has(runStart)) { + segments.push({ + start: runStart, + text: name.slice(runStart, index), + isMatch: matched.has(runStart), + }); + runStart = index; + } + } + + return segments.map((segment) => + segment.isMatch ? ( + + {segment.text} + + ) : ( + {segment.text} + ), + ); +}; + +const CELL_ICON_SIZE = 12; + +const cellPresentation = ( + net: ActiveNetDefinition, + cell: NotebookCellModel, + parts: BodyPartContext, +): { + iconColor: string | undefined; + name: string; + summary: string; + body: React.ReactNode; +} => { + switch (cell.kind) { + case "place": { + const color = net.types.find(({ id }) => id === cell.place.colorId); + return { + iconColor: color?.displayColor, + name: cell.place.name || cell.id, + summary: placeSummary(net, cell.place), + body: , + }; + } + case "transition": + return { + iconColor: undefined, + name: cell.transition.name || cell.id, + summary: transitionSummary(net, cell.transition), + body: ( + + ), + }; + case "type": + return { + iconColor: cell.color.displayColor, + name: cell.color.name || cell.id, + summary: colorSummary(cell.color), + body: , + }; + case "differentialEquation": + return { + iconColor: undefined, + name: cell.equation.name || cell.id, + summary: equationSummary(net, cell.equation), + body: , + }; + case "parameter": + return { + iconColor: undefined, + name: cell.parameter.name || cell.id, + summary: parameterSummary(cell.parameter), + body: , + }; + } +}; + +export interface NotebookCellProps { + net: ActiveNetDefinition; + cell: NotebookCellModel; + isSelected: boolean; + isExpanded: boolean; + /** Row is faded because a search is active and this cell doesn't match. */ + isDimmed: boolean; + /** Matched character indices in the name while searching, or null. */ + nameMatchIndices: number[] | null; + /** How much depends on this cell, shown at the end of the row. */ + dependentCount: DependentCount | undefined; + onSelect: () => void; + onSetExpanded: (expanded: boolean) => void; + /** The row's slice of the list's worksheet focus flow. */ + rowFocus: CellRowFocus; + /** Enrols the expanded body's parts in the same flow. */ + bodyParts: BodyPartContext; + onFocusSearch: () => void; +} + +export interface CellRowFocus { + /** 0 for the list's one tabbable row, -1 for the rest (roving tab stop). */ + tabIndex: 0 | -1; + /** Reports row focus, keeping selection and the roving stop in step. */ + onFocus: () => void; + /** Vertical arrow navigation — the keys the row doesn't own itself. */ + onNavigate: React.KeyboardEventHandler; +} + +export const NotebookCell: React.FC = ({ + net, + cell, + isSelected, + isExpanded, + isDimmed, + nameMatchIndices, + dependentCount, + onSelect, + onSetExpanded, + rowFocus, + bodyParts, + onFocusSearch, +}) => { + const { iconColor, name, summary, body } = cellPresentation( + net, + cell, + bodyParts, + ); + const KindIcon = CELL_KIND_ICONS[cell.kind]; + const kindLabel = CELL_KIND_LABELS[cell.kind]; + + return ( +
+
{ + // Only the row itself: focus events bubble from the caret button. + if (event.target === event.currentTarget) { + rowFocus.onFocus(); + } + }} + onKeyDown={(event) => { + if ( + (event.key === "Enter" || event.key === " ") && + event.target === event.currentTarget + ) { + event.preventDefault(); + onSelect(); + } else if (event.key === "ArrowRight") { + // The row owns the horizontal arrows for expand/collapse, so it + // never emits horizontal moves into the worksheet flow. + event.preventDefault(); + onSetExpanded(true); + } else if (event.key === "ArrowLeft") { + event.preventDefault(); + onSetExpanded(false); + } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { + rowFocus.onNavigate(event); + } else if (event.key === "/") { + event.preventDefault(); + onFocusSearch(); + } + }} + > + + + + + {kindLabel} + + + + {summary} + {dependentCount !== undefined && dependentCount.transitive > 0 && ( + + {dependentCount.direct} → {dependentCount.transitive} + + )} +
+ {isExpanded &&
{body}
} +
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-view.tsx new file mode 100644 index 00000000000..2f4c929f093 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-view.tsx @@ -0,0 +1,482 @@ +import { use, useEffect, useRef, useState } from "react"; + +import { Button, SegmentedControl, TextInput } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { ActiveNetContext } from "../../../react/state/active-net-context"; +import { EditorContext } from "../../../react/state/editor-context"; +import { useUndoRedoShortcuts } from "../../../react/state/use-undo-redo-shortcuts"; +import { focusLands } from "../../worksheet/focus-flow"; +import { useFocusStops } from "../../worksheet/use-focus-stops"; +import { CELL_KIND_PLURAL_LABELS, CELL_KINDS } from "./cell-kinds"; +import { CONNECTION_GUTTER_WIDTH, ConnectionLines } from "./connection-lines"; +import { layoutNetGraph } from "./net-graph-layout"; +import { cellBodyParts, NotebookCell, partStopId } from "./notebook-cell"; +import { + buildConnectionIndex, + buildDependentCounts, + buildNetGraph, + buildNotebookCells, + cellName, + cellToSelectionItem, + fuzzyMatchName, + noConnections, +} from "./notebook-model"; +import { orderCellsTopologically } from "./notebook-order"; + +import type { FocusStop } from "../../worksheet/use-focus-stops"; +import type { + NotebookCellKind, + NotebookCell as NotebookCellModel, +} from "./notebook-model"; +import type { CellOrder } from "./notebook-order"; + +const containerStyle = css({ + display: "flex", + flexDirection: "row", + width: "full", + height: "full", + backgroundColor: "neutral.s00", +}); + +const cellsColumnStyle = css({ + flex: "[1]", + minWidth: "[0]", + display: "flex", + flexDirection: "column", +}); + +const searchBarStyle = css({ + display: "flex", + alignItems: "center", + flexWrap: "wrap", + gap: "2", + paddingX: "4", + paddingY: "2", + borderBottomWidth: "[1px]", + borderBottomStyle: "solid", + borderBottomColor: "neutral.s30", +}); + +const searchInputStyle = css({ + maxWidth: "[320px]", + flex: "[1 1 200px]", +}); + +const matchCountStyle = css({ + fontSize: "xs", + color: "neutral.fg.subtle", + whiteSpace: "nowrap", +}); + +const filterGroupStyle = css({ + display: "flex", + alignItems: "center", + gap: "1", + marginLeft: "auto", +}); + +const cellListStyle = css({ + flex: "[1]", + minHeight: "[0]", + overflowY: "auto", +}); + +const cellListContentStyle = css({ + position: "relative", + paddingY: "3", +}); + +const emptyStyle = css({ + fontSize: "sm", + color: "neutral.fg.subtle", + padding: "4", +}); + +/** + * The experimental Notebook view: a code-like rendering of the net where + * every entity (place, transition, type, differential equation, parameter) + * is a one-line cell — inspired by Observable notebooks. Everything an + * expanded cell shows edits in place through the same mutations as the + * properties panel — names, fields, arc weights, type assignments, and the + * code editors; only adding and removing nodes, arcs, and fields stays in + * Edit mode. Cells are + * closed by default and stay as the user leaves them: the caret or + * ArrowRight/ArrowLeft opens and closes, ArrowUp/ArrowDown moves the + * selection, and "/" focuses the fuzzy name search. Selecting a cell draws + * angled connector lines in the gutters to its dependencies (left) and + * dependents (right) — dependencies span every kind, so a place links to + * its type and equation + * and a transition links to the parameters its code reads. The toolbar + * controls the cell order (document or topological) and which kinds are listed + * and searched; rows with dependents end with how many cells depend on them, + * directly and in total. + */ +export const NotebookView: React.FC = () => { + const { activeNet } = use(ActiveNetContext); + const { selection, selectItem } = use(EditorContext); + + // The canvas BottomBar (which owns the editor-wide shortcuts) isn't + // mounted in notebook mode, so undo/redo is bound here. + useUndoRedoShortcuts(); + + const [expandedIds, setExpandedIds] = useState>( + () => new Set(), + ); + const [searchQuery, setSearchQuery] = useState(""); + const [cellOrder, setCellOrder] = useState("document"); + const [visibleKinds, setVisibleKinds] = useState< + ReadonlySet + >(() => new Set(CELL_KINDS)); + + const documentCells = buildNotebookCells(activeNet); + const connectionIndex = buildConnectionIndex(activeNet); + const dependentCounts = buildDependentCounts(connectionIndex); + const netGraph = buildNetGraph(activeNet); + + // The topological list follows the diagram's default layer order (not the + // focused re-layout, which is a transient lens on the same net), with + // declarations inlined before their first user. + const cells = + cellOrder === "document" + ? documentCells + : orderCellsTopologically( + documentCells, + layoutNetGraph(netGraph).nodes.map(({ id }) => id), + connectionIndex, + ); + const visibleCells = cells.filter(({ kind }) => visibleKinds.has(kind)); + + const query = searchQuery.trim(); + const matchesById = new Map(); + if (query !== "") { + for (const cell of visibleCells) { + const match = fuzzyMatchName(query, cellName(cell)); + if (match !== null) { + matchesById.set(cell.id, match); + } + } + } + const isSearching = query !== ""; + // The rows arrows walk and the search box steps through: every visible + // cell, narrowed to the matches while a search is active. + const navigableCells = isSearching + ? visibleCells.filter(({ id }) => matchesById.has(id)) + : visibleCells; + + // A single selection carried over from the canvas can be something the + // notebook has no cell for (an arc, a component instance), so the view + // keys off the resolved cell rather than the raw selection. + const selectedItemId = + selection.size === 1 ? [...selection.values()][0]!.id : null; + const selectedCell = + selectedItemId === null + ? undefined + : cells.find(({ id }) => id === selectedItemId); + const selectedId = selectedCell?.id ?? null; + + const selectedConnections = + selectedCell === undefined + ? null + : (connectionIndex.get(selectedCell.id) ?? noConnections()); + + const contentRef = useRef(null); + const searchInputRef = useRef(null); + + // The cell list is one member of the worksheet focus flow: each navigable + // row is a full-width stop, followed by its body parts while it is + // expanded — so vertical arrows walk exactly what is on screen, and the + // whole list is a single roving tab stop. A part line with several cells + // (an arc's place, weight and type) is a sparse stop whose cells + // horizontal arrows walk individually. + const rowStops: FocusStop[] = navigableCells.flatMap((cell) => [ + { id: cell.id, kind: "full" as const }, + ...(expandedIds.has(cell.id) + ? cellBodyParts(cell, activeNet).map((part): FocusStop => { + const stopId = partStopId(cell.id, part.id); + return part.columns > 1 + ? { + id: stopId, + kind: "sparse", + columns: Array.from({ length: part.columns }, (_, at) => at), + } + : { id: stopId, kind: "full" }; + }) + : []), + ]); + const maxPartColumns = Math.max( + 1, + ...rowStops.map((stop) => + stop.kind === "sparse" ? stop.columns.length : 1, + ), + ); + const listFocus = useFocusStops({ + stops: rowStops, + columnCount: maxPartColumns, + focusTarget: ({ stopId, column }) => { + const content = contentRef.current; + if (!content) { + return false; + } + const escaped = CSS.escape(stopId); + // A declared column with no matching cell (or column 0 on a full-width + // part, which carries no column attribute) falls back to the line's + // first focusable element. + const target = + (typeof column === "number" + ? content.querySelector( + `[data-cell-part="${escaped}"][data-part-column="${String(column)}"]`, + ) + : null) ?? + content.querySelector( + `[data-cell-row="${escaped}"], [data-cell-part="${escaped}"]`, + ); + return focusLands(target); + }, + }); + + useEffect(() => { + if (selectedId === null) { + return; + } + contentRef.current + ?.querySelector(`[data-cell-id="${CSS.escape(selectedId)}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [selectedId]); + + const setCellExpanded = (cellId: string, expanded: boolean) => { + setExpandedIds((previous) => { + const next = new Set(previous); + if (expanded) { + next.add(cellId); + } else { + next.delete(cellId); + } + return next; + }); + }; + + const toggleKind = (kind: NotebookCellKind) => { + setVisibleKinds((previous) => { + const next = new Set(previous); + if (next.has(kind)) { + next.delete(kind); + } else { + next.add(kind); + } + return next; + }); + }; + + const focusCellRow = (cellId: string) => { + contentRef.current + ?.querySelector(`[data-cell-row="${CSS.escape(cellId)}"]`) + ?.focus({ preventScroll: true }); + }; + + const selectCell = ( + cell: NotebookCellModel, + options?: { focus?: boolean }, + ) => { + selectItem(cellToSelectionItem(cell)); + if (options?.focus) { + focusCellRow(cell.id); + } + }; + + /** + * Step the selection to the next/previous navigable cell without moving + * focus, so arrows work from the search box while typing continues. + * Wraps around at both ends. + */ + const stepSelection = (direction: 1 | -1) => { + if (navigableCells.length === 0) { + return; + } + const currentIndex = navigableCells.findIndex( + ({ id }) => id === selectedId, + ); + const nextIndex = + currentIndex === -1 + ? direction === 1 + ? 0 + : navigableCells.length - 1 + : (currentIndex + direction + navigableCells.length) % + navigableCells.length; + selectCell(navigableCells[nextIndex]!); + }; + + const focusSearch = () => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }; + + return ( +
+
+
+ setSearchQuery(value)} + placeholder={`Search cells… ("/" to focus)`} + prefix={{ iconName: "search" }} + clearable={{ + clearable: searchQuery !== "", + onClear: () => setSearchQuery(""), + }} + inputRef={searchInputRef} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + stepSelection(1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + stepSelection(-1); + } else if (event.key === "Enter") { + event.preventDefault(); + const firstMatch = visibleCells.find(({ id }) => + matchesById.has(id), + ); + if (firstMatch) { + selectCell(firstMatch, { focus: true }); + } + } else if (event.key === "Escape") { + setSearchQuery(""); + (event.target as HTMLElement).blur(); + } + }} + /> + {isSearching && ( + + {matchesById.size} match{matchesById.size === 1 ? "" : "es"} + + )} + + size="xs" + value={cellOrder} + items={[ + { + value: "document", + label: "Document", + tooltip: "List cells in the order the net stores them", + }, + { + value: "topological", + label: "Topological", + tooltip: + "Follow token flow, with each type, equation and parameter inlined just before its first use", + }, + ]} + onChange={setCellOrder} + /> + +
+ {CELL_KINDS.map((kind) => ( + + ))} +
+
+ +
+
{ + contentRef.current = element; + listFocus.attach(element); + }} + className={cellListContentStyle} + style={{ + paddingLeft: CONNECTION_GUTTER_WIDTH, + paddingRight: CONNECTION_GUTTER_WIDTH, + }} + > + {cells.length === 0 ? ( +
+ This net is empty — switch to Edit mode to add places and + transitions. +
+ ) : visibleCells.length === 0 ? ( +
+ All cell kinds are filtered out — enable a kind above to see + cells. +
+ ) : ( + visibleCells.map((cell) => ( + selectCell(cell)} + onSetExpanded={(expanded) => + setCellExpanded(cell.id, expanded) + } + rowFocus={{ + tabIndex: listFocus.tabIndexFor({ + stopId: cell.id, + column: 0, + }), + onFocus: () => { + listFocus.onFocusTarget({ stopId: cell.id, column: 0 }); + selectCell(cell); + }, + onNavigate: listFocus.onKeyDown({ + stopId: cell.id, + column: 0, + }), + }} + bodyParts={{ + stopIdFor: (partId) => partStopId(cell.id, partId), + focusFor: (partId, column = 0) => { + const stopId = partStopId(cell.id, partId); + return { + tabIndex: listFocus.tabIndexFor({ stopId, column }), + onFocus: () => { + listFocus.onFocusTarget({ stopId, column }); + selectCell(cell); + }, + onNavigate: listFocus.onKeyDown({ stopId, column }), + }; + }, + navigateToCell: (cellId) => { + const target = cells.find(({ id }) => id === cellId); + if (target) { + selectCell(target, { focus: true }); + } + }, + }} + onFocusSearch={focusSearch} + /> + )) + )} + id)} + upstreamIds={(selectedConnections?.upstream ?? []).map( + ({ id }) => id, + )} + downstreamIds={(selectedConnections?.downstream ?? []).map( + ({ id }) => id, + )} + /> +
+
+
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/README.md b/libs/@hashintel/petrinaut/src/ui/views/README.md index 8293e3d2da6..a90676dc638 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/README.md +++ b/libs/@hashintel/petrinaut/src/ui/views/README.md @@ -1,6 +1,6 @@ --- layer: ui.views -role: The top-level screens the editor composes — the editor shell and the net canvas +role: The top-level screens the editor composes — the editor shell, the net canvas, and the notebook --- Each subfolder is a screen rather than a widget. The split matters because the diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx index 45d4c19051f..a370c552428 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx @@ -103,6 +103,8 @@ export const ViewportSettingsDialog: React.FC = ({ setUseEntitiesTreeView, enableNetComponents, setEnableNetComponents, + enableNotebookView, + setEnableNotebookView, } = use(UserSettingsContext); const { extensions } = use(SDCPNContext); @@ -197,6 +199,23 @@ export const ViewportSettingsDialog: React.FC = ({ size="sm" /> + + Notebook view{" "} + + Experimental + + + } + description="Add a read-only Notebook mode to the top bar, listing the net as expandable cells" + > + + {extensions.subnets && ( Date: Sat, 29 Aug 2026 04:10:07 +0200 Subject: [PATCH 3/3] FE-1509: Add the notebook's whole-net graph explorer A resizable right-hand pane drawing the whole net as a layered flow graph of places and transitions, laid out from the arc structure alone. Selection highlights dependencies, dependents, and both-direction neighbours; a focus mode re-layers the graph around the selection by hop distance, animated FLIP-style by a write-only requestAnimationFrame loop that runs before paint. The explorer joins the worksheet focus flow: its connection lists are a single-column grid (one roving tab stop, remounted per selection), and a horizontal move at their edge crosses back into the cell list at its remembered row. Explorer navigation can reveal a filtered-out kind, so row focus gains a one-shot retry that runs after the reveal renders. --- .../src/ui/views/Notebook/graph-explorer.tsx | 356 ++++++++++++++++++ .../Notebook/net-graph-animation.test.ts | 74 ++++ .../ui/views/Notebook/net-graph-animation.ts | 198 ++++++++++ .../src/ui/views/Notebook/net-graph.tsx | 342 +++++++++++++++++ .../src/ui/views/Notebook/notebook-view.tsx | 150 +++++++- 5 files changed, 1113 insertions(+), 7 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/graph-explorer.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-animation.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-animation.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph.tsx diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/graph-explorer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Notebook/graph-explorer.tsx new file mode 100644 index 00000000000..e2420532b27 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/graph-explorer.tsx @@ -0,0 +1,356 @@ +import { useState } from "react"; + +import { Button } from "@hashintel/ds-components"; +import { css, cva } from "@hashintel/ds-helpers/css"; + +import { ResizeHandle } from "../../resize/resize-handle"; +import { useFocusGrid } from "../../worksheet/use-focus-grid"; +import { CELL_KIND_ICONS, CELL_KIND_LABELS } from "./cell-kinds"; +import { NetGraphView } from "./net-graph"; + +import type { FocusGrid } from "../../worksheet/use-focus-grid"; +import type { CellConnections, NetGraph, NodeRef } from "./notebook-model"; + +const containerStyle = css({ + display: "flex", + flexDirection: "column", + height: "full", + minHeight: "[0]", +}); + +const headerStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + paddingX: "3", + paddingTop: "3", + paddingBottom: "2", +}); + +const headerActionsStyle = css({ + marginLeft: "auto", + display: "flex", + alignItems: "center", + gap: "1", +}); + +/** The graph takes every pixel the lists below don't claim. */ +const graphPaneStyle = css({ + flex: "[1]", + minHeight: "[160px]", + display: "flex", + paddingX: "3", + paddingBottom: "3", +}); + +const listsPaneStyle = css({ + position: "relative", + flexShrink: 0, + display: "flex", + flexDirection: "column", + gap: "3", + paddingX: "3", + paddingTop: "3", + paddingBottom: "3", + overflowY: "auto", + borderTopWidth: "[1px]", + borderTopStyle: "solid", + borderTopColor: "neutral.s30", +}); + +const titleStyle = css({ + fontSize: "xs", + fontWeight: "semibold", + textTransform: "uppercase", + letterSpacing: "wide", + color: "neutral.fg.subtle", +}); + +const selectedNameStyle = css({ + fontSize: "sm", + fontWeight: "medium", + color: "neutral.s115", + overflowWrap: "anywhere", +}); + +const sectionStyle = css({ + display: "flex", + flexDirection: "column", + gap: "1", +}); + +/** Section titles are tinted to match the edge colours in the diagram. */ +const sectionTitleStyle = cva({ + base: { + fontSize: "xs", + fontWeight: "semibold", + }, + variants: { + direction: { + upstream: { color: "blue.s100" }, + downstream: { color: "orange.s100" }, + }, + }, +}); + +const nodeRowStyle = css({ + display: "flex", + alignItems: "center", + gap: "1.5", + minHeight: "7", + paddingX: "1.5", + borderRadius: "lg", + fontSize: "sm", + color: "neutral.s115", + cursor: "pointer", + textAlign: "left", + backgroundColor: "[transparent]", + borderWidth: "[0]", + width: "full", + _hover: { + backgroundColor: "neutral.bg.surface.hover", + }, +}); + +const nodeKindStyle = css({ + flexShrink: 0, + fontSize: "xs", + fontFamily: "mono", + color: "purple.s100", +}); + +const nodeNameStyle = css({ + flex: "[1]", + minWidth: "[0]", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", +}); + +const iconStyle = css({ + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + color: "neutral.s80", +}); + +const hintStyle = css({ + fontSize: "xs", + color: "neutral.fg.subtle", + lineHeight: "[1.5]", +}); + +const NODE_ICON_SIZE = 10; + +const DEFAULT_LISTS_HEIGHT = 220; +const MIN_LISTS_HEIGHT = 90; +const MAX_LISTS_HEIGHT = 640; + +const NodeRow: React.FC<{ + node: NodeRef; + onNavigate: (node: NodeRef) => void; + /** The lists' single-column grid in the worksheet focus flow. */ + grid: FocusGrid; + /** The row's position in that grid, across both sections. */ + index: number; +}> = ({ node, onNavigate, grid, index }) => { + const KindIcon = CELL_KIND_ICONS[node.type]; + + return ( + + ); +}; + +const NodeSection: React.FC<{ + title: string; + direction: "upstream" | "downstream"; + nodes: NodeRef[]; + onNavigate: (node: NodeRef) => void; + grid: FocusGrid; + /** Grid position of this section's first row. */ + baseIndex: number; +}> = ({ title, direction, nodes, onNavigate, grid, baseIndex }) => ( +
+ {title} + {nodes.length > 0 ? ( + nodes.map((node, offset) => ( + + )) + ) : ( + None + )} +
+); + +const contentsStyle = css({ display: "contents" }); + +/** + * The two connection lists as one single-column member of the worksheet + * focus flow: vertical arrows walk every row across both sections, the pair + * is one roving tab stop, and a horizontal move at the edge crosses back + * into the cell list. Remounted per selection (via `key`) so the roving + * memory never points at a row the new selection doesn't have. + */ +const ConnectionNodeLists: React.FC<{ + connections: CellConnections; + onNavigate: (node: NodeRef) => void; +}> = ({ connections, onNavigate }) => { + const grid = useFocusGrid(); + + return ( +
grid.attach(element)}> + + +
+ ); +}; + +/** Everything the whole-net diagram needs to draw and highlight itself. */ +export type ExplorerGraph = { + net: NetGraph; + /** The selected place or transition, or null for any other selection. */ + selectedId: string | null; + dependencyIds: ReadonlySet; + dependentIds: ReadonlySet; + placeColors: ReadonlyMap; + /** Set to re-layer the diagram around this node. */ + focusId: string | null; +}; + +export interface GraphExplorerProps { + /** The whole net as a graph of places and transitions. */ + graph: ExplorerGraph; + /** Connections for the selected cell, or null when nothing is selected. */ + connections: CellConnections | null; + /** Id of the selected cell — keys the connection lists' focus memory. */ + selectedCellId: string | null; + selectedName: string | null; + isFocusMode: boolean; + /** Focus mode needs a place or transition selected to have something to centre. */ + canFocus: boolean; + onToggleFocus: () => void; + onNavigate: (node: NodeRef) => void; +} + +/** + * The right-hand pane of the notebook view. Always draws the whole net as a + * layered graph of places and transitions; when a node is selected it and its + * direct dependencies and dependents are highlighted there. Below the graph, + * the selected cell's dependencies and dependents are listed in full — those + * lists span all cell kinds, so a place's token type and a transition's + * parameters remain reachable. + */ +export const GraphExplorer: React.FC = ({ + graph, + connections, + selectedCellId, + selectedName, + isFocusMode, + canFocus, + onToggleFocus, + onNavigate, +}) => { + // How much of the pane the lists claim; the graph fills whatever is left. + const [listsHeight, setListsHeight] = useState(DEFAULT_LISTS_HEIGHT); + + return ( +
+
+ Graph explorer +
+
+
+ +
+ + onNavigate({ type: node.kind, id: node.id, name: node.name }) + } + /> +
+ +
+ + + {connections === null ? ( + + Select a cell to see what it is connected to. + + ) : ( + <> + {selectedName} + + + + )} +
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-animation.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-animation.test.ts new file mode 100644 index 00000000000..6c6703a88e6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-animation.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { + easeOutCubic, + interpolate, + layoutSignature, +} from "./net-graph-animation"; + +import type { NetGraphLayout } from "./net-graph-layout"; + +const layout = ( + nodes: { id: string; x: number; y: number }[], +): NetGraphLayout => ({ + width: 100, + height: 100, + nodes: nodes.map((node) => ({ + ...node, + name: node.id, + kind: "place", + layer: 0, + })), + edges: [], +}); + +describe("easeOutCubic", () => { + it("pins both ends of the animation", () => { + expect(easeOutCubic(0)).toBe(0); + expect(easeOutCubic(1)).toBe(1); + }); + + it("decelerates: more than half the distance is covered by halfway", () => { + expect(easeOutCubic(0.5)).toBeGreaterThan(0.5); + }); +}); + +describe("interpolate", () => { + it("returns the endpoints exactly", () => { + expect(interpolate(10, 50, 0)).toBe(10); + expect(interpolate(10, 50, 1)).toBe(50); + }); + + it("moves proportionally in between", () => { + expect(interpolate(0, 100, 0.25)).toBe(25); + }); + + it("handles a backwards move", () => { + expect(interpolate(100, 0, 0.5)).toBe(50); + }); +}); + +describe("layoutSignature", () => { + it("is stable when only positions are unchanged", () => { + expect(layoutSignature(layout([{ id: "a", x: 1, y: 2 }]))).toBe( + layoutSignature(layout([{ id: "a", x: 1, y: 2 }])), + ); + }); + + it("changes when a node moves", () => { + expect(layoutSignature(layout([{ id: "a", x: 1, y: 2 }]))).not.toBe( + layoutSignature(layout([{ id: "a", x: 1, y: 9 }])), + ); + }); + + it("changes when the node set changes", () => { + expect(layoutSignature(layout([{ id: "a", x: 0, y: 0 }]))).not.toBe( + layoutSignature( + layout([ + { id: "a", x: 0, y: 0 }, + { id: "b", x: 0, y: 0 }, + ]), + ), + ); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-animation.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-animation.ts new file mode 100644 index 00000000000..9f6daff81e8 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-animation.ts @@ -0,0 +1,198 @@ +/** + * Animates the net diagram between layouts. + * + * React renders the target layout exactly once. This hook then plays the + * change back: a single `requestAnimationFrame` loop interpolates each node + * from where it currently sits to where React has already placed it, and + * writes the difference straight to the DOM — one `transform` per node and one + * `d` per edge, no React re-render per frame. The offsets decay to zero, so + * when the animation ends the DOM already matches what React rendered and + * there is nothing to unwind. + */ + +import { useLayoutEffect, useRef } from "react"; + +import { edgePath } from "./net-graph-layout"; + +import type { NetGraphLayout, Point } from "./net-graph-layout"; + +const DURATION_MS = 340; + +/** Decelerating ease: fast off the mark, settles gently. */ +export const easeOutCubic = (progress: number): number => + 1 - (1 - progress) ** 3; + +export const interpolate = (from: number, to: number, eased: number): number => + from + (to - from) * eased; + +/** + * Identifies a layout by where its nodes ended up, so a re-render that changes + * only colours or labels doesn't restart the animation. + */ +export const layoutSignature = (layout: NetGraphLayout): string => + layout.nodes.map((node) => `${node.id}@${node.x},${node.y}`).join("|"); + +const prefersReducedMotion = (): boolean => + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +export interface NetGraphTransition { + /** Ref callback for a node's outer group, which carries the animation offset. */ + nodeRef: (id: string) => (element: SVGGElement | null) => void; + /** Ref callback for an edge path, whose `d` is rewritten while animating. */ + edgeRef: (key: string) => (element: SVGPathElement | null) => void; +} + +/** + * Drive the transition between the previous layout and `layout`. + * + * Nodes that are new to the layout appear at their final position rather than + * flying in from nowhere, and an animation already in flight is picked up from + * its current on-screen positions instead of snapping back. + */ +export function useNetGraphTransition( + layout: NetGraphLayout, + { enabled }: { enabled: boolean }, +): NetGraphTransition { + const nodeElements = useRef(new Map()); + const edgeElements = useRef(new Map()); + /** Where each node currently is on screen, updated every frame. */ + const onScreen = useRef(new Map()); + const animatedSignature = useRef(null); + const frameHandle = useRef(null); + + // A layout effect, not a passive one: the first offset must land before the + // browser paints the commit, or the graph visibly snaps to the target and + // rewinds when the animation starts. + useLayoutEffect(() => { + const signature = layoutSignature(layout); + const targets = new Map( + layout.nodes.map((node) => [node.id, { x: node.x, y: node.y }]), + ); + + // Every node already sitting where React put it means there is nothing to + // play. This is checked as well as the signature because a re-render can + // interrupt an animation mid-flight: the cleanup cancels the frame, and + // bailing out on the signature alone would leave the offsets frozen. + const settled = layout.nodes.every((node) => { + const point = onScreen.current.get(node.id); + return point !== undefined && point.x === node.x && point.y === node.y; + }); + if (signature === animatedSignature.current && settled) { + return; + } + const origins = new Map(onScreen.current); + const isFirstLayout = animatedSignature.current === null; + animatedSignature.current = signature; + + const moving = layout.nodes.filter((node) => { + const origin = origins.get(node.id); + return ( + origin !== undefined && (origin.x !== node.x || origin.y !== node.y) + ); + }); + + // Nothing to play: adopt the target and let React's own attributes stand. + if ( + isFirstLayout || + !enabled || + moving.length === 0 || + prefersReducedMotion() + ) { + onScreen.current = targets; + for (const [id, element] of nodeElements.current) { + if (targets.has(id)) { + element.removeAttribute("transform"); + } + } + // An interrupted animation leaves edges at interpolated positions that + // React believes are already final, so their `d` is restored here too. + for (const edge of layout.edges) { + const element = edgeElements.current.get(edge.key); + const from = targets.get(edge.from); + const to = targets.get(edge.to); + if (element !== undefined && from !== undefined && to !== undefined) { + element.setAttribute("d", edgePath(from, to, edge.isBackEdge)); + } + } + return; + } + + const startedAt = performance.now(); + + const drawFrame = (now: number) => { + const progress = Math.min(1, (now - startedAt) / DURATION_MS); + const eased = easeOutCubic(progress); + const frame = new Map(); + + for (const node of layout.nodes) { + const origin = origins.get(node.id) ?? { x: node.x, y: node.y }; + const point = { + x: interpolate(origin.x, node.x, eased), + y: interpolate(origin.y, node.y, eased), + }; + frame.set(node.id, point); + + const element = nodeElements.current.get(node.id); + if (element === undefined) { + continue; + } + // React already positions the inner group at the target, so the offset + // written here is purely the remaining distance. + if (progress === 1) { + element.removeAttribute("transform"); + } else { + element.setAttribute( + "transform", + `translate(${point.x - node.x},${point.y - node.y})`, + ); + } + } + + for (const edge of layout.edges) { + const element = edgeElements.current.get(edge.key); + const from = frame.get(edge.from); + const to = frame.get(edge.to); + if (element === undefined || from === undefined || to === undefined) { + continue; + } + element.setAttribute("d", edgePath(from, to, edge.isBackEdge)); + } + + onScreen.current = frame; + + if (progress < 1) { + frameHandle.current = requestAnimationFrame(drawFrame); + } else { + frameHandle.current = null; + onScreen.current = targets; + } + }; + + frameHandle.current = requestAnimationFrame(drawFrame); + + return () => { + if (frameHandle.current !== null) { + cancelAnimationFrame(frameHandle.current); + frameHandle.current = null; + } + }; + }, [layout, enabled]); + + return { + nodeRef: (id: string) => (element: SVGGElement | null) => { + if (element === null) { + nodeElements.current.delete(id); + } else { + nodeElements.current.set(id, element); + } + }, + edgeRef: (key: string) => (element: SVGPathElement | null) => { + if (element === null) { + edgeElements.current.delete(key); + } else { + edgeElements.current.set(key, element); + } + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph.tsx b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph.tsx new file mode 100644 index 00000000000..b55f26d4f3d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph.tsx @@ -0,0 +1,342 @@ +import { use, useId } from "react"; + +import { css, cva } from "@hashintel/ds-helpers/css"; + +import { UserSettingsContext } from "../../../react/state/user-settings-context"; +import { useNetGraphTransition } from "./net-graph-animation"; +import { + edgePath, + layoutNetGraph, + NET_NODE_HEIGHT, + NET_NODE_WIDTH, +} from "./net-graph-layout"; + +import type { PositionedNetNode } from "./net-graph-layout"; +import type { NetGraph, NetGraphNode } from "./notebook-model"; + +/** Fills the pane it is given; the diagram scrolls inside when it overflows. */ +const scrollContainerStyle = css({ + flex: "[1]", + minWidth: "[0]", + minHeight: "[0]", + overflow: "auto", + borderWidth: "[1px]", + borderStyle: "solid", + borderColor: "neutral.s30", + borderRadius: "md", + backgroundColor: "neutral.s05", + display: "grid", +}); + +/** + * Centres the diagram when it is smaller than the pane, while still letting + * it grow past the edges (and scroll) when the net is large. + */ +const svgWrapperStyle = css({ + margin: "auto", + padding: "2", +}); + +const shapeStyle = cva({ + base: { + strokeWidth: "[1.5]", + transition: "[fill 100ms ease-out, stroke 100ms ease-out]", + }, + variants: { + role: { + selected: { fill: "neutral.s00", stroke: "neutral.s115" }, + dependency: { fill: "blue.s20", stroke: "blue.s90" }, + dependent: { fill: "orange.s20", stroke: "orange.s90" }, + both: { fill: "purple.s20", stroke: "purple.s90" }, + plain: { fill: "neutral.s00", stroke: "neutral.s45" }, + muted: { fill: "neutral.s00", stroke: "neutral.s35" }, + }, + }, +}); + +const nodeGroupStyle = css({ + cursor: "pointer", + _hover: { "& [data-shape]": { filter: "[brightness(0.96)]" } }, + _focusVisible: { + outline: "[none]", + "& [data-shape]": { strokeWidth: "[2.5]" }, + }, +}); + +const labelStyle = cva({ + base: { + fontSize: "[9px]", + textAnchor: "middle", + dominantBaseline: "central", + pointerEvents: "none", + }, + variants: { + role: { + selected: { fill: "neutral.s115", fontWeight: "semibold" }, + dependency: { fill: "blue.s110" }, + dependent: { fill: "orange.s115" }, + both: { fill: "purple.s115" }, + plain: { fill: "neutral.s110" }, + muted: { fill: "neutral.s90" }, + }, + }, +}); + +const edgeStyle = cva({ + base: { fill: "[none]" }, + variants: { + role: { + incoming: { stroke: "blue.s90", strokeWidth: "[1.5]" }, + outgoing: { stroke: "orange.s90", strokeWidth: "[1.5]" }, + plain: { stroke: "neutral.s45", strokeWidth: "[1]" }, + muted: { stroke: "neutral.s35", strokeWidth: "[1]" }, + }, + isBackEdge: { + true: { strokeDasharray: "[3 3]" }, + false: {}, + }, + }, +}); + +const arrowFillStyle = cva({ + base: {}, + variants: { + role: { + incoming: { fill: "blue.s90" }, + outgoing: { fill: "orange.s90" }, + plain: { fill: "neutral.s45" }, + muted: { fill: "neutral.s35" }, + }, + }, +}); + +const emptyHintStyle = css({ + fontSize: "xs", + color: "neutral.fg.subtle", + padding: "3", +}); + +type NodeRole = + | "selected" + | "dependency" + | "dependent" + /** Both a dependency and a dependent — a cycle through the selected node. */ + | "both" + | "plain" + | "muted"; +type EdgeRole = "incoming" | "outgoing" | "plain" | "muted"; + +const EDGE_ROLES = ["incoming", "outgoing", "plain", "muted"] as const; + +// Marker ids are document-global, so they carry a per-instance prefix to keep +// two mounted graphs from resolving to each other's arrowheads. +const markerId = (instanceId: string, role: EdgeRole) => + `${instanceId}-net-graph-arrow-${role}`; + +const MAX_LABEL_CHARS = 13; + +const truncate = (name: string, maxChars: number): string => + name.length > maxChars ? `${name.slice(0, maxChars - 1)}…` : name; + +/** Places read as pills, transitions as squared boxes, as on the canvas. */ +const cornerRadius = (node: NetGraphNode): number => + node.kind === "place" ? NET_NODE_HEIGHT / 2 : 3; + +export interface NetGraphViewProps { + graph: NetGraph; + /** The selected place or transition, or null when nothing relevant is selected. */ + selectedId: string | null; + /** Direct dependencies of the selection, highlighted upstream. */ + dependencyIds: ReadonlySet; + /** Direct dependents of the selection, highlighted downstream. */ + dependentIds: ReadonlySet; + /** Token-type display colour per place id, shown as a dot on the node. */ + placeColors: ReadonlyMap; + /** Re-layer the diagram around this node instead of by longest path. */ + focusId: string | null; + onNavigate: (node: NetGraphNode) => void; +} + +/** + * The whole net drawn as a layered flow graph of places and transitions, laid + * out from the arc structure rather than the stored x/y positions. With a + * place or transition selected, that node and its direct dependencies and + * dependents are highlighted and the rest of the net recedes — a neighbour + * that is both, forming a cycle through the selection, gets its own colour. + * With nothing selected the graph is drawn plainly, rooted at the nodes that + * have no incoming arcs. + */ +export const NetGraphView: React.FC = ({ + graph, + selectedId, + dependencyIds, + dependentIds, + placeColors, + focusId, + onNavigate, +}) => { + const instanceId = useId(); + const layout = layoutNetGraph(graph, { focusId }); + const { showAnimations } = use(UserSettingsContext); + const { nodeRef, edgeRef } = useNetGraphTransition(layout, { + enabled: showAnimations, + }); + + if (layout.nodes.length === 0) { + return ( +

+ This net has no places or transitions yet. +

+ ); + } + + const hasSelection = selectedId !== null; + + const nodeRole = (node: PositionedNetNode): NodeRole => { + if (node.id === selectedId) { + return "selected"; + } + const isDependency = dependencyIds.has(node.id); + const isDependent = dependentIds.has(node.id); + if (isDependency && isDependent) { + return "both"; + } + if (isDependency) { + return "dependency"; + } + if (isDependent) { + return "dependent"; + } + return hasSelection ? "muted" : "plain"; + }; + + const edgeRole = (from: string, to: string): EdgeRole => { + if (!hasSelection) { + return "plain"; + } + if (to === selectedId && dependencyIds.has(from)) { + return "incoming"; + } + if (from === selectedId && dependentIds.has(to)) { + return "outgoing"; + } + return "muted"; + }; + + const nodesById = new Map(layout.nodes.map((node) => [node.id, node])); + + return ( +
+
+ + + {EDGE_ROLES.map((role) => ( + + + + ))} + + + {layout.edges.map((edge) => { + const from = nodesById.get(edge.from); + const to = nodesById.get(edge.to); + if (from === undefined || to === undefined) { + return null; + } + const role = edgeRole(edge.from, edge.to); + const path = edgePath(from, to, edge.isBackEdge); + + return ( + + ); + })} + + {layout.nodes.map((node) => { + const role = nodeRole(node); + const placeColor = placeColors.get(node.id); + + return ( + // Outer group: animation offset only, written straight to the + // DOM while a re-layout plays. Inner group: everything React owns. + + onNavigate(node)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onNavigate(node); + } + }} + > + {node.name} + + {placeColor !== undefined && ( + + )} + + {truncate(node.name, MAX_LABEL_CHARS)} + + + + ); + })} + +
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-view.tsx index 2f4c929f093..435d196f474 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-view.tsx @@ -6,16 +6,20 @@ import { css } from "@hashintel/ds-helpers/css"; import { ActiveNetContext } from "../../../react/state/active-net-context"; import { EditorContext } from "../../../react/state/editor-context"; import { useUndoRedoShortcuts } from "../../../react/state/use-undo-redo-shortcuts"; +import { ResizeHandle } from "../../resize/resize-handle"; import { focusLands } from "../../worksheet/focus-flow"; +import { FocusRoot, FocusStack } from "../../worksheet/focus-stack"; import { useFocusStops } from "../../worksheet/use-focus-stops"; import { CELL_KIND_PLURAL_LABELS, CELL_KINDS } from "./cell-kinds"; import { CONNECTION_GUTTER_WIDTH, ConnectionLines } from "./connection-lines"; +import { GraphExplorer } from "./graph-explorer"; import { layoutNetGraph } from "./net-graph-layout"; import { cellBodyParts, NotebookCell, partStopId } from "./notebook-cell"; import { buildConnectionIndex, buildDependentCounts, buildNetGraph, + buildNodeNeighbourhood, buildNotebookCells, cellName, cellToSelectionItem, @@ -26,6 +30,7 @@ import { orderCellsTopologically } from "./notebook-order"; import type { FocusStop } from "../../worksheet/use-focus-stops"; import type { + NodeRef, NotebookCellKind, NotebookCell as NotebookCellModel, } from "./notebook-model"; @@ -87,6 +92,23 @@ const cellListContentStyle = css({ paddingY: "3", }); +const explorerColumnStyle = css({ + position: "relative", + flexShrink: 0, + minWidth: "[0]", + // The drag bound is absolute pixels; this keeps a narrow window from letting + // the explorer squeeze the cell list to nothing. + maxWidth: "[85%]", + borderLeftWidth: "[1px]", + borderLeftStyle: "solid", + borderLeftColor: "neutral.s40", + backgroundColor: "neutral.s00", +}); + +const DEFAULT_EXPLORER_WIDTH = 520; +const MIN_EXPLORER_WIDTH = 320; +const MAX_EXPLORER_WIDTH = 1100; + const emptyStyle = css({ fontSize: "sm", color: "neutral.fg.subtle", @@ -105,14 +127,14 @@ const emptyStyle = css({ * ArrowRight/ArrowLeft opens and closes, ArrowUp/ArrowDown moves the * selection, and "/" focuses the fuzzy name search. Selecting a cell draws * angled connector lines in the gutters to its dependencies (left) and - * dependents (right) — dependencies span every kind, so a place links to - * its type and equation + * dependents (right), and lists them in the explorer on the right — + * dependencies span every kind, so a place links to its type and equation * and a transition links to the parameters its code reads. The toolbar * controls the cell order (document or topological) and which kinds are listed * and searched; rows with dependents end with how many cells depend on them, * directly and in total. */ -export const NotebookView: React.FC = () => { +const NotebookViewContent: React.FC = () => { const { activeNet } = use(ActiveNetContext); const { selection, selectItem } = use(EditorContext); @@ -124,7 +146,9 @@ export const NotebookView: React.FC = () => { () => new Set(), ); const [searchQuery, setSearchQuery] = useState(""); + const [explorerWidth, setExplorerWidth] = useState(DEFAULT_EXPLORER_WIDTH); const [cellOrder, setCellOrder] = useState("document"); + const [focusOnSelection, setFocusOnSelection] = useState(false); const [visibleKinds, setVisibleKinds] = useState< ReadonlySet >(() => new Set(CELL_KINDS)); @@ -174,12 +198,56 @@ export const NotebookView: React.FC = () => { ? undefined : cells.find(({ id }) => id === selectedItemId); const selectedId = selectedCell?.id ?? null; + const selectedName = + selectedCell === undefined ? null : cellName(selectedCell); const selectedConnections = selectedCell === undefined ? null : (connectionIndex.get(selectedCell.id) ?? noConnections()); + // Token-type colour per place, so the diagram can echo the canvas palette. + const placeColors = new Map( + activeNet.places.flatMap((place) => { + const color = activeNet.types.find(({ id }) => id === place.colorId); + return color === undefined + ? [] + : [[place.id, color.displayColor] as const]; + }), + ); + + // The diagram always shows the whole net; a selected place or transition + // just decides what gets highlighted within it. Nodes reachable both ways + // count as a dependency and a dependent, so they light up on both sides. + const neighbourhood = + selectedConnections === null + ? null + : buildNodeNeighbourhood(selectedConnections); + const selectedNodeId = + selectedCell !== undefined && + (selectedCell.kind === "place" || selectedCell.kind === "transition") + ? selectedCell.id + : null; + + const explorerGraph = { + net: netGraph, + selectedId: selectedNodeId, + dependencyIds: new Set( + [ + ...(neighbourhood?.dependencies ?? []), + ...(neighbourhood?.bidirectional ?? []), + ].map(({ id }) => id), + ), + dependentIds: new Set( + [ + ...(neighbourhood?.dependents ?? []), + ...(neighbourhood?.bidirectional ?? []), + ].map(({ id }) => id), + ), + placeColors, + focusId: focusOnSelection ? selectedNodeId : null, + }; + const contentRef = useRef(null); const searchInputRef = useRef(null); @@ -268,10 +336,33 @@ export const NotebookView: React.FC = () => { }); }; + // Navigation can reveal a filtered-out kind, in which case the row to focus + // doesn't exist until after the re-render — so a miss is parked here and + // retried by the effect below once the row is in the DOM. + const pendingFocusCellIdRef = useRef(null); + + const focusRowElement = (cellId: string): boolean => { + const row = contentRef.current?.querySelector( + `[data-cell-row="${CSS.escape(cellId)}"]`, + ); + row?.focus({ preventScroll: true }); + return row !== null && row !== undefined; + }; + + // One retry is enough: the reveal of the hidden kind lands in the very next + // render, and clearing unconditionally means a deleted cell can't leave a + // stale id behind to steal focus later. + useEffect(() => { + if (pendingFocusCellIdRef.current !== null) { + focusRowElement(pendingFocusCellIdRef.current); + pendingFocusCellIdRef.current = null; + } + }); + const focusCellRow = (cellId: string) => { - contentRef.current - ?.querySelector(`[data-cell-row="${CSS.escape(cellId)}"]`) - ?.focus({ preventScroll: true }); + if (!focusRowElement(cellId)) { + pendingFocusCellIdRef.current = cellId; + } }; const selectCell = ( @@ -284,6 +375,16 @@ export const NotebookView: React.FC = () => { } }; + const navigateToNode = (node: NodeRef) => { + // Jumping to a kind the filter hides would select an invisible cell, so + // reveal that kind as part of the navigation. + if (!visibleKinds.has(node.type)) { + setVisibleKinds((previous) => new Set(previous).add(node.type)); + } + selectItem({ type: node.type, id: node.id }); + focusCellRow(node.id); + }; + /** * Step the selection to the next/previous navigable cell without moving * focus, so arrows work from the search box while typing continues. @@ -431,7 +532,10 @@ export const NotebookView: React.FC = () => { column: 0, }), onFocus: () => { - listFocus.onFocusTarget({ stopId: cell.id, column: 0 }); + listFocus.onFocusTarget({ + stopId: cell.id, + column: 0, + }); selectCell(cell); }, onNavigate: listFocus.onKeyDown({ @@ -477,6 +581,38 @@ export const NotebookView: React.FC = () => { + +
+ + setFocusOnSelection((previous) => !previous)} + onNavigate={navigateToNode} + /> +
); }; + +export const NotebookView: React.FC = () => ( + // The stack must sit above the component whose hooks join the flow: a + // hook reads the context of its own component's position, so a stack + // rendered inside NotebookViewContent could never enrol its list. + + + + + +);