diff --git a/.changeset/square-arcs-detour.md b/.changeset/square-arcs-detour.md new file mode 100644 index 00000000000..769cd1eebe3 --- /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. Fix ID generation when the editor runs over HTTP on a local network. 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/optimizations/provider.tsx b/libs/@hashintel/petrinaut/src/react/optimizations/provider.tsx index d6d8b29b105..e8bb3fb7e74 100644 --- a/libs/@hashintel/petrinaut/src/react/optimizations/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/optimizations/provider.tsx @@ -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, @@ -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, 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/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.test.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.test.tsx index b314ffb3e06..1a09f880acf 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.test.tsx @@ -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, @@ -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, diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.ts b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.ts index 5e673501edb..5b6fa9b3eff 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.ts +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.ts @@ -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"; @@ -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 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) => ( +