From 786bfefb1ab728bf86d0c6e933ad36dd74bb618a Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 4 Jul 2026 02:49:51 +0200 Subject: [PATCH] FE-874: Prototype in-place subnet expansion with automatic layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Double-clicking a component instance expands it in place: the box becomes a frame containing the subnet's internal places/transitions (laid out with the existing ELK pipeline), and the surrounding net is displaced at display time to make room. Double-clicking the frame collapses it again. Expansion is pure view-layer state — stored x/y positions are never mutated. Displayed positions are `stored + deterministic shift`, and drag commits map back through the inverse shift so the model always stores undisplaced coordinates. Child node/edge ids are namespaced by instance id so multiple instances of the same subnet expand independently. Arcs into ports reattach directly to the port places inside the frame while expanded. Includes a Storybook fixture/story ("Subnets — expand in place") and a design doc (dev-docs/fe-874-expand-subnets-in-place.md) covering the approach, alternatives considered, limitations, and next steps. Co-Authored-By: Claude Fable 5 --- .../petrinaut-expand-subnets-in-place.md | 5 + .../fe-874-expand-subnets-in-place.md | 182 +++++++++ .../petrinaut/src/ui/petrinaut.stories.tsx | 209 ++++++++++ .../expanded-component-instance-node.tsx | 112 ++++++ .../SDCPN/hooks/use-apply-node-changes.ts | 21 +- .../views/SDCPN/hooks/use-expanded-subnets.ts | 237 +++++++++++ .../SDCPN/hooks/use-sdcpn-to-react-flow.ts | 367 +++++++++++++++++- .../src/ui/views/SDCPN/reactflow-types.ts | 22 +- .../src/ui/views/SDCPN/sdcpn-view.tsx | 126 +++++- 9 files changed, 1248 insertions(+), 33 deletions(-) create mode 100644 .changeset/petrinaut-expand-subnets-in-place.md create mode 100644 libs/@hashintel/petrinaut/dev-docs/fe-874-expand-subnets-in-place.md create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/expanded-component-instance-node.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-expanded-subnets.ts diff --git a/.changeset/petrinaut-expand-subnets-in-place.md b/.changeset/petrinaut-expand-subnets-in-place.md new file mode 100644 index 00000000000..8ae1bcee787 --- /dev/null +++ b/.changeset/petrinaut-expand-subnets-in-place.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": minor +--- + +Prototype: expand a subnet's component instance in place by double-clicking it. The instance becomes a frame showing the subnet's internal structure (auto-laid-out with ELK), the surrounding net is displaced at display time to make room, and double-clicking the frame collapses it again. View-layer only — stored positions are never mutated. diff --git a/libs/@hashintel/petrinaut/dev-docs/fe-874-expand-subnets-in-place.md b/libs/@hashintel/petrinaut/dev-docs/fe-874-expand-subnets-in-place.md new file mode 100644 index 00000000000..cbe60b67fb0 --- /dev/null +++ b/libs/@hashintel/petrinaut/dev-docs/fe-874-expand-subnets-in-place.md @@ -0,0 +1,182 @@ +# FE-874 — Expand subnets in place with automatic layout + +_Research + prototype notes. The prototype is implemented on this branch and +demonstrated by the Storybook story **"Subnets — expand in place (FE-874 +prototype)"** (`cd libs/@hashintel/petrinaut && yarn dev`)._ + +## Summary + +**Yes, this is possible with React Flow, and the prototype works.** React Flow +12 supports nested nodes ("subflows"): a node with `parentId` is positioned +relative to its parent, and edges may cross the parent boundary freely. Because +Petrinaut's canvas is fully controlled — every node is re-derived from the +SDCPN model on each render — we can display something different from what the +model stores without touching the model at all. That is exactly the shape the +ticket asks for: _"the current x,y positions that we expose are not what we +necessarily display."_ + +The prototype makes expansion a **pure view-layer state**: + +- double-click a component instance box → it becomes a large "frame" node + containing the subnet's internal places/transitions, laid out by ELK; +- the surrounding net is **displaced at display time** to make room — stored + positions never change; +- double-click the frame → collapses back, and the net returns to its stored + positions. + +## What the prototype does (files on this branch) + +| Piece | File | +| ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Expansion state, ELK layout of the subnet, displacement math | `src/ui/views/SDCPN/hooks/use-expanded-subnets.ts` | +| Frame node component (header + dashed border) | `src/ui/views/SDCPN/components/expanded-component-instance-node.tsx` | +| Node/edge derivation: frame + namespaced children, displaced positions, rewired port arcs | `src/ui/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts` | +| Double-click handler, connection/hover/selection guards | `src/ui/views/SDCPN/sdcpn-view.tsx` | +| Drag commits mapped displayed → stored coordinates | `src/ui/views/SDCPN/hooks/use-apply-node-changes.ts` | +| New `componentInstanceExpanded` node type | `src/ui/views/SDCPN/reactflow-types.ts` | +| Demo fixture: root net + two instances of the same Warehouse subnet | `src/ui/petrinaut.stories.tsx` | + +### Mechanism + +1. **Expand** (`onNodeDoubleClick` on a `componentInstance` node): run the + existing `calculateGraphLayout` (ELK, `layered`, direction RIGHT — the same + engine behind the "Layout" menu action) over the referenced subnet. The + result plus a header offset gives child positions and the frame size. This + is stored in React state keyed by **instance id** (not subnet id — two + instances of the same subnet expand independently). +2. **Render**: the instance node switches to type `componentInstanceExpanded` + with the computed width/height. Subnet elements are emitted as child nodes + with `parentId` = instance id and ids namespaced as + `::` so multiple instances of one subnet never + collide. Children are `draggable: false / selectable: false / +connectable: false` — a read-only projection of the subnet definition. +3. **Arc rewiring**: arcs that end on a `componentPort` of an expanded instance + attach **directly to the port place node inside the frame** instead of the + port handle on the box. Arc ids stay identical, so selection/hover logic is + unaffected. The subnet's internal arcs are also rendered (namespaced, + non-selectable). +4. **Displacement** (`computeExpansionShift`): an expanded frame grows + symmetrically around the instance's stored center. Every other node's + _displayed_ position is shifted away from that center by half the growth on + each axis ("insert space" rule): + `displayed = stored + Σ ±(Δw/2, Δh/2)` over all expanded instances. + The rule is **deterministic and invertible from stored positions**, which is + what makes dragging work while expanded: on drag-end, + `useApplyNodeChanges` maps the displayed drop position back through the + inverse shift before committing, so the model always stores undisplaced + coordinates. Undo/redo and multiplayer stay consistent. + +### What was verified (Playwright against the Storybook story) + +- Expanding either or both instances of the same subnet renders the correct + node/edge counts, ELK-arranged content, and edges flowing from the root net + into the inner port places and out again. +- Dragging a root place while both instances are expanded commits exactly two + model patches (`replace /places/0/x`, `/y`) with no visual snap-back — the + displayed↔stored round-trip is consistent. +- Collapsing restores the box, reattaches arcs to its port handles, and the + surrounding net returns to its stored positions. + +## Design decisions + +**View-layer expansion, not model mutation.** Expanding is an inspection +gesture; it must be non-destructive, undo-neutral, and per-user. The model +keeps one source of truth (`ComponentInstance` + `Subnet`), and the simulation +engine's existing runtime flattening +(`petrinaut-core/src/simulation/engine/flatten-component-instances.ts`) +remains the only place that materializes subnet contents. + +**Reuse the existing ELK pipeline.** `calculateGraphLayout` already accepts a +subnet (it only reads `places`/`transitions`/`componentInstances`) and returns +center coordinates with padding. No new layout dependency, and expanded +content matches the aesthetic of the "Layout" action. + +**"Insert space" displacement instead of physics.** The ticket suggests "some +kind of force" from subnet elements onto parent ones. A literal force +simulation (d3-force) is iterative and non-deterministic, which breaks the +requirement that drags remain committable: we need `displayed → stored` to be +a well-defined inverse function. The half-plane shift is O(nets), stable, +cannot create new overlaps, and inverts trivially. Its known cosmetic cost: +nodes diagonal to the frame move diagonally, and nodes far above/below still +shift horizontally — acceptable in practice (see screenshots/story). + +## Alternatives considered + +1. **Model-level flattening** — replace the instance with copies of the + subnet's places/transitions (what the simulator does at runtime). Simple to + build (reference implementation exists), but destructive: component + semantics, parameter bindings, and the subnet link are lost. Right shape + for a future explicit "inline component" action, wrong shape for + expand/inspect. +2. **Full-net ELK re-layout on expand** — give ELK the expanded node size and + re-lay-out the whole net. Best-looking result and no displacement math, but + it either overwrites the user's hand-tuned positions or forces layout to be + display-only for the entire net. Could still be offered as an optional + "re-layout with expanded view" action on top of the current approach. +3. **Force/overlap-removal displacement** — d3-force collision or ELK's + overlap-removal (`sporeOverlap`). More organic motion, but iterative, + order-sensitive, and effectively non-invertible for drag commits; also a + worse fit for the current synchronous, derive-from-model rendering. +4. **True nested editing via subflows** (`extent: "parent"`, + `expandParent`) — make children real, editable nodes whose positions write + back to the subnet definition. This is the natural **next milestone** on + top of the prototype: the id namespacing (`instanceId::elementId`) already + gives an unambiguous mapping back to the subnet, but position write-back, + arc editing, and "which instance's edit wins" semantics need product + decisions. +5. **Drill-down only (status quo)** — `activeSubnetId` navigation already + exists; it loses the parent context, which is precisely what FE-874 wants + to keep. + +## Known limitations / follow-ups + +- **No animation**: expansion/collapse snaps. Since nodes are controlled, + animating is a matter of interpolating displayed positions (or CSS + transitions on node transforms) between the two displacement states. +- **Double-click side effects**: the double-click also selects the instance + (opens the properties panel and triggers the recenter-on-panel-open + behaviour), and React Flow's default `zoomOnDoubleClick` zooms when the + gesture lands on the pane. Both should be suppressed/disabled for the final + UX; an explicit expand/collapse affordance on the node (icon button) is + worth considering alongside the gesture. +- **Read-only children**: elements inside the frame can't be selected, edited, + dragged, or connected yet (guarded deliberately so no invalid mutations can + occur). Editability is the next milestone (alternative 4). +- **Simulation state not projected**: token counts / firing states are not + shown on expanded children (the engine flattens with its own id scoping; a + mapping layer is needed). +- **Expansion state is ephemeral**: reset on net switch / drill-down; not + persisted per user or document. +- **Inverse displacement uses the pre-drag stored position**: dragging a node + _across_ an expanded frame's center axis lands it slightly off from the + cursor; also snap-to-grid applies to displayed (not stored) coordinates + while something is expanded. +- **Stale layout on concurrent subnet edits**: the ELK layout is computed at + expand time; elements added to the subnet afterwards (e.g. by a + collaborator) won't appear until re-expand. Recompute on subnet change if + this matters. +- **Nested component instances** render as collapsed boxes inside the frame; + recursive expansion needs path-based namespacing (`a::b::c`) — the id scheme + extends naturally. +- **User docs** (`docs/`, read by the in-app AI assistant) intentionally not + updated yet — the interaction is prototype-grade and may change. Must be + done before shipping. +- Per the ticket's Linear thread, the **Visualizer** needs a follow-up ticket + once this lands. + +## Proposed next steps + +1. **UX review of the prototype** (Storybook story): gesture, frame styling, + displacement feel, whether the properties panel should open on expand. +2. **Productionize**: move displacement + frame-layout math into + `petrinaut-core` (next to `layout/`), unit-test the shift/inverse + round-trip and frame sizing, disable `zoomOnDoubleClick`, suppress + select-on-expand, add an affordance + keyboard access, animate. +3. **Milestone 2 — edit in place**: make children real editable nodes writing + back to the subnet definition (position commits per subnet, arc editing + rules, multi-instance semantics). +4. **Milestone 3 — simulation overlay**: map engine's flattened ids back to + `instanceId::elementId` so token counts and firing animations render inside + expanded frames. +5. Update user docs + AI-assistant doc registry; create the Visualizer + follow-up ticket. diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx index e6206755912..b9d5004b6e4 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx @@ -450,6 +450,215 @@ export const StochasticTimingOnly: Story = { ), }; +/** + * Fixture for the FE-874 "expand subnets in place" prototype: a root net with + * two instances of the same Warehouse subnet, wired through its In/Out ports. + * Double-click an instance box on the canvas to expand it in place. + */ +const warehouseNetworkNet: SDCPN = { + places: [ + { + id: "p_supplier", + name: "Supplier", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 300, + showAsInitialState: true, + }, + { + id: "p_customers", + name: "Customers", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 1300, + y: 300, + }, + ], + transitions: [ + { + id: "t_dispatch_north", + name: "DispatchNorth", + inputArcs: [{ placeId: "p_supplier", weight: 1, type: "standard" }], + outputArcs: [ + { + endpoint: { + kind: "componentPort", + componentInstanceId: "ci_warehouse_north", + portPlaceId: "wp_inbound", + }, + weight: 1, + }, + ], + lambdaType: "predicate", + lambdaCode: "return true;", + transitionKernelCode: "", + x: 380, + y: 160, + }, + { + id: "t_dispatch_south", + name: "DispatchSouth", + inputArcs: [{ placeId: "p_supplier", weight: 1, type: "standard" }], + outputArcs: [ + { + endpoint: { + kind: "componentPort", + componentInstanceId: "ci_warehouse_south", + portPlaceId: "wp_inbound", + }, + weight: 1, + }, + ], + lambdaType: "predicate", + lambdaCode: "return true;", + transitionKernelCode: "", + x: 380, + y: 440, + }, + { + id: "t_deliver_north", + name: "DeliverNorth", + inputArcs: [ + { + endpoint: { + kind: "componentPort", + componentInstanceId: "ci_warehouse_north", + portPlaceId: "wp_outbound", + }, + weight: 1, + type: "standard", + }, + ], + outputArcs: [{ placeId: "p_customers", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "return true;", + transitionKernelCode: "", + x: 1020, + y: 160, + }, + { + id: "t_deliver_south", + name: "DeliverSouth", + inputArcs: [ + { + endpoint: { + kind: "componentPort", + componentInstanceId: "ci_warehouse_south", + portPlaceId: "wp_outbound", + }, + weight: 1, + type: "standard", + }, + ], + outputArcs: [{ placeId: "p_customers", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "return true;", + transitionKernelCode: "", + x: 1020, + y: 440, + }, + ], + componentInstances: [ + { + id: "ci_warehouse_north", + name: "WarehouseNorth", + subnetId: "subnet_warehouse", + parameterValues: {}, + x: 700, + y: 160, + }, + { + id: "ci_warehouse_south", + name: "WarehouseSouth", + subnetId: "subnet_warehouse", + parameterValues: {}, + x: 700, + y: 440, + }, + ], + subnets: [ + { + id: "subnet_warehouse", + name: "Warehouse", + places: [ + { + id: "wp_inbound", + name: "Inbound", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + isPort: true, + x: 100, + y: 100, + }, + { + id: "wp_storage", + name: "Storage", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 420, + y: 100, + }, + { + id: "wp_outbound", + name: "Outbound", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + isPort: true, + x: 740, + y: 100, + }, + ], + transitions: [ + { + id: "wt_store", + name: "Store", + inputArcs: [{ placeId: "wp_inbound", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "wp_storage", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "return true;", + transitionKernelCode: "", + x: 260, + y: 125, + }, + { + id: "wt_ship", + name: "Ship", + inputArcs: [{ placeId: "wp_storage", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "wp_outbound", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "return true;", + transitionKernelCode: "", + x: 580, + y: 125, + }, + ], + types: [], + differentialEquations: [], + parameters: [], + }, + ], + types: [], + parameters: [], + differentialEquations: [], +}; + +export const SubnetsExpandInPlace: Story = { + name: "Subnets — expand in place (FE-874 prototype)", + render: () => ( + + ), +}; + export const SubnetsWithColors: Story = { name: "Subnets — with colours", render: () => ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/expanded-component-instance-node.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/expanded-component-instance-node.tsx new file mode 100644 index 00000000000..99791b34b3b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/expanded-component-instance-node.tsx @@ -0,0 +1,112 @@ +import { type NodeProps } from "@xyflow/react"; +import { use } from "react"; + +import { Icon } from "@hashintel/ds-components"; +import { css, cva } from "@hashintel/ds-helpers/css"; + +import { EditorContext } from "../../../../react/state/editor-context"; +import { EXPANDED_FRAME_HEADER_HEIGHT } from "../hooks/use-expanded-subnets"; + +import type { ComponentInstanceExpandedNodeType } from "../reactflow-types"; + +const frameStyle = cva({ + base: { + width: "full", + height: "full", + border: "2px dashed", + borderColor: "neutral.s60", + borderRadius: "sm", + backgroundColor: "[rgba(243, 245, 247, 0.75)]", + transition: "[outline 0.2s ease, box-shadow 0.2s ease]", + outline: "[0px solid rgba(75, 126, 156, 0)]", + _hover: { + outline: "[4px solid rgba(75, 126, 156, 0.15)]", + }, + }, + variants: { + selection: { + resource: { + outline: "[4px solid rgba(59, 178, 246, 0.6)]", + }, + reactflow: { + outline: "[4px solid rgba(40, 172, 233, 0.6)]", + }, + none: {}, + }, + }, + defaultVariants: { + selection: "none", + }, +}); + +const headerStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + padding: "[0 12px]", + borderBottom: "1px solid", + borderColor: "neutral.s40", + backgroundColor: "[rgba(233, 236, 240, 0.9)]", + borderTopRadius: "sm", + cursor: "grab", +}); + +const titleStyle = css({ + fontSize: "sm", + fontWeight: "semibold", + color: "neutral.s120", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +const subtitleStyle = css({ + fontSize: "xs", + color: "neutral.s80", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +const hintStyle = css({ + marginLeft: "auto", + fontSize: "[10px]", + color: "neutral.s70", + whiteSpace: "nowrap", +}); + +const iconStyle = css({ + color: "neutral.s90", + flexShrink: "0", +}); + +/** + * Frame node rendered when a component instance is expanded in place + * (FE-874 prototype). The subnet's internal places/transitions are separate + * React Flow child nodes (`parentId`) rendered on top of this frame. + */ +export const ExpandedComponentInstanceNode: React.FC< + NodeProps +> = ({ id, data, selected }: NodeProps) => { + const { isSelected } = use(EditorContext); + + const selectionVariant = isSelected(id) + ? "resource" + : selected + ? "reactflow" + : "none"; + + return ( +
+
+ + {data.label} + {data.subnetName} + double-click to collapse +
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts index 74472767e75..2b79adf65e9 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts @@ -15,11 +15,23 @@ import type { EdgeChange, NodeChange } from "@xyflow/react"; * * @see https://github.com/xyflow/xyflow/blob/04055c9625cbd92cf83a2f4c340d6fae5199bfa3/packages/react/src/utils/changes.ts#L107 */ -export function useApplyNodeChanges() { +export function useApplyNodeChanges(options?: { + /** + * Maps a displayed canvas position back to a stored (model) position. + * Used while subnets are expanded in place (FE-874 prototype): the canvas + * displays displaced positions, but drag commits must store undisplaced + * coordinates. + */ + displayedPositionToModelPosition?: ( + nodeId: string, + position: { x: number; y: number }, + ) => { x: number; y: number }; +}) { const { getItemType } = use(SDCPNContext); const { commitNodePositions } = usePetrinautMutations(); const { updateDraggingStateByNodeId, setSelection } = use(EditorContext); const { snapToGrid } = use(UserSettingsContext); + const { displayedPositionToModelPosition } = options ?? {}; return (changes: (NodeChange | EdgeChange)[]) => { const positionCommits: Array<{ @@ -136,10 +148,15 @@ export function useApplyNodeChanges() { type === "transition" || type === "componentInstance" ) { + const displayedPosition = snapToGrid + ? snapPositionToGrid(position) + : position; commits.push({ id, itemType: type, - position: snapToGrid ? snapPositionToGrid(position) : position, + position: displayedPositionToModelPosition + ? displayedPositionToModelPosition(id, displayedPosition) + : displayedPosition, }); } } diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-expanded-subnets.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-expanded-subnets.ts new file mode 100644 index 00000000000..3c0832f8976 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-expanded-subnets.ts @@ -0,0 +1,237 @@ +import { useState } from "react"; + +import { + calculateGraphLayout, + layoutNodeDimensions, + type ComponentInstance, + type SDCPN, + type Subnet, +} from "@hashintel/petrinaut-core"; + +/** + * Prototype for FE-874 — expanding a component instance (subnet box) in place. + * + * Expansion is a **view-layer** concept: the SDCPN store is never mutated. + * When an instance is expanded we: + * 1. run ELK layout over the referenced subnet's internal net, + * 2. render the instance as a large "frame" node whose children (the + * subnet's places/transitions) are React Flow child nodes (`parentId`), + * 3. displace the *displayed* positions of the surrounding nodes so the + * frame has room — stored x/y positions in the model stay untouched. + * + * The displayed position of every node is `model position + expansion shift` + * (see {@link computeExpansionShift}); committing a drag applies the inverse. + */ + +/** + * Separator used to build React Flow node/edge ids for elements rendered + * inside an expanded instance. Ids must be namespaced by instance id because + * two instances of the same subnet would otherwise produce colliding ids. + */ +const EXPANDED_CHILD_ID_SEPARATOR = "::"; + +export const makeExpandedChildId = ( + instanceId: string, + childId: string, +): string => `${instanceId}${EXPANDED_CHILD_ID_SEPARATOR}${childId}`; + +/** Whether a React Flow node/edge id refers to an element inside an expanded instance. */ +export const isExpandedChildId = (id: string): boolean => + id.includes(EXPANDED_CHILD_ID_SEPARATOR); + +/** Height of the title bar rendered at the top of an expanded instance frame. */ +export const EXPANDED_FRAME_HEADER_HEIGHT = 40; + +/** Padding around the subnet content inside the frame (matches ELK's graph padding). */ +const EXPANDED_FRAME_PADDING = 30; + +/** + * Height of a collapsed component instance box, which grows with its port + * count. Must match the rendering in `use-sdcpn-to-react-flow.ts`. + */ +export const collapsedInstanceHeight = ( + minHeight: number, + portCount: number, +): number => Math.max(minHeight, portCount * 28 + 28); + +export type ExpandedSubnetLayout = { + /** + * Center positions of the subnet's internal nodes, relative to the + * top-left corner of the expanded frame (header offset already applied). + */ + positionsByNodeId: Record; + /** Total size of the expanded frame node. */ + width: number; + height: number; +}; + +export type ExpandedSubnetsByInstanceId = Record; + +/** + * A single expanded instance's contribution to the display-time displacement + * of the rest of the net. Expansion grows symmetrically around the instance's + * stored center, pushing surrounding nodes away on each axis. + */ +export type ExpansionDisplacementSource = { + instanceId: string; + /** Stored (model) center of the instance. */ + centerX: number; + centerY: number; + /** How much bigger the expanded frame is than the collapsed box. */ + deltaWidth: number; + deltaHeight: number; +}; + +/** + * Shift applied to a node's *stored* position to obtain its *displayed* + * position, given the currently expanded instances. + * + * The rule is an "insert space" displacement: every expanded instance pushes + * nodes strictly to its right further right by half its width growth (and + * symmetrically to the left / above / below). It is deterministic and + * invertible from the node's stored position, which lets drag commits map + * displayed coordinates back to stored coordinates. + */ +export function computeExpansionShift( + sources: ExpansionDisplacementSource[], + nodeId: string, + modelPosition: { x: number; y: number }, +): { dx: number; dy: number } { + let dx = 0; + let dy = 0; + for (const source of sources) { + if (source.instanceId === nodeId) { + // An instance is never displaced by its own expansion — it grows + // symmetrically around its stored center. + continue; + } + if (modelPosition.x > source.centerX) { + dx += source.deltaWidth / 2; + } else if (modelPosition.x < source.centerX) { + dx -= source.deltaWidth / 2; + } + if (modelPosition.y > source.centerY) { + dy += source.deltaHeight / 2; + } else if (modelPosition.y < source.centerY) { + dy -= source.deltaHeight / 2; + } + } + return { dx, dy }; +} + +/** + * Builds the displacement sources for the currently expanded instances of the + * active net. `collapsedDimensions` are the rendering dimensions of a + * collapsed component instance box. + */ +export function computeDisplacementSources( + componentInstances: ComponentInstance[], + subnets: Subnet[], + expandedSubnets: ExpandedSubnetsByInstanceId, + collapsedDimensions: { width: number; height: number }, +): ExpansionDisplacementSource[] { + const sources: ExpansionDisplacementSource[] = []; + for (const instance of componentInstances) { + const layout = expandedSubnets[instance.id]; + if (!layout) { + continue; + } + const subnet = subnets.find(({ id }) => id === instance.subnetId); + const portCount = + subnet?.places.filter((place) => place.isPort).length ?? 0; + const collapsedHeight = collapsedInstanceHeight( + collapsedDimensions.height, + portCount, + ); + sources.push({ + instanceId: instance.id, + centerX: instance.x, + centerY: instance.y, + deltaWidth: Math.max(0, layout.width - collapsedDimensions.width), + deltaHeight: Math.max(0, layout.height - collapsedHeight), + }); + } + return sources; +} + +const dimensionsForSubnetNode = ( + subnet: Subnet, + nodeId: string, +): { width: number; height: number } => { + if (subnet.places.some(({ id }) => id === nodeId)) { + return layoutNodeDimensions.place; + } + if ((subnet.componentInstances ?? []).some(({ id }) => id === nodeId)) { + return ( + layoutNodeDimensions.componentInstance ?? layoutNodeDimensions.transition + ); + } + return layoutNodeDimensions.transition; +}; + +/** + * Holds which component instances are currently expanded in place, along with + * the ELK-computed layout of their subnet content. Purely view state. + */ +export function useExpandedSubnets() { + const [expandedSubnets, setExpandedSubnets] = + useState({}); + + async function expandInstance(instanceId: string, subnet: Subnet) { + // The subnet is a full nested net definition; ELK layout only reads + // places / transitions / componentInstances from it. + const layoutInput: SDCPN = { ...subnet, subnets: [] }; + const rawPositions = await calculateGraphLayout( + layoutInput, + layoutNodeDimensions, + ); + + // ELK output uses center coordinates with `EXPANDED_FRAME_PADDING` + // already applied on the top/left. Compute the frame size from the + // content bounds and shift content down to make room for the header. + let maxRight = 0; + let maxBottom = 0; + const positionsByNodeId: ExpandedSubnetLayout["positionsByNodeId"] = {}; + for (const [nodeId, position] of Object.entries(rawPositions)) { + const { width, height } = dimensionsForSubnetNode(subnet, nodeId); + maxRight = Math.max(maxRight, position.x + width / 2); + maxBottom = Math.max(maxBottom, position.y + height / 2); + positionsByNodeId[nodeId] = { + x: position.x, + y: position.y + EXPANDED_FRAME_HEADER_HEIGHT, + }; + } + + const width = Math.max(maxRight + EXPANDED_FRAME_PADDING, 240); + const height = Math.max( + maxBottom + EXPANDED_FRAME_PADDING + EXPANDED_FRAME_HEADER_HEIGHT, + 120, + ); + + setExpandedSubnets((existing) => ({ + ...existing, + [instanceId]: { positionsByNodeId, width, height }, + })); + } + + function collapseInstance(instanceId: string) { + setExpandedSubnets((existing) => { + if (!(instanceId in existing)) { + return existing; + } + const { [instanceId]: _, ...rest } = existing; + return rest; + }); + } + + function resetExpandedSubnets() { + setExpandedSubnets({}); + } + + return { + expandedSubnets, + expandInstance, + collapseInstance, + resetExpandedSubnets, + }; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts index 95e4ac34283..cb2298faf1d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts @@ -21,14 +21,28 @@ import { compactNodeDimensions, } from "../node-dimensions"; import { NOT_SELECTED_CONNECTION_OVERLAY_OPACITY } from "../styles/styling"; +import { + collapsedInstanceHeight, + computeDisplacementSources, + computeExpansionShift, + makeExpandedChildId, + type ExpandedSubnetsByInstanceId, +} from "./use-expanded-subnets"; import type { EdgeType, NodeType, PetrinautReactFlowDefinitionObject, } from "../reactflow-types"; +import type { ArcEndpoint } from "@hashintel/petrinaut-core"; -export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { +export function useSdcpnToReactFlow( + /** + * Component instances of the active net currently expanded in place + * (FE-874 prototype). Empty when nothing is expanded. + */ + expandedSubnets: ExpandedSubnetsByInstanceId = {}, +): PetrinautReactFlowDefinitionObject { const { activeNet: petriNetDefinition } = use(ActiveNetContext); const { extensions, petriNetDefinition: fullSdcpn } = use(SDCPNContext); const { @@ -45,6 +59,34 @@ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { ? compactNodeDimensions : classicNodeDimensions; + const subnets = fullSdcpn.subnets ?? []; + + /** + * Display-time displacement of the net around expanded instances: stored + * positions stay untouched, displayed positions shift to make room. + */ + const displacementSources = computeDisplacementSources( + petriNetDefinition.componentInstances, + subnets, + expandedSubnets, + dimensions.componentInstance, + ); + + const displayPosition = ( + nodeId: string, + modelPosition: { x: number; y: number }, + ): { x: number; y: number } => { + if (displacementSources.length === 0) { + return modelPosition; + } + const { dx, dy } = computeExpansionShift( + displacementSources, + nodeId, + modelPosition, + ); + return { x: modelPosition.x + dx, y: modelPosition.y + dy }; + }; + const nodes: NodeType[] = []; for (const place of petriNetDefinition.places) { @@ -62,7 +104,7 @@ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { type: "place", position: draggingState?.dragging ? draggingState.position - : { x: place.x, y: place.y }, + : displayPosition(place.id, { x: place.x, y: place.y }), width: dimensions.place.width, height: dimensions.place.height, measured: { @@ -97,7 +139,7 @@ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { type: "transition", position: draggingState?.dragging ? draggingState.position - : { x: transition.x, y: transition.y }, + : displayPosition(transition.id, { x: transition.x, y: transition.y }), width: dimensions.transition.width, height: dimensions.transition.height, measured: { @@ -119,21 +161,159 @@ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { for (const instance of petriNetDefinition.componentInstances) { const draggingState = draggingStateByNodeId[instance.id]; - const subnet = (fullSdcpn.subnets ?? []).find( - ({ id }) => id === instance.subnetId, - ); + const subnet = subnets.find(({ id }) => id === instance.subnetId); + const expandedLayout = expandedSubnets[instance.id]; + + if (expandedLayout && subnet) { + // Expanded in place: a frame node whose children are the subnet's + // internal elements, positioned by the ELK layout computed on expand. + nodes.push({ + id: instance.id, + type: "componentInstanceExpanded", + position: draggingState?.dragging + ? draggingState.position + : displayPosition(instance.id, { x: instance.x, y: instance.y }), + width: expandedLayout.width, + height: expandedLayout.height, + measured: { + width: expandedLayout.width, + height: expandedLayout.height, + }, + dragging: draggingState?.dragging ?? false, + selected: isSelected(instance.id), + data: { + label: instance.name, + type: "componentInstanceExpanded", + subnetName: subnet.name, + }, + }); + + for (const place of subnet.places) { + const childPosition = expandedLayout.positionsByNodeId[place.id]; + if (!childPosition) { + continue; + } + const placeType = + extensions.colors && place.colorId + ? subnet.types.find((type) => type.id === place.colorId) + : null; + nodes.push({ + id: makeExpandedChildId(instance.id, place.id), + type: "place", + parentId: instance.id, + position: childPosition, + width: dimensions.place.width, + height: dimensions.place.height, + measured: { + width: dimensions.place.width, + height: dimensions.place.height, + }, + draggable: false, + selectable: false, + connectable: false, + data: { + label: place.name, + type: "place", + dynamicsEnabled: + extensions.colors && extensions.dynamics && place.dynamicsEnabled, + hasColorType: !!(placeType && placeType.elements.length > 0), + hasVisualizer: !!place.visualizerCode, + typeColor: placeType?.displayColor, + }, + }); + } + + for (const transition of subnet.transitions) { + const childPosition = expandedLayout.positionsByNodeId[transition.id]; + if (!childPosition) { + continue; + } + const logicAvailability = getTransitionLogicAvailability( + transition, + fullSdcpn, + extensions, + subnet, + ); + nodes.push({ + id: makeExpandedChildId(instance.id, transition.id), + type: "transition", + parentId: instance.id, + position: childPosition, + width: dimensions.transition.width, + height: dimensions.transition.height, + measured: { + width: dimensions.transition.width, + height: dimensions.transition.height, + }, + draggable: false, + selectable: false, + connectable: false, + data: { + label: transition.name, + type: "transition", + lambdaType: logicAvailability.lambda + ? getEffectiveTransitionLambdaType(transition, logicAvailability) + : "none", + frame: null, + }, + }); + } + + for (const nested of subnet.componentInstances ?? []) { + const childPosition = expandedLayout.positionsByNodeId[nested.id]; + if (!childPosition) { + continue; + } + const nestedSubnet = subnets.find(({ id }) => id === nested.subnetId); + const nestedPorts = (nestedSubnet?.places ?? []) + .filter((place) => place.isPort) + .map((place) => ({ id: place.id, name: place.name })); + nodes.push({ + id: makeExpandedChildId(instance.id, nested.id), + type: "componentInstance", + parentId: instance.id, + position: childPosition, + width: dimensions.componentInstance.width, + height: collapsedInstanceHeight( + dimensions.componentInstance.height, + nestedPorts.length, + ), + measured: { + width: dimensions.componentInstance.width, + height: collapsedInstanceHeight( + dimensions.componentInstance.height, + nestedPorts.length, + ), + }, + draggable: false, + selectable: false, + connectable: false, + data: { + label: nested.name, + type: "componentInstance", + subnetName: nestedSubnet?.name ?? "Unknown subnet", + ports: nestedPorts, + }, + }); + } + + continue; + } + const ports = (subnet?.places ?? []) .filter((place) => place.isPort) .map((place) => ({ id: place.id, name: place.name })); - const minHeight = dimensions.componentInstance.height; - const portBasedHeight = Math.max(minHeight, ports.length * 28 + 28); + const portBasedHeight = collapsedInstanceHeight( + dimensions.componentInstance.height, + ports.length, + ); nodes.push({ id: instance.id, type: "componentInstance", position: draggingState?.dragging ? draggingState.position - : { x: instance.x, y: instance.y }, + : displayPosition(instance.id, { x: instance.x, y: instance.y }), width: dimensions.componentInstance.width, height: portBasedHeight, measured: { @@ -153,6 +333,34 @@ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { const edges: EdgeType[] = []; + /** + * Resolves the React Flow node + handle an arc endpoint attaches to. + * A componentPort endpoint normally attaches to a port handle on the + * collapsed instance box; when the instance is expanded it attaches + * directly to the port place rendered inside the frame. + */ + const resolveEndpointRef = ( + endpoint: ArcEndpoint, + direction: "in" | "out", + ): { nodeId: string; handleId: string | undefined } => { + if (endpoint.kind === "componentPort") { + if (expandedSubnets[endpoint.componentInstanceId]) { + return { + nodeId: makeExpandedChildId( + endpoint.componentInstanceId, + endpoint.portPlaceId, + ), + handleId: undefined, + }; + } + return { + nodeId: endpoint.componentInstanceId, + handleId: `port-${direction}-${endpoint.portPlaceId}`, + }; + } + return { nodeId: endpoint.placeId, handleId: undefined }; + }; + const getEndpointColor = ( endpoint: ReturnType, ): string | undefined => { @@ -196,16 +404,17 @@ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { isNotHoveredConnection(arcId) || (!hoveredItem && isNotSelectedConnection(arcId)); if (notSelectedConnection) { - arcColor = `color-mix(in oklab, white ${NOT_SELECTED_CONNECTION_OVERLAY_OPACITY * 100}%, ${arcColor})`; + arcColor = `color-mix(in oklab, white ${ + NOT_SELECTED_CONNECTION_OVERLAY_OPACITY * 100 + }%, ${arcColor})`; } + const sourceRef = resolveEndpointRef(endpoint, "out"); + edges.push({ id: arcId, - source: getArcEndpointNodeId(endpoint), - sourceHandle: - endpoint.kind === "componentPort" - ? `port-out-${endpoint.portPlaceId}` - : undefined, + source: sourceRef.nodeId, + sourceHandle: sourceRef.handleId, target: transition.id, type: "default", selected: isSelected(arcId), @@ -242,17 +451,18 @@ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { isNotHoveredConnection(arcId) || (!hoveredItem && isNotSelectedConnection(arcId)); if (notSelectedConnection) { - arcColor = `color-mix(in oklab, white ${NOT_SELECTED_CONNECTION_OVERLAY_OPACITY * 100}%, ${arcColor})`; + arcColor = `color-mix(in oklab, white ${ + NOT_SELECTED_CONNECTION_OVERLAY_OPACITY * 100 + }%, ${arcColor})`; } + const targetRef = resolveEndpointRef(endpoint, "in"); + edges.push({ id: arcId, source: transition.id, - target: getArcEndpointNodeId(endpoint), - targetHandle: - endpoint.kind === "componentPort" - ? `port-in-${endpoint.portPlaceId}` - : undefined, + target: targetRef.nodeId, + targetHandle: targetRef.handleId, type: "default", selected: isSelected(arcId), markerEnd: { @@ -274,6 +484,121 @@ export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { } } + // Internal arcs of expanded instances, namespaced by instance id so two + // instances of the same subnet don't collide. Not selectable — they are a + // read-only projection of the subnet definition. + for (const instance of petriNetDefinition.componentInstances) { + const expandedLayout = expandedSubnets[instance.id]; + const subnet = subnets.find(({ id }) => id === instance.subnetId); + if (!expandedLayout || !subnet) { + continue; + } + + const getSubnetEndpointColor = ( + endpoint: ReturnType, + ): string | undefined => { + if (endpoint.kind !== "place" || !extensions.colors) { + return undefined; + } + const place = subnet.places.find((pl) => pl.id === endpoint.placeId); + return place?.colorId + ? subnet.types.find((type) => type.id === place.colorId)?.displayColor + : undefined; + }; + + for (const transition of subnet.transitions) { + const transitionChildId = makeExpandedChildId(instance.id, transition.id); + + for (const inputArc of transition.inputArcs) { + const endpoint = getArcEndpoint(inputArc); + const endpointColor = getSubnetEndpointColor(endpoint); + const arcColor = endpointColor + ? hexToHsl(endpointColor).lighten(-15).saturate(-30).css(1) + : "#777"; + + edges.push({ + id: makeExpandedChildId( + instance.id, + generateArcId({ + inputId: getArcEndpointKey(endpoint), + outputId: transition.id, + }), + ), + source: makeExpandedChildId( + instance.id, + getArcEndpointNodeId(endpoint), + ), + sourceHandle: + endpoint.kind === "componentPort" + ? `port-out-${endpoint.portPlaceId}` + : undefined, + target: transitionChildId, + type: "default", + selectable: false, + markerEnd: { + type: MarkerType.ArrowClosed, + color: arcColor, + width: 20, + height: 20, + }, + style: { + stroke: arcColor, + strokeWidth: 2, + }, + data: { + weight: inputArc.weight, + arcType: inputArc.type, + frame: null, + }, + }); + } + + for (const outputArc of transition.outputArcs) { + const endpoint = getArcEndpoint(outputArc); + const endpointColor = getSubnetEndpointColor(endpoint); + const arcColor = endpointColor + ? hexToHsl(endpointColor).lighten(-15).saturate(-30).css(1) + : "#777"; + + edges.push({ + id: makeExpandedChildId( + instance.id, + generateArcId({ + inputId: transition.id, + outputId: getArcEndpointKey(endpoint), + }), + ), + source: transitionChildId, + target: makeExpandedChildId( + instance.id, + getArcEndpointNodeId(endpoint), + ), + targetHandle: + endpoint.kind === "componentPort" + ? `port-in-${endpoint.portPlaceId}` + : undefined, + type: "default", + selectable: false, + markerEnd: { + type: MarkerType.ArrowClosed, + color: arcColor, + width: 20, + height: 20, + }, + style: { + stroke: arcColor, + strokeWidth: 2, + }, + data: { + weight: outputArc.weight, + arcType: "standard", + frame: null, + }, + }); + } + } + } + return { nodes, edges, diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/reactflow-types.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/reactflow-types.ts index 521fcb8f234..31143a81e91 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/reactflow-types.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/reactflow-types.ts @@ -71,15 +71,33 @@ export type ComponentInstanceNodeType = Node< "componentInstance" >; +/** + * Data for the frame node rendered when a component instance is expanded in + * place (FE-874 prototype). The node id is the component instance id; the + * subnet's internal elements are separate child nodes. + */ +export type ComponentInstanceExpandedNodeData = { + label: string; + type: "componentInstanceExpanded"; + subnetName: string; +}; + +export type ComponentInstanceExpandedNodeType = Node< + ComponentInstanceExpandedNodeData, + "componentInstanceExpanded" +>; + export type NodeData = | PlaceNodeData | TransitionNodeData - | ComponentInstanceNodeData; + | ComponentInstanceNodeData + | ComponentInstanceExpandedNodeData; export type NodeType = | TransitionNodeType | PlaceNodeType - | ComponentInstanceNodeType; + | ComponentInstanceNodeType + | ComponentInstanceExpandedNodeType; export type EdgeType = ArcType; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx index 611d0eef4fe..282177ff536 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx @@ -17,6 +17,7 @@ import { } from "@hashintel/petrinaut-core"; import { usePetrinautMutations } from "../../../react"; +import { ActiveNetContext } from "../../../react/state/active-net-context"; import { EditorContext } from "../../../react/state/editor-context"; import { SDCPNContext } from "../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../react/state/use-is-read-only"; @@ -28,15 +29,26 @@ import { ClassicPlaceNode } from "./components/classic-place-node"; import { ClassicTransitionNode } from "./components/classic-transition-node"; import { ComponentInstanceNode } from "./components/component-instance-node"; import { CursorTooltip } from "./components/cursor-tooltip"; +import { ExpandedComponentInstanceNode } from "./components/expanded-component-instance-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 { useApplyNodeChanges } from "./hooks/use-apply-node-changes"; +import { + computeDisplacementSources, + computeExpansionShift, + isExpandedChildId, + useExpandedSubnets, +} from "./hooks/use-expanded-subnets"; import { useRecenterOnPanelOpen } from "./hooks/use-recenter-on-panel-open"; import { useSdcpnToReactFlow } from "./hooks/use-sdcpn-to-react-flow"; import { useDebounceCallback } from "./hooks/util/use-debounce-callback"; import { useResizeObserver } from "./hooks/util/use-resize-observer"; +import { + classicNodeDimensions, + compactNodeDimensions, +} from "./node-dimensions"; import type { ViewportAction } from "../../types/viewport-action"; import type { PetrinautReactFlowInstance } from "./reactflow-types"; @@ -78,12 +90,14 @@ const COMPACT_NODE_TYPES = { place: PlaceNode, transition: TransitionNode, componentInstance: ComponentInstanceNode, + componentInstanceExpanded: ExpandedComponentInstanceNode, }; const CLASSIC_NODE_TYPES = { place: ClassicPlaceNode, transition: ClassicTransitionNode, componentInstance: ComponentInstanceNode, + componentInstanceExpanded: ExpandedComponentInstanceNode, }; const REACTFLOW_EDGE_TYPES = { @@ -131,9 +145,26 @@ export const SDCPNView: React.FC<{ // SDCPN store const { petriNetId, petriNetDefinition } = use(SDCPNContext); + const { activeNet, activeSubnetId } = use(ActiveNetContext); const { addPlace, addTransition, addArc, addComponentInstance } = usePetrinautMutations(); + // In-place expansion of component instances (FE-874 prototype). + const { + expandedSubnets, + expandInstance, + collapseInstance, + resetExpandedSubnets, + } = useExpandedSubnets(); + + // Expansion state is scoped to the net currently displayed on the canvas. + const resetExpandedOnNetChange = useEffectEvent(() => { + resetExpandedSubnets(); + }); + useEffect(() => { + resetExpandedOnNetChange(); + }, [petriNetId, activeSubnetId]); + const { editionMode, setEditionMode, @@ -148,11 +179,40 @@ export const SDCPNView: React.FC<{ } = use(EditorContext); const isActualMode = globalMode === "actual"; - // Hook for applying node changes - const applyNodeChanges = useApplyNodeChanges(); + // Hook for applying node changes. While instances are expanded in place, + // displayed positions are displaced copies of the stored positions, so + // drag commits must map back through the inverse shift. + const applyNodeChanges = useApplyNodeChanges({ + displayedPositionToModelPosition: (nodeId, position) => { + const displacementSources = computeDisplacementSources( + activeNet.componentInstances, + petriNetDefinition.subnets ?? [], + expandedSubnets, + (compactNodes ? compactNodeDimensions : classicNodeDimensions) + .componentInstance, + ); + if (displacementSources.length === 0) { + return position; + } + const modelItem = + activeNet.places.find(({ id }) => id === nodeId) ?? + activeNet.transitions.find(({ id }) => id === nodeId) ?? + activeNet.componentInstances.find(({ id }) => id === nodeId); + if (!modelItem) { + return position; + } + // The shift is derived from the node's pre-drag stored position; for + // drags that cross an expanded frame's axis this is an approximation. + const { dx, dy } = computeExpansionShift(displacementSources, nodeId, { + x: modelItem.x, + y: modelItem.y, + }); + return { x: position.x - dx, y: position.y - dy }; + }, + }); // Convert SDCPN to ReactFlow format with dragging state - const { nodes, edges } = useSdcpnToReactFlow(); + const { nodes, edges } = useSdcpnToReactFlow(expandedSubnets); // When a panel opens, recenter the viewport to keep selected nodes visible useRecenterOnPanelOpen(canvasContainer, reactFlowInstance, nodes); @@ -212,6 +272,15 @@ export const SDCPNView: React.FC<{ const isReadonly = useIsReadOnly(); function isValidConnection(connection: Connection) { + // Elements rendered inside an expanded instance are a read-only + // projection of the subnet definition — no new arcs can attach to them. + if ( + isExpandedChildId(connection.source) || + isExpandedChildId(connection.target) + ) { + return false; + } + const sourceNode = nodes.find((node) => node.id === connection.source); const targetNode = nodes.find((node) => node.id === connection.target); @@ -359,21 +428,58 @@ export const SDCPNView: React.FC<{ // Edge selection is handled here instead of in applyNodeChanges, // because we want edges selectable only by click, not by drag-to-select. function onEdgeClick(_event: React.MouseEvent, edge: { id: string }) { + if (isExpandedChildId(edge.id)) { + return; + } selectItem({ type: "arc", id: edge.id, }); } + /** + * Double-clicking a component instance expands it in place; double-clicking + * the expanded frame collapses it again (FE-874 prototype). + */ + function onNodeDoubleClick( + _event: React.MouseEvent, + node: { id: string; type?: string }, + ) { + if (isExpandedChildId(node.id)) { + return; + } + if (node.type === "componentInstance") { + const instance = activeNet.componentInstances.find( + ({ id }) => id === node.id, + ); + const subnet = (petriNetDefinition.subnets ?? []).find( + ({ id }) => id === instance?.subnetId, + ); + if (instance && subnet) { + void expandInstance(instance.id, subnet); + } + } else if (node.type === "componentInstanceExpanded") { + collapseInstance(node.id); + } + } + function onNodeMouseEnter( _event: React.MouseEvent, node: { id: string; type?: string }, ) { - const type = node.type as - | "place" - | "transition" - | "componentInstance" - | undefined; + // Subnet elements inside an expanded frame don't exist in the active + // net, so hovering them must not drive hover-connection highlighting. + if (isExpandedChildId(node.id)) { + return; + } + const type = + node.type === "componentInstanceExpanded" + ? "componentInstance" + : (node.type as + | "place" + | "transition" + | "componentInstance" + | undefined); if (type) setHoveredItem({ type, id: node.id }); } @@ -382,6 +488,9 @@ export const SDCPNView: React.FC<{ } function onEdgeMouseEnter(_event: React.MouseEvent, edge: { id: string }) { + if (isExpandedChildId(edge.id)) { + return; + } setHoveredItem({ type: "arc", id: edge.id, @@ -547,6 +656,7 @@ export const SDCPNView: React.FC<{ onConnect={isReadonly ? undefined : onConnect} onInit={onInit} onEdgeClick={onEdgeClick} + onNodeDoubleClick={onNodeDoubleClick} onNodeMouseEnter={onNodeMouseEnter} onNodeMouseLeave={onNodeMouseLeave} onEdgeMouseEnter={onEdgeMouseEnter}