From e23efde0ed8951ac644d58a73ea7483fe9b34334 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 12 Sep 2026 03:27:18 +0200 Subject: [PATCH 1/2] Route square automatic arcs around nearby nodes --- .changeset/square-arcs-detour.md | 5 + .../petrinaut/docs/drawing-a-net.md | 2 + .../petrinaut/docs/visual-settings.md | 8 +- .../src/react/state/user-settings-context.ts | 10 + .../state/user-settings-provider.test.tsx | 56 +++++ .../react/state/user-settings-provider.tsx | 4 + .../ui/automatic-arc-connections.stories.tsx | 48 +++- .../Editor/editor-view/user-settings.test.tsx | 18 +- .../user-settings/user-settings-dialog.tsx | 32 ++- .../create-experiment-drawer.test.tsx | 2 + .../outline-connection-line.tsx | 36 ++- .../react-flow-canvas/shared/outline-arcs.ts | 4 +- .../shared/square-arcs.test.ts | 167 +++++++++++++ .../react-flow-canvas/shared/square-arcs.ts | 233 ++++++++++++++++++ .../shared/square-arcs/find-route.ts | 214 ++++++++++++++++ .../use-automatic-arc-paths.ts | 93 +++++++ .../use-react-flow-elements.test.tsx | 58 ++++- .../use-react-flow-elements.ts | 27 +- 18 files changed, 979 insertions(+), 38 deletions(-) create mode 100644 .changeset/square-arcs-detour.md create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/square-arcs.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/square-arcs.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/square-arcs/find-route.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-automatic-arc-paths.ts diff --git a/.changeset/square-arcs-detour.md b/.changeset/square-arcs-detour.md new file mode 100644 index 00000000000..acbaf0a6742 --- /dev/null +++ b/.changeset/square-arcs-detour.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Add square automatic arcs that can route around nearby nodes. diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md index d5979178fa5..a573e477c5a 100644 --- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md +++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md @@ -86,6 +86,8 @@ Enable **Automatic arc connections** in [Viewport Settings](visual-settings.md#a 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. +Choose **Square** under **Automatic arc shape** for right-angle paths. With **Avoid nodes** on, both the drag preview and completed arcs route around nearby nodes. Move obstructing nodes apart if a route cannot fit. See [automatic arc settings](visual-settings.md#automatic-arc-connections-experimental) for limits. + 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. diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index f5f858be507..01976ce2956 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -138,9 +138,13 @@ Disable it and the pointer changes nothing. Selecting a node still highlights it ### 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. +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 attachment points. -This setting uses automatic curves and temporarily hides the **Arc 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). +Choose **Curved** (the default) or **Square** under **Automatic arc shape**. Square arcs use horizontal and vertical segments and choose their attachment sides automatically. + +For square arcs, **Avoid nodes** is on by default. It routes around nearby places, transitions, and subnet boxes with a gap around their edges, and updates when you move a node. Turn it off for simpler square paths. Arcs and their labels can still cross each other. Overlapping nodes, blocked endpoints, or very crowded areas can prevent a route; the arc then falls back to a square path that may cross nodes. Move the blocking nodes apart to make room. + +Turning automatic connections off restores your previous **Arcs rendering** 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 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 3e5528a2467..290179b7b14 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts @@ -16,6 +16,8 @@ import type { TimelineChartType, } from "./editor-context"; +export type AutomaticArcRendering = "curved" | "square"; + export type ArcRendering = "smoothstep" | "bezier" | "custom"; export type SubViewSectionSettings = { @@ -36,6 +38,8 @@ export type UserSettings = { compactNodes: boolean; enableExperimentalIconPack: boolean; enableAutomaticArcConnections: boolean; + automaticArcRendering: AutomaticArcRendering; + avoidArcObstacles: boolean; arcRendering: ArcRendering; cursorMode: CursorMode; isLeftSidebarOpen: boolean; @@ -116,6 +120,8 @@ export type UserSettingsActions = { setCompactNodes: (value: boolean) => void; setEnableExperimentalIconPack: (value: boolean) => void; setEnableAutomaticArcConnections: (value: boolean) => void; + setAutomaticArcRendering: (value: AutomaticArcRendering) => void; + setAvoidArcObstacles: (value: boolean) => void; setArcRendering: (value: ArcRendering) => void; setIsLeftSidebarOpen: (value: boolean) => void; setLeftSidebarWidth: (value: number) => void; @@ -153,6 +159,8 @@ export const defaultUserSettings: UserSettings = { compactNodes: false, enableExperimentalIconPack: false, enableAutomaticArcConnections: false, + automaticArcRendering: "curved", + avoidArcObstacles: true, arcRendering: "custom", cursorMode: "pan", isLeftSidebarOpen: true, @@ -189,6 +197,8 @@ export const defaultUserSettingsContextValue: UserSettingsContextValue = { setCompactNodes: () => {}, setEnableExperimentalIconPack: () => {}, setEnableAutomaticArcConnections: () => {}, + setAutomaticArcRendering: () => {}, + setAvoidArcObstacles: () => {}, 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 0f6ae45762d..b463fbe4fc0 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 @@ -58,6 +58,28 @@ const ArcConnectionsProbe = () => { ); }; +const SquareArcsProbe = () => { + const { + automaticArcRendering, + setAutomaticArcRendering, + avoidArcObstacles, + setAvoidArcObstacles, + } = use(UserSettingsContext); + return ( + <> + + + + ); +}; + describe("UserSettingsProvider", () => { it("defaults automatic arcs off for saved preferences from before the experiment", () => { localStorage.setItem( @@ -102,6 +124,40 @@ describe("UserSettingsProvider", () => { }); }); + it("preserves the old arc style while persisting square routing preferences", () => { + localStorage.setItem( + "petrinaut:user-settings", + JSON.stringify({ + enableAutomaticArcConnections: true, + arcRendering: "bezier", + }), + ); + const first = render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Shape: curved" })); + fireEvent.click(screen.getByRole("button", { name: "Avoid nodes: on" })); + first.unmount(); + render( + + + , + ); + expect(screen.getByRole("button", { name: "Shape: square" })).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Avoid nodes: off" }), + ).toBeTruthy(); + expect( + JSON.parse(localStorage.getItem("petrinaut:user-settings") ?? "{}"), + ).toMatchObject({ + arcRendering: "bezier", + automaticArcRendering: "square", + avoidArcObstacles: false, + }); + }); + 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 7a9eb8bbc59..d83d9b10b96 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx @@ -102,6 +102,10 @@ const OwnedUserSettingsProvider: React.FC = ({ ...settings, enableAutomaticArcConnections: value, })), + setAutomaticArcRendering: (value: UserSettings["automaticArcRendering"]) => + setState((settings) => ({ ...settings, automaticArcRendering: value })), + setAvoidArcObstacles: (value: boolean) => + setState((settings) => ({ ...settings, avoidArcObstacles: 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 index 415f6fde938..6ce2449705d 100644 --- a/libs/@hashintel/petrinaut/src/ui/automatic-arc-connections.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/automatic-arc-connections.stories.tsx @@ -4,6 +4,7 @@ import { UserSettingsContext } from "../react/state/user-settings-context"; import { UserSettingsProvider } from "../react/state/user-settings-provider"; import { PetrinautStoryProvider } from "./petrinaut-story-provider"; +import type { AutomaticArcRendering } from "../react/state/user-settings-context"; import type { SDCPN } from "@hashintel/petrinaut-core"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -111,26 +112,66 @@ const definitionWithSubnet: SDCPN = { ], }; +const definitionWithObstacles: SDCPN = { + ...definition, + places: [ + ...definition.places.map((place) => ({ + ...place, + x: place.id === "serving" || place.id === "served" ? 860 : 0, + y: place.id === "staff" || place.id === "served" ? 280 : 0, + })), + { + id: "obstacle", + name: "Obstacle", + x: 280, + y: 0, + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + }, + ], + transitions: definition.transitions.map((transition) => ({ + ...transition, + x: 560, + y: transition.id === "begin" ? 0 : 280, + })), +}; + const AutomaticArcEditor = ({ readonly = false, withSubnet = false, + withObstacles = false, + initialArcShape, }: { readonly?: boolean; withSubnet?: boolean; + withObstacles?: boolean; + initialArcShape?: AutomaticArcRendering; }) => { const settings = use(UserSettingsContext); const [automaticArcs, setAutomaticArcs] = useState(true); + const [arcShape, setArcShape] = useState( + initialArcShape ?? settings.automaticArcRendering, + ); return ( @@ -156,3 +197,8 @@ type Story = StoryObj; export const Editable: Story = {}; export const ReadOnly: Story = { args: { readonly: true } }; export const WithSubnet: Story = { args: { withSubnet: true } }; + +export const Square: Story = { args: { initialArcShape: "square" } }; +export const SquareWithObstacles: Story = { + args: { initialArcShape: "square", withObstacles: true }, +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx index 8c63bc7059b..056d727a7af 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx @@ -494,6 +494,20 @@ describe("combined UX settings", () => { }), ), ); + const shape = await screen.findByRole("combobox", { + name: "Automatic arc shape", + }); + fireEvent.click(shape); + fireEvent.click(await screen.findByRole("option", { name: "Square" })); + await waitFor(() => + expect(shape.getAttribute("aria-expanded")).toBe("false"), + ); + const avoid = await screen.findByRole("checkbox", { name: "Avoid nodes" }); + expect((avoid as HTMLInputElement).checked).toBe(true); + await act(async () => fireEvent.click(avoid)); + await waitFor(() => + expect((avoid as HTMLInputElement).checked).toBe(false), + ); first.unmount(); renderSettings({ overlay: { type: "user-settings", section: "viewport" } }); expect( @@ -506,10 +520,10 @@ describe("combined UX settings", () => { expect( ( screen.getByRole("checkbox", { - name: "Automatic arc connections", + name: "Avoid nodes", }) as HTMLInputElement ).checked, - ).toBe(true); + ).toBe(false); expect( screen.queryByRole("combobox", { name: "Arc rendering" }), ).toBeNull(); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx index 2bcbcea49e1..bc241af7b1d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx @@ -522,7 +522,37 @@ export const UserSettingsDialog = ({ value={settings.enableAutomaticArcConnections} onChange={settings.setEnableAutomaticArcConnections} /> - {!settings.enableAutomaticArcConnections && ( + {settings.enableAutomaticArcConnections ? ( + <> + + {(aria) => ( +