Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-arcs-follow.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions libs/@hashintel/petrinaut/docs/drawing-a-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions libs/@hashintel/petrinaut/docs/visual-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ Off by default. Adds a **Surface** section to an optimization study with two or

Shown only when the host application provides an optimizer that runs in your browser. Off by default. On, Petrinaut connects that optimizer: the **Optimizations** tab appears under Simulate, each study's steps run on the experiments backend, and the study drawer streams the objective's metrics for the step being evaluated (see [Running in the browser](optimization.md#running-in-the-browser)). Off, the tab stays hidden and any running in-browser optimization is cancelled.

### 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type UserSettings = {
showAnimations: boolean;
keepPanelsMounted: boolean;
compactNodes: boolean;
enableAutomaticArcConnections: boolean;
arcRendering: ArcRendering;
cursorMode: CursorMode;
isLeftSidebarOpen: boolean;
Expand Down Expand Up @@ -121,6 +122,7 @@ export type UserSettingsActions = {
setShowAnimations: (value: boolean) => void;
setKeepPanelsMounted: (value: boolean) => void;
setCompactNodes: (value: boolean) => void;
setEnableAutomaticArcConnections: (value: boolean) => void;
setArcRendering: (value: ArcRendering) => void;
setIsLeftSidebarOpen: (value: boolean) => void;
setLeftSidebarWidth: (value: number) => void;
Expand Down Expand Up @@ -158,6 +160,7 @@ export const defaultUserSettings: UserSettings = {
showAnimations: true,
keepPanelsMounted: true,
compactNodes: false,
enableAutomaticArcConnections: false,
arcRendering: "custom",
cursorMode: "pan",
isLeftSidebarOpen: true,
Expand Down Expand Up @@ -194,6 +197,7 @@ export const defaultUserSettingsContextValue: UserSettingsContextValue = {
setShowAnimations: () => {},
setKeepPanelsMounted: () => {},
setCompactNodes: () => {},
setEnableAutomaticArcConnections: () => {},
setArcRendering: () => {},
setIsLeftSidebarOpen: () => {},
setLeftSidebarWidth: () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,65 @@ const DemoModeProbe = ({ name }: { name: string }) => {
);
};

const ArcConnectionsProbe = () => {
const { enableAutomaticArcConnections, setEnableAutomaticArcConnections } =
use(UserSettingsContext);
return (
<button
type="button"
onClick={() =>
setEnableAutomaticArcConnections(!enableAutomaticArcConnections)
}
>
Automatic arcs: {enableAutomaticArcConnections ? "on" : "off"}
</button>
);
};

describe("UserSettingsProvider", () => {
it("defaults automatic arcs off for saved preferences from before the experiment", () => {
localStorage.setItem(
"petrinaut:user-settings",
JSON.stringify({ compactNodes: false }),
);
render(
<UserSettingsProvider>
<ArcConnectionsProbe />
</UserSettingsProvider>,
);
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(
<UserSettingsProvider>
<ArcConnectionsProbe />
</UserSettingsProvider>,
);
fireEvent.click(
screen.getByRole("button", { name: "Automatic arcs: off" }),
);
first.unmount();
render(
<UserSettingsProvider>
<ArcConnectionsProbe />
</UserSettingsProvider>,
);
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(
<UserSettingsProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ const OwnedUserSettingsProvider: React.FC<React.PropsWithChildren> = ({
setState((prev) => ({ ...prev, keepPanelsMounted: value })),
setCompactNodes: (value: boolean) =>
setState((prev) => ({ ...prev, compactNodes: value })),
setEnableAutomaticArcConnections: (value: boolean) =>
setState((settings) => ({
...settings,
enableAutomaticArcConnections: value,
})),
setArcRendering: (value: ArcRendering) =>
setState((prev) => ({ ...prev, arcRendering: value })),
setCursorMode: (value: CursorMode) =>
Expand Down
158 changes: 158 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/automatic-arc-connections.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<UserSettingsContext
value={{
...settings,
enableAutomaticArcConnections: automaticArcs,
setEnableAutomaticArcConnections: setAutomaticArcs,
}}
>
<PetrinautStoryProvider
initialTitle="Automatic arc connections"
initialDefinition={withSubnet ? definitionWithSubnet : definition}
readonly={readonly}
/>
</UserSettingsContext>
);
};

const meta = {
title: "Petrinaut/Automatic arc connections",
parameters: { layout: "fullscreen" },
render: (args) => (
<div style={{ height: "100vh", width: "100vw" }}>
<UserSettingsProvider>
<AutomaticArcEditor {...args} />
</UserSettingsProvider>
</div>
),
} satisfies Meta<typeof AutomaticArcEditor>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Editable: Story = {};
export const ReadOnly: Story = { args: { readonly: true } };
export const WithSubnet: Story = { args: { withSubnet: true } };
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ const TestProviders = ({
setShowAnimations: () => {},
setKeepPanelsMounted: () => {},
setCompactNodes: () => {},
setEnableAutomaticArcConnections: () => {},
setArcRendering: () => {},
setCursorMode: () => {},
setIsLeftSidebarOpen: () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export const ViewportSettingsDialog: React.FC<ViewportSettingsDialogProps> = ({
setKeepPanelsMounted,
compactNodes,
setCompactNodes,
enableAutomaticArcConnections,
setEnableAutomaticArcConnections,
arcRendering,
setArcRendering,
showMinimap,
Expand Down Expand Up @@ -177,11 +179,37 @@ export const ViewportSettingsDialog: React.FC<ViewportSettingsDialogProps> = ({
size="sm"
/>
</SettingRow>
<SettingRow label="Arcs rendering">
<SettingRow
label={
<>
Automatic arc connections{" "}
<Chip size="xs" color="orange" variant="outline" shape="round">
Experimental
</Chip>
</>
}
description="Create arcs from one hover handle. Connections follow the outlines of places and transitions."
>
<Toggle
aria-label="Automatic arc connections"
value={enableAutomaticArcConnections}
onChange={setEnableAutomaticArcConnections}
size="sm"
/>
</SettingRow>
<SettingRow
label="Arcs rendering"
description={
enableAutomaticArcConnections
? "Automatic arc connections uses curves that follow node direction."
: undefined
}
>
<Select
size="sm"
className={selectStyle}
required
disabled={enableAutomaticArcConnections}
value={arcRendering}
onChange={(nextArcRendering) =>
setArcRendering(nextArcRendering as ArcRendering)
Expand Down
Loading
Loading