diff --git a/libs/@hashintel/petrinaut-core/README.md b/libs/@hashintel/petrinaut-core/README.md index 4b95ac21d45..7c5d1d57ff9 100644 --- a/libs/@hashintel/petrinaut-core/README.md +++ b/libs/@hashintel/petrinaut-core/README.md @@ -5,3 +5,31 @@ language services, and supporting domain utilities. This package intentionally has no React or UI dependencies. The visual editor package, `@hashintel/petrinaut`, builds on top of it. + +## Handle Creation + +Petrinaut reads and writes documents through `PetrinautDocHandle`. Use +`createJsonDocHandle()` for an in-memory handle with patch events, +extension sanitization, and optional undo/redo history: + +```ts +import { createJsonDocHandle } from "@hashintel/petrinaut-core"; + +const handle = createJsonDocHandle({ + id: "my-net", + initial: { + places: [{ id: "p1", name: "P1", x: 0, y: 0 }], + transitions: [], + }, +}); +``` + +`initial` accepts `SDCPNInput`, a loose document shape for host integrations. +Plain-net defaults are filled in automatically: omitted arc weights become `1`, +input arc types become `"standard"`, extension arrays default to `[]`, and +disabled extension data is sanitized according to handle capabilities. + +When another application is the source of truth, implement `PetrinautDocHandle` +directly so editor edits can emit `source: "local"` and host/store updates can +emit `source: "remote"`. The visual editor package has a fuller guide in +`@hashintel/petrinaut/INTEGRATION.md`. diff --git a/libs/@hashintel/petrinaut-core/src/handle/json-doc-handle/create-json-doc-handle.ts b/libs/@hashintel/petrinaut-core/src/handle/json-doc-handle/create-json-doc-handle.ts index b35bba7b7af..460192bd946 100644 --- a/libs/@hashintel/petrinaut-core/src/handle/json-doc-handle/create-json-doc-handle.ts +++ b/libs/@hashintel/petrinaut-core/src/handle/json-doc-handle/create-json-doc-handle.ts @@ -12,8 +12,10 @@ import { type PetrinautHandleCapabilities, } from "../../extensions"; import { createReadableStore } from "../../store"; +import { normalizeSDCPN } from "../../types/sdcpn-input"; import type { SDCPN } from "../../types/sdcpn"; +import type { SDCPNInput } from "../../types/sdcpn-input"; import type { DocChangeEvent, DocHandleState, @@ -50,7 +52,14 @@ const DEFAULT_HISTORY_LIMIT = 50; export type CreateJsonDocHandleOptions = { id?: DocumentId; - initial: SDCPN; + /** + * Initial document. Accepts a loose {@link SDCPNInput} — extension fields + * (`colorId`, `lambdaCode`, arc `type`/`weight`, the `types` / + * `parameters` / `differentialEquations` arrays, ...) may be omitted and are + * filled with plain-net defaults via {@link normalizeSDCPN}. A complete + * {@link SDCPN} is a valid input too. + */ + initial: SDCPNInput; capabilities?: PetrinautHandleCapabilities; /** * Maximum number of history checkpoints retained. Older entries are dropped @@ -82,7 +91,7 @@ export function createJsonDocHandle( const subscribers = new Set<(event: DocChangeEvent) => void>(); let current: SDCPN = sanitizeSDCPNForExtensions( - opts.initial, + normalizeSDCPN(opts.initial), resolvedCapabilities.extensions, ); diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index ff2de8ba260..3592006eb73 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -261,6 +261,14 @@ export type { // --- Domain types --- export type * from "./types/sdcpn"; +export { normalizeSDCPN } from "./types/sdcpn-input"; +export type { + SDCPNInput, + SDCPNInputArcInput, + SDCPNOutputArcInput, + SDCPNPlaceInput, + SDCPNTransitionInput, +} from "./types/sdcpn-input"; export { parseArcId } from "./types/selection"; export type * from "./types/selection"; diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.test.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.test.ts new file mode 100644 index 00000000000..8f019d8f780 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; + +import { isSDCPNEqual } from "../lib/deep-equal"; + +import { normalizeSDCPN } from "./sdcpn-input"; + +import type { SDCPN } from "./sdcpn"; + +describe("normalizeSDCPN", () => { + it("fills plain-net defaults for omitted place and transition fields", () => { + const result = normalizeSDCPN({ + places: [{ id: "p1", name: "P1", x: 1, y: 2 }], + transitions: [ + { + id: "t1", + name: "T1", + inputArcs: [{ placeId: "p1" }], + outputArcs: [{ placeId: "p1" }], + x: 3, + y: 4, + }, + ], + }); + + expect(result.places[0]).toEqual({ + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 1, + y: 2, + }); + expect(result.transitions[0]).toEqual({ + id: "t1", + name: "T1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p1", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 3, + y: 4, + }); + expect(result.types).toEqual([]); + expect(result.parameters).toEqual([]); + expect(result.differentialEquations).toEqual([]); + }); + + it("omits optional keys that are absent so structural equality is preserved", () => { + const result = normalizeSDCPN({ + places: [{ id: "p1", name: "P1", x: 0, y: 0 }], + transitions: [], + }); + + expect(Object.hasOwn(result.places[0]!, "visualizerCode")).toBe(false); + expect(Object.hasOwn(result.places[0]!, "showAsInitialState")).toBe(false); + expect(Object.hasOwn(result, "scenarios")).toBe(false); + expect(Object.hasOwn(result, "metrics")).toBe(false); + }); + + it("preserves provided extension values instead of overwriting them", () => { + const result = normalizeSDCPN({ + places: [ + { + id: "p1", + name: "P1", + x: 0, + y: 0, + colorId: "c1", + dynamicsEnabled: true, + differentialEquationId: "d1", + visualizerCode: "code", + showAsInitialState: true, + }, + ], + transitions: [ + { + id: "t1", + name: "T1", + inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }], + outputArcs: [{ placeId: "p1", weight: 3 }], + x: 0, + y: 0, + lambdaType: "stochastic", + lambdaCode: "l", + transitionKernelCode: "k", + }, + ], + }); + + expect(result.places[0]).toMatchObject({ + colorId: "c1", + dynamicsEnabled: true, + differentialEquationId: "d1", + visualizerCode: "code", + showAsInitialState: true, + }); + expect(result.transitions[0]).toMatchObject({ + inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }], + outputArcs: [{ placeId: "p1", weight: 3 }], + lambdaType: "stochastic", + lambdaCode: "l", + transitionKernelCode: "k", + }); + }); + + it("is idempotent on an already-complete SDCPN", () => { + const complete: SDCPN = { + places: [ + { + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "t1", + name: "T1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p1", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + types: [], + parameters: [], + differentialEquations: [], + }; + + expect(isSDCPNEqual(normalizeSDCPN(complete), complete)).toBe(true); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.ts new file mode 100644 index 00000000000..e8eb057cf78 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.ts @@ -0,0 +1,147 @@ +import type { + Color, + DifferentialEquation, + ID, + InputArcType, + Metric, + Parameter, + Scenario, + SDCPN, +} from "./sdcpn"; + +/** + * Loose, authoring-friendly variant of {@link SDCPN}. + * + * The canonical {@link SDCPN} type is what the editor, simulation, LSP, and + * serialization consume, so every extension field is present on it. When a + * host integrates Petrinaut and maps its own domain model into a Petri net, it + * usually doesn't care about colours, dynamics, stochasticity, or parameters — + * yet the strict type still forces it to spell out `colorId: null`, + * `lambdaCode: ""`, empty `types`/`parameters`/`differentialEquations` arrays, + * and so on for every node. + * + * `SDCPNInput` makes all of that optional. Pass it to + * {@link normalizeSDCPN} (or straight to `createJsonDocHandle`, which + * normalizes internally) to get a fully-populated {@link SDCPN} with plain-net + * defaults filled in. A complete {@link SDCPN} is always a valid `SDCPNInput`, + * so existing callers are unaffected. + */ +export type SDCPNInput = { + places: SDCPNPlaceInput[]; + transitions: SDCPNTransitionInput[]; + /** @default [] */ + types?: Color[]; + /** @default [] */ + parameters?: Parameter[]; + /** @default [] */ + differentialEquations?: DifferentialEquation[]; + scenarios?: Scenario[]; + metrics?: Metric[]; +}; + +export type SDCPNPlaceInput = { + id: ID; + name: string; + x: number; + y: number; + /** @default null */ + colorId?: ID | null; + /** @default false */ + dynamicsEnabled?: boolean; + /** @default null */ + differentialEquationId?: ID | null; + visualizerCode?: string; + showAsInitialState?: boolean; +}; + +export type SDCPNInputArcInput = { + placeId: string; + /** @default 1 */ + weight?: number; + /** @default "standard" */ + type?: InputArcType; +}; + +export type SDCPNOutputArcInput = { + placeId: string; + /** @default 1 */ + weight?: number; +}; + +export type SDCPNTransitionInput = { + id: ID; + name: string; + inputArcs: SDCPNInputArcInput[]; + outputArcs: SDCPNOutputArcInput[]; + x: number; + y: number; + /** @default "predicate" */ + lambdaType?: "predicate" | "stochastic"; + /** @default "" */ + lambdaCode?: string; + /** @default "" */ + transitionKernelCode?: string; +}; + +/** + * Fill plain-net defaults into an {@link SDCPNInput} to produce a canonical + * {@link SDCPN}. Idempotent: normalizing an already-complete `SDCPN` returns an + * equivalent value. + * + * Optional output fields (`visualizerCode`, `showAsInitialState`, `scenarios`, + * `metrics`) are only set when present on the input, so the result matches the + * shape the editor itself produces (relevant for structural dirty-tracking via + * `isSDCPNEqual`). + */ +export function normalizeSDCPN(input: SDCPNInput): SDCPN { + const result: SDCPN = { + places: input.places.map((place) => { + const normalized: SDCPN["places"][number] = { + id: place.id, + name: place.name, + colorId: place.colorId ?? null, + dynamicsEnabled: place.dynamicsEnabled ?? false, + differentialEquationId: place.differentialEquationId ?? null, + x: place.x, + y: place.y, + }; + if (place.visualizerCode !== undefined) { + normalized.visualizerCode = place.visualizerCode; + } + if (place.showAsInitialState !== undefined) { + normalized.showAsInitialState = place.showAsInitialState; + } + return normalized; + }), + transitions: input.transitions.map((transition) => ({ + id: transition.id, + name: transition.name, + inputArcs: transition.inputArcs.map((arc) => ({ + placeId: arc.placeId, + weight: arc.weight ?? 1, + type: arc.type ?? "standard", + })), + outputArcs: transition.outputArcs.map((arc) => ({ + placeId: arc.placeId, + weight: arc.weight ?? 1, + })), + lambdaType: transition.lambdaType ?? "predicate", + lambdaCode: transition.lambdaCode ?? "", + transitionKernelCode: transition.transitionKernelCode ?? "", + x: transition.x, + y: transition.y, + })), + types: input.types ?? [], + parameters: input.parameters ?? [], + differentialEquations: input.differentialEquations ?? [], + }; + + if (input.scenarios !== undefined) { + result.scenarios = input.scenarios; + } + if (input.metrics !== undefined) { + result.metrics = input.metrics; + } + + return result; +} diff --git a/libs/@hashintel/petrinaut/INTEGRATION.md b/libs/@hashintel/petrinaut/INTEGRATION.md new file mode 100644 index 00000000000..ec326163ff8 --- /dev/null +++ b/libs/@hashintel/petrinaut/INTEGRATION.md @@ -0,0 +1,301 @@ +# Petrinaut Integration Guide + +This guide covers embedding the `@hashintel/petrinaut` editor in another React +application where the host application owns the Petri net data. + +The main integration point is a `PetrinautDocHandle`. The handle is the bridge +between your store and the editor: + +```mermaid +flowchart LR + Store["Host app
source of truth"] + Handle["PetrinautDocHandle"] + Editor["<Petrinaut />"] + + Store -- "toSDCPN()" --> Handle + Handle -- "doc() / subscribe()" --> Editor + Editor -- "change(fn)" --> Handle + Handle -- "fromSDCPN()" --> Store +``` + +The interface is experimental and may change. The required pieces today are: + +```ts +import type { + DocChangeEvent, + DocHandleState, + PetrinautDocHandle, + ReadableStore, + SDCPN, +} from "@hashintel/petrinaut-core"; + +interface PetrinautDocHandle { + readonly id: string; + readonly state: ReadableStore; + whenReady(): Promise; + doc(): SDCPN | undefined; + change(fn: (draft: SDCPN) => void): void; + subscribe(listener: (event: DocChangeEvent) => void): () => void; +} +``` + +`state` can be a constant `"ready"` store for synchronous integrations. +`whenReady()` can return a resolved promise. Add `capabilities` when the host +wants to restrict extensions, and add `history` only if the handle can support +undo and redo. + +## Map Your Model To SDCPN + +Petrinaut edits `SDCPN` documents. External applications usually have their own +model, so start by writing mapping functions. + +Use `SDCPNInput` when creating documents from a simple host model. It is a +looser authoring shape: extension fields can be omitted and filled with +plain-net defaults by `normalizeSDCPN()` or `createJsonDocHandle()`. + +```ts +import type { SDCPNInput } from "@hashintel/petrinaut-core"; + +type WorkflowGraph = { + id: string; + title: string; + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; +}; + +function toSDCPN(graph: WorkflowGraph): SDCPNInput { + return { + places: graph.nodes.map((node) => ({ + id: node.id, + name: node.label, + x: node.x, + y: node.y, + showAsInitialState: node.isInitial, + })), + transitions: graph.edges.map((edge) => ({ + id: edge.id, + name: edge.label, + inputArcs: [{ placeId: edge.sourceId }], + outputArcs: [{ placeId: edge.targetId }], + x: edge.x ?? 0, + y: edge.y ?? 0, + })), + }; +} +``` + +Mandatory fields for plain nets: + +| Entity | Required fields | +| --- | --- | +| Place | `id`, `name`, `x`, `y` | +| Transition | `id`, `name`, `inputArcs`, `outputArcs`, `x`, `y` | +| Arc | `placeId` | + +Defaulted fields include arc `weight: 1`, input arc `type: "standard"`, +place `colorId: null`, `dynamicsEnabled: false`, transition +`lambdaType: "predicate"`, empty lambda/kernel code, and empty top-level +`types`, `parameters`, and `differentialEquations` arrays. + +The reverse mapping is application-specific: + +```ts +import type { SDCPN } from "@hashintel/petrinaut-core"; + +function fromSDCPN(sdcpn: SDCPN, baseGraph: WorkflowGraph): WorkflowGraph { + return { + ...baseGraph, + nodes: sdcpn.places.map((place) => ({ + id: place.id, + label: place.name, + x: place.x, + y: place.y, + isInitial: place.showAsInitialState ?? false, + })), + edges: sdcpn.transitions.map((transition) => ({ + id: transition.id, + label: transition.name, + sourceId: transition.inputArcs[0]?.placeId ?? "", + targetId: transition.outputArcs[0]?.placeId ?? "", + })), + }; +} +``` + +## Choose A Handle Pattern + +Use `createJsonDocHandle()` when Petrinaut can own an in-memory document: + +```ts +import { createJsonDocHandle } from "@hashintel/petrinaut-core"; + +const handle = createJsonDocHandle({ + id: graph.id, + initial: toSDCPN(graph), + capabilities: { + disabledExtensions: ["colors", "stochasticity", "dynamics", "parameters"], + }, +}); +``` + +This gives you patch events, extension sanitization, and undo/redo history. It +is the best fit for demos, local editing, and hosts that only need to seed the +editor. + +If your application is the source of truth, implement a small adapter handle +instead. That keeps editor-originated edits (`source: "local"`) separate from +host-originated updates (`source: "remote"`), which matters for collaboration, +history, and avoiding echo loops. + +```ts +import { produce } from "immer"; +import { + isSDCPNEqual, + normalizeSDCPN, + type DocChangeEvent, + type DocHandleState, + type PetrinautDocHandle, + type ReadableStore, + type SDCPN, +} from "@hashintel/petrinaut-core"; + +type WorkflowHandle = PetrinautDocHandle & { + applyExternal(graph: WorkflowGraph): void; +}; + +function readyStore(): ReadableStore { + return { + get: () => "ready", + subscribe: () => () => {}, + }; +} + +function createWorkflowHandle( + initialGraph: WorkflowGraph, + onGraphChange: (graph: WorkflowGraph) => void, +): WorkflowHandle { + let current: SDCPN = normalizeSDCPN(toSDCPN(initialGraph)); + let latestGraph = initialGraph; + const subscribers = new Set<(event: DocChangeEvent) => void>(); + + const emit = (event: DocChangeEvent) => { + for (const subscriber of subscribers) { + subscriber(event); + } + }; + + return { + id: initialGraph.id, + state: readyStore(), + whenReady: () => Promise.resolve(), + doc: () => current, + change(fn) { + const next = produce(current, fn); + if (next === current) { + return; + } + + current = next; + latestGraph = fromSDCPN(current, latestGraph); + emit({ next: current, source: "local" }); + onGraphChange(latestGraph); + }, + subscribe(listener) { + subscribers.add(listener); + return () => { + subscribers.delete(listener); + }; + }, + applyExternal(graph) { + const next = normalizeSDCPN(toSDCPN(graph)); + if (isSDCPNEqual(next, current)) { + return; + } + + current = next; + latestGraph = graph; + emit({ next: current, source: "remote" }); + }, + }; +} +``` + +## Mount The Editor + +Keep one handle per host document. Recreate the handle when switching to a +different document, and push later host-store updates through your adapter. + +```tsx +import { Petrinaut } from "@hashintel/petrinaut"; +import { useEffect, useMemo, useRef } from "react"; + +export function WorkflowEditor({ + graph, + onGraphChange, +}: { + graph: WorkflowGraph; + onGraphChange: (graph: WorkflowGraph) => void; +}) { + const handle = useMemo( + () => createWorkflowHandle(graph, onGraphChange), + [graph.id, onGraphChange], + ); + const seededRef = useRef(false); + + useEffect(() => { + if (!seededRef.current) { + seededRef.current = true; + return; + } + handle.applyExternal(graph); + }, [graph, handle]); + + return ( + onGraphChange({ ...graph, title })} + hideNetManagementControls="except-title" + /> + ); +} +``` + +Key props: + +| Prop | Purpose | +| --- | --- | +| `handle` | Required document handle | +| `title` / `setTitle` | Host-owned title shown in the top bar | +| `readonly` | Disable all editing | +| `hideNetManagementControls` | Hide New/Open/Import controls; use `"except-title"` to keep the title | +| `existingNets`, `createNewNet`, `loadPetriNet` | Optional multi-net management hooks | +| `aiAssistant`, `viewportActions`, `slots` | Optional host extension points | +| Worker factories | Optional simulation, Monte Carlo, and language-server worker factories | + +## Capabilities + +Pass handle capabilities to restrict editor features. Disabled extension data is +hidden from the UI and stripped from the document. + +```ts +import type { PetrinautHandleCapabilities } from "@hashintel/petrinaut-core"; + +const capabilities: PetrinautHandleCapabilities = { + disabledExtensions: ["colors", "stochasticity", "dynamics", "parameters"], +}; +``` + +Available extensions are `"colors"`, `"stochasticity"`, `"dynamics"`, and +`"parameters"`. Disabling `"colors"` automatically disables `"dynamics"`. + +## Reference + +| What | Location | +| --- | --- | +| Visual editor props | `libs/@hashintel/petrinaut/src/ui/petrinaut.tsx` | +| In-memory handle | `libs/@hashintel/petrinaut-core/src/handle/json-doc-handle/create-json-doc-handle.ts` | +| Handle interface | `libs/@hashintel/petrinaut-core/src/handle/types.ts` | +| SDCPN types | `libs/@hashintel/petrinaut-core/src/types/sdcpn.ts` | +| SDCPN input helpers | `libs/@hashintel/petrinaut-core/src/types/sdcpn-input.ts` | +| Storybook multi-net pattern | `libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx` | diff --git a/libs/@hashintel/petrinaut/README.md b/libs/@hashintel/petrinaut/README.md index f7c5d8a2f33..c6cddd3a70d 100644 --- a/libs/@hashintel/petrinaut/README.md +++ b/libs/@hashintel/petrinaut/README.md @@ -4,6 +4,28 @@ A component for editing [**Petri nets**](https://en.wikipedia.org/wiki/Petri_net Currently **under development** and not ready for usage. +## Embedding Petrinaut + +The visual editor is exposed as a React component: + +```tsx +import { Petrinaut } from "@hashintel/petrinaut"; +import { createJsonDocHandle } from "@hashintel/petrinaut-core"; + +const handle = createJsonDocHandle({ + id: "my-net", + initial: { places: [], transitions: [] }, +}); + +export function App() { + return ; +} +``` + +For host applications that own their Petri net data, implement a +`PetrinautDocHandle` adapter and pass it to ``. See +[INTEGRATION.md](INTEGRATION.md) for the recommended patterns. + ## Development Mode For development and testing, you can use the included dev mode: diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index 236513bf3e5..786b4f72816 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -12,6 +12,7 @@ "files": [ "dist", "CHANGELOG.md", + "INTEGRATION.md", "LICENSE", "LICENSE-APACHE.md", "LICENSE-MIT.md",