From 0504ef46a21a490b8b30bc9e1bbb5a691fb4dee3 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 14 Sep 2026 00:23:57 +0200 Subject: [PATCH 1/5] Give the default canvas nodes the compact nodes' surface --- .changeset/classic-transition-dimensions.md | 5 + .changeset/default-nodes-compact-surface.md | 5 + .../petrinaut-core/src/layout/dimensions.ts | 5 +- .../petrinaut/docs/visual-settings.md | 6 +- .../src/ui/lib/label-wrap-points.test.ts | 49 ++++++++ .../petrinaut/src/ui/lib/label-wrap-points.ts | 17 +++ .../src/ui/lib/split-pascal-case.test.ts | 101 ---------------- .../petrinaut/src/ui/lib/split-pascal-case.ts | 33 ------ .../react-flow-canvas/classic-place-node.tsx | 108 ++++++++---------- .../classic-transition-node.tsx | 92 +++++++-------- .../react-flow/react-flow-canvas/mini-map.tsx | 15 +-- .../use-transition-firing-animation.ts | 3 +- .../views/SDCPN/styles/classic-node-layout.ts | 60 ++++++++++ .../src/ui/views/SDCPN/styles/focus.ts | 2 +- .../src/ui/views/SDCPN/styles/node-surface.ts | 25 ++++ 15 files changed, 267 insertions(+), 259 deletions(-) create mode 100644 .changeset/classic-transition-dimensions.md create mode 100644 .changeset/default-nodes-compact-surface.md create mode 100644 libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.ts delete mode 100644 libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.test.ts delete mode 100644 libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/classic-node-layout.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts diff --git a/.changeset/classic-transition-dimensions.md b/.changeset/classic-transition-dimensions.md new file mode 100644 index 00000000000..0a8f3d62ef7 --- /dev/null +++ b/.changeset/classic-transition-dimensions.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +`classicNodeDimensions` draws a place as a 128px square, so it renders as a circle, and a transition as a 107px square. diff --git a/.changeset/default-nodes-compact-surface.md b/.changeset/default-nodes-compact-surface.md new file mode 100644 index 00000000000..692be466d35 --- /dev/null +++ b/.changeset/default-nodes-compact-surface.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +The default (classic) canvas nodes take the compact nodes' colours and shadow. A place is a circle and a transition a square, each showing its name over several lines. Minimap transitions sit on the same footprint as places, where they used to be drawn offset from it. Their outline is a 1.5px ring, a shade heavier than the compact card's border. A node name that is not written in PascalCase keeps all of its words. diff --git a/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts index cfa208583ba..1895e7c5fc9 100644 --- a/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts +++ b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts @@ -24,8 +24,9 @@ export const compactNodeDimensions: RenderNodeDimensions = { }; export const classicNodeDimensions: RenderNodeDimensions = { - place: { width: 130, height: 130 }, - transition: { width: 160, height: 80 }, + /** Both square: the place draws as a circle, the transition as a box. */ + place: { width: 128, height: 128 }, + transition: { width: 107, height: 107 }, componentInstance: { width: 180, height: 96 }, }; diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index e3a2627d5e4..e92353ca7f8 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -104,8 +104,10 @@ 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) -- 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. +- **Compact** (enabled) -- 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. + +Both styles draw a node the same way -- a thin outline in the place's token colour, a white transition, and a soft shadow -- and differ only in a node's size, shape, and how much it shows. Toggle freely -- this setting only affects rendering, not the underlying net. 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/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx index 587eda9b080..0f3e47ce6c1 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx @@ -3,9 +3,15 @@ import { Handle, type NodeProps, Position } from "@xyflow/react"; import { Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; -import { splitPascalCase } from "../../../../../lib/split-pascal-case"; +import { withLabelWrapPoints } from "../../../../../lib/label-wrap-points"; import { usePlaceTokenCount } from "../../../canvas-frame-store"; +import { + classicNodeBoxStyle, + classicNodeLabelStyle, + classicNodeRowStyle, +} from "../../../styles/classic-node-layout"; import { nodeFocusStyle } from "../../../styles/focus"; +import { nodeSurfaceStyle } from "../../../styles/node-surface"; import { handleStyling } from "../../../styles/styling"; import { placeBorderColor, placeFillColor } from "../../../styles/type-colors"; import { PlaceStateTooltip } from "./place-state-tooltip"; @@ -17,62 +23,38 @@ const containerStyle = css({ height: "full", }); -const placeCircleStyle = css({ - paddingY: "4", - paddingX: "2", +const placeBoxStyle = css({ + // A circle, since the node is square. borderRadius: "[50%]", - width: "full", - height: "full", - display: "flex", - flexDirection: "column", - justifyContent: "center", - alignItems: "center", - gap: "3", - minWidth: "0", - border: "2px solid color-mix(in oklab, black, white 35%)", + // Wider than the transition's, to keep the name clear of the curve. + padding: "[8px 20px]", fontSize: "[15px]", - boxSizing: "border-box", - position: "relative", - textAlign: "center", - lineHeight: "[1.3]", - cursor: "default", }); -const dynamicsIconStyle = css({ - position: "absolute", - top: "[25px]", - left: "[0px]", - width: "[100%]", - display: "flex", - alignItems: "center", - gap: "4", - justifyContent: "center", - color: "blue.s110", - fontSize: "lg", +const placeRowStyle = css({ + height: "[18px]", }); -const labelContainerStyle = css({ - textAlign: "center", - padding: "[12px 0]", - lineHeight: "[1.1]", - maxWidth: "[100%]", - overflowWrap: "break-word", +const placeLabelStyle = css({ lineClamp: "3", }); +const dynamicsIconStyle = css({ + color: "blue.s110", + fontSize: "lg", +}); + const tokenCountBadgeStyle = css({ - position: "absolute", - top: "[70%]", - fontSize: "base", display: "flex", alignItems: "center", justifyContent: "center", + height: "[18px]", + minWidth: "[22px]", + borderRadius: "[9px]", + padding: "[0 6px]", + fontSize: "sm", color: "neutral.s00", backgroundColor: "[black]", - minWidth: "[26px]", - height: "[26px]", - borderRadius: "[13px]", - padding: "[0 6px]", fontWeight: "semibold", fontVariantNumeric: "tabular-nums", }); @@ -97,11 +79,9 @@ export const ClassicPlaceNode: React.FC> = ({ !data.dragging && (data.hovered || data.visualizerPinned); - // Add zero width space to labels between pascal case points as text-wrapping breakpoints - const label = splitPascalCase(data.label).join("\u200B"); + // Wrap points let a long name break inside the box instead of clipping. + const label = withLabelWrapPoints(data.label); - // React Flow marks a node selected as a drag-selection is drawn, before the - // change reaches the editor's own selection. const focus = selected ? "focused" : data.focus; return ( @@ -114,21 +94,29 @@ export const ClassicPlaceNode: React.FC> = ({ style={handleStyling} />
- {data.dynamicsEnabled && ( -
- -
- )} -
{label}
- {tokenCount !== null && ( -
{tokenCount}
- )} +
+ {data.dynamicsEnabled ? ( +
+ +
+ ) : null} +
+
+ {label} +
+
+ {tokenCount === null ? null : ( +
{tokenCount}
+ )} +
> = ({ positionAbsoluteY, selected, }: NodeProps) => { - const { label } = data; + // Wrap points let a long name break inside the square instead of clipping. + const label = withLabelWrapPoints(data.label); // Refs for animated elements const boxRef = useRef(null); @@ -98,8 +82,6 @@ export const ClassicTransitionNode: React.FC> = ({ y: positionAbsoluteY, }); - // React Flow marks a node selected as a drag-selection is drawn, before the - // change reaches the editor's own selection. const focus = selected ? "focused" : data.focus; return ( @@ -112,16 +94,22 @@ export const ClassicTransitionNode: React.FC> = ({ />
- {data.lambdaType === "stochastic" && ( -
- +
+ {data.lambdaType === "stochastic" ? ( +
+ +
+ ) : null} +
+
+ {label} +
+
+
+
- )} -
{label}
-
-
= ({ id, x, y }) => { = ({ id, x, y }) => { return ( = ({ id, x, y }) => { /** * A wrapper around ReactFlow's MiniMap with custom styling. - * Renders place nodes as circles and transition nodes as rectangles. + * Renders place nodes as circles and transition nodes as squared rectangles. * Positions at top-right, offset by properties panel width when visible. */ export const MiniMap: React.FC> = (props) => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-transition-firing-animation.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-transition-firing-animation.ts index 690f16026cf..d582e55503d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-transition-firing-animation.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-transition-firing-animation.ts @@ -49,7 +49,8 @@ export const useTransitionFiringAnimation = ( [ { background: "rgba(255, 224, 132, 0.7)", - boxShadow: "0 0 6px 1px rgba(255, 132, 0, 0.59)", + boxShadow: + "var(--node-outline-ring, 0 0 #0000), 0 0 6px 1px rgba(255, 132, 0, 0.59)", offset: 0, }, ], diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/classic-node-layout.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/classic-node-layout.ts new file mode 100644 index 00000000000..d38cef7a805 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/classic-node-layout.ts @@ -0,0 +1,60 @@ +/** + * The layout the classic place and transition share: a centred name with a + * fixed row above and below it. + * + * The rows are always in the layout, so a name sits at the same height on + * every node and whatever a row holds -- the dynamics or lambda mark above, + * a token count or the firing bolt below -- appears over or under the name + * rather than pushing it aside. Each node sets its own row height and font + * size, since a transition has less height to give than a circle, and two + * classes setting one property would leave the winner to Panda's ordering. + */ + +import { css } from "@hashintel/ds-helpers/css"; + +/** Half a pixel heavier than the compact card's hairline. */ +const OUTLINE_WIDTH = "1.5px"; + +/** + * The classic node is outlined by a ring rather than a border: a browser + * floors a 1.5px border to a whole pixel, while a shadow's spread is painted + * as given. `--node-outline-color` carries the colour, which a place takes + * from its token type, and `--node-outline-ring` the shadow that draws it, + * which the firing flash reuses so a transition keeps its outline while it + * glows. Only a classic node sets the ring, so a compact card's flash draws + * none. + */ +export const classicNodeBoxStyle = css({ + "--node-outline-ring": `0 0 0 ${OUTLINE_WIDTH} var(--node-outline-color, currentColor)`, + "--node-elevation": "0px 2px 9px rgba(0, 0, 0, 0.04)", + _hover: { + "--node-elevation": "0px 4px 11px rgba(0, 0, 0, 0.1)", + }, + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + gap: "[6px]", + textAlign: "center", +}); + +export const classicNodeRowStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: "0", + lineHeight: "[1]", +}); + +/** + * How many lines a name may take is left to each node: a circle has the + * height for three, a flat transition for one. Both classes setting the + * clamp would leave the winner to Panda's own ordering. + */ +export const classicNodeLabelStyle = css({ + maxWidth: "[100%]", + overflowWrap: "break-word", + textOverflow: "ellipsis", + overflow: "hidden", + lineHeight: "[1.2]", +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/focus.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/focus.ts index c02b86fafe2..3bc4b0793b1 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/focus.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/focus.ts @@ -39,7 +39,7 @@ export const nodeFocusStyle = cva({ outline: "[4px solid transparent]", outlineOffset: "[3px]", boxShadow: - "[var(--focus-glow, 0 0 #0000), var(--node-elevation, 0 0 #0000)]", + "[var(--node-outline-ring, 0 0 #0000), var(--focus-glow, 0 0 #0000), var(--node-elevation, 0 0 #0000)]", /** * Only the ring and the glow fade. Muting recolours the border and label * of most of the net at once, so those two switch rather than animate. diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts new file mode 100644 index 00000000000..9bc1ce7cfc8 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts @@ -0,0 +1,25 @@ +import { css } from "@hashintel/ds-helpers/css"; + +export const nodeSurfaceStyle = css({ + width: "full", + height: "full", + boxSizing: "border-box", + position: "relative", + cursor: "default", +}); + +/** + * A transition's colours. Places take theirs from their token type; a + * transition carries none, so its surface names them here. + */ +export const transitionSurfaceStyle = css({ + background: "neutral.s00", + // The compact card outlines itself with a border, the classic node with a + // ring; each mode reads the one that applies to it. + borderColor: "neutral.s70", + "--node-outline-color": "var(--colors-neutral-s70)", + _hover: { + borderColor: "neutral.s100", + "--node-outline-color": "var(--colors-neutral-s100)", + }, +}); From 8b16265851e6416e9cca5af04fb55ee75c9906da Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 15 Sep 2026 16:39:23 +0200 Subject: [PATCH 2/5] Refine default node proportions and soften outlines --- .changeset/classic-transition-dimensions.md | 2 +- .changeset/default-nodes-compact-surface.md | 2 +- .../petrinaut-core/src/layout/dimensions.ts | 5 +- .../petrinaut/docs/visual-settings.md | 8 ++- .../react-flow-canvas/classic-place-node.tsx | 9 ++- .../classic-transition-node.tsx | 71 +++++++++++++------ .../views/SDCPN/styles/classic-node-layout.ts | 27 +------ 7 files changed, 68 insertions(+), 56 deletions(-) diff --git a/.changeset/classic-transition-dimensions.md b/.changeset/classic-transition-dimensions.md index 0a8f3d62ef7..12e8d97420f 100644 --- a/.changeset/classic-transition-dimensions.md +++ b/.changeset/classic-transition-dimensions.md @@ -2,4 +2,4 @@ "@hashintel/petrinaut-core": patch --- -`classicNodeDimensions` draws a place as a 128px square, so it renders as a circle, and a transition as a 107px square. +`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 index 692be466d35..bdddc7e30f7 100644 --- a/.changeset/default-nodes-compact-surface.md +++ b/.changeset/default-nodes-compact-surface.md @@ -2,4 +2,4 @@ "@hashintel/petrinaut": patch --- -The default (classic) canvas nodes take the compact nodes' colours and shadow. A place is a circle and a transition a square, each showing its name over several lines. Minimap transitions sit on the same footprint as places, where they used to be drawn offset from it. Their outline is a 1.5px ring, a shade heavier than the compact card's border. A node name that is not written in PascalCase keeps all of its words. +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. diff --git a/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts index 1895e7c5fc9..98d12456971 100644 --- a/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts +++ b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts @@ -24,9 +24,8 @@ export const compactNodeDimensions: RenderNodeDimensions = { }; export const classicNodeDimensions: RenderNodeDimensions = { - /** Both square: the place draws as a circle, the transition as a box. */ - place: { width: 128, height: 128 }, - transition: { width: 107, height: 107 }, + place: { width: 104, height: 104 }, + transition: { width: 180, height: 60 }, componentInstance: { width: 180, height: 96 }, }; diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index e92353ca7f8..c9f12aaf20f 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -104,10 +104,12 @@ When enabled, node positions snap to a grid when placing new nodes or dragging e Switch between two node rendering styles: -- **Classic** (disabled, the default) -- 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. -- **Compact** (enabled) -- 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, 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 draw a node the same way -- a thin outline in the place's token colour, a white transition, and a soft shadow -- and differ only in a node's size, shape, and how much it shows. +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. diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx index 0f3e47ce6c1..cf310ba7cfc 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx @@ -24,11 +24,16 @@ const containerStyle = css({ }); const placeBoxStyle = css({ + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + gap: "[2px]", + textAlign: "center", // A circle, since the node is square. borderRadius: "[50%]", // Wider than the transition's, to keep the name clear of the curve. - padding: "[8px 20px]", - fontSize: "[15px]", + padding: "[8px 14px]", }); const placeRowStyle = css({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-transition-node.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-transition-node.tsx index 5a9b69cb993..e492218aff9 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-transition-node.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-transition-node.tsx @@ -10,7 +10,6 @@ import { useFiringDelta } from "../../../hooks/use-firing-delta"; import { classicNodeBoxStyle, classicNodeLabelStyle, - classicNodeRowStyle, } from "../../../styles/classic-node-layout"; import { nodeFocusStyle } from "../../../styles/focus"; import { @@ -18,6 +17,7 @@ import { transitionSurfaceStyle, } from "../../../styles/node-surface"; import { handleStyling } from "../../../styles/styling"; +import { iconBadgeStyle } from "./node-card"; import { useTransitionFiringAnimation } from "./use-transition-firing-animation"; import type { TransitionNodeType } from "./react-flow-types"; @@ -29,29 +29,49 @@ const containerStyle = css({ }); const transitionBoxStyle = css({ - // Tighter than the circle's, so four lines of a name and the two rows - // around them fit the square with room to spare. - padding: "[2px 10px]", - // The flat box leaves less room for a name than a circle does, so its - // three lines are set smaller. - fontSize: "[13px]", + display: "flex", + alignItems: "center", + gap: "[10px]", + padding: "[6px 12px]", + textAlign: "left", }); -const transitionRowStyle = css({ - height: "[12px]", +const transitionIconStyle = css({ + position: "relative", + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "[28px]", + height: "[28px]", + flexShrink: "0", + color: "neutral.s80", +}); + +const transitionTextStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[2px]", + minWidth: "0", }); const transitionLabelStyle = css({ - lineClamp: "4", + lineClamp: "2", +}); + +const transitionTypeStyle = css({ + fontSize: "[11px]", + lineHeight: "[1.2]", + color: "neutral.a90", }); const stochasticIconStyle = css({ color: "blue.s60", - fontSize: "lg", }); const firingIndicatorStyle = css({ - fontSize: "xl", + position: "absolute", + top: "[-8px]", + right: "[-8px]", color: "yellow.s60", opacity: "[0]", transform: "scale(0.5)", @@ -65,8 +85,13 @@ export const ClassicTransitionNode: React.FC> = ({ positionAbsoluteY, selected, }: NodeProps) => { - // Wrap points let a long name break inside the square instead of clipping. const label = withLabelWrapPoints(data.label); + const subtitle = + data.lambdaType === "none" + ? "Transition" + : data.lambdaType === "stochastic" + ? "Stochastic" + : "Predicate"; // Refs for animated elements const boxRef = useRef(null); @@ -96,20 +121,22 @@ export const ClassicTransitionNode: React.FC> = ({ ref={boxRef} className={`${nodeSurfaceStyle} ${nodeFocusStyle({ focus })} ${transitionSurfaceStyle} ${classicNodeBoxStyle} ${transitionBoxStyle}`} > -
+
+ {data.lambdaType === "stochastic" ? ( -
- +
+
) : null}
-
- {label} -
-
-
- +
+
+ {label}
+
{subtitle}
+
+
+
Date: Tue, 15 Sep 2026 16:53:53 +0200 Subject: [PATCH 3/5] Match minimap geometry to canvas node dimensions --- .changeset/default-nodes-compact-surface.md | 2 +- .../react-flow/react-flow-canvas/mini-map.tsx | 40 +++++++------------ .../src/ui/views/SDCPN/styles/node-surface.ts | 4 -- 3 files changed, 16 insertions(+), 30 deletions(-) diff --git a/.changeset/default-nodes-compact-surface.md b/.changeset/default-nodes-compact-surface.md index bdddc7e30f7..80dee7e481e 100644 --- a/.changeset/default-nodes-compact-surface.md +++ b/.changeset/default-nodes-compact-surface.md @@ -2,4 +2,4 @@ "@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. +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/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/mini-map.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/mini-map.tsx index e6040e3672c..edbf671acb5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/mini-map.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/mini-map.tsx @@ -21,9 +21,6 @@ const miniMapClassName = css({ }, }); -const SHAPE_SIZE = 90; -/** Component instances are the one wide shape on the canvas. */ -const COMPONENT_INSTANCE_WIDTH_RATIO = 1.5; const DEFAULT_TRANSITION_FILL = "#6b7280"; const DEFAULT_COMPONENT_FILL = "#0f766e"; /** Thick and solid: at map scale a ring has to carry the whole signal. */ @@ -31,14 +28,20 @@ const FOCUS_STROKE_WIDTH = 22; /** * Custom node renderer for the MiniMap. - * Renders place nodes as circles and transition nodes as squared rectangles, carrying + * Renders place nodes as circles and transition nodes as rectangles, carrying * the canvas's focus roles: a shape at the focused item is boxed in the * role's colour and the rest of the net drops far back, so a glance at the * map answers "where is this neighbourhood" on a net larger than the screen. * The map is too small for the canvas's white band, so the box and the fade * carry it alone. */ -const MiniMapNode: React.FC = ({ id, x, y }) => { +const MiniMapNode: React.FC = ({ + id, + x, + y, + width, + height, +}) => { // MiniMapNodeProps doesn't include node data, so we look it up from the store const node = useStore( (state) => state.nodeLookup.get(id) as NodeType | undefined, @@ -77,23 +80,9 @@ const MiniMapNode: React.FC = ({ id, x, y }) => { if (node.data.kind === "place") { return ( - ); - } - - if (node.data.kind === "componentInstance") { - return ( - @@ -104,8 +93,9 @@ const MiniMapNode: React.FC = ({ id, x, y }) => { @@ -114,7 +104,7 @@ const MiniMapNode: React.FC = ({ id, x, y }) => { /** * A wrapper around ReactFlow's MiniMap with custom styling. - * Renders place nodes as circles and transition nodes as squared rectangles. + * Renders place nodes as circles and transition nodes as rectangles. * Positions at top-right, offset by properties panel width when visible. */ export const MiniMap: React.FC> = (props) => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts index 9bc1ce7cfc8..e018e968d5e 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts @@ -14,12 +14,8 @@ export const nodeSurfaceStyle = css({ */ export const transitionSurfaceStyle = css({ background: "neutral.s00", - // The compact card outlines itself with a border, the classic node with a - // ring; each mode reads the one that applies to it. - borderColor: "neutral.s70", "--node-outline-color": "var(--colors-neutral-s70)", _hover: { - borderColor: "neutral.s100", "--node-outline-color": "var(--colors-neutral-s100)", }, }); From 92a588379283a5bdb40a727d37f746af9528de15 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 16:24:27 +0200 Subject: [PATCH 4/5] Add experimental automatic arc connections --- .changeset/quiet-arcs-follow.md | 5 + .../petrinaut/docs/drawing-a-net.md | 12 + .../petrinaut/docs/visual-settings.md | 6 + .../src/react/state/user-settings-context.ts | 4 + .../state/user-settings-provider.test.tsx | 58 +++++ .../react/state/user-settings-provider.tsx | 5 + .../ui/automatic-arc-connections.stories.tsx | 158 +++++++++++++ .../create-experiment-drawer.test.tsx | 1 + .../components/viewport-settings-dialog.tsx | 30 ++- .../react-flow/react-flow-canvas.tsx | 70 +++++- .../react-flow/react-flow-canvas/arc.tsx | 5 +- .../react-flow-canvas/classic-place-node.tsx | 18 +- .../classic-transition-node.tsx | 17 +- .../react-flow-canvas/node-card.tsx | 17 +- .../outline-connection-line.tsx | 69 ++++++ .../react-flow-canvas/port-handles.ts | 4 +- .../react-flow-canvas/react-flow-types.ts | 5 +- .../react-flow-canvas/shared/node-handles.tsx | 156 +++++++++++++ .../shared/outline-arcs.test.ts | 112 +++++++++ .../react-flow-canvas/shared/outline-arcs.ts | 132 +++++++++++ .../use-react-flow-elements.test.tsx | 219 ++++++++++++++++++ .../use-react-flow-elements.ts | 32 ++- 22 files changed, 1077 insertions(+), 58 deletions(-) create mode 100644 .changeset/quiet-arcs-follow.md create mode 100644 libs/@hashintel/petrinaut/src/ui/automatic-arc-connections.stories.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/outline-connection-line.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/node-handles.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/outline-arcs.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/outline-arcs.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.test.tsx 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/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 c9f12aaf20f..dfc39f2d0e6 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -142,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/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." + > + + +