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/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-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-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; +} 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 && (