-
- {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/node-card.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/node-card.tsx
index 6c7b5613bbc..223a3adfcdf 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/node-card.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/node-card.tsx
@@ -1,8 +1,6 @@
-import { Handle, Position } from "@xyflow/react";
-
import { css } from "@hashintel/ds-helpers/css";
-import { handleStyling } from "../../../styles/styling";
+import { NodeHandles } from "./shared/node-handles";
import type { ReactNode } from "react";
@@ -120,12 +118,6 @@ export const NodeCard: React.FC = ({
}) => {
return (
-
{iconContainer}
@@ -134,12 +126,7 @@ export const NodeCard: React.FC = ({
{badge}
-
+
);
};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/outline-connection-line.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/outline-connection-line.tsx
new file mode 100644
index 00000000000..da33e4d220a
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/outline-connection-line.tsx
@@ -0,0 +1,65 @@
+import { useStore, type ConnectionLineComponent } from "@xyflow/react";
+import { useId } from "react";
+
+import { getOutlineArcPath, getOutlineNode } from "./shared/outline-arcs";
+
+import type { NodeType } from "./react-flow-types";
+
+export const OutlineConnectionLine: ConnectionLineComponent = ({
+ fromNode,
+ toNode,
+ toX,
+ toY,
+ connectionStatus,
+ connectionLineStyle,
+}) => {
+ const markerId = useId();
+ const hasReverseArc = useStore(
+ (state) =>
+ toNode !== null &&
+ state.edges.some(
+ (edge) => edge.source === toNode.id && edge.target === fromNode.id,
+ ),
+ );
+ const source = getOutlineNode(fromNode.data);
+ const target =
+ connectionStatus === "valid" && toNode ? getOutlineNode(toNode.data) : null;
+ if (!source) {
+ return null;
+ }
+ const [path] = getOutlineArcPath(
+ source,
+ target ?? { x: toX, y: toY },
+ target !== null && hasReverseArc,
+ );
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/port-handles.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/port-handles.ts
index 79518e3bfbe..e54593ce3ec 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/port-handles.ts
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/port-handles.ts
@@ -5,7 +5,7 @@
*/
import type { CanvasConnection } from "../../../use-canvas-interactions";
-import type { Connection } from "@xyflow/react";
+import type { Edge } from "@xyflow/react";
const portInPrefix = "port-in-";
const portOutPrefix = "port-out-";
@@ -20,7 +20,7 @@ const portIdOf = (handle: string | null | undefined, prefix: string) =>
handle?.startsWith(prefix) ? handle.slice(prefix.length) : null;
export const toCanvasConnection = (
- connection: Connection,
+ connection: Pick,
): CanvasConnection => ({
sourceId: connection.source,
targetId: connection.target,
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/react-flow-types.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/react-flow-types.ts
index fade0a11e5e..814d04135f4 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/react-flow-types.ts
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/react-flow-types.ts
@@ -10,6 +10,7 @@ import type {
CanvasPlaceNode,
CanvasTransitionNode,
} from "../../../canvas-scene";
+import type { OutlineArcPath } from "./shared/outline-arcs";
import type { Edge, Node } from "@xyflow/react";
export type TransitionFrameState = NonNullable<
@@ -39,6 +40,8 @@ export type NodeType =
export type ArcData = Pick<
CanvasArc,
"kind" | "weight" | "focus" | "transitionId"
->;
+> & {
+ outlinePath?: OutlineArcPath;
+};
export type ArcEdgeType = Edge;
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/node-handles.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/node-handles.tsx
new file mode 100644
index 00000000000..c18aade07e7
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/node-handles.tsx
@@ -0,0 +1,154 @@
+import { Handle, Position, useNodeId, useStore } from "@xyflow/react";
+import { use } from "react";
+
+import { css } from "@hashintel/ds-helpers/css";
+
+import { UserSettingsContext } from "../../../../../../../react/state/user-settings-context";
+import { handleStyling } from "../../../../styles/styling";
+
+import type { KeyboardEvent } from "react";
+
+const launchHandleStyle = css({
+ opacity: "[0]",
+ ".react-flow__node:hover &, .react-flow__node:focus-within &": {
+ opacity: "[1]",
+ },
+ "@media (hover: none)": { opacity: "[1]" },
+ _before: {
+ content: '""',
+ position: "absolute",
+ inset: "[-7.5px]",
+ },
+ _after: {
+ content: '""',
+ position: "absolute",
+ inset: "[0]",
+ borderRadius: "full",
+ background: "blue.s80",
+ border: "[1px solid white]",
+ pointerEvents: "none",
+ },
+});
+
+// Pseudo-elements expand hit areas without moving the anchors of subnet arcs.
+const targetHandleStyle = css({
+ _after: {
+ content: '""',
+ position: "absolute",
+ left: "[50%]",
+ top: "[calc(50% - var(--arc-target-height) / 2)]",
+ width: "[var(--arc-target-width)]",
+ height: "[var(--arc-target-height)]",
+ borderRadius: "[var(--arc-target-radius)]",
+ outline: "[var(--arc-target-outline)]",
+ },
+ _focusVisible: { _after: { outline: "[3px solid var(--colors-blue-s80)]" } },
+});
+
+const activateHandle = (event: KeyboardEvent) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ event.stopPropagation();
+ event.currentTarget.click();
+ }
+};
+
+export const NodeHandles = ({ isConnectable }: { isConnectable: boolean }) => {
+ const { enableAutomaticArcConnections } = use(UserSettingsContext);
+ const nodeId = useNodeId();
+ const node = useStore((state) =>
+ nodeId ? state.nodeLookup.get(nodeId) : undefined,
+ );
+ const nodeKind = node?.type;
+ const sourceKind = useStore((state) =>
+ state.connection.inProgress
+ ? state.connection.fromNode.type
+ : state.connectionClickStartHandle
+ ? state.nodeLookup.get(state.connectionClickStartHandle.nodeId)?.type
+ : undefined,
+ );
+ const isActiveTarget = useStore(
+ (state) =>
+ state.connection.isValid === true &&
+ state.connection.toNode?.id === nodeId,
+ );
+
+ const canReceive =
+ isConnectable &&
+ ((sourceKind === "place" && nodeKind === "transition") ||
+ (sourceKind === "transition" && nodeKind === "place"));
+ const borderRadius = nodeKind === "place" ? "999px" : "0";
+
+ return (
+ <>
+
+
+ >
+ );
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/outline-arcs.test.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/outline-arcs.test.ts
new file mode 100644
index 00000000000..9ce6b7a2d53
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/outline-arcs.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ getOutlineArcPath,
+ getOutlineAttachment,
+ type OutlineNode,
+} from "./outline-arcs";
+
+const circle: OutlineNode = {
+ position: { x: 40, y: 60 },
+ width: 100,
+ height: 100,
+ cornerRadius: 50,
+};
+
+describe("outline attachments", () => {
+ it.each([0, 30, 45, 90, 135, 180, 225, 270, 315])(
+ "intersects a circle at %s degrees with an outward unit normal",
+ (degrees) => {
+ const angle = (degrees * Math.PI) / 180;
+ const direction = { x: Math.cos(angle), y: Math.sin(angle) };
+ const { point, normal } = getOutlineAttachment(circle, direction);
+ expect(point.x).toBeCloseTo(circle.position.x + 50 * direction.x);
+ expect(point.y).toBeCloseTo(circle.position.y + 50 * direction.y);
+ expect(normal.x).toBeCloseTo(direction.x);
+ expect(normal.y).toBeCloseTo(direction.y);
+ },
+ );
+
+ it("uses the top of a wide capsule and the curve at its end", () => {
+ const capsule = { ...circle, width: 200, height: 40, cornerRadius: 20 };
+ const top = getOutlineAttachment(capsule, { x: 1, y: -1 });
+ expect(top.point).toEqual({ x: 60, y: 40 });
+ expect(top.normal).toEqual({ x: 0, y: -1 });
+
+ const end = getOutlineAttachment(capsule, { x: 10, y: 1 });
+ expect(Math.hypot(end.point.x - 120, end.point.y - 60)).toBeCloseTo(20);
+ expect(Math.hypot(end.normal.x, end.normal.y)).toBeCloseTo(1);
+ expect(end.normal.x).toBeGreaterThan(0);
+ expect(end.normal.y).toBeGreaterThan(0);
+ });
+
+ it("clips a diagonal to a rounded corner rather than its bounding box", () => {
+ const rounded = { ...circle, cornerRadius: 12 };
+ const { point, normal } = getOutlineAttachment(rounded, { x: 1, y: 1 });
+ expect(point.x).toBeCloseTo(40 + 38 + 12 / Math.sqrt(2));
+ expect(point.y).toBeCloseTo(60 + 38 + 12 / Math.sqrt(2));
+ expect(normal.x).toBeCloseTo(1 / Math.sqrt(2));
+ expect(normal.y).toBeCloseTo(1 / Math.sqrt(2));
+ });
+
+ it("keeps rectangular transitions on their boundary", () => {
+ const rectangle = { ...circle, width: 160, height: 40, cornerRadius: 0 };
+ expect(getOutlineAttachment(rectangle, { x: -1, y: 0 })).toEqual({
+ point: { x: -40, y: 60 },
+ normal: { x: -1, y: 0 },
+ });
+ expect(getOutlineAttachment(rectangle, { x: 1, y: 1 })).toEqual({
+ point: { x: 60, y: 80 },
+ normal: { x: 0, y: 1 },
+ });
+ });
+});
+
+describe("outline arc paths", () => {
+ const source = { ...circle, position: { x: 0, y: 0 } };
+ const target = { ...circle, position: { x: 300, y: 0 } };
+
+ it("places the label halfway along a symmetric connection", () => {
+ const [path, labelX, labelY] = getOutlineArcPath(source, target);
+ expect(path).toMatch(/^M 50,0 C /);
+ expect(path).toMatch(/ 250,0$/);
+ expect(labelX).toBe(150);
+ expect(labelY).toBe(0);
+ });
+
+ it("separates both directions onto opposite sides", () => {
+ const [forward, forwardX, forwardY] = getOutlineArcPath(
+ source,
+ target,
+ true,
+ );
+ const [reverse, reverseX, reverseY] = getOutlineArcPath(
+ target,
+ source,
+ true,
+ );
+ expect(forward).not.toBe(reverse);
+ expect(forwardX).toBeCloseTo(reverseX);
+ expect(forwardY).toBeGreaterThan(0);
+ expect(reverseY).toBeCloseTo(-forwardY);
+ });
+
+ it("ends an unsnapped preview at the pointer", () => {
+ const [path] = getOutlineArcPath(source, { x: 75, y: 200 });
+ expect(path).toMatch(/ 75,200$/);
+ });
+
+ it.each([0, 1, 50, 100])(
+ "stays finite with %s pixels between centers",
+ (distance) => {
+ const result = getOutlineArcPath(
+ source,
+ { ...target, position: { x: distance, y: 0 } },
+ true,
+ );
+ expect(result[0]).not.toMatch(/NaN|Infinity/);
+ expect(Number.isFinite(result[1])).toBe(true);
+ expect(Number.isFinite(result[2])).toBe(true);
+ },
+ );
+});
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/outline-arcs.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/outline-arcs.ts
new file mode 100644
index 00000000000..28cf5b50636
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/outline-arcs.ts
@@ -0,0 +1,125 @@
+import type { CanvasNode, CanvasPoint } from "../../../../canvas-scene";
+
+export type OutlineNode = {
+ position: CanvasPoint;
+ width: number;
+ height: number;
+ cornerRadius: number;
+};
+
+export type OutlineArcPath = [path: string, labelX: number, labelY: number];
+
+export const getOutlineNode = (node: CanvasNode): OutlineNode | null =>
+ node.kind === "componentInstance"
+ ? null
+ : {
+ position: node.position,
+ width: node.width,
+ height: node.height,
+ cornerRadius:
+ node.kind === "place" ? Math.min(node.width, node.height) / 2 : 0,
+ };
+
+export const getOutlineAttachment = (
+ node: OutlineNode,
+ direction: CanvasPoint,
+): { point: CanvasPoint; normal: CanvasPoint } => {
+ const length = Math.hypot(direction.x, direction.y);
+ const unit =
+ length > 0
+ ? { x: direction.x / length, y: direction.y / length }
+ : { x: 1, y: 0 };
+ const halfWidth = Math.max(0, node.width / 2);
+ const halfHeight = Math.max(0, node.height / 2);
+ const radius = Math.max(
+ 0,
+ Math.min(node.cornerRadius, halfWidth, halfHeight),
+ );
+ const horizontalDistance =
+ unit.x === 0 ? Infinity : halfWidth / Math.abs(unit.x);
+ const verticalDistance =
+ unit.y === 0 ? Infinity : halfHeight / Math.abs(unit.y);
+ let distance = Math.min(horizontalDistance, verticalDistance);
+ let normal =
+ horizontalDistance < verticalDistance
+ ? { x: Math.sign(unit.x), y: 0 }
+ : { x: 0, y: Math.sign(unit.y) };
+
+ if (
+ radius > 0 &&
+ Math.abs(unit.x * distance) > halfWidth - radius &&
+ Math.abs(unit.y * distance) > halfHeight - radius
+ ) {
+ const corner = {
+ x: Math.sign(unit.x) * (halfWidth - radius),
+ y: Math.sign(unit.y) * (halfHeight - radius),
+ };
+ const projection = unit.x * corner.x + unit.y * corner.y;
+ distance =
+ projection +
+ Math.sqrt(
+ Math.max(
+ 0,
+ projection ** 2 - corner.x ** 2 - corner.y ** 2 + radius ** 2,
+ ),
+ );
+ normal = {
+ x: (unit.x * distance - corner.x) / radius,
+ y: (unit.y * distance - corner.y) / radius,
+ };
+ }
+
+ return {
+ point: {
+ x: node.position.x + unit.x * distance,
+ y: node.position.y + unit.y * distance,
+ },
+ normal,
+ };
+};
+
+export const getOutlineArcPath = (
+ source: OutlineNode,
+ target: OutlineNode | CanvasPoint,
+ hasReverseArc = false,
+): OutlineArcPath => {
+ const targetCenter = "position" in target ? target.position : target;
+ const direction = {
+ x: targetCenter.x - source.position.x,
+ y: targetCenter.y - source.position.y,
+ };
+ if (direction.x === 0 && direction.y === 0) {
+ direction.x = 1;
+ }
+ const lane = hasReverseArc ? 0.3 : 0;
+ const sourceAttachment = getOutlineAttachment(source, {
+ x: direction.x - direction.y * lane,
+ y: direction.y + direction.x * lane,
+ });
+ const targetAttachment =
+ "position" in target
+ ? getOutlineAttachment(target, {
+ x: -direction.x - direction.y * lane,
+ y: -direction.y + direction.x * lane,
+ })
+ : { point: target, normal: { x: 0, y: 0 } };
+ const start = sourceAttachment.point;
+ const end = targetAttachment.point;
+ const controlDistance = Math.min(
+ 80,
+ Math.hypot(end.x - start.x, end.y - start.y) * 0.35,
+ );
+ const firstControl = {
+ x: start.x + sourceAttachment.normal.x * controlDistance,
+ y: start.y + sourceAttachment.normal.y * controlDistance,
+ };
+ const lastControl = {
+ x: end.x + targetAttachment.normal.x * controlDistance,
+ y: end.y + targetAttachment.normal.y * controlDistance,
+ };
+ return [
+ `M ${start.x},${start.y} C ${firstControl.x},${firstControl.y} ${lastControl.x},${lastControl.y} ${end.x},${end.y}`,
+ (start.x + 3 * firstControl.x + 3 * lastControl.x + end.x) / 8,
+ (start.y + 3 * firstControl.y + 3 * lastControl.y + end.y) / 8,
+ ];
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.test.tsx
new file mode 100644
index 00000000000..6bb2ce7b605
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.test.tsx
@@ -0,0 +1,280 @@
+/** @vitest-environment jsdom */
+import { cleanup, fireEvent, renderHook, screen } from "@testing-library/react";
+import { useState, type ReactNode } from "react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import {
+ classicNodeDimensions,
+ compactNodeDimensions,
+} from "@hashintel/petrinaut-core";
+
+import {
+ defaultUserSettingsContextValue,
+ UserSettingsContext,
+} from "../../../../../../react/state/user-settings-context";
+import { useReactFlowElements } from "./use-react-flow-elements";
+
+import type { CanvasArc, CanvasNode, CanvasScene } from "../../../canvas-scene";
+
+afterEach(cleanup);
+
+const nodeBase = {
+ position: { x: 0, y: 0 },
+ width: 100,
+ height: 100,
+ label: "Node",
+ dragging: false,
+ selected: false,
+ hovered: false,
+ focus: "none" as const,
+};
+const nodes: CanvasNode[] = [
+ {
+ ...nodeBase,
+ id: "place",
+ kind: "place",
+ dynamicsEnabled: false,
+ hasColorType: false,
+ hasVisualizer: false,
+ visualizerPinned: false,
+ typeColor: undefined,
+ },
+ {
+ ...nodeBase,
+ id: "transition",
+ kind: "transition",
+ position: { x: 300, y: 200 },
+ width: 160,
+ height: 80,
+ lambdaType: "none",
+ },
+ {
+ ...nodeBase,
+ id: "subnet",
+ kind: "componentInstance",
+ position: { x: 600, y: 0 },
+ subnetName: "Subnet",
+ ports: [{ id: "port", name: "Port" }],
+ },
+];
+const arcBase: Omit = {
+ kind: "read",
+ weight: 3,
+ sourcePortId: null,
+ targetPortId: null,
+ transitionId: "transition",
+ color: "#123456",
+ selected: true,
+ focus: "none",
+};
+const scene: CanvasScene = {
+ nodes,
+ dimensions: compactNodeDimensions,
+ focusActive: false,
+ arcs: [
+ { ...arcBase, id: "input", sourceId: "place", targetId: "transition" },
+ {
+ ...arcBase,
+ id: "output",
+ sourceId: "transition",
+ targetId: "place",
+ kind: "standard",
+ },
+ {
+ ...arcBase,
+ id: "port-input",
+ sourceId: "subnet",
+ targetId: "transition",
+ sourcePortId: "port",
+ },
+ {
+ ...arcBase,
+ id: "port-output",
+ sourceId: "transition",
+ targetId: "subnet",
+ targetPortId: "port",
+ },
+ ],
+};
+
+const Settings = ({ children }: { children: ReactNode }) => {
+ const [enabled, setEnabled] = useState(false);
+ return (
+
+
+ {children}
+
+ );
+};
+
+describe("automatic arc rendering", () => {
+ it.each([false, true])(
+ "attaches diagonal arcs to square transition corners with compact nodes %s",
+ (compactNodes) => {
+ const dimensions = compactNodes
+ ? compactNodeDimensions
+ : classicNodeDimensions;
+ const transitionSize = dimensions.transition;
+ const transitionPosition = {
+ x: transitionSize.width * 3,
+ y: transitionSize.height * 3,
+ };
+ const { result } = renderHook(
+ () =>
+ useReactFlowElements({
+ ...scene,
+ dimensions,
+ nodes: nodes.map((node) =>
+ node.kind === "transition"
+ ? { ...node, ...transitionSize, position: transitionPosition }
+ : node,
+ ),
+ arcs: [
+ {
+ ...arcBase,
+ id: "input",
+ sourceId: "place",
+ targetId: "transition",
+ },
+ ],
+ }),
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+ );
+
+ const path = result.current.edges.find((edge) => edge.id === "input")
+ ?.data?.outlinePath?.[0];
+ expect(path).toBeDefined();
+ const endpoint = path?.split(" ").at(-1)?.split(",").map(Number);
+ expect(endpoint?.[0]).toBeCloseTo(
+ transitionPosition.x - transitionSize.width / 2,
+ );
+ expect(endpoint?.[1]).toBeCloseTo(
+ transitionPosition.y - transitionSize.height / 2,
+ );
+ },
+ );
+
+ it("changes only ordinary arc geometry and restores all legacy edge data when switched off", () => {
+ const { result } = renderHook(() => useReactFlowElements(scene), {
+ wrapper: Settings,
+ });
+ const legacy = result.current.edges;
+ expect(legacy.every((edge) => edge.data?.outlinePath === undefined)).toBe(
+ true,
+ );
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "Toggle automatic arcs" }),
+ );
+ for (const edge of result.current.edges) {
+ const original = legacy.find((candidate) => candidate.id === edge.id);
+ if (edge.id.startsWith("port-")) {
+ expect(edge).toEqual(original);
+ } else {
+ expect(edge.data?.outlinePath).toBeDefined();
+ expect({
+ ...edge,
+ data: { ...edge.data, outlinePath: undefined },
+ }).toEqual(original);
+ }
+ }
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "Toggle automatic arcs" }),
+ );
+ expect(result.current.edges).toEqual(legacy);
+ });
+
+ it("recomputes attachments during node movement without mutating the scene", () => {
+ const snapshot = structuredClone(scene);
+ const { result, rerender } = renderHook(
+ (input: CanvasScene) => useReactFlowElements(input),
+ { initialProps: scene, wrapper: Settings },
+ );
+ fireEvent.click(
+ screen.getByRole("button", { name: "Toggle automatic arcs" }),
+ );
+ const previous = result.current.edges.find((edge) => edge.id === "input")
+ ?.data?.outlinePath;
+ rerender({
+ ...scene,
+ nodes: nodes.map((node) =>
+ node.id === "transition"
+ ? { ...node, position: { x: -200, y: 100 }, dragging: true }
+ : node,
+ ),
+ });
+ expect(
+ result.current.edges.find((edge) => edge.id === "input")?.data
+ ?.outlinePath,
+ ).not.toEqual(previous);
+ expect(scene).toEqual(snapshot);
+ });
+
+ it("preserves unaffected nodes and arcs when focus changes", () => {
+ const { result, rerender } = renderHook(
+ (input: CanvasScene) => useReactFlowElements(input),
+ { initialProps: scene, wrapper: Settings },
+ );
+ fireEvent.click(
+ screen.getByRole("button", { name: "Toggle automatic arcs" }),
+ );
+ const previous = result.current;
+ rerender({
+ ...scene,
+ focusActive: true,
+ nodes: nodes.map((node) =>
+ node.id === "place"
+ ? { ...node, focus: "focused", hovered: true }
+ : { ...node },
+ ),
+ arcs: scene.arcs.map((arc) =>
+ arc.id === "input" ? { ...arc, focus: "outgoing" } : { ...arc },
+ ),
+ });
+
+ for (const node of result.current.nodes) {
+ const original = previous.nodes.find(
+ (candidate) => candidate.id === node.id,
+ );
+ if (node.id === "place") {
+ expect(node).not.toBe(original);
+ expect(node.data.focus).toBe("focused");
+ } else {
+ expect(node).toBe(original);
+ }
+ }
+ for (const edge of result.current.edges) {
+ const original = previous.edges.find(
+ (candidate) => candidate.id === edge.id,
+ );
+ if (edge.id === "input") {
+ expect(edge).not.toBe(original);
+ expect(edge.data?.focus).toBe("outgoing");
+ expect(edge.data?.outlinePath).toEqual(original?.data?.outlinePath);
+ expect(edge.markerEnd).not.toEqual(original?.markerEnd);
+ } else {
+ expect(edge).toBe(original);
+ }
+ }
+ });
+});
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.ts
index beefd3f60fa..00ae6bf5d7b 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.ts
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.ts
@@ -1,8 +1,11 @@
import { MarkerType } from "@xyflow/react";
+import { use } from "react";
+import { UserSettingsContext } from "../../../../../../react/state/user-settings-context";
import { arcHaloColor } from "../../../styles/focus";
import { useStableItems } from "../../../use-stable-items";
import { portInHandleId, portOutHandleId } from "./port-handles";
+import { getOutlineArcPath, getOutlineNode } from "./shared/outline-arcs";
import type { CanvasArc, CanvasNode, CanvasScene } from "../../../canvas-scene";
import type { ArcEdgeType, NodeType } from "./react-flow-types";
@@ -79,10 +82,36 @@ const toReactFlowEdge = (arc: CanvasArc): ArcEdgeType => {
export const useReactFlowElements = (
scene: CanvasScene,
): { nodes: NodeType[]; edges: ArcEdgeType[] } => {
+ const { enableAutomaticArcConnections } = use(UserSettingsContext);
+ const nodesById = new Map(scene.nodes.map((node) => [node.id, node]));
+ const connections = new Set(
+ scene.arcs
+ .filter((arc) => !arc.sourcePortId && !arc.targetPortId)
+ .map((arc) => JSON.stringify([arc.sourceId, arc.targetId])),
+ );
+
// Rebuilt from the scene, then held at their previous identity where
// nothing changed, so React Flow re-renders only what a hover touched.
return {
nodes: useStableItems(scene.nodes.map(toReactFlowNode)),
- edges: useStableItems(scene.arcs.map(toReactFlowEdge)),
+ edges: useStableItems(
+ scene.arcs.map((arc) => {
+ const edge = toReactFlowEdge(arc);
+ if (enableAutomaticArcConnections && edge.data) {
+ const source = nodesById.get(arc.sourceId);
+ const target = nodesById.get(arc.targetId);
+ const sourceOutline = source && getOutlineNode(source);
+ const targetOutline = target && getOutlineNode(target);
+ if (sourceOutline && targetOutline) {
+ edge.data.outlinePath = getOutlineArcPath(
+ sourceOutline,
+ targetOutline,
+ connections.has(JSON.stringify([arc.targetId, arc.sourceId])),
+ );
+ }
+ }
+ return edge;
+ }),
+ ),
};
};
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)",
+ },
+});