diff --git a/.changeset/classic-transition-dimensions.md b/.changeset/classic-transition-dimensions.md new file mode 100644 index 00000000000..12e8d97420f --- /dev/null +++ b/.changeset/classic-transition-dimensions.md @@ -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. diff --git a/.changeset/default-nodes-compact-surface.md b/.changeset/default-nodes-compact-surface.md new file mode 100644 index 00000000000..80dee7e481e --- /dev/null +++ b/.changeset/default-nodes-compact-surface.md @@ -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. diff --git a/.changeset/quiet-arcs-follow.md b/.changeset/quiet-arcs-follow.md new file mode 100644 index 00000000000..634d00e8e48 --- /dev/null +++ b/.changeset/quiet-arcs-follow.md @@ -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. diff --git a/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts index cfa208583ba..98d12456971 100644 --- a/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts +++ b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts @@ -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 }, }; diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md index 5d5fd1de3b2..81ac58dc6be 100644 --- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md +++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md @@ -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. diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index e3a2627d5e4..dfc39f2d0e6 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -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. @@ -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: 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 5271fba5310..3e5528a2467 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts @@ -35,6 +35,7 @@ export type UserSettings = { keepPanelsMounted: boolean; compactNodes: boolean; enableExperimentalIconPack: boolean; + enableAutomaticArcConnections: boolean; arcRendering: ArcRendering; cursorMode: CursorMode; isLeftSidebarOpen: boolean; @@ -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; @@ -150,6 +152,7 @@ export const defaultUserSettings: UserSettings = { keepPanelsMounted: true, compactNodes: false, enableExperimentalIconPack: false, + enableAutomaticArcConnections: false, arcRendering: "custom", cursorMode: "pan", isLeftSidebarOpen: true, @@ -185,6 +188,7 @@ export const defaultUserSettingsContextValue: UserSettingsContextValue = { setKeepPanelsMounted: () => {}, setCompactNodes: () => {}, setEnableExperimentalIconPack: () => {}, + setEnableAutomaticArcConnections: () => {}, 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 6530852f3e8..0f6ae45762d 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 @@ -43,7 +43,65 @@ const WalkthroughProbe = () => { ); }; +const ArcConnectionsProbe = () => { + const { enableAutomaticArcConnections, setEnableAutomaticArcConnections } = + use(UserSettingsContext); + return ( + + ); +}; + describe("UserSettingsProvider", () => { + it("defaults automatic arcs off for saved preferences from before the experiment", () => { + localStorage.setItem( + "petrinaut:user-settings", + JSON.stringify({ compactNodes: false }), + ); + render( + + + , + ); + 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( + + + , + ); + fireEvent.click( + screen.getByRole("button", { name: "Automatic arcs: off" }), + ); + first.unmount(); + render( + + + , + ); + 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( 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 7c0a250e88d..7a9eb8bbc59 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx @@ -97,6 +97,11 @@ const OwnedUserSettingsProvider: React.FC = ({ ...settings, enableExperimentalIconPack: value, })), + setEnableAutomaticArcConnections: (value: boolean) => + setState((settings) => ({ + ...settings, + enableAutomaticArcConnections: 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 new file mode 100644 index 00000000000..415f6fde938 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/automatic-arc-connections.stories.tsx @@ -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 ( + + + + ); +}; + +const meta = { + title: "Petrinaut/Automatic arc connections", + parameters: { layout: "fullscreen" }, + render: (args) => ( +
+ + + +
+ ), +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Editable: Story = {}; +export const ReadOnly: Story = { args: { readonly: true } }; +export const WithSubnet: Story = { args: { withSubnet: true } }; diff --git a/libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.test.ts b/libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.test.ts new file mode 100644 index 00000000000..1d25511ba29 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.test.ts @@ -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(""); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.ts b/libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.ts new file mode 100644 index 00000000000..7a5e6047919 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.ts @@ -0,0 +1,17 @@ +/** + * Inserts zero width spaces at a label's word boundaries, so a long name + * wraps inside a node instead of overflowing it. + * + * Boundaries are the PascalCase ones a browser will not break on its own: the + * start of a capitalised word, the start of a word after an acronym, and the + * start of a run of digits. Every other character is left as it is, so a label + * written as a sentence comes back untouched. + * + * "CriticalOxygenTank" wraps as Critical / Oxygen / Tank, "XMLHttpRequest" as + * XML / Http / Request, "Space42" as Space / 42. + */ +const WORD_BOUNDARY = + /(?<=[\p{Ll}\d])(?=\p{Lu})|(?<=\p{Lu})(?=\p{Lu}\p{Ll})|(?<=\p{L})(?=\d)/gu; + +export const withLabelWrapPoints = (label: string): string => + label.replace(WORD_BOUNDARY, "\u200B"); diff --git a/libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.test.ts b/libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.test.ts deleted file mode 100644 index b2eb69b30c3..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { splitPascalCase } from "./split-pascal-case"; - -describe("splitPascalCase", () => { - it("should split a simple PascalCase string into segments", () => { - const result = splitPascalCase("HelloWorld"); - - expect(result).toEqual(["Hello", "World"]); - }); - - it("should handle acronyms followed by words correctly", () => { - const result = splitPascalCase("QAQueue"); - - expect(result).toEqual(["QA", "Queue"]); - }); - - it("should handle longer acronyms", () => { - const result = splitPascalCase("XMLHttpRequest"); - - expect(result).toEqual(["XML", "Http", "Request"]); - }); - - it("should handle acronyms at the end", () => { - const result = splitPascalCase("IOError"); - - expect(result).toEqual(["IO", "Error"]); - }); - - it("should handle a single word", () => { - const result = splitPascalCase("Hello"); - - expect(result).toEqual(["Hello"]); - }); - - it("should handle multiple consecutive words", () => { - const result = splitPascalCase("MyVeryLongClassName"); - - expect(result).toEqual(["My", "Very", "Long", "Class", "Name"]); - }); - - it("should handle mixed acronyms and words", () => { - const result = splitPascalCase("HTTPSConnection"); - - expect(result).toEqual(["HTTPS", "Connection"]); - }); - - it("should handle a string ending with an acronym", () => { - const result = splitPascalCase("ParseXML"); - - expect(result).toEqual(["Parse", "XML"]); - }); - - it("should handle single uppercase letter words", () => { - const result = splitPascalCase("AQuickTest"); - - expect(result).toEqual(["A", "Quick", "Test"]); - }); - - it("should handle only uppercase letters (acronym)", () => { - const result = splitPascalCase("HTML"); - - expect(result).toEqual(["HTML"]); - }); - - it("should handle only uppercase letters (acronym) with numbers", () => { - const result = splitPascalCase("HTML42"); - - expect(result).toEqual(["HTML", "42"]); - }); - - it("should handle empty string", () => { - const result = splitPascalCase(""); - - expect(result).toEqual([""]); - }); - - it("should handle complex real-world examples", () => { - const result = splitPascalCase("HTTPResponseCodeXML"); - - expect(result).toEqual(["HTTP", "Response", "Code", "XML"]); - }); - - it("should handle UIElement pattern", () => { - const result = splitPascalCase("UIElement"); - - expect(result).toEqual(["UI", "Element"]); - }); - - it("should handle APIKey pattern", () => { - const result = splitPascalCase("APIKey"); - - expect(result).toEqual(["API", "Key"]); - }); - - it("should handle PascalCase with numbers", () => { - const result = splitPascalCase("Space42"); - - expect(result).toEqual(["Space", "42"]); - }); -}); diff --git a/libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.ts b/libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.ts deleted file mode 100644 index 16ab720556d..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Splits a PascalCase string into segments. - * - * Algorithm: - * - Splits on capital letters that are followed by lowercase letters (start of new word) - * - Handles consecutive capitals (acronyms) by keeping them together until a lowercase letter appears - * - Treats sequences of digits as separate segments - * - * Examples: - * - "HelloWorld" -> ["Hello", "World"] - * - "QAQueue" -> ["QA", "Queue"] - * - "XMLHttpRequest" -> ["XML", "Http", "Request"] - * - "IOError" -> ["IO", "Error"] - * - "Space42" -> ["Space", "42"] - * - * @param pascalCaseString - The PascalCase string to split - * @returns An array of string segments - */ -export const splitPascalCase = (pascalCaseString: string): string[] => { - // Match segments: either a capital letter followed by lowercase letters, - // or a sequence of capitals followed by a lowercase letter (acronym), - // or a sequence of capitals at the end or before digits (final acronym), - // or a sequence of digits - const segments = pascalCaseString.match( - /([A-Z]+(?=[A-Z][a-z]|\b|\d)|[A-Z][a-z]+|\d+)/g, - ); - - if (!segments) { - return [pascalCaseString]; - } - - return segments; -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx index 893093fa058..1e49e49f500 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx @@ -271,6 +271,7 @@ const TestProviders = ({ setKeepPanelsMounted: () => {}, setCompactNodes: () => {}, setEnableExperimentalIconPack: () => {}, + setEnableAutomaticArcConnections: () => {}, setArcRendering: () => {}, setCursorMode: () => {}, setIsLeftSidebarOpen: () => {}, diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx index 4f0418ddcf1..37453c49bdd 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-settings-dialog.tsx @@ -96,6 +96,8 @@ export const ViewportSettingsDialog: React.FC = ({ setCompactNodes, enableExperimentalIconPack, setEnableExperimentalIconPack, + enableAutomaticArcConnections, + setEnableAutomaticArcConnections, arcRendering, setArcRendering, showMinimap, @@ -193,11 +195,37 @@ export const ViewportSettingsDialog: React.FC = ({ size="sm" /> - + + Automatic arc connections{" "} + + Experimental + + + } + description="Create arcs from one hover handle. Connections follow the outlines of places and transitions." + > + + +