Skip to content
Merged
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/classic-transition-dimensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut-core": patch
---

`classicNodeDimensions` gives places a 104 × 104 footprint and transitions a 180 × 60 footprint for canvas rendering and layout.
5 changes: 5 additions & 0 deletions .changeset/default-nodes-compact-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Give default canvas nodes lighter colours, a soft shadow, a muted 1.5px outline, and matching name styling. Places are circular; transitions are rectangular with an icon on the left, a wrapped name, and the transition type. Preserve words in node names and align minimap nodes with their canvas positions and dimensions.
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.
4 changes: 2 additions & 2 deletions libs/@hashintel/petrinaut-core/src/layout/dimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export const compactNodeDimensions: RenderNodeDimensions = {
};

export const classicNodeDimensions: RenderNodeDimensions = {
place: { width: 130, height: 130 },
transition: { width: 160, height: 80 },
place: { width: 104, height: 104 },
transition: { width: 180, height: 60 },
componentInstance: { width: 180, height: 96 },
};

Expand Down
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
14 changes: 12 additions & 2 deletions libs/@hashintel/petrinaut/docs/visual-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,12 @@ When enabled, node positions snap to a grid when placing new nodes or dragging e

Switch between two node rendering styles:

- **Compact** (enabled, the default) -- small card-style nodes that show just the place / transition name. Fits more of the net on screen at once; best for large or dense models where you mostly select-and-inspect via the Properties panel.
- **Classic** (disabled) -- larger nodes with inline summary information (e.g. token counts, firing-rate type indicators). Easier to read at a glance for small models or when teaching / demoing.
- **Classic** (disabled, the default) -- circular places show their names and token counts. Rectangular transitions show an icon on the left, a name wrapping over up to two lines, and the transition type below it. Easier to read at a glance for small models or when teaching / demoing.
- **Compact** (enabled) -- shorter card-style nodes show an icon, a single-line name, and the place or transition type. Fits more of the net on screen at once; best for large or dense models where you mostly select-and-inspect via the Properties panel.

Both styles use thin outlines and soft shadows. Places follow their token type's colour; classic nodes use softer, more muted outlines. Transitions have a white surface.

Classic places and transitions use the same text size and weight for their names.

Toggle freely -- this setting only affects rendering, not the underlying net.

Expand Down Expand Up @@ -138,6 +142,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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type UserSettings = {
keepPanelsMounted: boolean;
compactNodes: boolean;
enableExperimentalIconPack: boolean;
enableAutomaticArcConnections: boolean;
arcRendering: ArcRendering;
cursorMode: CursorMode;
isLeftSidebarOpen: boolean;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -150,6 +152,7 @@ export const defaultUserSettings: UserSettings = {
keepPanelsMounted: true,
compactNodes: false,
enableExperimentalIconPack: false,
enableAutomaticArcConnections: false,
arcRendering: "custom",
cursorMode: "pan",
isLeftSidebarOpen: true,
Expand Down Expand Up @@ -185,6 +188,7 @@ export const defaultUserSettingsContextValue: UserSettingsContextValue = {
setKeepPanelsMounted: () => {},
setCompactNodes: () => {},
setEnableExperimentalIconPack: () => {},
setEnableAutomaticArcConnections: () => {},
setArcRendering: () => {},
setIsLeftSidebarOpen: () => {},
setLeftSidebarWidth: () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,65 @@ const WalkthroughProbe = () => {
);
};

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 @@ -97,6 +97,11 @@ const OwnedUserSettingsProvider: React.FC<React.PropsWithChildren> = ({
...settings,
enableExperimentalIconPack: 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 } };
49 changes: 49 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";

import { withLabelWrapPoints } from "./label-wrap-points";

const ZERO_WIDTH_SPACE = "\u200B";

const segments = (label: string): string[] =>
withLabelWrapPoints(label).split(ZERO_WIDTH_SPACE);

describe("withLabelWrapPoints", () => {
it("breaks a PascalCase name between its words", () => {
expect(segments("HelloWorld")).toEqual(["Hello", "World"]);
});

it("keeps an acronym together", () => {
expect(segments("QAQueue")).toEqual(["QA", "Queue"]);
expect(segments("XMLHttpRequest")).toEqual(["XML", "Http", "Request"]);
expect(segments("IOError")).toEqual(["IO", "Error"]);
});

it("breaks before a run of digits", () => {
expect(segments("Space42")).toEqual(["Space", "42"]);
});

it("leaves a single word alone", () => {
expect(segments("Hello")).toEqual(["Hello"]);
});

it("leaves a label written as a sentence alone", () => {
for (const label of [
"A motorway load is offered",
"Unload the tanker (SteadyNitrogen)",
"idle tankers",
]) {
expect(withLabelWrapPoints(label).replace(/\u200B/gu, "")).toBe(label);
}
});

it("still breaks the PascalCase parts of a sentence", () => {
expect(segments("Unload the tanker (SteadyNitrogen)")).toEqual([
"Unload the tanker (Steady",
"Nitrogen)",
]);
});

it("returns an empty string unchanged", () => {
expect(withLabelWrapPoints("")).toBe("");
});
});
Loading
Loading