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/square-arcs-detour.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Add square automatic arcs that can route around nearby nodes. Fix ID generation when the editor runs over HTTP on a local network.
2 changes: 2 additions & 0 deletions libs/@hashintel/petrinaut/docs/drawing-a-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions libs/@hashintel/petrinaut/docs/visual-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* @role Tracks the studies driving parameter sweeps: connects the host's in-browser optimizer, folds each study's event stream into a record, and routes its trials to the sweep that evaluates them
*/
import { use, useCallback, useEffect, useRef, useState } from "react";
import { v4 as generateUuid } from "uuid";

import {
PETRINAUT_OPTIMIZATION_CANCELLED_ERROR_CODE,
Expand Down Expand Up @@ -381,7 +382,7 @@ export const OptimizationsProvider = ({ children }: PropsWithChildren) => {
}
const { capability } = connection;
const input = petrinautOptimizationInputSchema.parse(rawInput);
const optimizationId = crypto.randomUUID();
const optimizationId = generateUuid();
const abortController = new AbortController();
sweepEvaluatorsRef.current.set(
optimizationId,
Expand Down
10 changes: 10 additions & 0 deletions libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type {
TimelineChartType,
} from "./editor-context";

export type AutomaticArcRendering = "curved" | "square";

export type ArcRendering = "smoothstep" | "bezier" | "custom";

export type SubViewSectionSettings = {
Expand All @@ -36,6 +38,8 @@ export type UserSettings = {
compactNodes: boolean;
enableExperimentalIconPack: boolean;
enableAutomaticArcConnections: boolean;
automaticArcRendering: AutomaticArcRendering;
avoidArcObstacles: boolean;
arcRendering: ArcRendering;
cursorMode: CursorMode;
isLeftSidebarOpen: boolean;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -153,6 +159,8 @@ export const defaultUserSettings: UserSettings = {
compactNodes: false,
enableExperimentalIconPack: false,
enableAutomaticArcConnections: false,
automaticArcRendering: "curved",
avoidArcObstacles: true,
arcRendering: "custom",
cursorMode: "pan",
isLeftSidebarOpen: true,
Expand Down Expand Up @@ -189,6 +197,8 @@ export const defaultUserSettingsContextValue: UserSettingsContextValue = {
setCompactNodes: () => {},
setEnableExperimentalIconPack: () => {},
setEnableAutomaticArcConnections: () => {},
setAutomaticArcRendering: () => {},
setAvoidArcObstacles: () => {},
setArcRendering: () => {},
setIsLeftSidebarOpen: () => {},
setLeftSidebarWidth: () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,28 @@ const ArcConnectionsProbe = () => {
);
};

const SquareArcsProbe = () => {
const {
automaticArcRendering,
setAutomaticArcRendering,
avoidArcObstacles,
setAvoidArcObstacles,
} = use(UserSettingsContext);
return (
<>
<button type="button" onClick={() => setAutomaticArcRendering("square")}>
Shape: {automaticArcRendering}
</button>
<button
type="button"
onClick={() => setAvoidArcObstacles(!avoidArcObstacles)}
>
Avoid nodes: {avoidArcObstacles ? "on" : "off"}
</button>
</>
);
};

describe("UserSettingsProvider", () => {
it("defaults automatic arcs off for saved preferences from before the experiment", () => {
localStorage.setItem(
Expand Down Expand Up @@ -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(
<UserSettingsProvider>
<SquareArcsProbe />
</UserSettingsProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Shape: curved" }));
fireEvent.click(screen.getByRole("button", { name: "Avoid nodes: on" }));
first.unmount();
render(
<UserSettingsProvider>
<SquareArcsProbe />
</UserSettingsProvider>,
);
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(
<UserSettingsProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ const OwnedUserSettingsProvider: React.FC<React.PropsWithChildren> = ({
...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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 (
<UserSettingsContext
value={{
...settings,
enableAutomaticArcConnections: automaticArcs,
setEnableAutomaticArcConnections: setAutomaticArcs,
automaticArcRendering: arcShape,
setAutomaticArcRendering: setArcShape,
}}
>
<PetrinautStoryProvider
initialTitle="Automatic arc connections"
initialDefinition={withSubnet ? definitionWithSubnet : definition}
initialDefinition={
withObstacles
? definitionWithObstacles
: withSubnet
? definitionWithSubnet
: definition
}
readonly={readonly}
/>
</UserSettingsContext>
Expand All @@ -156,3 +197,8 @@ type Story = StoryObj<typeof meta>;
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 },
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";

import {
DEFAULT_LANGUAGE_CLIENT_CONTEXT,
Expand All @@ -25,6 +25,25 @@ const stateWith = (expression: string): AdHocScenarioState => ({
});

describe("useAdHocLspSession", () => {
afterEach(() => vi.unstubAllGlobals());

it("keeps a stable session ID when crypto.randomUUID is unavailable", () => {
vi.stubGlobal("crypto", {
getRandomValues: crypto.getRandomValues.bind(crypto),
});
const { result, rerender, unmount } = renderHook(() =>
useAdHocLspSession(stateWith("1")),
);
const sessionId = result.current;

expect(sessionId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
rerender();
expect(result.current).toBe(sessionId);
unmount();
});

it("syncs the worker on content changes, never on state identity alone", () => {
const client = {
...DEFAULT_LANGUAGE_CLIENT_CONTEXT,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { use, useEffect, useRef, useState } from "react";
import { v4 as generateUuid } from "uuid";

import { useLatest } from "../../../react/hooks/use-latest";
import { LanguageClientContext } from "../../../react/lsp/context";
Expand Down Expand Up @@ -29,7 +30,7 @@ export function useAdHocLspSession(
LanguageClientContext,
);
// useState (not useRef/useMemo) — needed for a stable per-mount value.
const [generatedSessionId] = useState(() => crypto.randomUUID());
const [generatedSessionId] = useState(() => generateUuid());
const sessionId = externalSessionId ?? generatedSessionId;
const initializedRef = useRef(false);
// The content key; the effect reads the state itself through the ref so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,37 @@ export const UserSettingsDialog = ({
value={settings.enableAutomaticArcConnections}
onChange={settings.setEnableAutomaticArcConnections}
/>
{!settings.enableAutomaticArcConnections && (
{settings.enableAutomaticArcConnections ? (
<>
<SettingRow
label="Automatic arc shape"
description="Choose curved or square paths."
wideControl
>
{(aria) => (
<Select
{...aria}
size="sm"
required
value={settings.automaticArcRendering}
onChange={settings.setAutomaticArcRendering}
items={[
{ value: "curved", text: "Curved" },
{ value: "square", text: "Square" },
]}
/>
)}
</SettingRow>
{settings.automaticArcRendering === "square" && (
<SettingToggle
label="Avoid nodes"
description="Route square arcs around nearby nodes. Overlapping nodes can still block a route."
value={settings.avoidArcObstacles}
onChange={settings.setAvoidArcObstacles}
/>
)}
</>
) : (
<SettingRow
label="Arc rendering"
description="Choose the shape of connections between nodes."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ const TestProviders = ({
setCompactNodes: () => {},
setEnableExperimentalIconPack: () => {},
setEnableAutomaticArcConnections: () => {},
setAutomaticArcRendering: () => {},
setAvoidArcObstacles: () => {},
setArcRendering: () => {},
setCursorMode: () => {},
setIsLeftSidebarOpen: () => {},
Expand Down
Loading
Loading