From f23d6bbc796ff0020686afeca2e8b3dd663e9c1a Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 16:24:27 +0200 Subject: [PATCH] Add experimental automatic arc connections --- .changeset/quiet-arcs-follow.md | 5 + .../petrinaut/docs/drawing-a-net.md | 12 + .../petrinaut/docs/visual-settings.md | 6 + .../src/react/state/user-settings-context.ts | 4 + .../state/user-settings-provider.test.tsx | 58 +++++ .../react/state/user-settings-provider.tsx | 5 + .../ui/automatic-arc-connections.stories.tsx | 158 +++++++++++++ .../create-experiment-drawer.test.tsx | 1 + .../components/viewport-settings-dialog.tsx | 30 ++- .../react-flow/react-flow-canvas.tsx | 70 +++++- .../react-flow/react-flow-canvas/arc.tsx | 5 +- .../react-flow-canvas/classic-place-node.tsx | 18 +- .../classic-transition-node.tsx | 17 +- .../react-flow-canvas/node-card.tsx | 17 +- .../outline-connection-line.tsx | 69 ++++++ .../react-flow-canvas/port-handles.ts | 4 +- .../react-flow-canvas/react-flow-types.ts | 5 +- .../react-flow-canvas/shared/node-handles.tsx | 156 +++++++++++++ .../shared/outline-arcs.test.ts | 112 +++++++++ .../react-flow-canvas/shared/outline-arcs.ts | 132 +++++++++++ .../use-react-flow-elements.test.tsx | 219 ++++++++++++++++++ .../use-react-flow-elements.ts | 32 ++- 22 files changed, 1077 insertions(+), 58 deletions(-) create mode 100644 .changeset/quiet-arcs-follow.md create mode 100644 libs/@hashintel/petrinaut/src/ui/automatic-arc-connections.stories.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/outline-connection-line.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/node-handles.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/outline-arcs.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/outline-arcs.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.test.tsx diff --git a/.changeset/quiet-arcs-follow.md b/.changeset/quiet-arcs-follow.md new file mode 100644 index 00000000000..634d00e8e48 --- /dev/null +++ b/.changeset/quiet-arcs-follow.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Add experimental automatic arc connections with a single outgoing hover handle and curves that follow place and transition outlines. diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md index 783ac50134f..00a2243960a 100644 --- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md +++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md @@ -78,6 +78,18 @@ Petri nets are bipartite: you cannot connect a place to another place or a trans ![drawing-arc](https://github.com/user-attachments/assets/ac688560-bba8-44fe-a6f8-c7ff320474a4) +### Automatic arc connections (experimental) + +Enable **Automatic arc connections** in [Viewport Settings](visual-settings.md#automatic-arc-connections-experimental) to try a different way to connect nodes: + +1. Hover over a place or transition to reveal its outgoing handle. +2. Drag the handle onto the target node. A blue outline shows a valid target. +3. Release to create the arc. Its endpoints follow the node outlines when you move either node. + +Drag from the source: place to transition creates an input arc; transition to place creates an output arc. Release on empty space or press **Escape** to cancel. Dropping onto a subnet does not create an arc in this mode. + +You can also focus the outgoing handle with **Tab**, press **Enter** or **Space**, then focus a target and press **Enter** or **Space** again. On touch devices, the outgoing handle stays visible. + ## Component ports Subnets can expose selected places as ports. If you don't see subnet or component controls, enable **Settings → Net Components** first. diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index 6fefbefe11b..c5003a06099 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -135,6 +135,12 @@ Off by default. Adds an interval toggle to every numeric value of the experiment Shown only when the host application provides an optimizer that runs in your browser. Off by default. On, the experiment form's interval toggles read **Optimize**: creating the experiment starts a study over the selected intervals, with an **Objective** and **Constraints** chosen in the form. Off, the toggles read **Sweep** and the sweep waits for your selection; any running in-browser optimization is cancelled. See [Optimizing a sweep](experiments.md#optimizing-a-sweep). +### Automatic arc connections (experimental) + +Off by default. Hides the fixed handles on places and transitions. Hover over a node to reveal one outgoing handle, then drag it onto a place or transition to create an arc. Arcs attach to the node outlines and adjust their direction as you move nodes. Opposite directions use separate curves. + +This setting uses automatic curves and temporarily disables the **Arcs rendering** selector. Turning it off restores your previous style. Existing subnet connections stay visible; turn the experiment off to create connections through subnet ports. See [Connecting with arcs](drawing-a-net.md#connecting-with-arcs). + ### Arcs rendering Choose how arcs are drawn between nodes: diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts index 5271fba5310..3e5528a2467 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts @@ -35,6 +35,7 @@ export type UserSettings = { keepPanelsMounted: boolean; compactNodes: boolean; enableExperimentalIconPack: boolean; + enableAutomaticArcConnections: boolean; arcRendering: ArcRendering; cursorMode: CursorMode; isLeftSidebarOpen: boolean; @@ -114,6 +115,7 @@ export type UserSettingsActions = { setKeepPanelsMounted: (value: boolean) => void; setCompactNodes: (value: boolean) => void; setEnableExperimentalIconPack: (value: boolean) => void; + setEnableAutomaticArcConnections: (value: boolean) => void; setArcRendering: (value: ArcRendering) => void; setIsLeftSidebarOpen: (value: boolean) => void; setLeftSidebarWidth: (value: number) => void; @@ -150,6 +152,7 @@ export const defaultUserSettings: UserSettings = { keepPanelsMounted: true, compactNodes: false, enableExperimentalIconPack: false, + enableAutomaticArcConnections: false, arcRendering: "custom", cursorMode: "pan", isLeftSidebarOpen: true, @@ -185,6 +188,7 @@ export const defaultUserSettingsContextValue: UserSettingsContextValue = { setKeepPanelsMounted: () => {}, setCompactNodes: () => {}, setEnableExperimentalIconPack: () => {}, + setEnableAutomaticArcConnections: () => {}, setArcRendering: () => {}, setIsLeftSidebarOpen: () => {}, setLeftSidebarWidth: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.test.tsx b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.test.tsx index 6530852f3e8..0f6ae45762d 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.test.tsx @@ -43,7 +43,65 @@ const WalkthroughProbe = () => { ); }; +const ArcConnectionsProbe = () => { + const { enableAutomaticArcConnections, setEnableAutomaticArcConnections } = + use(UserSettingsContext); + return ( + + ); +}; + describe("UserSettingsProvider", () => { + it("defaults automatic arcs off for saved preferences from before the experiment", () => { + localStorage.setItem( + "petrinaut:user-settings", + JSON.stringify({ compactNodes: false }), + ); + render( + + + , + ); + expect( + screen.getByRole("button", { name: "Automatic arcs: off" }), + ).toBeTruthy(); + }); + + it("persists automatic arcs independently of the saved arc style", () => { + localStorage.setItem( + "petrinaut:user-settings", + JSON.stringify({ arcRendering: "smoothstep" }), + ); + const first = render( + + + , + ); + fireEvent.click( + screen.getByRole("button", { name: "Automatic arcs: off" }), + ); + first.unmount(); + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Automatic arcs: on" })); + expect( + JSON.parse(localStorage.getItem("petrinaut:user-settings") ?? "{}"), + ).toMatchObject({ + enableAutomaticArcConnections: false, + arcRendering: "smoothstep", + }); + }); + it("starts with Brunch demo mode off and toggles it", () => { render( diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx index 7c0a250e88d..7a9eb8bbc59 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx @@ -97,6 +97,11 @@ const OwnedUserSettingsProvider: React.FC = ({ ...settings, enableExperimentalIconPack: value, })), + setEnableAutomaticArcConnections: (value: boolean) => + setState((settings) => ({ + ...settings, + enableAutomaticArcConnections: value, + })), setArcRendering: (value: ArcRendering) => setState((prev) => ({ ...prev, arcRendering: value })), setCursorMode: (value: CursorMode) => diff --git a/libs/@hashintel/petrinaut/src/ui/automatic-arc-connections.stories.tsx b/libs/@hashintel/petrinaut/src/ui/automatic-arc-connections.stories.tsx new file mode 100644 index 00000000000..415f6fde938 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/automatic-arc-connections.stories.tsx @@ -0,0 +1,158 @@ +import { use, useState } from "react"; + +import { UserSettingsContext } from "../react/state/user-settings-context"; +import { UserSettingsProvider } from "../react/state/user-settings-provider"; +import { PetrinautStoryProvider } from "./petrinaut-story-provider"; + +import type { SDCPN } from "@hashintel/petrinaut-core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const definition: SDCPN = { + places: [ + { id: "waiting", name: "Waiting", x: 0, y: 0 }, + { id: "staff", name: "Free staff", x: 0, y: 240 }, + { id: "serving", name: "Serving", x: 460, y: 0 }, + { id: "served", name: "Served", x: 920, y: 0 }, + ].map((place) => ({ + ...place, + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + })), + transitions: [ + { + id: "begin", + name: "Begin service", + x: 230, + y: 140, + inputArcs: [ + { placeId: "waiting", weight: 2, type: "standard" }, + { placeId: "staff", weight: 1, type: "read" }, + ], + outputArcs: [{ placeId: "serving", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "return true;", + transitionKernelCode: "return {};", + }, + { + id: "finish", + name: "Finish service", + x: 690, + y: 140, + inputArcs: [ + { placeId: "serving", weight: 1, type: "standard" }, + { placeId: "served", weight: 3, type: "inhibitor" }, + ], + outputArcs: [ + { placeId: "served", weight: 1 }, + { placeId: "staff", weight: 1 }, + ], + lambdaType: "predicate", + lambdaCode: "return true;", + transitionKernelCode: "return {};", + }, + ], + types: [], + parameters: [], + differentialEquations: [], +}; + +const definitionWithSubnet: SDCPN = { + ...definition, + transitions: definition.transitions.map((transition) => + transition.id === "finish" + ? { + ...transition, + outputArcs: [ + ...transition.outputArcs, + { + endpoint: { + kind: "componentPort", + componentInstanceId: "archive", + portPlaceId: "inbox", + }, + weight: 1, + }, + ], + } + : transition, + ), + componentInstances: [ + { + id: "archive", + name: "Archive", + subnetId: "archive-subnet", + parameterValues: {}, + x: 920, + y: 320, + }, + ], + subnets: [ + { + id: "archive-subnet", + name: "Archive subnet", + places: [ + { + id: "inbox", + name: "Inbox", + isPort: true, + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], + }, + ], +}; + +const AutomaticArcEditor = ({ + readonly = false, + withSubnet = false, +}: { + readonly?: boolean; + withSubnet?: boolean; +}) => { + const settings = use(UserSettingsContext); + const [automaticArcs, setAutomaticArcs] = useState(true); + return ( + + + + ); +}; + +const meta = { + title: "Petrinaut/Automatic arc connections", + parameters: { layout: "fullscreen" }, + render: (args) => ( +
+ + + +
+ ), +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Editable: Story = {}; +export const ReadOnly: Story = { args: { readonly: true } }; +export const WithSubnet: Story = { args: { withSubnet: true } }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx index 893093fa058..1e49e49f500 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx @@ -271,6 +271,7 @@ const TestProviders = ({ setKeepPanelsMounted: () => {}, setCompactNodes: () => {}, setEnableExperimentalIconPack: () => {}, + setEnableAutomaticArcConnections: () => {}, setArcRendering: () => {}, setCursorMode: () => {}, setIsLeftSidebarOpen: () => {}, diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx index 4f0418ddcf1..37453c49bdd 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx @@ -96,6 +96,8 @@ export const ViewportSettingsDialog: React.FC = ({ setCompactNodes, enableExperimentalIconPack, setEnableExperimentalIconPack, + enableAutomaticArcConnections, + setEnableAutomaticArcConnections, arcRendering, setArcRendering, showMinimap, @@ -193,11 +195,37 @@ export const ViewportSettingsDialog: React.FC = ({ size="sm" /> - + + Automatic arc connections{" "} + + Experimental + + + } + description="Create arcs from one hover handle. Connections follow the outlines of places and transitions." + > + + +