From 318b855aa4c5f2db3c8f72e51bdd47e2741fa148 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 23 Apr 2026 11:31:09 +0100 Subject: [PATCH 1/4] FE-522: Add basic subnet support for component-based net composition Introduces the Subnet type (isolated sub-networks within a net) across the core types, file format, import/export, mutation layer, and UI. Adds a "Nets" subview to the left sidebar listing the root net and subnets, with a button to create new subnets. Includes a Hospital Network example demonstrating the feature. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../petrinaut/src/constants/ui-subviews.ts | 7 +- .../petrinaut/src/core/types/sdcpn.ts | 11 + .../src/examples/hospital-network.ts | 193 ++++++++++++++++++ .../petrinaut/src/file-format/import-sdcpn.ts | 34 +++ .../src/file-format/remove-visual-info.ts | 46 ++++- .../petrinaut/src/file-format/types.ts | 11 + .../petrinaut/src/state/mutation-context.ts | 5 + .../petrinaut/src/state/mutation-provider.tsx | 22 ++ .../petrinaut/src/state/sdcpn-context.ts | 1 + .../src/views/Editor/editor-view.tsx | 10 + .../panels/LeftSideBar/subviews/nets-list.tsx | 123 +++++++++++ 11 files changed, 453 insertions(+), 10 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/examples/hospital-network.ts create mode 100644 libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx diff --git a/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts b/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts index 75a801e9eb1..fc1062a73cf 100644 --- a/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts +++ b/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts @@ -11,6 +11,7 @@ import { simulationSettingsSubView } from "../views/Editor/panels/BottomPanel/su import { simulationTimelineSubView } from "../views/Editor/panels/BottomPanel/subviews/simulation-timeline"; import { differentialEquationsListSubView } from "../views/Editor/panels/LeftSideBar/subviews/differential-equations-list"; import { entitiesTreeSubView } from "../views/Editor/panels/LeftSideBar/subviews/entities-tree"; +import { netsListSubView } from "../views/Editor/panels/LeftSideBar/subviews/nets-list"; import { nodesListSubView } from "../views/Editor/panels/LeftSideBar/subviews/nodes-list"; import { parametersListSubView } from "../views/Editor/panels/LeftSideBar/subviews/parameters-list"; import { typesListSubView } from "../views/Editor/panels/LeftSideBar/subviews/types-list"; @@ -20,9 +21,13 @@ export const LEFT_SIDEBAR_SUBVIEWS: SubView[] = [ typesListSubView, differentialEquationsListSubView, parametersListSubView, + netsListSubView, ]; -export const LEFT_SIDEBAR_TREE_SUBVIEWS: SubView[] = [entitiesTreeSubView]; +export const LEFT_SIDEBAR_TREE_SUBVIEWS: SubView[] = [ + entitiesTreeSubView, + netsListSubView, +]; // Base subviews always visible in the bottom panel export const BOTTOM_PANEL_SUBVIEWS: SubView[] = [ diff --git a/libs/@hashintel/petrinaut/src/core/types/sdcpn.ts b/libs/@hashintel/petrinaut/src/core/types/sdcpn.ts index 87ad4c9e4a9..ebac09cd28b 100644 --- a/libs/@hashintel/petrinaut/src/core/types/sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/core/types/sdcpn.ts @@ -112,6 +112,16 @@ export type Scenario = { }; }; +export type Subnet = { + id: ID; + name: string; + places: Place[]; + transitions: Transition[]; + types: Color[]; + differentialEquations: DifferentialEquation[]; + parameters: Parameter[]; +}; + export type SDCPN = { places: Place[]; transitions: Transition[]; @@ -119,6 +129,7 @@ export type SDCPN = { differentialEquations: DifferentialEquation[]; parameters: Parameter[]; scenarios?: Scenario[]; + subnets?: Subnet[]; }; export type MinimalNetMetadata = { diff --git a/libs/@hashintel/petrinaut/src/examples/hospital-network.ts b/libs/@hashintel/petrinaut/src/examples/hospital-network.ts new file mode 100644 index 00000000000..1995a686a92 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/examples/hospital-network.ts @@ -0,0 +1,193 @@ +import { SNAP_GRID_SIZE } from "../constants/ui"; +import type { SDCPN } from "../core/types/sdcpn"; + +/** + * Hospital Network example — demonstrates subnets. + * + * The root net models patient flow between departments (ER → Ward → Discharge). + * A subnet models the internal triage process within the ER department. + */ +export const hospitalNetwork: { title: string; petriNetDefinition: SDCPN } = { + title: "Hospital Network", + petriNetDefinition: { + places: [ + { + id: "place__er", + name: "Emergency Room", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + showAsInitialState: true, + x: -20 * SNAP_GRID_SIZE, + y: 10 * SNAP_GRID_SIZE, + }, + { + id: "place__ward", + name: "Hospital Ward", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0 * SNAP_GRID_SIZE, + y: 10 * SNAP_GRID_SIZE, + }, + { + id: "place__discharged", + name: "Discharged", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 20 * SNAP_GRID_SIZE, + y: 10 * SNAP_GRID_SIZE, + }, + ], + transitions: [ + { + id: "transition__admit", + name: "Admit", + inputArcs: [{ placeId: "place__er", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place__ward", weight: 1 }], + lambdaType: "stochastic", + lambdaCode: + "export default Lambda((tokens, parameters) => parameters.admission_rate)", + transitionKernelCode: + 'export default TransitionKernel(() => {\n return {\n "Hospital Ward": [{}],\n };\n});', + x: -10 * SNAP_GRID_SIZE, + y: 5 * SNAP_GRID_SIZE, + }, + { + id: "transition__discharge", + name: "Discharge", + inputArcs: [{ placeId: "place__ward", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place__discharged", weight: 1 }], + lambdaType: "stochastic", + lambdaCode: + "export default Lambda((tokens, parameters) => parameters.discharge_rate)", + transitionKernelCode: + "export default TransitionKernel(() => {\n return {\n Discharged: [{}],\n };\n});", + x: 10 * SNAP_GRID_SIZE, + y: 5 * SNAP_GRID_SIZE, + }, + ], + types: [], + differentialEquations: [], + parameters: [ + { + id: "param__admission_rate", + name: "Admission Rate", + variableName: "admission_rate", + type: "real", + defaultValue: "2", + }, + { + id: "param__discharge_rate", + name: "Discharge Rate", + variableName: "discharge_rate", + type: "real", + defaultValue: "1", + }, + ], + scenarios: [ + { + id: "scenario__normal_day", + name: "Normal Day", + description: "Typical daily patient flow through the hospital.", + scenarioParameters: [], + parameterOverrides: {}, + initialState: { + type: "per_place", + content: { + place__er: "10", + place__ward: "5", + place__discharged: "0", + }, + }, + }, + ], + subnets: [ + { + id: "subnet__er_triage", + name: "ER Triage", + places: [ + { + id: "place__waiting", + name: "Waiting", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + showAsInitialState: true, + x: -15 * SNAP_GRID_SIZE, + y: 5 * SNAP_GRID_SIZE, + }, + { + id: "place__assessment", + name: "Assessment", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0 * SNAP_GRID_SIZE, + y: 5 * SNAP_GRID_SIZE, + }, + { + id: "place__treated", + name: "Treated", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 15 * SNAP_GRID_SIZE, + y: 5 * SNAP_GRID_SIZE, + }, + ], + transitions: [ + { + id: "transition__triage", + name: "Triage", + inputArcs: [ + { placeId: "place__waiting", weight: 1, type: "standard" }, + ], + outputArcs: [{ placeId: "place__assessment", weight: 1 }], + lambdaType: "stochastic", + lambdaCode: + "export default Lambda((tokens, parameters) => parameters.triage_rate)", + transitionKernelCode: + "export default TransitionKernel(() => {\n return {\n Assessment: [{}],\n };\n});", + x: -7 * SNAP_GRID_SIZE, + y: 0 * SNAP_GRID_SIZE, + }, + { + id: "transition__treat", + name: "Treat", + inputArcs: [ + { placeId: "place__assessment", weight: 1, type: "standard" }, + ], + outputArcs: [{ placeId: "place__treated", weight: 1 }], + lambdaType: "stochastic", + lambdaCode: + "export default Lambda((tokens, parameters) => parameters.treatment_rate)", + transitionKernelCode: + "export default TransitionKernel(() => {\n return {\n Treated: [{}],\n };\n});", + x: 7 * SNAP_GRID_SIZE, + y: 0 * SNAP_GRID_SIZE, + }, + ], + types: [], + differentialEquations: [], + parameters: [ + { + id: "param__triage_rate", + name: "Triage Rate", + variableName: "triage_rate", + type: "real", + defaultValue: "5", + }, + { + id: "param__treatment_rate", + name: "Treatment Rate", + variableName: "treatment_rate", + type: "real", + defaultValue: "3", + }, + ], + }, + ], + }, +}; diff --git a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts index 9ed74ff2c50..552f1e0697c 100644 --- a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts @@ -20,12 +20,23 @@ export type ImportResult = const hasMissingPositions = (sdcpn: { places: { x?: number; y?: number }[]; transitions: { x?: number; y?: number }[]; + subnets?: { + places: { x?: number; y?: number }[]; + transitions: { x?: number; y?: number }[]; + }[]; }): boolean => { for (const node of [...sdcpn.places, ...sdcpn.transitions]) { if (node.x === undefined || node.y === undefined) { return true; } } + for (const subnet of sdcpn.subnets ?? []) { + for (const node of [...subnet.places, ...subnet.transitions]) { + if (node.x === undefined || node.y === undefined) { + return true; + } + } + } return false; }; @@ -39,6 +50,11 @@ const fillMissingVisualInfo = (sdcpn: { places: Array<{ x?: number; y?: number }>; transitions: Array<{ x?: number; y?: number }>; types: Array<{ iconSlug?: string; displayColor?: string }>; + subnets?: Array<{ + places: Array<{ x?: number; y?: number }>; + transitions: Array<{ x?: number; y?: number }>; + types: Array<{ iconSlug?: string; displayColor?: string }>; + }>; }): SDCPNWithTitle => ({ ...sdcpn, @@ -57,6 +73,24 @@ const fillMissingVisualInfo = (sdcpn: { iconSlug: type.iconSlug ?? "circle", displayColor: type.displayColor ?? "#808080", })), + subnets: (sdcpn.subnets ?? []).map((subnet) => ({ + ...subnet, + places: subnet.places.map((place) => ({ + ...place, + x: place.x ?? 0, + y: place.y ?? 0, + })), + transitions: subnet.transitions.map((transition) => ({ + ...transition, + x: transition.x ?? 0, + y: transition.y ?? 0, + })), + types: subnet.types.map((type) => ({ + ...type, + iconSlug: type.iconSlug ?? "circle", + displayColor: type.displayColor ?? "#808080", + })), + })), }) as SDCPNWithTitle; /** diff --git a/libs/@hashintel/petrinaut/src/file-format/remove-visual-info.ts b/libs/@hashintel/petrinaut/src/file-format/remove-visual-info.ts index 61744ed3078..7c74d2e1ba3 100644 --- a/libs/@hashintel/petrinaut/src/file-format/remove-visual-info.ts +++ b/libs/@hashintel/petrinaut/src/file-format/remove-visual-info.ts @@ -1,14 +1,44 @@ -import type { Color, Place, SDCPN, Transition } from "../core/types/sdcpn"; +import type { + Color, + Place, + SDCPN, + Subnet, + Transition, +} from "../core/types/sdcpn"; + +type SubnetWithoutVisualInfo = Omit< + Subnet, + "places" | "transitions" | "types" +> & { + places: Array>; + transitions: Array>; + types: Array>; +}; type SDCPNWithoutVisualInfo = Omit< SDCPN, - "places" | "transitions" | "types" + "places" | "transitions" | "types" | "subnets" > & { places: Array>; transitions: Array>; types: Array>; + subnets: SubnetWithoutVisualInfo[]; }; +const stripVisualFromNodes = (nodes: { + places: Place[]; + transitions: Transition[]; + types: Color[]; +}) => ({ + places: nodes.places.map(({ x: _x, y: _y, ...place }) => place), + transitions: nodes.transitions.map( + ({ x: _x, y: _y, ...transition }) => transition, + ), + types: nodes.types.map( + ({ displayColor: _displayColor, iconSlug: _iconSlug, ...type }) => type, + ), +}); + /** * Removes graphical information from an SDCPN. * @param sdcpn - The SDCPN to process @@ -17,12 +47,10 @@ type SDCPNWithoutVisualInfo = Omit< export function removeVisualInformation(sdcpn: SDCPN): SDCPNWithoutVisualInfo { return { ...sdcpn, - places: sdcpn.places.map(({ x: _x, y: _y, ...place }) => place), - transitions: sdcpn.transitions.map( - ({ x: _x, y: _y, ...transition }) => transition, - ), - types: sdcpn.types.map( - ({ displayColor: _displayColor, iconSlug: _iconSlug, ...type }) => type, - ), + ...stripVisualFromNodes(sdcpn), + subnets: (sdcpn.subnets ?? []).map((subnet) => ({ + ...subnet, + ...stripVisualFromNodes(subnet), + })), }; } diff --git a/libs/@hashintel/petrinaut/src/file-format/types.ts b/libs/@hashintel/petrinaut/src/file-format/types.ts index 45362078ee4..6918cbe979e 100644 --- a/libs/@hashintel/petrinaut/src/file-format/types.ts +++ b/libs/@hashintel/petrinaut/src/file-format/types.ts @@ -95,6 +95,16 @@ const scenarioSchema = z.object({ initialState: initialStateSchema.default({ type: "per_place", content: {} }), }); +const subnetSchema = z.object({ + id: z.string(), + name: z.string(), + places: z.array(placeSchema), + transitions: z.array(transitionSchema), + types: z.array(colorSchema).default([]), + differentialEquations: z.array(differentialEquationSchema).default([]), + parameters: z.array(parameterSchema).default([]), +}); + const sdcpnSchema = z.object({ places: z.array(placeSchema), transitions: z.array(transitionSchema), @@ -102,6 +112,7 @@ const sdcpnSchema = z.object({ differentialEquations: z.array(differentialEquationSchema).default([]), parameters: z.array(parameterSchema).default([]), scenarios: z.array(scenarioSchema).default([]), + subnets: z.array(subnetSchema).default([]), }); const fileMetaSchema = z.object({ diff --git a/libs/@hashintel/petrinaut/src/state/mutation-context.ts b/libs/@hashintel/petrinaut/src/state/mutation-context.ts index 6bdc270e777..6ad97ee67d6 100644 --- a/libs/@hashintel/petrinaut/src/state/mutation-context.ts +++ b/libs/@hashintel/petrinaut/src/state/mutation-context.ts @@ -6,6 +6,7 @@ import type { Parameter, Place, Scenario, + Subnet, Transition, } from "../core/types/sdcpn"; import type { SelectionMap } from "./selection"; @@ -71,6 +72,8 @@ export type MutationHelperFunctions = { updateFn: (scenario: Scenario) => void, ) => void; removeScenario: (scenarioId: string) => void; + addSubnet: (subnet: Subnet) => void; + removeSubnet: (subnetId: string) => void; deleteItemsByIds: (items: SelectionMap) => void; layoutGraph: () => Promise; pasteEntities: () => Promise | null>; @@ -110,6 +113,8 @@ const DEFAULT_CONTEXT_VALUE: MutationContextValue = { addScenario: () => {}, updateScenario: () => {}, removeScenario: () => {}, + addSubnet: () => {}, + removeSubnet: () => {}, deleteItemsByIds: () => {}, layoutGraph: async () => {}, pasteEntities: async () => null, diff --git a/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx b/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx index 7eb8fe428bc..feb4fa0706b 100644 --- a/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx +++ b/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx @@ -326,6 +326,28 @@ export const MutationProvider: React.FC = ({ } }); }, + addSubnet(subnet) { + guardedMutate((sdcpn) => { + const subnets = sdcpn.subnets ?? []; + subnets.push(subnet); + // eslint-disable-next-line no-param-reassign -- mutating draft inside immer/structuredClone + sdcpn.subnets = subnets; + }); + }, + removeSubnet(subnetId) { + guardedMutate((sdcpn) => { + const subnets = sdcpn.subnets; + if (!subnets) { + return; + } + for (const [index, subnet] of subnets.entries()) { + if (subnet.id === subnetId) { + subnets.splice(index, 1); + break; + } + } + }); + }, deleteItemsByIds(items) { guardedMutate((sdcpn) => { // Partition selection by type for targeted deletion diff --git a/libs/@hashintel/petrinaut/src/state/sdcpn-context.ts b/libs/@hashintel/petrinaut/src/state/sdcpn-context.ts index 0af26d0306e..17ab4e75c2c 100644 --- a/libs/@hashintel/petrinaut/src/state/sdcpn-context.ts +++ b/libs/@hashintel/petrinaut/src/state/sdcpn-context.ts @@ -55,6 +55,7 @@ const DEFAULT_CONTEXT_VALUE: SDCPNContextValue = { types: [], differentialEquations: [], parameters: [], + subnets: [], }, readonly: true, setTitle: () => {}, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index e862a215fd4..ba4a8c4a86c 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -5,6 +5,7 @@ import { Box } from "../../components/box"; import { Stack } from "../../components/stack"; import { productionMachines } from "../../examples/broken-machines"; import { deploymentPipelineSDCPN } from "../../examples/deployment-pipeline"; +import { hospitalNetwork } from "../../examples/hospital-network"; import { satellitesSDCPN } from "../../examples/satellites"; import { probabilisticSatellitesSDCPN } from "../../examples/satellites-launcher"; import { sirModel } from "../../examples/sir-model"; @@ -137,6 +138,7 @@ export const EditorView = ({ types: [], differentialEquations: [], parameters: [], + subnets: [], }, }); clearSelection(); @@ -320,6 +322,14 @@ export const EditorView = ({ clearSelection(); }, }, + { + id: "load-example-hospital-network", + label: "Hospital Network (Subnets)", + onClick: () => { + createNewNet(hospitalNetwork); + clearSelection(); + }, + }, ], }, ] diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx new file mode 100644 index 00000000000..6be8c1ecf6a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx @@ -0,0 +1,123 @@ +import { css, cva } from "@hashintel/ds-helpers/css"; +import { use } from "react"; +import { LuNetwork } from "react-icons/lu"; +import { TbPlus } from "react-icons/tb"; +import { v4 as uuidv4 } from "uuid"; + +import { IconButton } from "../../../../../components/icon-button"; +import type { SubView } from "../../../../../components/sub-view/types"; +import { UI_MESSAGES } from "../../../../../constants/ui-messages"; +import { MutationContext } from "../../../../../state/mutation-context"; +import { SDCPNContext } from "../../../../../state/sdcpn-context"; +import { useIsReadOnly } from "../../../../../state/use-is-read-only"; + +const listStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[1px]", + mx: "-1", +}); + +const itemStyle = cva({ + base: { + display: "flex", + alignItems: "center", + gap: "1.5", + minHeight: "8", + p: "1", + borderRadius: "lg", + fontSize: "sm", + fontWeight: "medium", + color: "neutral.s115", + }, + variants: { + kind: { + root: { + fontWeight: "semibold", + }, + subnet: {}, + }, + }, +}); + +const iconStyle = css({ + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + color: "[#9ca3af]", +}); + +const ICON_SIZE = 12; + +const NetsHeaderAction: React.FC = () => { + const { + petriNetDefinition: { subnets }, + } = use(SDCPNContext); + const { addSubnet } = use(MutationContext); + const isReadOnly = useIsReadOnly(); + + const handleAddSubnet = () => { + const count = (subnets ?? []).length; + addSubnet({ + id: uuidv4(), + name: `Subnet ${count + 1}`, + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }); + }; + + return ( + + + + ); +}; + +const NetsListContent: React.FC = () => { + const { + petriNetDefinition: { subnets }, + } = use(SDCPNContext); + + return ( +
+
+ + + + Root +
+ {(subnets ?? []).map((subnet) => ( +
+ + + + {subnet.name} +
+ ))} +
+ ); +}; + +/** + * SubView definition for the Nets list. + * Shows the root net and any subnets defined in the SDCPN. + */ +export const netsListSubView: SubView = { + id: "nets-list", + title: "Nets", + tooltip: + "View the root net and its subnets. Subnets are isolated sub-networks within the net.", + component: NetsListContent, + renderHeaderAction: () => , + defaultCollapsed: false, +}; From 68ee2e6f280aef144abb52e37e0b0efcca28d8ae Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 23 Apr 2026 16:45:39 +0100 Subject: [PATCH 2/4] FE-522: Add ActiveNetContext, component mode, and cursor tooltip Introduces ActiveNetContext to decouple "which net is being viewed" from the full SDCPN. Display consumers (canvas, sidebar lists, properties panel, diagnostics) read from ActiveNetContext while export/simulation/LSP keep reading the full SDCPN. Mutations route through resolveNet() so edits target the active subnet. Adds "add-component" edition mode with componentSubnetId in EditorContext, a Component dropdown in the BottomBar toolbar (root net only), and a cursor-following tooltip showing what will be created on click. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/hooks/use-default-parameter-values.ts | 6 +- .../src/lib/calculate-graph-layout.ts | 4 +- libs/@hashintel/petrinaut/src/petrinaut.tsx | 45 +++--- .../petrinaut/src/state/active-net-context.ts | 48 ++++++ .../src/state/active-net-provider.tsx | 45 ++++++ .../petrinaut/src/state/editor-context.ts | 12 +- .../petrinaut/src/state/editor-provider.tsx | 14 +- .../src/state/mutation-provider.test.tsx | 1 + .../petrinaut/src/state/mutation-provider.tsx | 137 ++++++++++-------- .../petrinaut/src/state/sdcpn-provider.tsx | 33 +++-- .../src/state/use-selection-cleanup.ts | 5 +- .../components/BottomBar/bottom-bar.tsx | 5 +- .../components/BottomBar/toolbar-modes.tsx | 64 +++++++- .../BottomPanel/subviews/diagnostics.tsx | 4 +- .../subviews/differential-equations-list.tsx | 10 +- .../LeftSideBar/subviews/entities-tree.tsx | 6 +- .../panels/LeftSideBar/subviews/nets-list.tsx | 50 ++++++- .../LeftSideBar/subviews/nodes-list.tsx | 6 +- .../LeftSideBar/subviews/parameters-list.tsx | 10 +- .../LeftSideBar/subviews/types-list.tsx | 10 +- .../Editor/panels/PropertiesPanel/panel.tsx | 4 +- .../place-properties/subviews/main.tsx | 7 +- .../views/SDCPN/components/cursor-tooltip.tsx | 81 +++++++++++ .../SDCPN/hooks/use-sdcpn-to-react-flow.ts | 5 +- .../petrinaut/src/views/SDCPN/sdcpn-view.tsx | 9 +- 25 files changed, 478 insertions(+), 143 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/state/active-net-context.ts create mode 100644 libs/@hashintel/petrinaut/src/state/active-net-provider.tsx create mode 100644 libs/@hashintel/petrinaut/src/views/SDCPN/components/cursor-tooltip.tsx diff --git a/libs/@hashintel/petrinaut/src/hooks/use-default-parameter-values.ts b/libs/@hashintel/petrinaut/src/hooks/use-default-parameter-values.ts index 5a1d3c24d10..2c69e099d22 100644 --- a/libs/@hashintel/petrinaut/src/hooks/use-default-parameter-values.ts +++ b/libs/@hashintel/petrinaut/src/hooks/use-default-parameter-values.ts @@ -1,7 +1,7 @@ import { use, useMemo } from "react"; import type { Parameter } from "../core/types/sdcpn"; -import { SDCPNContext } from "../state/sdcpn-context"; +import { ActiveNetContext } from "../state/active-net-context"; /** * A type-safe representation of parameter values that can be used in the simulation. @@ -71,8 +71,8 @@ export function mergeParameterValues( */ export function useDefaultParameterValues(): DefaultParameterValues { const { - petriNetDefinition: { parameters }, - } = use(SDCPNContext); + activeNet: { parameters }, + } = use(ActiveNetContext); return useMemo(() => { return deriveDefaultParameterValues(parameters); diff --git a/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts b/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts index cb116402959..e8dde097347 100644 --- a/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts +++ b/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts @@ -1,7 +1,7 @@ import type { ElkNode } from "elkjs"; import ELK from "elkjs"; -import type { SDCPN } from "../core/types/sdcpn"; +import type { Place, Transition } from "../core/types/sdcpn"; /** * @see https://eclipse.dev/elk/documentation/tooldevelopers @@ -37,7 +37,7 @@ export type NodePosition = { * @returns A promise that resolves to a map of node IDs to their calculated positions */ export const calculateGraphLayout = async ( - sdcpn: SDCPN, + sdcpn: { places: Place[]; transitions: Transition[] }, dimensions: { place: { width: number; height: number }; transition: { width: number; height: number }; diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index f5343cece23..09b672690da 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -21,6 +21,7 @@ import { MonacoProvider } from "./monaco/provider"; import { NotificationsProvider } from "./notifications/notifications-provider"; import { PlaybackProvider } from "./playback/provider"; import { SimulationProvider } from "./simulation/provider"; +import { ActiveNetProvider } from "./state/active-net-provider"; import { EditorProvider } from "./state/editor-provider"; import { MutationProvider } from "./state/mutation-provider"; import { SDCPNProvider } from "./state/sdcpn-provider"; @@ -127,26 +128,30 @@ export const Petrinaut: FunctionComponent = ({ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/libs/@hashintel/petrinaut/src/state/active-net-context.ts b/libs/@hashintel/petrinaut/src/state/active-net-context.ts new file mode 100644 index 00000000000..e60fe28f466 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/state/active-net-context.ts @@ -0,0 +1,48 @@ +import { createContext } from "react"; + +import type { + Color, + DifferentialEquation, + Parameter, + Place, + Transition, +} from "../core/types/sdcpn"; + +/** + * The shape of the currently active net (root or a selected subnet). + * This is the subset of SDCPN fields that vary depending on which net is viewed. + */ +export type ActiveNetDefinition = { + places: Place[]; + transitions: Transition[]; + types: Color[]; + differentialEquations: DifferentialEquation[]; + parameters: Parameter[]; +}; + +export type ActiveNetContextValue = { + /** The currently viewed net's definition (root or subnet). */ + activeNet: ActiveNetDefinition; + /** The ID of the active subnet, or null when viewing the root net. */ + activeSubnetId: string | null; + /** Switch the active view to a subnet (by ID) or back to root (null). */ + setActiveSubnetId: (subnetId: string | null) => void; +}; + +const DEFAULT_ACTIVE_NET: ActiveNetDefinition = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +const DEFAULT_CONTEXT_VALUE: ActiveNetContextValue = { + activeNet: DEFAULT_ACTIVE_NET, + activeSubnetId: null, + setActiveSubnetId: () => {}, +}; + +export const ActiveNetContext = createContext( + DEFAULT_CONTEXT_VALUE, +); diff --git a/libs/@hashintel/petrinaut/src/state/active-net-provider.tsx b/libs/@hashintel/petrinaut/src/state/active-net-provider.tsx new file mode 100644 index 00000000000..2ffc0a4575c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/state/active-net-provider.tsx @@ -0,0 +1,45 @@ +import { use, useState } from "react"; + +import { + ActiveNetContext, + type ActiveNetContextValue, +} from "./active-net-context"; +import { SDCPNContext } from "./sdcpn-context"; + +/** + * Derives the active net from the full SDCPN based on `activeSubnetId`. + * + * When `activeSubnetId` is null the root net is active. + * When it points to a subnet, that subnet's definition is exposed instead. + * If the selected subnet no longer exists (e.g. it was deleted), falls back to root. + */ +export const ActiveNetProvider: React.FC = ({ + children, +}) => { + const { petriNetDefinition } = use(SDCPNContext); + const [activeSubnetId, setActiveSubnetId] = useState(null); + + const subnet = + activeSubnetId !== null + ? petriNetDefinition.subnets?.find((s) => s.id === activeSubnetId) + : undefined; + + // Fall back to root if the subnet was deleted + const resolvedSubnetId = subnet ? activeSubnetId : null; + + const activeNet = subnet ?? { + places: petriNetDefinition.places, + transitions: petriNetDefinition.transitions, + types: petriNetDefinition.types, + differentialEquations: petriNetDefinition.differentialEquations, + parameters: petriNetDefinition.parameters, + }; + + const value: ActiveNetContextValue = { + activeNet, + activeSubnetId: resolvedSubnetId, + setActiveSubnetId, + }; + + return {children}; +}; diff --git a/libs/@hashintel/petrinaut/src/state/editor-context.ts b/libs/@hashintel/petrinaut/src/state/editor-context.ts index dac7a581b7e..677155ac6c6 100644 --- a/libs/@hashintel/petrinaut/src/state/editor-context.ts +++ b/libs/@hashintel/petrinaut/src/state/editor-context.ts @@ -13,7 +13,11 @@ export type DraggingStateByNodeId = Record< >; type EditorGlobalMode = "edit" | "simulate"; -type EditorEditionMode = "cursor" | "add-place" | "add-transition"; +type EditorEditionMode = + | "cursor" + | "add-place" + | "add-transition" + | "add-component"; export type CursorMode = "select" | "pan"; export type BottomPanelTab = | "diagnostics" @@ -38,6 +42,8 @@ export type EditorState = { selection: SelectionMap; /** Whether any items are currently selected. */ hasSelection: boolean; + /** The subnet ID to instantiate when `editionMode` is `"add-component"`. */ + componentSubnetId: string | null; draggingStateByNodeId: DraggingStateByNodeId; timelineChartType: TimelineChartType; isPanelAnimating: boolean; @@ -50,6 +56,8 @@ export type EditorState = { export type EditorActions = { setGlobalMode: (mode: EditorGlobalMode) => void; setEditionMode: (mode: EditorEditionMode) => void; + /** Enter `"add-component"` mode for the given subnet. */ + setAddComponentMode: (subnetId: string) => void; setCursorMode: (mode: CursorMode) => void; setLeftSidebarOpen: (isOpen: boolean) => void; setLeftSidebarWidth: (width: number) => void; @@ -96,6 +104,7 @@ export const initialEditorState: EditorState = { activeBottomPanelTab: "diagnostics", selection: new Map(), hasSelection: false, + componentSubnetId: null, draggingStateByNodeId: {}, timelineChartType: "run", isPanelAnimating: false, @@ -106,6 +115,7 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = { ...initialEditorState, setGlobalMode: () => {}, setEditionMode: () => {}, + setAddComponentMode: () => {}, setCursorMode: () => {}, setLeftSidebarOpen: () => {}, setLeftSidebarWidth: () => {}, diff --git a/libs/@hashintel/petrinaut/src/state/editor-provider.tsx b/libs/@hashintel/petrinaut/src/state/editor-provider.tsx index f916c0fa9f5..60f9b0df13a 100644 --- a/libs/@hashintel/petrinaut/src/state/editor-provider.tsx +++ b/libs/@hashintel/petrinaut/src/state/editor-provider.tsx @@ -83,7 +83,19 @@ export const EditorProvider: React.FC = ({ children }) => { setGlobalMode: (mode) => setState((prev) => ({ ...prev, globalMode: mode })), setEditionMode: (mode) => - setState((prev) => ({ ...prev, editionMode: mode })), + setState((prev) => ({ + ...prev, + editionMode: mode, + // Clear componentSubnetId when leaving add-component mode + componentSubnetId: + mode === "add-component" ? prev.componentSubnetId : null, + })), + setAddComponentMode: (subnetId) => + setState((prev) => ({ + ...prev, + editionMode: "add-component" as const, + componentSubnetId: subnetId, + })), setCursorMode: (mode) => setState((prev) => ({ ...prev, cursorMode: mode })), setLeftSidebarOpen: (isOpen) => { diff --git a/libs/@hashintel/petrinaut/src/state/mutation-provider.test.tsx b/libs/@hashintel/petrinaut/src/state/mutation-provider.test.tsx index 37701fa9a7b..901b0597340 100644 --- a/libs/@hashintel/petrinaut/src/state/mutation-provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/state/mutation-provider.test.tsx @@ -73,6 +73,7 @@ const DEFAULT_EDITOR: EditorContextValue = { ...initialEditorState, setGlobalMode: () => {}, setEditionMode: () => {}, + setAddComponentMode: () => {}, setCursorMode: () => {}, setLeftSidebarOpen: () => {}, setLeftSidebarWidth: () => {}, diff --git a/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx b/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx index feb4fa0706b..63c8b3c0ab8 100644 --- a/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx +++ b/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx @@ -1,12 +1,13 @@ import { use } from "react"; import { pasteFromClipboard } from "../clipboard/clipboard"; -import type { MutateSDCPN, SDCPN } from "../core/types/sdcpn"; +import type { MutateSDCPN, SDCPN, Subnet } from "../core/types/sdcpn"; import { calculateGraphLayout } from "../lib/calculate-graph-layout"; import { classicNodeDimensions, compactNodeDimensions, } from "../views/SDCPN/styles/styling"; +import { ActiveNetContext } from "./active-net-context"; import { MutationContext, type MutationContextValue } from "./mutation-context"; import { generateArcId, SDCPNContext } from "./sdcpn-context"; import { useIsReadOnly } from "./use-is-read-only"; @@ -20,7 +21,8 @@ export const MutationProvider: React.FC = ({ mutatePetriNetDefinition, children, }) => { - const { petriNetDefinition, readonly } = use(SDCPNContext); + const { readonly } = use(SDCPNContext); + const { activeSubnetId, activeNet } = use(ActiveNetContext); const { compactNodes } = use(UserSettingsContext); const isReadOnly = useIsReadOnly(); @@ -35,6 +37,18 @@ export const MutationProvider: React.FC = ({ mutatePetriNetDefinition(fn); } + /** + * Resolve the active net target (root or subnet) inside a mutation callback. + * Because mutations operate on the mutable draft, mutating the returned + * reference modifies the correct part of the SDCPN tree. + */ + function resolveNet(sdcpn: SDCPN): SDCPN | Subnet { + if (activeSubnetId === null) { + return sdcpn; + } + return sdcpn.subnets?.find((s) => s.id === activeSubnetId) ?? sdcpn; + } + /** * Scenario CRUD is allowed even in simulate mode (the Simulate panel is * where scenarios are managed). Only true `readonly` blocks them. @@ -49,12 +63,12 @@ export const MutationProvider: React.FC = ({ const value: MutationContextValue = { addPlace(place) { guardedMutate((sdcpn) => { - sdcpn.places.push(place); + resolveNet(sdcpn).places.push(place); }); }, updatePlace(placeId, updateFn) { guardedMutate((sdcpn) => { - for (const place of sdcpn.places) { + for (const place of resolveNet(sdcpn).places) { if (place.id === placeId) { updateFn(place); break; @@ -64,7 +78,7 @@ export const MutationProvider: React.FC = ({ }, updatePlacePosition(placeId, position) { guardedMutate((sdcpn) => { - for (const place of sdcpn.places) { + for (const place of resolveNet(sdcpn).places) { if (place.id === placeId) { place.x = position.x; place.y = position.y; @@ -75,12 +89,13 @@ export const MutationProvider: React.FC = ({ }, removePlace(placeId) { guardedMutate((sdcpn) => { - for (const [placeIndex, place] of sdcpn.places.entries()) { + const net = resolveNet(sdcpn); + for (const [placeIndex, place] of net.places.entries()) { if (place.id === placeId) { - sdcpn.places.splice(placeIndex, 1); + net.places.splice(placeIndex, 1); // Iterate backwards to avoid skipping entries when splicing - for (const transition of sdcpn.transitions) { + for (const transition of net.transitions) { for (let i = transition.inputArcs.length - 1; i >= 0; i--) { if (transition.inputArcs[i]!.placeId === placeId) { transition.inputArcs.splice(i, 1); @@ -99,12 +114,12 @@ export const MutationProvider: React.FC = ({ }, addTransition(transition) { guardedMutate((sdcpn) => { - sdcpn.transitions.push(transition); + resolveNet(sdcpn).transitions.push(transition); }); }, updateTransition(transitionId, updateFn) { guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { + for (const transition of resolveNet(sdcpn).transitions) { if (transition.id === transitionId) { updateFn(transition); break; @@ -114,7 +129,7 @@ export const MutationProvider: React.FC = ({ }, updateTransitionPosition(transitionId, position) { guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { + for (const transition of resolveNet(sdcpn).transitions) { if (transition.id === transitionId) { transition.x = position.x; transition.y = position.y; @@ -125,9 +140,10 @@ export const MutationProvider: React.FC = ({ }, removeTransition(transitionId) { guardedMutate((sdcpn) => { - for (const [index, transition] of sdcpn.transitions.entries()) { + const net = resolveNet(sdcpn); + for (const [index, transition] of net.transitions.entries()) { if (transition.id === transitionId) { - sdcpn.transitions.splice(index, 1); + net.transitions.splice(index, 1); break; } } @@ -135,7 +151,7 @@ export const MutationProvider: React.FC = ({ }, addArc(transitionId, arcDirection, placeId, weight) { guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { + for (const transition of resolveNet(sdcpn).transitions) { if (transition.id === transitionId) { if (arcDirection === "input") { transition["inputArcs"].push({ @@ -153,7 +169,7 @@ export const MutationProvider: React.FC = ({ }, removeArc(transitionId, arcDirection, placeId) { guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { + for (const transition of resolveNet(sdcpn).transitions) { if (transition.id === transitionId) { for (const [index, arc] of transition[ arcDirection === "input" ? "inputArcs" : "outputArcs" @@ -172,7 +188,7 @@ export const MutationProvider: React.FC = ({ }, updateArcWeight(transitionId, arcDirection, placeId, weight) { guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { + for (const transition of resolveNet(sdcpn).transitions) { if (transition.id === transitionId) { for (const arc of transition[ arcDirection === "input" ? "inputArcs" : "outputArcs" @@ -189,7 +205,7 @@ export const MutationProvider: React.FC = ({ }, updateArcType(transitionId, placeId, type) { guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { + for (const transition of resolveNet(sdcpn).transitions) { if (transition.id === transitionId) { for (const arc of transition["inputArcs"]) { if (arc.placeId === placeId) { @@ -204,12 +220,12 @@ export const MutationProvider: React.FC = ({ }, addType(type) { guardedMutate((sdcpn) => { - sdcpn.types.push(type); + resolveNet(sdcpn).types.push(type); }); }, updateType(typeId, updateFn) { guardedMutate((sdcpn) => { - for (const type of sdcpn.types) { + for (const type of resolveNet(sdcpn).types) { if (type.id === typeId) { updateFn(type); break; @@ -219,19 +235,20 @@ export const MutationProvider: React.FC = ({ }, removeType(typeId) { guardedMutate((sdcpn) => { - for (const [index, type] of sdcpn.types.entries()) { + const net = resolveNet(sdcpn); + for (const [index, type] of net.types.entries()) { if (type.id === typeId) { - sdcpn.types.splice(index, 1); + net.types.splice(index, 1); break; } } // Clear dangling colorId references - for (const place of sdcpn.places) { + for (const place of net.places) { if (place.colorId === typeId) { place.colorId = null; } } - for (const equation of sdcpn.differentialEquations) { + for (const equation of net.differentialEquations) { if (equation.colorId === typeId) { equation.colorId = ""; } @@ -240,12 +257,12 @@ export const MutationProvider: React.FC = ({ }, addDifferentialEquation(equation) { guardedMutate((sdcpn) => { - sdcpn.differentialEquations.push(equation); + resolveNet(sdcpn).differentialEquations.push(equation); }); }, updateDifferentialEquation(equationId, updateFn) { guardedMutate((sdcpn) => { - for (const equation of sdcpn.differentialEquations) { + for (const equation of resolveNet(sdcpn).differentialEquations) { if (equation.id === equationId) { updateFn(equation); break; @@ -255,14 +272,15 @@ export const MutationProvider: React.FC = ({ }, removeDifferentialEquation(equationId) { guardedMutate((sdcpn) => { - for (const [index, equation] of sdcpn.differentialEquations.entries()) { + const net = resolveNet(sdcpn); + for (const [index, equation] of net.differentialEquations.entries()) { if (equation.id === equationId) { - sdcpn.differentialEquations.splice(index, 1); + net.differentialEquations.splice(index, 1); break; } } // Clear dangling differentialEquationId references - for (const place of sdcpn.places) { + for (const place of net.places) { if (place.differentialEquationId === equationId) { place.differentialEquationId = null; } @@ -271,12 +289,12 @@ export const MutationProvider: React.FC = ({ }, addParameter(parameter) { guardedMutate((sdcpn) => { - sdcpn.parameters.push(parameter); + resolveNet(sdcpn).parameters.push(parameter); }); }, updateParameter(parameterId, updateFn) { guardedMutate((sdcpn) => { - for (const parameter of sdcpn.parameters) { + for (const parameter of resolveNet(sdcpn).parameters) { if (parameter.id === parameterId) { updateFn(parameter); break; @@ -286,9 +304,10 @@ export const MutationProvider: React.FC = ({ }, removeParameter(parameterId) { guardedMutate((sdcpn) => { - for (const [index, parameter] of sdcpn.parameters.entries()) { + const net = resolveNet(sdcpn); + for (const [index, parameter] of net.parameters.entries()) { if (parameter.id === parameterId) { - sdcpn.parameters.splice(index, 1); + net.parameters.splice(index, 1); break; } } @@ -350,6 +369,8 @@ export const MutationProvider: React.FC = ({ }, deleteItemsByIds(items) { guardedMutate((sdcpn) => { + const net = resolveNet(sdcpn); + // Partition selection by type for targeted deletion const placeIds = new Set(); const transitionIds = new Set(); @@ -388,10 +409,10 @@ export const MutationProvider: React.FC = ({ placeIds.size > 0 || transitionIds.size > 0 || arcIds.size > 0; if (hasCanvasDeletes) { - for (let i = sdcpn.transitions.length - 1; i >= 0; i--) { - const transition = sdcpn.transitions[i]!; + for (let i = net.transitions.length - 1; i >= 0; i--) { + const transition = net.transitions[i]!; if (transitionIds.has(transition.id)) { - sdcpn.transitions.splice(i, 1); + net.transitions.splice(i, 1); continue; } @@ -428,26 +449,26 @@ export const MutationProvider: React.FC = ({ } } - for (let i = sdcpn.places.length - 1; i >= 0; i--) { - if (placeIds.has(sdcpn.places[i]!.id)) { - sdcpn.places.splice(i, 1); + for (let i = net.places.length - 1; i >= 0; i--) { + if (placeIds.has(net.places[i]!.id)) { + net.places.splice(i, 1); } } } if (typeIds.size > 0) { - for (let i = sdcpn.types.length - 1; i >= 0; i--) { - if (typeIds.has(sdcpn.types[i]!.id)) { - sdcpn.types.splice(i, 1); + for (let i = net.types.length - 1; i >= 0; i--) { + if (typeIds.has(net.types[i]!.id)) { + net.types.splice(i, 1); } } // Clear dangling colorId references on places and equations - for (const place of sdcpn.places) { + for (const place of net.places) { if (place.colorId && typeIds.has(place.colorId)) { place.colorId = null; } } - for (const equation of sdcpn.differentialEquations) { + for (const equation of net.differentialEquations) { if (typeIds.has(equation.colorId)) { equation.colorId = ""; } @@ -455,13 +476,13 @@ export const MutationProvider: React.FC = ({ } if (equationIds.size > 0) { - for (let i = sdcpn.differentialEquations.length - 1; i >= 0; i--) { - if (equationIds.has(sdcpn.differentialEquations[i]!.id)) { - sdcpn.differentialEquations.splice(i, 1); + for (let i = net.differentialEquations.length - 1; i >= 0; i--) { + if (equationIds.has(net.differentialEquations[i]!.id)) { + net.differentialEquations.splice(i, 1); } } // Clear dangling differentialEquationId references on places - for (const place of sdcpn.places) { + for (const place of net.places) { if ( place.differentialEquationId && equationIds.has(place.differentialEquationId) @@ -472,9 +493,9 @@ export const MutationProvider: React.FC = ({ } if (parameterIds.size > 0) { - for (let i = sdcpn.parameters.length - 1; i >= 0; i--) { - if (parameterIds.has(sdcpn.parameters[i]!.id)) { - sdcpn.parameters.splice(i, 1); + for (let i = net.parameters.length - 1; i >= 0; i--) { + if (parameterIds.has(net.parameters[i]!.id)) { + net.parameters.splice(i, 1); } } } @@ -485,16 +506,17 @@ export const MutationProvider: React.FC = ({ return; } - const sdcpn = petriNetDefinition; + const net = activeNet; - if (sdcpn.places.length === 0 && sdcpn.transitions.length === 0) { + if (net.places.length === 0 && net.transitions.length === 0) { return; } - const positions = await calculateGraphLayout(sdcpn, dimensions); + const positions = await calculateGraphLayout(net, dimensions); guardedMutate((sdcpnToMutate) => { - for (const place of sdcpnToMutate.places) { + const target = resolveNet(sdcpnToMutate); + for (const place of target.places) { const position = positions[place.id]; if (position) { if (place.x !== position.x) { @@ -506,7 +528,7 @@ export const MutationProvider: React.FC = ({ } } - for (const transition of sdcpnToMutate.transitions) { + for (const transition of target.transitions) { const position = positions[transition.id]; if (position) { if (transition.x !== position.x) { @@ -527,9 +549,10 @@ export const MutationProvider: React.FC = ({ }, commitNodePositions(commits) { guardedMutate((sdcpn) => { + const net = resolveNet(sdcpn); for (const { id, itemType, position } of commits) { if (itemType === "place") { - for (const place of sdcpn.places) { + for (const place of net.places) { if (place.id === id) { place.x = position.x; place.y = position.y; @@ -537,7 +560,7 @@ export const MutationProvider: React.FC = ({ } } } else { - for (const transition of sdcpn.transitions) { + for (const transition of net.transitions) { if (transition.id === id) { transition.x = position.x; transition.y = position.y; diff --git a/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx b/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx index 469b62b4b4d..1f0ba9eb33f 100644 --- a/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx +++ b/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx @@ -18,24 +18,29 @@ export const SDCPNProvider: React.FC< return "arc"; } - if (sdcpn.types.some((type) => type.id === id)) { - return "type"; - } + // Search root net and all subnets + const nets = [sdcpn, ...(sdcpn.subnets ?? [])]; - if (sdcpn.parameters.some((parameter) => parameter.id === id)) { - return "parameter"; - } + for (const net of nets) { + if (net.types.some((type) => type.id === id)) { + return "type"; + } - if (sdcpn.differentialEquations.some((equation) => equation.id === id)) { - return "differentialEquation"; - } + if (net.parameters.some((parameter) => parameter.id === id)) { + return "parameter"; + } - if (sdcpn.places.some((place) => place.id === id)) { - return "place"; - } + if (net.differentialEquations.some((equation) => equation.id === id)) { + return "differentialEquation"; + } + + if (net.places.some((place) => place.id === id)) { + return "place"; + } - if (sdcpn.transitions.some((transition) => transition.id === id)) { - return "transition"; + if (net.transitions.some((transition) => transition.id === id)) { + return "transition"; + } } return null; diff --git a/libs/@hashintel/petrinaut/src/state/use-selection-cleanup.ts b/libs/@hashintel/petrinaut/src/state/use-selection-cleanup.ts index b7f770f9e94..2ca43d40844 100644 --- a/libs/@hashintel/petrinaut/src/state/use-selection-cleanup.ts +++ b/libs/@hashintel/petrinaut/src/state/use-selection-cleanup.ts @@ -1,14 +1,15 @@ import { use, useEffect } from "react"; +import { ActiveNetContext } from "./active-net-context"; import { EditorContext } from "./editor-context"; -import { generateArcId, SDCPNContext } from "./sdcpn-context"; +import { generateArcId } from "./sdcpn-context"; import type { SelectionMap } from "./selection"; /** * Reactively removes stale IDs from the selection when items are deleted from the SDCPN. */ export function useSelectionCleanup() { - const { petriNetDefinition } = use(SDCPNContext); + const { activeNet: petriNetDefinition } = use(ActiveNetContext); const { selection, setSelection } = use(EditorContext); useEffect(() => { diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx index 4b0a859bc02..f27eae13aa1 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx @@ -95,10 +95,7 @@ export const BottomBar: React.FC = ({ // Fallback to 'pan' mode when switching to simulate mode if mutative mode useEffect(() => { - if ( - mode === "simulate" && - (editionMode === "add-place" || editionMode === "add-transition") - ) { + if (mode === "simulate" && editionMode !== "cursor") { onEditionModeChange("cursor"); } }, [mode, editionMode, onEditionModeChange]); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/toolbar-modes.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/toolbar-modes.tsx index fc619347290..752b6ae9009 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/toolbar-modes.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/toolbar-modes.tsx @@ -1,10 +1,18 @@ import { css, cva } from "@hashintel/ds-helpers/css"; +import { use } from "react"; import { FaChevronDown, FaRegHand } from "react-icons/fa6"; +import { IoCubeOutline } from "react-icons/io5"; import { LuMousePointerClick } from "react-icons/lu"; -import { TbCirclePlus2, TbSquarePlus2 } from "react-icons/tb"; +import { TbCirclePlus2, TbHexagonPlus2, TbSquarePlus2 } from "react-icons/tb"; import { Menu, type MenuItem } from "../../../../components/menu"; -import type { CursorMode, EditorState } from "../../../../state/editor-context"; +import { ActiveNetContext } from "../../../../state/active-net-context"; +import { + EditorContext, + type CursorMode, + type EditorState, +} from "../../../../state/editor-context"; +import { SDCPNContext } from "../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; import { ToolbarButton } from "./toolbar-button"; import { ToolbarDivider } from "./toolbar-divider"; @@ -110,6 +118,55 @@ const CursorModeDropdown: React.FC<{ ); }; +const ComponentDropdown: React.FC<{ + editionMode: EditorEditionMode; +}> = ({ editionMode }) => { + const { + petriNetDefinition: { subnets }, + } = use(SDCPNContext); + const { setAddComponentMode, componentSubnetId } = use(EditorContext); + + const items: MenuItem[] = (subnets ?? []).map((subnet) => ({ + id: subnet.id, + icon: , + label: subnet.name, + selected: + editionMode === "add-component" && componentSubnetId === subnet.id, + onClick: () => { + setAddComponentMode(subnet.id); + }, + })); + + if (items.length === 0) { + items.push({ + id: "empty", + label: "No subnets defined", + disabled: true, + }); + } + + const isActive = + editionMode === "add-component" && componentSubnetId !== null; + + return ( + + + + + } + items={items} + placement="top" + animated + /> + ); +}; + interface ToolbarModesProps { editionMode: EditorEditionMode; onEditionModeChange: (mode: EditorEditionMode) => void; @@ -124,6 +181,8 @@ export const ToolbarModes: React.FC = ({ onCursorModeChange, }) => { const isReadOnly = useIsReadOnly(); + const { activeSubnetId } = use(ActiveNetContext); + const isRootNet = activeSubnetId === null; return ( <> @@ -164,6 +223,7 @@ export const ToolbarModes: React.FC = ({ > + {isRootNet && } )} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/diagnostics.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/diagnostics.tsx index 1898a39cbf8..9147300b77d 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/diagnostics.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/diagnostics.tsx @@ -8,6 +8,7 @@ import type { SubView } from "../../../../../components/sub-view/types"; import { LanguageClientContext } from "../../../../../lsp/context"; import { parseDocumentUri } from "../../../../../monaco/editor-paths"; import { SimulationContext } from "../../../../../simulation/context"; +import { ActiveNetContext } from "../../../../../state/active-net-context"; import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import type { SelectionItemType } from "../../../../../state/selection"; @@ -157,7 +158,8 @@ const DiagnosticsContent: React.FC = () => { const { diagnosticsByUri, totalDiagnosticsCount } = use( LanguageClientContext, ); - const { petriNetDefinition, getItemType } = use(SDCPNContext); + const { getItemType } = use(SDCPNContext); + const { activeNet: petriNetDefinition } = use(ActiveNetContext); const { selectItem, setGlobalMode } = use(EditorContext); const { state: simulationState, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index dd18c4439e8..7e8d92ba17e 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -9,7 +9,7 @@ import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { DEFAULT_DIFFERENTIAL_EQUATION_CODE } from "../../../../../core/default-codes"; import { EditorContext } from "../../../../../state/editor-context"; import { MutationContext } from "../../../../../state/mutation-context"; -import { SDCPNContext } from "../../../../../state/sdcpn-context"; +import { ActiveNetContext } from "../../../../../state/active-net-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; import { RowMenu, @@ -21,8 +21,8 @@ import { */ export const DifferentialEquationsSectionHeaderAction: React.FC = () => { const { - petriNetDefinition: { types, differentialEquations }, - } = use(SDCPNContext); + activeNet: { types, differentialEquations }, + } = use(ActiveNetContext); const { addDifferentialEquation } = use(MutationContext); const { selectItem } = use(EditorContext); @@ -87,8 +87,8 @@ export const differentialEquationsListSubView: SubView = }, useItems: () => { const { - petriNetDefinition: { differentialEquations }, - } = use(SDCPNContext); + activeNet: { differentialEquations }, + } = use(ActiveNetContext); return differentialEquations.map((eq) => ({ ...eq, icon: DifferentialEquationIcon, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx index 06043b95b0d..7178d32fc81 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx @@ -13,7 +13,7 @@ import { } from "../../../../../constants/entity-icons"; import { EditorContext } from "../../../../../state/editor-context"; import { MutationContext } from "../../../../../state/mutation-context"; -import { SDCPNContext } from "../../../../../state/sdcpn-context"; +import { ActiveNetContext } from "../../../../../state/active-net-context"; import type { SelectionItem } from "../../../../../state/selection"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; import { DifferentialEquationsSectionHeaderAction } from "./differential-equations-list"; @@ -94,14 +94,14 @@ const EntityRowMenu: React.FC<{ item: EntityTreeItem }> = ({ item }) => { function useEntityTreeItems(): EntityTreeItem[] { const { - petriNetDefinition: { + activeNet: { places, transitions, types, differentialEquations, parameters, }, - } = use(SDCPNContext); + } = use(ActiveNetContext); return [ { diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx index 6be8c1ecf6a..cefba563ed9 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx @@ -7,6 +7,8 @@ import { v4 as uuidv4 } from "uuid"; import { IconButton } from "../../../../../components/icon-button"; import type { SubView } from "../../../../../components/sub-view/types"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; +import { ActiveNetContext } from "../../../../../state/active-net-context"; +import { EditorContext } from "../../../../../state/editor-context"; import { MutationContext } from "../../../../../state/mutation-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; @@ -29,13 +31,21 @@ const itemStyle = cva({ fontSize: "sm", fontWeight: "medium", color: "neutral.s115", + cursor: "pointer", + transition: "[background-color 100ms ease-out]", + _hover: { + backgroundColor: "neutral.bg.surface.hover", + }, }, variants: { - kind: { - root: { + active: { + true: { + backgroundColor: "blue.s30", fontWeight: "semibold", + _hover: { + backgroundColor: "blue.s40", + }, }, - subnet: {}, }, }, }); @@ -87,17 +97,47 @@ const NetsListContent: React.FC = () => { const { petriNetDefinition: { subnets }, } = use(SDCPNContext); + const { activeSubnetId, setActiveSubnetId } = use(ActiveNetContext); + const { clearSelection } = use(EditorContext); + + const handleSelect = (subnetId: string | null) => { + setActiveSubnetId(subnetId); + clearSelection(); + }; return (
-
+
handleSelect(null)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + handleSelect(null); + } + }} + role="option" + aria-selected={activeSubnetId === null} + tabIndex={0} + > Root
{(subnets ?? []).map((subnet) => ( -
+
handleSelect(subnet.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + handleSelect(subnet.id); + } + }} + role="option" + aria-selected={activeSubnetId === subnet.id} + tabIndex={0} + > diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx index dc3f7de90ea..2c9586f8642 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx @@ -5,7 +5,7 @@ import { PlaceFilledIcon, TransitionFilledIcon, } from "../../../../../constants/entity-icons"; -import { SDCPNContext } from "../../../../../state/sdcpn-context"; +import { ActiveNetContext } from "../../../../../state/active-net-context"; import { createFilterableListSubView } from "./filterable-list-sub-view"; interface NodeItem { @@ -29,8 +29,8 @@ export const nodesListSubView: SubView = createFilterableListSubView({ }, useItems: () => { const { - petriNetDefinition: { places, transitions }, - } = use(SDCPNContext); + activeNet: { places, transitions }, + } = use(ActiveNetContext); return [ ...places.map((place) => ({ diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index 0419cb56485..b83ab61c5d3 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -9,7 +9,7 @@ import { ParameterIcon } from "../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { EditorContext } from "../../../../../state/editor-context"; import { MutationContext } from "../../../../../state/mutation-context"; -import { SDCPNContext } from "../../../../../state/sdcpn-context"; +import { ActiveNetContext } from "../../../../../state/active-net-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; import { RowMenu, @@ -29,8 +29,8 @@ const parameterVarNameStyle = css({ */ export const ParametersHeaderAction: React.FC = () => { const { - petriNetDefinition: { parameters }, - } = use(SDCPNContext); + activeNet: { parameters }, + } = use(ActiveNetContext); const { addParameter } = use(MutationContext); const { selectItem } = use(EditorContext); @@ -103,8 +103,8 @@ export const parametersListSubView: SubView = createFilterableListSubView({ }, useItems: () => { const { - petriNetDefinition: { parameters }, - } = use(SDCPNContext); + activeNet: { parameters }, + } = use(ActiveNetContext); return parameters.map((param) => ({ ...param, icon: ParameterIcon, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index 8ed05de4104..c135a977be3 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -7,7 +7,7 @@ import { TokenTypeIcon } from "../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { EditorContext } from "../../../../../state/editor-context"; import { MutationContext } from "../../../../../state/mutation-context"; -import { SDCPNContext } from "../../../../../state/sdcpn-context"; +import { ActiveNetContext } from "../../../../../state/active-net-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; import { RowMenu, @@ -63,8 +63,8 @@ function getNextTypeNumber(existingNames: string[]): number { */ export const TypesSectionHeaderAction: React.FC = () => { const { - petriNetDefinition: { types }, - } = use(SDCPNContext); + activeNet: { types }, + } = use(ActiveNetContext); const { addType } = use(MutationContext); const { selectItem } = use(EditorContext); @@ -140,8 +140,8 @@ export const typesListSubView: SubView = createFilterableListSubView({ }, useItems: () => { const { - petriNetDefinition: { types }, - } = use(SDCPNContext); + activeNet: { types }, + } = use(ActiveNetContext); return types.map((type) => ({ ...type, icon: TokenTypeIcon, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/panel.tsx index d375382e9c4..9f18db881cd 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/panel.tsx @@ -10,7 +10,7 @@ import { } from "../../../../constants/ui"; import { EditorContext } from "../../../../state/editor-context"; import { MutationContext } from "../../../../state/mutation-context"; -import { SDCPNContext } from "../../../../state/sdcpn-context"; +import { ActiveNetContext } from "../../../../state/active-net-context"; import { usePanelTarget } from "../../../../state/use-selection"; import { UserSettingsContext } from "../../../../state/user-settings-context"; import { ArcProperties } from "./arc-properties/main"; @@ -65,7 +65,7 @@ export const PropertiesPanel: React.FC = () => { isPanelAnimating, } = use(EditorContext); - const { petriNetDefinition } = use(SDCPNContext); + const { activeNet: petriNetDefinition } = use(ActiveNetContext); const { updatePlace, updateTransition, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx index d30de6727c2..860278d4e56 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx @@ -12,6 +12,7 @@ import type { SubView } from "../../../../../../components/sub-view/types"; import { Switch } from "../../../../../../components/switch"; import { PlaceIcon } from "../../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../../constants/ui-messages"; +import { ActiveNetContext } from "../../../../../../state/active-net-context"; import { EditorContext } from "../../../../../../state/editor-context"; import { MutationContext } from "../../../../../../state/mutation-context"; import { SDCPNContext } from "../../../../../../state/sdcpn-context"; @@ -47,10 +48,10 @@ const PlaceMainContent: React.FC = () => { const { place, types, isReadOnly, updatePlace } = usePlacePropertiesContext(); const { selectItem } = use(EditorContext); + const { getItemType } = use(SDCPNContext); const { - getItemType, - petriNetDefinition: { differentialEquations, types: availableTypes }, - } = use(SDCPNContext); + activeNet: { differentialEquations, types: availableTypes }, + } = use(ActiveNetContext); // State for name input validation const [nameInputValue, setNameInputValue] = useState(place.name); diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/components/cursor-tooltip.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/components/cursor-tooltip.tsx new file mode 100644 index 00000000000..0f4d69aba63 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/components/cursor-tooltip.tsx @@ -0,0 +1,81 @@ +import { css } from "@hashintel/ds-helpers/css"; +import { use, useEffect, useRef, useState } from "react"; + +import { EditorContext } from "../../../state/editor-context"; +import { SDCPNContext } from "../../../state/sdcpn-context"; + +const tooltipStyle = css({ + position: "fixed", + pointerEvents: "none", + zIndex: 9999, + paddingX: "2", + paddingY: "1", + borderRadius: "md", + backgroundColor: "neutral.s120", + color: "neutral.s00", + fontSize: "xs", + fontWeight: "medium", + whiteSpace: "nowrap", + opacity: "[0.9]", +}); + +const OFFSET_X = 14; +const OFFSET_Y = 18; + +/** + * A small tooltip that follows the cursor when in an add mode, + * showing what will be created on click. + */ +export const CursorTooltip: React.FC = () => { + const { editionMode, componentSubnetId } = use(EditorContext); + const { + petriNetDefinition: { subnets }, + } = use(SDCPNContext); + + const [position, setPosition] = useState<{ x: number; y: number } | null>( + null, + ); + const rafRef = useRef(0); + + useEffect(() => { + const handleMouseMove = (event: MouseEvent) => { + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + setPosition({ x: event.clientX, y: event.clientY }); + }); + }; + + window.addEventListener("mousemove", handleMouseMove); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + cancelAnimationFrame(rafRef.current); + }; + }, []); + + let label: string | null = null; + + if (editionMode === "add-place") { + label = "Add Place"; + } else if (editionMode === "add-transition") { + label = "Add Transition"; + } else if (editionMode === "add-component" && componentSubnetId) { + const subnet = (subnets ?? []).find((s) => s.id === componentSubnetId); + label = subnet ? `Instantiate ${subnet.name}` : null; + } + + if (!label || !position) { + return null; + } + + return ( +
+ {label} +
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts index c325b9824d0..93cc5472197 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts @@ -3,8 +3,9 @@ import { use } from "react"; import { hexToHsl } from "../../../lib/hsl-color"; import { PlaybackContext } from "../../../playback/context"; +import { ActiveNetContext } from "../../../state/active-net-context"; import { EditorContext } from "../../../state/editor-context"; -import { generateArcId, SDCPNContext } from "../../../state/sdcpn-context"; +import { generateArcId } from "../../../state/sdcpn-context"; import { UserSettingsContext } from "../../../state/user-settings-context"; import type { NodeType, @@ -26,7 +27,7 @@ import { * @returns An object with nodes (including dragging state) and arcs for ReactFlow */ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { - const { petriNetDefinition } = use(SDCPNContext); + const { activeNet: petriNetDefinition } = use(ActiveNetContext); const { draggingStateByNodeId, isSelected } = use(EditorContext); const { currentViewedFrame } = use(PlaybackContext); const { compactNodes } = use(UserSettingsContext); diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx index d247115acb3..0b0f94eea96 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx @@ -21,6 +21,7 @@ import { useIsReadOnly } from "../../state/use-is-read-only"; import { UserSettingsContext } from "../../state/user-settings-context"; import type { ViewportAction } from "../../types/viewport-action"; import { Arc } from "./components/arc"; +import { CursorTooltip } from "./components/cursor-tooltip"; import { ClassicPlaceNode } from "./components/classic-place-node"; import { ClassicTransitionNode } from "./components/classic-transition-node"; import { MiniMap } from "./components/mini-map"; @@ -252,8 +253,7 @@ export const SDCPNView: React.FC<{ return; } - // Only create nodes in add modes - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // Only create nodes in add-place / add-transition modes if (editionMode !== "add-place" && editionMode !== "add-transition") { return; } @@ -333,7 +333,9 @@ export const SDCPNView: React.FC<{ // Determine ReactFlow props based on edition mode const isAddMode = - editionMode === "add-place" || editionMode === "add-transition"; + editionMode === "add-place" || + editionMode === "add-transition" || + editionMode === "add-component"; const isPanMode = editionMode === "cursor" && cursorMode === "pan"; const isSelectMode = editionMode === "cursor" && cursorMode === "select"; @@ -397,6 +399,7 @@ export const SDCPNView: React.FC<{ {showMinimap && } +
); }; From 949459900da7534c29bf48e94ec37161ccbce654 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 24 Apr 2026 15:08:45 +0100 Subject: [PATCH 3/4] FE-522: Add ComponentInstance type, rendering, wiring, and click-to-place Introduces the ComponentInstance entity: a subnet instantiation with parameter values, wiring (place-to-port connections), and canvas positioning. Places gain an `isPort` flag to mark them as ports exposed on component instances. Rendering: sharp-cornered rectangle node with port handles derived from the subnet's port places. Wiring rendered as a dedicated "wire" edge type (dashed, bezier) distinct from arcs. Edges array replaces the former arcs-only array to hold both arc and wire edge types. Mutations: addComponentInstance, updateComponentInstance, removeComponentInstance, plus deleteItemsByIds and commitNodePositions support. Hospital Network example updated with an ER Triage component instance wired to the root net. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../petrinaut/src/core/types/sdcpn.ts | 36 ++++ .../src/examples/hospital-network.ts | 25 +++ .../petrinaut/src/file-format/import-sdcpn.ts | 88 +++++----- .../src/file-format/remove-visual-info.ts | 29 ++-- .../petrinaut/src/file-format/types.ts | 18 ++ .../petrinaut/src/state/active-net-context.ts | 3 + .../src/state/active-net-provider.tsx | 24 ++- .../petrinaut/src/state/mutation-context.ts | 12 +- .../petrinaut/src/state/mutation-provider.tsx | 58 ++++++- .../petrinaut/src/state/sdcpn-context.ts | 1 + .../petrinaut/src/state/sdcpn-provider.tsx | 7 + .../petrinaut/src/state/selection.ts | 6 +- .../src/state/use-selection-cleanup.ts | 3 + .../components/component-instance-node.tsx | 164 ++++++++++++++++++ .../src/views/SDCPN/components/wire-edge.tsx | 43 +++++ .../SDCPN/hooks/use-apply-node-changes.ts | 8 +- .../SDCPN/hooks/use-sdcpn-to-react-flow.ts | 80 ++++++++- .../src/views/SDCPN/reactflow-types.ts | 46 ++++- .../petrinaut/src/views/SDCPN/sdcpn-view.tsx | 42 ++++- .../src/views/SDCPN/styles/styling.ts | 2 + 20 files changed, 615 insertions(+), 80 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/views/SDCPN/components/component-instance-node.tsx create mode 100644 libs/@hashintel/petrinaut/src/views/SDCPN/components/wire-edge.tsx diff --git a/libs/@hashintel/petrinaut/src/core/types/sdcpn.ts b/libs/@hashintel/petrinaut/src/core/types/sdcpn.ts index ebac09cd28b..7901b7c06de 100644 --- a/libs/@hashintel/petrinaut/src/core/types/sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/core/types/sdcpn.ts @@ -30,6 +30,8 @@ export type Place = { colorId: null | ID; dynamicsEnabled: boolean; differentialEquationId: null | ID; + /** When true, this place is exposed as a port on component instances of this subnet. */ + isPort?: boolean; visualizerCode?: string; showAsInitialState?: boolean; // UI positioning @@ -112,6 +114,38 @@ export type Scenario = { }; }; +/** + * An instance of a subnet placed inside a net. + * It references a subnet definition and provides concrete parameter values. + */ +/** + * A wire connects a place in the parent net to a place inside the subnet. + */ +export type Wire = { + /** ID of a place in the parent net (external to the subnet). */ + externalPlaceId: ID; + /** ID of a place inside the referenced subnet. */ + internalPlaceId: ID; +}; + +export type ComponentInstance = { + id: ID; + /** Display name for this instance. */ + name: string; + /** ID of the subnet this instance instantiates. */ + subnetId: ID; + /** + * Concrete values for the subnet's parameters. + * Keys are parameter IDs from the referenced subnet; values are expressions. + */ + parameterValues: Record; + /** Connections between places in the parent net and places inside the subnet. */ + wiring: Wire[]; + // UI positioning + x: number; + y: number; +}; + export type Subnet = { id: ID; name: string; @@ -120,6 +154,7 @@ export type Subnet = { types: Color[]; differentialEquations: DifferentialEquation[]; parameters: Parameter[]; + componentInstances?: ComponentInstance[]; }; export type SDCPN = { @@ -130,6 +165,7 @@ export type SDCPN = { parameters: Parameter[]; scenarios?: Scenario[]; subnets?: Subnet[]; + componentInstances?: ComponentInstance[]; }; export type MinimalNetMetadata = { diff --git a/libs/@hashintel/petrinaut/src/examples/hospital-network.ts b/libs/@hashintel/petrinaut/src/examples/hospital-network.ts index 1995a686a92..aed47204176 100644 --- a/libs/@hashintel/petrinaut/src/examples/hospital-network.ts +++ b/libs/@hashintel/petrinaut/src/examples/hospital-network.ts @@ -86,6 +86,29 @@ export const hospitalNetwork: { title: string; petriNetDefinition: SDCPN } = { defaultValue: "1", }, ], + componentInstances: [ + { + id: "instance__er_triage", + name: "ER Triage Unit", + subnetId: "subnet__er_triage", + parameterValues: { + param__triage_rate: "5", + param__treatment_rate: "3", + }, + wiring: [ + { + externalPlaceId: "place__er", + internalPlaceId: "place__waiting", + }, + { + externalPlaceId: "place__ward", + internalPlaceId: "place__treated", + }, + ], + x: -10 * SNAP_GRID_SIZE, + y: 20 * SNAP_GRID_SIZE, + }, + ], scenarios: [ { id: "scenario__normal_day", @@ -114,6 +137,7 @@ export const hospitalNetwork: { title: string; petriNetDefinition: SDCPN } = { colorId: null, dynamicsEnabled: false, differentialEquationId: null, + isPort: true, showAsInitialState: true, x: -15 * SNAP_GRID_SIZE, y: 5 * SNAP_GRID_SIZE, @@ -133,6 +157,7 @@ export const hospitalNetwork: { title: string; petriNetDefinition: SDCPN } = { colorId: null, dynamicsEnabled: false, differentialEquationId: null, + isPort: true, x: 15 * SNAP_GRID_SIZE, y: 5 * SNAP_GRID_SIZE, }, diff --git a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts index 552f1e0697c..410a77c4022 100644 --- a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts @@ -20,18 +20,28 @@ export type ImportResult = const hasMissingPositions = (sdcpn: { places: { x?: number; y?: number }[]; transitions: { x?: number; y?: number }[]; + componentInstances?: { x?: number; y?: number }[]; subnets?: { places: { x?: number; y?: number }[]; transitions: { x?: number; y?: number }[]; + componentInstances?: { x?: number; y?: number }[]; }[]; }): boolean => { - for (const node of [...sdcpn.places, ...sdcpn.transitions]) { + for (const node of [ + ...sdcpn.places, + ...sdcpn.transitions, + ...(sdcpn.componentInstances ?? []), + ]) { if (node.x === undefined || node.y === undefined) { return true; } } for (const subnet of sdcpn.subnets ?? []) { - for (const node of [...subnet.places, ...subnet.transitions]) { + for (const node of [ + ...subnet.places, + ...subnet.transitions, + ...(subnet.componentInstances ?? []), + ]) { if (node.x === undefined || node.y === undefined) { return true; } @@ -45,51 +55,49 @@ const hasMissingPositions = (sdcpn: { * - Places/transitions at (0, 0) will be laid out by ELK after import. * - Colors get default iconSlug and displayColor when missing (e.g. exported without visual info). */ -const fillMissingVisualInfo = (sdcpn: { - title: string; +type VisualInfoInput = { places: Array<{ x?: number; y?: number }>; transitions: Array<{ x?: number; y?: number }>; types: Array<{ iconSlug?: string; displayColor?: string }>; - subnets?: Array<{ - places: Array<{ x?: number; y?: number }>; - transitions: Array<{ x?: number; y?: number }>; - types: Array<{ iconSlug?: string; displayColor?: string }>; - }>; -}): SDCPNWithTitle => + componentInstances?: Array<{ x?: number; y?: number }>; +}; + +const fillNetVisualInfo = (net: T) => ({ + ...net, + places: net.places.map((place) => ({ + ...place, + x: place.x ?? 0, + y: place.y ?? 0, + })), + transitions: net.transitions.map((transition) => ({ + ...transition, + x: transition.x ?? 0, + y: transition.y ?? 0, + })), + types: net.types.map((type) => ({ + ...type, + iconSlug: type.iconSlug ?? "circle", + displayColor: type.displayColor ?? "#808080", + })), + componentInstances: (net.componentInstances ?? []).map((instance) => ({ + ...instance, + x: instance.x ?? 0, + y: instance.y ?? 0, + })), +}); + +const fillMissingVisualInfo = ( + sdcpn: VisualInfoInput & { + title: string; + subnets?: VisualInfoInput[]; + }, +): SDCPNWithTitle => ({ ...sdcpn, - places: sdcpn.places.map((place) => ({ - ...place, - x: place.x ?? 0, - y: place.y ?? 0, - })), - transitions: sdcpn.transitions.map((transition) => ({ - ...transition, - x: transition.x ?? 0, - y: transition.y ?? 0, - })), - types: sdcpn.types.map((type) => ({ - ...type, - iconSlug: type.iconSlug ?? "circle", - displayColor: type.displayColor ?? "#808080", - })), + ...fillNetVisualInfo(sdcpn), subnets: (sdcpn.subnets ?? []).map((subnet) => ({ ...subnet, - places: subnet.places.map((place) => ({ - ...place, - x: place.x ?? 0, - y: place.y ?? 0, - })), - transitions: subnet.transitions.map((transition) => ({ - ...transition, - x: transition.x ?? 0, - y: transition.y ?? 0, - })), - types: subnet.types.map((type) => ({ - ...type, - iconSlug: type.iconSlug ?? "circle", - displayColor: type.displayColor ?? "#808080", - })), + ...fillNetVisualInfo(subnet), })), }) as SDCPNWithTitle; diff --git a/libs/@hashintel/petrinaut/src/file-format/remove-visual-info.ts b/libs/@hashintel/petrinaut/src/file-format/remove-visual-info.ts index 7c74d2e1ba3..79d2858f740 100644 --- a/libs/@hashintel/petrinaut/src/file-format/remove-visual-info.ts +++ b/libs/@hashintel/petrinaut/src/file-format/remove-visual-info.ts @@ -1,34 +1,38 @@ import type { Color, + ComponentInstance, Place, SDCPN, Subnet, Transition, } from "../core/types/sdcpn"; -type SubnetWithoutVisualInfo = Omit< - Subnet, - "places" | "transitions" | "types" -> & { +type NetWithoutVisualInfo = { places: Array>; transitions: Array>; types: Array>; + componentInstances: Array>; }; +type SubnetWithoutVisualInfo = Omit< + Subnet, + "places" | "transitions" | "types" | "componentInstances" +> & + NetWithoutVisualInfo; + type SDCPNWithoutVisualInfo = Omit< SDCPN, - "places" | "transitions" | "types" | "subnets" -> & { - places: Array>; - transitions: Array>; - types: Array>; - subnets: SubnetWithoutVisualInfo[]; -}; + "places" | "transitions" | "types" | "componentInstances" | "subnets" +> & + NetWithoutVisualInfo & { + subnets: SubnetWithoutVisualInfo[]; + }; const stripVisualFromNodes = (nodes: { places: Place[]; transitions: Transition[]; types: Color[]; + componentInstances?: ComponentInstance[]; }) => ({ places: nodes.places.map(({ x: _x, y: _y, ...place }) => place), transitions: nodes.transitions.map( @@ -37,6 +41,9 @@ const stripVisualFromNodes = (nodes: { types: nodes.types.map( ({ displayColor: _displayColor, iconSlug: _iconSlug, ...type }) => type, ), + componentInstances: (nodes.componentInstances ?? []).map( + ({ x: _x, y: _y, ...instance }) => instance, + ), }); /** diff --git a/libs/@hashintel/petrinaut/src/file-format/types.ts b/libs/@hashintel/petrinaut/src/file-format/types.ts index 6918cbe979e..f0eb4aac24d 100644 --- a/libs/@hashintel/petrinaut/src/file-format/types.ts +++ b/libs/@hashintel/petrinaut/src/file-format/types.ts @@ -19,6 +19,7 @@ const placeSchema = z.object({ colorId: z.string().nullable(), dynamicsEnabled: z.boolean(), differentialEquationId: z.string().nullable(), + isPort: z.boolean().optional(), visualizerCode: z.string().optional(), showAsInitialState: z.boolean().optional(), x: z.number().optional(), @@ -95,6 +96,21 @@ const scenarioSchema = z.object({ initialState: initialStateSchema.default({ type: "per_place", content: {} }), }); +const wireSchema = z.object({ + externalPlaceId: z.string(), + internalPlaceId: z.string(), +}); + +const componentInstanceSchema = z.object({ + id: z.string(), + name: z.string(), + subnetId: z.string(), + parameterValues: z.record(z.string(), z.string()).default({}), + wiring: z.array(wireSchema).default([]), + x: z.number().optional(), + y: z.number().optional(), +}); + const subnetSchema = z.object({ id: z.string(), name: z.string(), @@ -103,6 +119,7 @@ const subnetSchema = z.object({ types: z.array(colorSchema).default([]), differentialEquations: z.array(differentialEquationSchema).default([]), parameters: z.array(parameterSchema).default([]), + componentInstances: z.array(componentInstanceSchema).default([]), }); const sdcpnSchema = z.object({ @@ -113,6 +130,7 @@ const sdcpnSchema = z.object({ parameters: z.array(parameterSchema).default([]), scenarios: z.array(scenarioSchema).default([]), subnets: z.array(subnetSchema).default([]), + componentInstances: z.array(componentInstanceSchema).default([]), }); const fileMetaSchema = z.object({ diff --git a/libs/@hashintel/petrinaut/src/state/active-net-context.ts b/libs/@hashintel/petrinaut/src/state/active-net-context.ts index e60fe28f466..33d5c26e2e2 100644 --- a/libs/@hashintel/petrinaut/src/state/active-net-context.ts +++ b/libs/@hashintel/petrinaut/src/state/active-net-context.ts @@ -3,6 +3,7 @@ import { createContext } from "react"; import type { Color, DifferentialEquation, + ComponentInstance, Parameter, Place, Transition, @@ -18,6 +19,7 @@ export type ActiveNetDefinition = { types: Color[]; differentialEquations: DifferentialEquation[]; parameters: Parameter[]; + componentInstances: ComponentInstance[]; }; export type ActiveNetContextValue = { @@ -35,6 +37,7 @@ const DEFAULT_ACTIVE_NET: ActiveNetDefinition = { types: [], differentialEquations: [], parameters: [], + componentInstances: [], }; const DEFAULT_CONTEXT_VALUE: ActiveNetContextValue = { diff --git a/libs/@hashintel/petrinaut/src/state/active-net-provider.tsx b/libs/@hashintel/petrinaut/src/state/active-net-provider.tsx index 2ffc0a4575c..c730a7287b6 100644 --- a/libs/@hashintel/petrinaut/src/state/active-net-provider.tsx +++ b/libs/@hashintel/petrinaut/src/state/active-net-provider.tsx @@ -27,13 +27,23 @@ export const ActiveNetProvider: React.FC = ({ // Fall back to root if the subnet was deleted const resolvedSubnetId = subnet ? activeSubnetId : null; - const activeNet = subnet ?? { - places: petriNetDefinition.places, - transitions: petriNetDefinition.transitions, - types: petriNetDefinition.types, - differentialEquations: petriNetDefinition.differentialEquations, - parameters: petriNetDefinition.parameters, - }; + const activeNet = subnet + ? { + places: subnet.places, + transitions: subnet.transitions, + types: subnet.types, + differentialEquations: subnet.differentialEquations, + parameters: subnet.parameters, + componentInstances: subnet.componentInstances ?? [], + } + : { + places: petriNetDefinition.places, + transitions: petriNetDefinition.transitions, + types: petriNetDefinition.types, + differentialEquations: petriNetDefinition.differentialEquations, + parameters: petriNetDefinition.parameters, + componentInstances: petriNetDefinition.componentInstances ?? [], + }; const value: ActiveNetContextValue = { activeNet, diff --git a/libs/@hashintel/petrinaut/src/state/mutation-context.ts b/libs/@hashintel/petrinaut/src/state/mutation-context.ts index 6ad97ee67d6..1be0cd6c39f 100644 --- a/libs/@hashintel/petrinaut/src/state/mutation-context.ts +++ b/libs/@hashintel/petrinaut/src/state/mutation-context.ts @@ -2,6 +2,7 @@ import { createContext } from "react"; import type { Color, + ComponentInstance, DifferentialEquation, Parameter, Place, @@ -72,6 +73,12 @@ export type MutationHelperFunctions = { updateFn: (scenario: Scenario) => void, ) => void; removeScenario: (scenarioId: string) => void; + addComponentInstance: (instance: ComponentInstance) => void; + updateComponentInstance: ( + instanceId: string, + updateFn: (instance: ComponentInstance) => void, + ) => void; + removeComponentInstance: (instanceId: string) => void; addSubnet: (subnet: Subnet) => void; removeSubnet: (subnetId: string) => void; deleteItemsByIds: (items: SelectionMap) => void; @@ -80,7 +87,7 @@ export type MutationHelperFunctions = { commitNodePositions: ( commits: Array<{ id: string; - itemType: "place" | "transition"; + itemType: "place" | "transition" | "componentInstance"; position: { x: number; y: number }; }>, ) => void; @@ -113,6 +120,9 @@ const DEFAULT_CONTEXT_VALUE: MutationContextValue = { addScenario: () => {}, updateScenario: () => {}, removeScenario: () => {}, + addComponentInstance: () => {}, + updateComponentInstance: () => {}, + removeComponentInstance: () => {}, addSubnet: () => {}, removeSubnet: () => {}, deleteItemsByIds: () => {}, diff --git a/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx b/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx index 63c8b3c0ab8..79fa9c2a50f 100644 --- a/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx +++ b/libs/@hashintel/petrinaut/src/state/mutation-provider.tsx @@ -345,6 +345,39 @@ export const MutationProvider: React.FC = ({ } }); }, + addComponentInstance(instance) { + guardedMutate((sdcpn) => { + const net = resolveNet(sdcpn); + const componentInstances = net.componentInstances ?? []; + componentInstances.push(instance); + net.componentInstances = componentInstances; + }); + }, + updateComponentInstance(instanceId, updateFn) { + guardedMutate((sdcpn) => { + for (const instance of resolveNet(sdcpn).componentInstances ?? []) { + if (instance.id === instanceId) { + updateFn(instance); + break; + } + } + }); + }, + removeComponentInstance(instanceId) { + guardedMutate((sdcpn) => { + const net = resolveNet(sdcpn); + const componentInstances = net.componentInstances; + if (!componentInstances) { + return; + } + for (const [index, instance] of componentInstances.entries()) { + if (instance.id === instanceId) { + componentInstances.splice(index, 1); + break; + } + } + }); + }, addSubnet(subnet) { guardedMutate((sdcpn) => { const subnets = sdcpn.subnets ?? []; @@ -378,6 +411,7 @@ export const MutationProvider: React.FC = ({ const typeIds = new Set(); const equationIds = new Set(); const parameterIds = new Set(); + const componentInstanceIds = new Set(); for (const [id, item] of items) { switch (item.type) { @@ -399,6 +433,9 @@ export const MutationProvider: React.FC = ({ case "parameter": parameterIds.add(id); break; + case "componentInstance": + componentInstanceIds.add(id); + break; } } @@ -499,6 +536,17 @@ export const MutationProvider: React.FC = ({ } } } + + if (componentInstanceIds.size > 0) { + const instances = net.componentInstances; + if (instances) { + for (let i = instances.length - 1; i >= 0; i--) { + if (componentInstanceIds.has(instances[i]!.id)) { + instances.splice(i, 1); + } + } + } + } }); }, async layoutGraph() { @@ -559,7 +607,7 @@ export const MutationProvider: React.FC = ({ break; } } - } else { + } else if (itemType === "transition") { for (const transition of net.transitions) { if (transition.id === id) { transition.x = position.x; @@ -567,6 +615,14 @@ export const MutationProvider: React.FC = ({ break; } } + } else { + for (const instance of net.componentInstances ?? []) { + if (instance.id === id) { + instance.x = position.x; + instance.y = position.y; + break; + } + } } } }); diff --git a/libs/@hashintel/petrinaut/src/state/sdcpn-context.ts b/libs/@hashintel/petrinaut/src/state/sdcpn-context.ts index 17ab4e75c2c..b5d1317a074 100644 --- a/libs/@hashintel/petrinaut/src/state/sdcpn-context.ts +++ b/libs/@hashintel/petrinaut/src/state/sdcpn-context.ts @@ -41,6 +41,7 @@ export type SDCPNContextValue = SDCPNProviderProps & { | "type" | "differentialEquation" | "parameter" + | "componentInstance" | null; }; diff --git a/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx b/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx index 1f0ba9eb33f..dfa60d59735 100644 --- a/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx +++ b/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx @@ -41,6 +41,13 @@ export const SDCPNProvider: React.FC< if (net.transitions.some((transition) => transition.id === id)) { return "transition"; } + + if ( + "componentInstances" in net && + net.componentInstances?.some((instance) => instance.id === id) + ) { + return "componentInstance"; + } } return null; diff --git a/libs/@hashintel/petrinaut/src/state/selection.ts b/libs/@hashintel/petrinaut/src/state/selection.ts index fe78cf38183..2277026f706 100644 --- a/libs/@hashintel/petrinaut/src/state/selection.ts +++ b/libs/@hashintel/petrinaut/src/state/selection.ts @@ -6,7 +6,8 @@ export type SelectionItemType = | "arc" | "type" | "differentialEquation" - | "parameter"; + | "parameter" + | "componentInstance"; export type SelectionItem = | { type: "place"; id: string } @@ -14,7 +15,8 @@ export type SelectionItem = | { type: "arc"; id: string } | { type: "type"; id: string } | { type: "differentialEquation"; id: string } - | { type: "parameter"; id: string }; + | { type: "parameter"; id: string } + | { type: "componentInstance"; id: string }; /** Map from item ID -> typed SelectionItem. O(1) lookup for ReactFlow bridge. */ export type SelectionMap = Map; diff --git a/libs/@hashintel/petrinaut/src/state/use-selection-cleanup.ts b/libs/@hashintel/petrinaut/src/state/use-selection-cleanup.ts index 2ca43d40844..dfb034145f3 100644 --- a/libs/@hashintel/petrinaut/src/state/use-selection-cleanup.ts +++ b/libs/@hashintel/petrinaut/src/state/use-selection-cleanup.ts @@ -48,6 +48,9 @@ export function useSelectionCleanup() { for (const param of petriNetDefinition.parameters) { validIds.add(param.id); } + for (const instance of petriNetDefinition.componentInstances) { + validIds.add(instance.id); + } // Check if any selected ID is stale let hasStale = false; diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/components/component-instance-node.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/components/component-instance-node.tsx new file mode 100644 index 00000000000..a931f75855e --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/components/component-instance-node.tsx @@ -0,0 +1,164 @@ +import { css, cva } from "@hashintel/ds-helpers/css"; +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { use } from "react"; + +import { EditorContext } from "../../../state/editor-context"; +import type { ComponentInstanceNodeType } from "../reactflow-types"; + +const PORT_SIZE = 10; +const PORT_OFFSET = PORT_SIZE / 2; + +const containerStyle = css({ + position: "relative", +}); + +const cardStyle = cva({ + base: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: "0.5", + padding: "3", + border: "[2px solid]", + borderColor: "neutral.s60", + borderRadius: "[0px]", + backgroundColor: "neutral.s15", + cursor: "default", + transition: "[all 0.2s ease]", + outline: "[0px solid rgba(75, 126, 156, 0)]", + shadow: "[0px 2px 9px rgba(0, 0, 0, 0.04)]", + minWidth: "[120px]", + _hover: { + outline: "[4px solid rgba(75, 126, 156, 0.2)]", + shadow: "[0px 4px 11px rgba(0, 0, 0, 0.1)]", + }, + }, + variants: { + selection: { + resource: { + outline: "[4px solid rgba(59, 178, 246, 0.6)]", + _hover: { + outline: "[4px solid rgba(59, 178, 246, 0.7)]", + }, + }, + reactflow: { + outline: "[4px solid rgba(40, 172, 233, 0.6)]", + }, + none: {}, + }, + }, + defaultVariants: { + selection: "none", + }, +}); + +const titleStyle = css({ + fontSize: "sm", + fontWeight: "semibold", + lineHeight: "[1.2]", + color: "neutral.s120", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: "[100%]", +}); + +const subtitleStyle = css({ + fontSize: "xs", + color: "neutral.s80", + lineHeight: "[1.2]", +}); + +const portStyle = css({ + width: `[${PORT_SIZE}px]`, + height: `[${PORT_SIZE}px]`, + background: "[#6b7280]", + borderRadius: "[50%]", + border: "none", + zIndex: 3, +}); + +const portLabelStyle = css({ + position: "absolute", + fontSize: "[9px]", + color: "neutral.s80", + whiteSpace: "nowrap", + pointerEvents: "none", +}); + +export const ComponentInstanceNode: React.FC< + NodeProps +> = ({ id, data, selected }: NodeProps) => { + const { isSelected } = use(EditorContext); + + const isInSelection = isSelected(id); + const selectionVariant = isInSelection + ? "resource" + : selected + ? "reactflow" + : "none"; + + const { ports } = data; + const portCount = ports.length; + + return ( +
+ {/* Left-side ports */} + {ports.map((port, index) => { + const topPercent = + portCount === 1 ? 50 : (index / (portCount - 1)) * 100; + + return ( +
+ + + {port.name} + +
+ ); + })} + +
+
{data.label}
+
{data.subnetName}
+
+ + {/* Right-side ports */} + {ports.map((port, index) => { + const topPercent = + portCount === 1 ? 50 : (index / (portCount - 1)) * 100; + + return ( + + ); + })} +
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/components/wire-edge.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/components/wire-edge.tsx new file mode 100644 index 00000000000..28a3ff56fbd --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/components/wire-edge.tsx @@ -0,0 +1,43 @@ +import { BaseEdge, type EdgeProps, getBezierPath } from "@xyflow/react"; + +import type { WireEdgeType } from "../reactflow-types"; + +const WIRE_COLOR = "#999"; +const WIRE_DASH = "6 3"; + +/** + * A dashed edge representing a wire between an external place + * and a port on a component instance. + */ +export const WireEdge: React.FC> = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerEnd, +}) => { + const [path] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + return ( + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-apply-node-changes.ts b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-apply-node-changes.ts index 9b69ccad82b..083760a3832 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-apply-node-changes.ts +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-apply-node-changes.ts @@ -123,13 +123,17 @@ export function useApplyNodeChanges() { if (positionCommits.length > 0) { const commits: Array<{ id: string; - itemType: "place" | "transition"; + itemType: "place" | "transition" | "componentInstance"; position: { x: number; y: number }; }> = []; for (const { id, position } of positionCommits) { const type = getItemType(id); - if (type === "place" || type === "transition") { + if ( + type === "place" || + type === "transition" || + type === "componentInstance" + ) { commits.push({ id, itemType: type, diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts index 93cc5472197..d1e8631590b 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts @@ -5,9 +5,10 @@ import { hexToHsl } from "../../../lib/hsl-color"; import { PlaybackContext } from "../../../playback/context"; import { ActiveNetContext } from "../../../state/active-net-context"; import { EditorContext } from "../../../state/editor-context"; -import { generateArcId } from "../../../state/sdcpn-context"; +import { generateArcId, SDCPNContext } from "../../../state/sdcpn-context"; import { UserSettingsContext } from "../../../state/user-settings-context"; import type { + EdgeType, NodeType, PetrinautReactFlowDefinitionObject, } from "../reactflow-types"; @@ -28,6 +29,7 @@ import { */ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { const { activeNet: petriNetDefinition } = use(ActiveNetContext); + const { petriNetDefinition: fullSdcpn } = use(SDCPNContext); const { draggingStateByNodeId, isSelected } = use(EditorContext); const { currentViewedFrame } = use(PlaybackContext); const { compactNodes } = use(UserSettingsContext); @@ -99,8 +101,48 @@ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { }); } - // Create arcs from input and output arcs - const arcs = []; + // Create component instance nodes + for (const instance of petriNetDefinition.componentInstances) { + const draggingState = draggingStateByNodeId[instance.id]; + + // Resolve the subnet to find its port places + const subnet = (fullSdcpn.subnets ?? []).find( + (s) => s.id === instance.subnetId, + ); + const subnetName = subnet?.name ?? "Unknown"; + const ports = (subnet?.places ?? []) + .filter((place) => place.isPort) + .map((place) => ({ id: place.id, name: place.name })); + + // Dynamically size based on port count + const minHeight = dimensions.componentInstance.height; + const portBasedHeight = Math.max(minHeight, ports.length * 28 + 24); + + nodes.push({ + id: instance.id, + type: "componentInstance", + position: draggingState?.dragging + ? draggingState.position + : { x: instance.x, y: instance.y }, + width: dimensions.componentInstance.width, + height: portBasedHeight, + measured: { + width: dimensions.componentInstance.width, + height: portBasedHeight, + }, + dragging: draggingState?.dragging ?? false, + selected: isSelected(instance.id), + data: { + label: instance.name, + type: "componentInstance", + subnetName, + ports, + }, + }); + } + + // Create edges (arcs + wires) + const edges: EdgeType[] = []; for (const transition of petriNetDefinition.transitions) { // Input arcs (from places to transition) @@ -121,7 +163,7 @@ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { ? hexToHsl(placeType.displayColor).lighten(-15).saturate(-30).css(1) : "#777"; - arcs.push({ + edges.push({ id: arcId, source: inputArc.placeId, target: transition.id, @@ -163,7 +205,7 @@ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { ? hexToHsl(placeType.displayColor).lighten(-15).saturate(-30).css(1) : "#777"; - arcs.push({ + edges.push({ id: arcId, source: transition.id, target: outputArc.placeId, @@ -188,8 +230,34 @@ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { } } + // Create wire edges from component instance wiring (external place ↔ instance port) + for (const instance of petriNetDefinition.componentInstances) { + for (const wire of instance.wiring) { + const wireId = `wire__${instance.id}__${wire.externalPlaceId}__${wire.internalPlaceId}`; + + edges.push({ + id: wireId, + source: wire.externalPlaceId, + target: instance.id, + targetHandle: `port-in-${wire.internalPlaceId}`, + type: "wire" as const, + selected: isSelected(wireId), + markerEnd: { + type: MarkerType.ArrowClosed, + color: "#999", + width: 20, + height: 20, + }, + data: { + externalPlaceId: wire.externalPlaceId, + internalPlaceId: wire.internalPlaceId, + }, + }); + } + } + return { nodes, - arcs, + edges, }; } diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/reactflow-types.ts b/libs/@hashintel/petrinaut/src/views/SDCPN/reactflow-types.ts index aeeb443a9f9..512f0dad682 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/reactflow-types.ts +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/reactflow-types.ts @@ -19,7 +19,16 @@ export type ArcData = { export type ArcEdgeType = Edge; -export type ArcType = Omit; +export type ArcType = ArcEdgeType; + +export type WireData = { + externalPlaceId: string; + internalPlaceId: string; +}; + +export type WireEdgeType = Edge; + +export type WireType = WireEdgeType; export type PlaceNodeData = { label: string; @@ -48,15 +57,40 @@ export type TransitionNodeData = { export type TransitionNodeType = Node; -export type NodeData = PlaceNodeData | TransitionNodeData; +export type ComponentInstancePort = { + id: string; + name: string; +}; + +export type ComponentInstanceNodeData = { + label: string; + type: "componentInstance"; + subnetName: string; + ports: ComponentInstancePort[]; +}; + +export type ComponentInstanceNodeType = Node< + ComponentInstanceNodeData, + "componentInstance" +>; + +export type NodeData = + | PlaceNodeData + | TransitionNodeData + | ComponentInstanceNodeData; + +export type NodeType = + | TransitionNodeType + | PlaceNodeType + | ComponentInstanceNodeType; -export type NodeType = TransitionNodeType | PlaceNodeType; +export type EdgeType = ArcType | WireType; /** - * Object containing the nodes and arcs for the ReactFlow instance. + * Object containing the nodes and edges for the ReactFlow instance. */ export type PetrinautReactFlowDefinitionObject = { - arcs: ArcType[]; + edges: EdgeType[]; nodes: NodeType[]; }; @@ -65,5 +99,5 @@ export type PetrinautReactFlowDefinitionObject = { */ export type PetrinautReactFlowInstance = ReactFlowInstance< NodeType, - ArcEdgeType + ArcEdgeType | WireEdgeType >; diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx index 0b0f94eea96..8b278f150af 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx @@ -21,6 +21,7 @@ import { useIsReadOnly } from "../../state/use-is-read-only"; import { UserSettingsContext } from "../../state/user-settings-context"; import type { ViewportAction } from "../../types/viewport-action"; import { Arc } from "./components/arc"; +import { ComponentInstanceNode } from "./components/component-instance-node"; import { CursorTooltip } from "./components/cursor-tooltip"; import { ClassicPlaceNode } from "./components/classic-place-node"; import { ClassicTransitionNode } from "./components/classic-transition-node"; @@ -28,6 +29,7 @@ import { MiniMap } from "./components/mini-map"; import { PlaceNode } from "./components/place-node"; import { TransitionNode } from "./components/transition-node"; import { ViewportControls } from "./components/viewport-controls"; +import { WireEdge } from "./components/wire-edge"; import { useApplyNodeChanges } from "./hooks/use-apply-node-changes"; import { useRecenterOnPanelOpen } from "./hooks/use-recenter-on-panel-open"; import { useSdcpnToReactFlow } from "./hooks/use-sdcpn-to-react-flow"; @@ -36,15 +38,18 @@ import type { PetrinautReactFlowInstance } from "./reactflow-types"; const COMPACT_NODE_TYPES = { place: PlaceNode, transition: TransitionNode, + componentInstance: ComponentInstanceNode, }; const CLASSIC_NODE_TYPES = { place: ClassicPlaceNode, transition: ClassicTransitionNode, + componentInstance: ComponentInstanceNode, }; const REACTFLOW_EDGE_TYPES = { default: Arc, + wire: WireEdge, }; const ZOOM_PADDING = 0.4; @@ -80,12 +85,14 @@ export const SDCPNView: React.FC<{ const [minZoom, setMinZoom] = useState(0); // SDCPN store - const { petriNetId } = use(SDCPNContext); - const { addPlace, addTransition, addArc } = use(MutationContext); + const { petriNetId, petriNetDefinition } = use(SDCPNContext); + const { addPlace, addTransition, addArc, addComponentInstance } = + use(MutationContext); const { editionMode, setEditionMode, + componentSubnetId, cursorMode, selectItem, clearSelection, @@ -95,7 +102,7 @@ export const SDCPNView: React.FC<{ const applyNodeChanges = useApplyNodeChanges(); // Convert SDCPN to ReactFlow format with dragging state - const { nodes, arcs } = useSdcpnToReactFlow(); + const { nodes, edges } = useSdcpnToReactFlow(); // When a panel opens, recenter the viewport to keep selected nodes visible useRecenterOnPanelOpen(canvasContainer, reactFlowInstance, nodes); @@ -253,6 +260,33 @@ export const SDCPNView: React.FC<{ return; } + if (editionMode === "add-component" && componentSubnetId) { + const rawPosition = reactFlowInstance.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + const position = snapToGrid + ? snapPositionToGrid(rawPosition) + : rawPosition; + + const subnet = (petriNetDefinition.subnets ?? []).find( + (s) => s.id === componentSubnetId, + ); + const id = `componentInstance__${generateUuid()}`; + addComponentInstance({ + id, + name: subnet?.name ?? "Instance", + subnetId: componentSubnetId, + parameterValues: {}, + wiring: [], + x: position.x, + y: position.y, + }); + selectItem({ type: "componentInstance", id }); + setEditionMode("cursor"); + return; + } + // Only create nodes in add-place / add-transition modes if (editionMode !== "add-place" && editionMode !== "add-transition") { return; @@ -361,7 +395,7 @@ export const SDCPNView: React.FC<{ > { diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/styles/styling.ts b/libs/@hashintel/petrinaut/src/views/SDCPN/styles/styling.ts index 3f90a5090b3..11f28ef131b 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/styles/styling.ts +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/styles/styling.ts @@ -1,11 +1,13 @@ export const compactNodeDimensions = { place: { width: 180, height: 48 }, transition: { width: 180, height: 48 }, + componentInstance: { width: 200, height: 80 }, }; export const classicNodeDimensions = { place: { width: 130, height: 130 }, transition: { width: 160, height: 80 }, + componentInstance: { width: 200, height: 120 }, }; /** @deprecated Use compactNodeDimensions or classicNodeDimensions */ From 01705eaadd51f65afe5b8ab38fbb22d212d1f0e7 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 28 Apr 2026 09:36:52 +0100 Subject: [PATCH 4/4] FE-522: Add ComponentInstance properties panel Render a properties panel when a component instance node is selected, showing instance details and parameter overrides for the underlying subnet. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../component-instance-properties/context.tsx | 31 +++++++ .../component-instance-properties/main.tsx | 52 +++++++++++ .../subviews/main.tsx | 93 +++++++++++++++++++ .../Editor/panels/PropertiesPanel/panel.tsx | 24 +++++ 4 files changed, 200 insertions(+) create mode 100644 libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/component-instance-properties/context.tsx create mode 100644 libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/component-instance-properties/main.tsx create mode 100644 libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/component-instance-properties/subviews/main.tsx diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/component-instance-properties/context.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/component-instance-properties/context.tsx new file mode 100644 index 00000000000..13175091bb4 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/component-instance-properties/context.tsx @@ -0,0 +1,31 @@ +import { createContext, use } from "react"; + +import type { + ComponentInstance, + Parameter, + Subnet, +} from "../../../../../core/types/sdcpn"; + +export interface ComponentInstancePropertiesContextValue { + instance: ComponentInstance; + subnet: Subnet | null; + subnetParameters: Parameter[]; + updateComponentInstance: ( + instanceId: string, + updateFn: (instance: ComponentInstance) => void, + ) => void; +} + +export const ComponentInstancePropertiesContext = + createContext(null); + +export const useComponentInstancePropertiesContext = + (): ComponentInstancePropertiesContextValue => { + const context = use(ComponentInstancePropertiesContext); + if (!context) { + throw new Error( + "useComponentInstancePropertiesContext must be used within ComponentInstanceProperties", + ); + } + return context; + }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/component-instance-properties/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/component-instance-properties/main.tsx new file mode 100644 index 00000000000..d694ade1c11 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/component-instance-properties/main.tsx @@ -0,0 +1,52 @@ +import { css } from "@hashintel/ds-helpers/css"; + +import type { SubView } from "../../../../../components/sub-view/types"; +import { VerticalSubViewsContainer } from "../../../../../components/sub-view/vertical/vertical-sub-views-container"; +import type { + ComponentInstance, + Subnet, +} from "../../../../../core/types/sdcpn"; +import { ComponentInstancePropertiesContext } from "./context"; +import { componentInstanceMainContentSubView } from "./subviews/main"; + +const containerStyle = css({ + display: "flex", + flexDirection: "column", + height: "[100%]", + minHeight: "[0]", +}); + +const subViews: SubView[] = [componentInstanceMainContentSubView]; + +interface ComponentInstancePropertiesProps { + instance: ComponentInstance; + subnet: Subnet | null; + updateComponentInstance: ( + instanceId: string, + updateFn: (instance: ComponentInstance) => void, + ) => void; +} + +export const ComponentInstanceProperties: React.FC< + ComponentInstancePropertiesProps +> = ({ instance, subnet, updateComponentInstance }) => { + const subnetParameters = subnet?.parameters ?? []; + + const value = { + instance, + subnet, + subnetParameters, + updateComponentInstance, + }; + + return ( +
+ + + +
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/component-instance-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/component-instance-properties/subviews/main.tsx new file mode 100644 index 00000000000..a6338928c48 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/component-instance-properties/subviews/main.tsx @@ -0,0 +1,93 @@ +import { css } from "@hashintel/ds-helpers/css"; +import { PiGraph } from "react-icons/pi"; + +import { Input } from "../../../../../../components/input"; +import { Section, SectionList } from "../../../../../../components/section"; +import type { SubView } from "../../../../../../components/sub-view/types"; +import { UI_MESSAGES } from "../../../../../../constants/ui-messages"; +import { useIsReadOnly } from "../../../../../../state/use-is-read-only"; +import { useComponentInstancePropertiesContext } from "../context"; + +const titleStyle = css({ + fontSize: "lg", + fontWeight: "semibold", + lineHeight: "[1.2]", +}); + +const paramVarNameStyle = css({ + fontSize: "xs", + color: "neutral.s90", + fontFamily: "mono", +}); + +const ComponentInstanceMainContent: React.FC = () => { + const { instance, subnetParameters, updateComponentInstance } = + useComponentInstancePropertiesContext(); + const isDisabled = useIsReadOnly(); + + const handleUpdateName = (event: React.ChangeEvent) => { + updateComponentInstance(instance.id, (existing) => { + existing.name = event.target.value; + }); + }; + + const handleUpdateParameterValue = (paramId: string, value: string) => { + updateComponentInstance(instance.id, (existing) => { + existing.parameterValues[paramId] = value; + }); + }; + + return ( + +
+ +
+ + {subnetParameters.length > 0 && ( +
+ + {subnetParameters.map((param) => ( +
+ + handleUpdateParameterValue(param.id, event.target.value) + } + disabled={isDisabled} + tooltip={isDisabled ? UI_MESSAGES.READ_ONLY_MODE : undefined} + /> +
{param.variableName}
+
+ ))} +
+
+ )} +
+ ); +}; + +const ComponentInstanceTitle: React.FC = () => { + const { subnet } = useComponentInstancePropertiesContext(); + const subnetName = subnet?.name ?? "Component"; + return {subnetName} (instance); +}; + +export const componentInstanceMainContentSubView: SubView = { + id: "component-instance-main-content", + title: "Component Instance", + icon: PiGraph, + main: true, + renderTitle: () => , + component: ComponentInstanceMainContent, +}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/panel.tsx index 9f18db881cd..9a12077272f 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/panel.tsx @@ -11,9 +11,11 @@ import { import { EditorContext } from "../../../../state/editor-context"; import { MutationContext } from "../../../../state/mutation-context"; import { ActiveNetContext } from "../../../../state/active-net-context"; +import { SDCPNContext } from "../../../../state/sdcpn-context"; import { usePanelTarget } from "../../../../state/use-selection"; import { UserSettingsContext } from "../../../../state/user-settings-context"; import { ArcProperties } from "./arc-properties/main"; +import { ComponentInstanceProperties } from "./component-instance-properties/main"; import { DifferentialEquationProperties } from "./differential-equation-properties/main"; import { MultiSelectionPanel } from "./multi-selection-panel"; import { ParameterProperties } from "./parameter-properties/main"; @@ -66,6 +68,7 @@ export const PropertiesPanel: React.FC = () => { } = use(EditorContext); const { activeNet: petriNetDefinition } = use(ActiveNetContext); + const { petriNetDefinition: fullSdcpn } = use(SDCPNContext); const { updatePlace, updateTransition, @@ -75,6 +78,7 @@ export const PropertiesPanel: React.FC = () => { updateType, updateDifferentialEquation, updateParameter, + updateComponentInstance, deleteItemsByIds, } = use(MutationContext); @@ -194,6 +198,26 @@ export const PropertiesPanel: React.FC = () => { } break; } + + case "componentInstance": { + const instanceData = petriNetDefinition.componentInstances.find( + (inst) => inst.id === item.id, + ); + if (instanceData) { + const subnet = + (fullSdcpn.subnets ?? []).find( + (s) => s.id === instanceData.subnetId, + ) ?? null; + content = ( + + ); + } + break; + } } } else if (panelTarget.kind === "multi") { content = (