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/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/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index e3a2627d5e4..c9f12aaf20f 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. 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..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 @@ -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,43 @@ const containerStyle = css({ height: "full", }); -const placeCircleStyle = css({ - paddingY: "4", - paddingX: "2", - borderRadius: "[50%]", - width: "full", - height: "full", +const placeBoxStyle = css({ display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", - gap: "3", - minWidth: "0", - border: "2px solid color-mix(in oklab, black, white 35%)", - fontSize: "[15px]", - boxSizing: "border-box", - position: "relative", + gap: "[2px]", textAlign: "center", - lineHeight: "[1.3]", - cursor: "default", + // A circle, since the node is square. + borderRadius: "[50%]", + // Wider than the transition's, to keep the name clear of the curve. + padding: "[8px 14px]", }); -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 +84,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 +99,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; + const label = withLabelWrapPoints(data.label); + const subtitle = + data.lambdaType === "none" + ? "Transition" + : data.lambdaType === "stochastic" + ? "Stochastic" + : "Predicate"; // Refs for animated elements const boxRef = useRef(null); @@ -98,8 +107,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,14 +119,22 @@ export const ClassicTransitionNode: React.FC> = ({ />
- {data.lambdaType === "stochastic" && ( -
- +
+ + {data.lambdaType === "stochastic" ? ( +
+ +
+ ) : null} +
+
+
+ {label}
- )} -
{label}
+
{subtitle}
+
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 5d654891896..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,8 +21,6 @@ const miniMapClassName = css({ }, }); -const SHAPE_SIZE = 90; -const TRANSITION_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. */ @@ -37,7 +35,13 @@ const FOCUS_STROKE_WIDTH = 22; * 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, @@ -76,23 +80,9 @@ const MiniMapNode: React.FC = ({ id, x, y }) => { if (node.data.kind === "place") { return ( - ); - } - - if (node.data.kind === "componentInstance") { - return ( - @@ -101,10 +91,11 @@ const MiniMapNode: React.FC = ({ id, x, y }) => { return ( 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..134d34e1fea --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/classic-node-layout.ts @@ -0,0 +1,39 @@ +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} color-mix(in oklab, var(--node-outline-color, currentColor) 75%, var(--colors-neutral-s70))`, + "--node-elevation": "0px 2px 9px rgba(0, 0, 0, 0.04)", + _hover: { + "--node-elevation": "0px 4px 11px rgba(0, 0, 0, 0.1)", + }, +}); + +export const classicNodeRowStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: "0", + lineHeight: "[1]", +}); + +export const classicNodeLabelStyle = css({ + fontSize: "[13px]", + fontWeight: "medium", + 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..e018e968d5e --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts @@ -0,0 +1,21 @@ +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", + "--node-outline-color": "var(--colors-neutral-s70)", + _hover: { + "--node-outline-color": "var(--colors-neutral-s100)", + }, +});