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..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,49 @@ 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; + places: Place[]; + transitions: Transition[]; + types: Color[]; + differentialEquations: DifferentialEquation[]; + parameters: Parameter[]; + componentInstances?: ComponentInstance[]; +}; + export type SDCPN = { places: Place[]; transitions: Transition[]; @@ -119,6 +164,8 @@ export type SDCPN = { differentialEquations: DifferentialEquation[]; 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 new file mode 100644 index 00000000000..aed47204176 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/examples/hospital-network.ts @@ -0,0 +1,218 @@ +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", + }, + ], + 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", + 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, + isPort: true, + 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, + isPort: true, + 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..410a77c4022 100644 --- a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts @@ -20,12 +20,33 @@ 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, + ...(subnet.componentInstances ?? []), + ]) { + if (node.x === undefined || node.y === undefined) { + return true; + } + } + } return false; }; @@ -34,28 +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 }>; -}): 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, + ...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 61744ed3078..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,14 +1,51 @@ -import type { Color, Place, SDCPN, Transition } from "../core/types/sdcpn"; - -type SDCPNWithoutVisualInfo = Omit< +import type { + Color, + ComponentInstance, + Place, SDCPN, - "places" | "transitions" | "types" -> & { + Subnet, + Transition, +} from "../core/types/sdcpn"; + +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" | "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( + ({ x: _x, y: _y, ...transition }) => transition, + ), + types: nodes.types.map( + ({ displayColor: _displayColor, iconSlug: _iconSlug, ...type }) => type, + ), + componentInstances: (nodes.componentInstances ?? []).map( + ({ x: _x, y: _y, ...instance }) => instance, + ), +}); + /** * Removes graphical information from an SDCPN. * @param sdcpn - The SDCPN to process @@ -17,12 +54,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..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,32 @@ 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(), + places: z.array(placeSchema), + transitions: z.array(transitionSchema), + types: z.array(colorSchema).default([]), + differentialEquations: z.array(differentialEquationSchema).default([]), + parameters: z.array(parameterSchema).default([]), + componentInstances: z.array(componentInstanceSchema).default([]), +}); + const sdcpnSchema = z.object({ places: z.array(placeSchema), transitions: z.array(transitionSchema), @@ -102,6 +129,8 @@ const sdcpnSchema = z.object({ differentialEquations: z.array(differentialEquationSchema).default([]), 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/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..33d5c26e2e2 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/state/active-net-context.ts @@ -0,0 +1,51 @@ +import { createContext } from "react"; + +import type { + Color, + DifferentialEquation, + ComponentInstance, + 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[]; + componentInstances: ComponentInstance[]; +}; + +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: [], + componentInstances: [], +}; + +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..c730a7287b6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/state/active-net-provider.tsx @@ -0,0 +1,55 @@ +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: 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, + 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-context.ts b/libs/@hashintel/petrinaut/src/state/mutation-context.ts index 6bdc270e777..1be0cd6c39f 100644 --- a/libs/@hashintel/petrinaut/src/state/mutation-context.ts +++ b/libs/@hashintel/petrinaut/src/state/mutation-context.ts @@ -2,10 +2,12 @@ import { createContext } from "react"; import type { Color, + ComponentInstance, DifferentialEquation, Parameter, Place, Scenario, + Subnet, Transition, } from "../core/types/sdcpn"; import type { SelectionMap } from "./selection"; @@ -71,13 +73,21 @@ 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; layoutGraph: () => Promise; pasteEntities: () => Promise | null>; commitNodePositions: ( commits: Array<{ id: string; - itemType: "place" | "transition"; + itemType: "place" | "transition" | "componentInstance"; position: { x: number; y: number }; }>, ) => void; @@ -110,6 +120,11 @@ const DEFAULT_CONTEXT_VALUE: MutationContextValue = { addScenario: () => {}, updateScenario: () => {}, removeScenario: () => {}, + addComponentInstance: () => {}, + updateComponentInstance: () => {}, + removeComponentInstance: () => {}, + addSubnet: () => {}, + removeSubnet: () => {}, deleteItemsByIds: () => {}, layoutGraph: async () => {}, pasteEntities: async () => null, 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 7eb8fe428bc..79fa9c2a50f 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; } } @@ -326,8 +345,65 @@ 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 ?? []; + 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) => { + const net = resolveNet(sdcpn); + // Partition selection by type for targeted deletion const placeIds = new Set(); const transitionIds = new Set(); @@ -335,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) { @@ -356,6 +433,9 @@ export const MutationProvider: React.FC = ({ case "parameter": parameterIds.add(id); break; + case "componentInstance": + componentInstanceIds.add(id); + break; } } @@ -366,10 +446,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; } @@ -406,26 +486,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 = ""; } @@ -433,13 +513,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) @@ -450,9 +530,20 @@ 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); + } + } + } + + 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); + } } } } @@ -463,16 +554,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) { @@ -484,7 +576,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) { @@ -505,23 +597,32 @@ 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; break; } } - } else { - for (const transition of sdcpn.transitions) { + } else if (itemType === "transition") { + for (const transition of net.transitions) { if (transition.id === id) { transition.x = position.x; transition.y = position.y; 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 0af26d0306e..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; }; @@ -55,6 +56,7 @@ const DEFAULT_CONTEXT_VALUE: SDCPNContextValue = { types: [], differentialEquations: [], parameters: [], + subnets: [], }, readonly: true, setTitle: () => {}, diff --git a/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx b/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx index 469b62b4b4d..dfa60d59735 100644 --- a/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx +++ b/libs/@hashintel/petrinaut/src/state/sdcpn-provider.tsx @@ -18,24 +18,36 @@ export const SDCPNProvider: React.FC< return "arc"; } - if (sdcpn.types.some((type) => type.id === id)) { - return "type"; - } - - if (sdcpn.parameters.some((parameter) => parameter.id === id)) { - return "parameter"; - } - - if (sdcpn.differentialEquations.some((equation) => equation.id === id)) { - return "differentialEquation"; - } - - if (sdcpn.places.some((place) => place.id === id)) { - return "place"; - } - - if (sdcpn.transitions.some((transition) => transition.id === id)) { - return "transition"; + // Search root net and all subnets + const nets = [sdcpn, ...(sdcpn.subnets ?? [])]; + + for (const net of nets) { + if (net.types.some((type) => type.id === id)) { + return "type"; + } + + if (net.parameters.some((parameter) => parameter.id === id)) { + return "parameter"; + } + + if (net.differentialEquations.some((equation) => equation.id === id)) { + return "differentialEquation"; + } + + if (net.places.some((place) => place.id === id)) { + return "place"; + } + + 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 b7f770f9e94..dfb034145f3 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(() => { @@ -47,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/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/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/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 new file mode 100644 index 00000000000..cefba563ed9 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx @@ -0,0 +1,163 @@ +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 { 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"; + +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", + cursor: "pointer", + transition: "[background-color 100ms ease-out]", + _hover: { + backgroundColor: "neutral.bg.surface.hover", + }, + }, + variants: { + active: { + true: { + backgroundColor: "blue.s30", + fontWeight: "semibold", + _hover: { + backgroundColor: "blue.s40", + }, + }, + }, + }, +}); + +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); + 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} + > + + + + {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, +}; 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/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 d375382e9c4..9a12077272f 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/panel.tsx @@ -10,10 +10,12 @@ import { } from "../../../../constants/ui"; 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"; @@ -65,7 +67,8 @@ export const PropertiesPanel: React.FC = () => { isPanelAnimating, } = use(EditorContext); - const { petriNetDefinition } = use(SDCPNContext); + 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 = ( 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/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/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/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 c325b9824d0..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 @@ -3,10 +3,12 @@ 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 { UserSettingsContext } from "../../../state/user-settings-context"; import type { + EdgeType, NodeType, PetrinautReactFlowDefinitionObject, } from "../reactflow-types"; @@ -26,7 +28,8 @@ 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 { petriNetDefinition: fullSdcpn } = use(SDCPNContext); const { draggingStateByNodeId, isSelected } = use(EditorContext); const { currentViewedFrame } = use(PlaybackContext); const { compactNodes } = use(UserSettingsContext); @@ -98,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) @@ -120,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, @@ -162,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, @@ -187,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 d247115acb3..8b278f150af 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx @@ -21,12 +21,15 @@ 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"; 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"; @@ -35,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; @@ -79,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, @@ -94,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); @@ -252,8 +260,34 @@ export const SDCPNView: React.FC<{ return; } - // Only create nodes in add modes - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + 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; } @@ -333,7 +367,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"; @@ -359,7 +395,7 @@ export const SDCPNView: React.FC<{ > { @@ -397,6 +433,7 @@ export const SDCPNView: React.FC<{ {showMinimap && } + ); }; 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 */