Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions libs/@hashintel/petrinaut/src/lib/get-connections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { Transition } from "../core/types/sdcpn";
import { generateArcId } from "../state/sdcpn-context";
import type { SelectionMap } from "../state/selection";

/**
* Given a list of transitions and a set of selected item IDs,
* returns a {@link SelectionMap} of all items (places, transitions, arcs)
* that are directly connected to any selected item via an arc.
*
* An item is included if it shares an arc with a selected item:
* - A selected place includes its connected transitions and arcs.
* - A selected transition includes its connected places and arcs.
* - A selected arc includes its source place and target transition.
Copy link
Copy Markdown

@augmentcode augmentcode bot Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

libs/@hashintel/petrinaut/src/lib/get-connections.ts:13 — The docs say a selected arc includes its “source place and target transition”, but output arcs are transition→place as well. Consider rewording to “source and target nodes” to avoid confusion.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

*/
export function getNodeConnections(
transitions: readonly Transition[],
selectedIds: ReadonlySet<string>,
): SelectionMap {
const connections: SelectionMap = new Map();

for (const transition of transitions) {
const transitionSelected = selectedIds.has(transition.id);

for (const inputArc of transition.inputArcs) {
const arcId = generateArcId({
inputId: inputArc.placeId,
outputId: transition.id,
});
const placeSelected = selectedIds.has(inputArc.placeId);
const arcSelected = selectedIds.has(arcId);

if (transitionSelected || placeSelected || arcSelected) {
connections.set(inputArc.placeId, {
type: "place",
id: inputArc.placeId,
});
connections.set(transition.id, {
type: "transition",
id: transition.id,
});
connections.set(arcId, { type: "arc", id: arcId });
}
}

for (const outputArc of transition.outputArcs) {
const arcId = generateArcId({
inputId: transition.id,
outputId: outputArc.placeId,
});
const placeSelected = selectedIds.has(outputArc.placeId);
const arcSelected = selectedIds.has(arcId);

if (transitionSelected || placeSelected || arcSelected) {
connections.set(outputArc.placeId, {
type: "place",
id: outputArc.placeId,
});
connections.set(transition.id, {
type: "transition",
id: transition.id,
});
connections.set(arcId, { type: "arc", id: arcId });
}
}
}

// The logic above adds items even if they are selected, so we now remove all selected items from the
// connected map. I suspect this approach is also faster than adding extra conditions to build the list
selectedIds.forEach((id) => connections.delete(id));

return connections;
}
9 changes: 9 additions & 0 deletions libs/@hashintel/petrinaut/src/state/editor-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export type EditorActions = {
setActiveBottomPanelTab: (tab: BottomPanelTab) => void;
/** Check whether a given ID is in the current selection. */
isSelected: (id: string) => boolean;
/** Check whether a node/edge is connected to any selected item via an arc. */
isSelectedConnection: (id: string) => boolean;
/** Check whether a node/edge is connected to any selected item via an arc. */
isNotSelectedConnection: (id: string) => boolean;
Copy link
Copy Markdown

@augmentcode augmentcode bot Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

libs/@hashintel/petrinaut/src/state/editor-context.ts:66 — The JSDoc for isNotSelectedConnection says “connected to any selected item”, but the implementation returns true for items that are neither selected nor connected. Consider adjusting the comment so callers don’t invert the meaning.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

/** Map of all items connected to the current selection, keyed by id. */
selectedConnections: SelectionMap;
setSelection: (
selection: SelectionMap | ((prev: SelectionMap) => SelectionMap),
) => void;
Expand Down Expand Up @@ -115,6 +121,9 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = {
setBottomPanelHeight: () => {},
setActiveBottomPanelTab: () => {},
isSelected: () => false,
isSelectedConnection: () => false,
isNotSelectedConnection: () => false,
selectedConnections: new Map(),
setSelection: () => {},
selectItem: () => {},
toggleItem: () => {},
Expand Down
23 changes: 22 additions & 1 deletion libs/@hashintel/petrinaut/src/state/editor-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
type EditorState,
initialEditorState,
} from "./editor-context";
import { getNodeConnections } from "../lib/get-connections";
import { SDCPNContext } from "./sdcpn-context";
import type { SelectionItem, SelectionMap } from "./selection";
import { useSyncEditorToSettings } from "./use-sync-editor-to-settings";
import { UserSettingsContext } from "./user-settings-context";
Expand All @@ -16,6 +18,7 @@ export type EditorProviderProps = React.PropsWithChildren;

export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
const userSettings = use(UserSettingsContext);
const { petriNetDefinition } = use(SDCPNContext);

const [state, setState] = useState<EditorState>(() => ({
...initialEditorState,
Expand Down Expand Up @@ -79,7 +82,13 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
});
};

const actions: Omit<EditorActions, "isSelected"> = {
const actions: Omit<
EditorActions,
| "isSelected"
| "isSelectedConnection"
| "isNotSelectedConnection"
| "selectedConnections"
> = {
setGlobalMode: (mode) =>
setState((prev) => ({ ...prev, globalMode: mode })),
setEditionMode: (mode) =>
Expand Down Expand Up @@ -220,12 +229,24 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({ children }) => {
const { selection } = state;
const isSelected = (id: string) => selection.has(id);

const selectedConnections = getNodeConnections(
petriNetDefinition.transitions,
new Set(selection.keys()),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expensive computation runs on every unrelated render

Medium Severity

getNodeConnections is called in the render body of EditorProvider without useMemo, so it recomputes on every state change — sidebar resizes, panel toggles, dragging state updates — even when selection and petriNetDefinition.transitions haven't changed. For large Petri nets with many transitions and arcs, this O(n) computation runs unnecessarily on unrelated interactions.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f1f6ff2. Configure here.


const isSelectedConnection = (id: string) => selectedConnections.has(id);
const isNotSelectedConnection = (id: string) =>
selection.size > 0 && !isSelected(id) && !selectedConnections.has(id);
Copy link
Copy Markdown

@augmentcode augmentcode bot Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

libs/@hashintel/petrinaut/src/state/editor-provider.tsx:239 — isNotSelectedConnection becomes true for all unselected IDs whenever selection.size > 0, even if the selection is a non-canvas item (e.g. parameter/type), which could unexpectedly dim the whole net. Consider gating this behavior to selections that include places/transitions/arcs (or when selectedConnections is non-empty).

Severity: medium

Other Locations
  • libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx:400

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-graph item selection fades entire graph unexpectedly

Medium Severity

When a non-graph item (type, parameter, or differential equation) is selected from the sidebar, getNodeConnections returns an empty map because it only traverses transitions/arcs. Combined with hasSelection being true, this causes: (1) the global fadeBgStyle overlay to render over the entire canvas, and (2) isNotSelectedConnection to return true for every graph item, applying the dimmed/faded visual to all nodes and the brightness(1.5) filter to all arcs. The entire Petri net appears heavily washed out when selecting a non-graph item.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f1f6ff2. Configure here.


const searchInputRef = useRef<HTMLInputElement>(null);

const contextValue: EditorContextValue = {
...state,
...actions,
isSelected,
isSelectedConnection,
isNotSelectedConnection,
selectedConnections,
searchInputRef,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ const DEFAULT_EDITOR: EditorContextValue = {
setBottomPanelHeight: () => {},
setActiveBottomPanelTab: () => {},
isSelected: () => false,
isSelectedConnection: () => false,
isNotSelectedConnection: () => false,
selectedConnections: new Map(),
setSelection: () => {},
selectItem: () => {},
toggleItem: () => {},
Expand Down
22 changes: 19 additions & 3 deletions libs/@hashintel/petrinaut/src/views/SDCPN/components/arc.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { css } from "@hashintel/ds-helpers/css";
import { NOT_SELECTED_CONNECTION_OVERLAY_OPACITY } from "../styles/styling";
import {
BaseEdge,
type EdgeProps,
Expand Down Expand Up @@ -182,11 +183,14 @@ export const Arc: React.FC<EdgeProps<ArcEdgeType>> = ({
markerEnd,
}) => {
// Derive selected state from EditorContext
const { isSelected } = use(EditorContext);
const { isSelected, isNotSelectedConnection, isSelectedConnection } =
use(EditorContext);
const { arcRendering } = use(UserSettingsContext);

// Check if this arc is selected by its ID
const selected = isSelected(id);
const notSelectedConnection = isNotSelectedConnection(id);
const selectedConnection = isSelectedConnection(id);

const inhibitorMarkerId = `inhibitor-circle-${id}`;

Expand Down Expand Up @@ -236,7 +240,19 @@ export const Arc: React.FC<EdgeProps<ArcEdgeType>> = ({
const strokeColor = style?.stroke ?? "#b1b1b7";

return (
<>
<g
style={
selectedConnection
? {
filter: `brightness(${0.8})`,
}
: notSelectedConnection
? {
filter: `brightness(${1 + NOT_SELECTED_CONNECTION_OVERLAY_OPACITY})`,
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arc brightness filter makes strokes completely invisible

Medium Severity

The NOT_SELECTED_CONNECTION_OVERLAY_OPACITY (0.5) was designed as a white overlay opacity for nodes (rgba(255,255,255, 0.5)), but for arcs it's reused as a brightness additive: brightness(1 + 0.5) = brightness(1.5). The default arc stroke #b1b1b7 has channel values ~177; multiplied by 1.5 they clip to 255, producing pure white. Unconnected arcs become completely invisible instead of subtly faded like nodes.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f1f6ff2. Configure here.

: undefined
}
>
{/* Custom SVG marker definition for inhibitor arcs (empty circle) */}
{data?.arcType === "inhibitor" && (
<defs>
Expand Down Expand Up @@ -339,6 +355,6 @@ export const Arc: React.FC<EdgeProps<ArcEdgeType>> = ({
</g>
) : null}
</g>
</>
</g>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const placeCircleStyle = cva({
textAlign: "center",
lineHeight: "[1.3]",
cursor: "default",
transition: "[all 0.2s ease]",
transition: "[outline 0.2s ease]",
outline: "[0px solid rgba(75, 126, 156, 0)]",
_hover: {
outline: "[4px solid rgba(75, 126, 156, 0.2)]",
Expand All @@ -52,6 +52,17 @@ const placeCircleStyle = cva({
reactflow: {
outline: "[4px solid rgba(40, 172, 233, 0.6)]",
},
notSelectedConnection: {
borderColor: "neutral.s80",
_after: {
content: '""',
position: "absolute",
pointerEvents: "none",
borderRadius: "[inherit]",
background: "[rgba(255, 255, 255, 0.5)]",
inset: "[-2px]", // override to cover border, since parent uses box-sizing border-box
},
},
none: {},
},
},
Expand Down Expand Up @@ -105,7 +116,8 @@ export const ClassicPlaceNode: React.FC<NodeProps<PlaceNodeType>> = ({
isConnectable,
selected,
}: NodeProps<PlaceNodeType>) => {
const { globalMode, isSelected } = use(EditorContext);
const { globalMode, isSelected, isNotSelectedConnection } =
use(EditorContext);
const isSimulateMode = globalMode === "simulate";
const { initialMarking } = use(SimulationContext);
const { currentViewedFrame } = use(PlaybackContext);
Expand All @@ -129,7 +141,9 @@ export const ClassicPlaceNode: React.FC<NodeProps<PlaceNodeType>> = ({
? "resource"
: selected
? "reactflow"
: "none";
: isNotSelectedConnection(id)
? "notSelectedConnection"
: "none";

return (
<div className={containerStyle}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const transitionBoxStyle = cva({
boxSizing: "border-box",
position: "relative",
cursor: "default",
transition: "[outline 0.2s ease, border-color 0.2s ease]",
transition: "[outline 0.2s ease]",
outline: "[0px solid rgba(75, 126, 156, 0)]",
_hover: {
borderColor: "neutral.s100",
Expand All @@ -50,6 +50,19 @@ const transitionBoxStyle = cva({
reactflow: {
outline: "[4px solid rgba(40, 172, 233, 0.6)]",
},
selectedConnection: {
borderColor: "neutral.s100",
},
notSelectedConnection: {
_after: {
content: '""',
position: "absolute",
inset: "0",
pointerEvents: "none",
borderRadius: "[inherit]",
background: "[rgba(255, 255, 255, 0.5)]",
},
},
none: {},
},
},
Expand Down Expand Up @@ -157,7 +170,8 @@ export const ClassicTransitionNode: React.FC<NodeProps<TransitionNodeType>> = ({
}: NodeProps<TransitionNodeType>) => {
const { label } = data;

const { isSelected } = use(EditorContext);
const { isSelected, isNotSelectedConnection, isSelectedConnection } =
use(EditorContext);

// Refs for animated elements
const boxRef = useRef<HTMLDivElement | null>(null);
Expand All @@ -175,7 +189,11 @@ export const ClassicTransitionNode: React.FC<NodeProps<TransitionNodeType>> = ({
? "resource"
: selected
? "reactflow"
: "none";
: isSelectedConnection(id)
? "selectedConnection"
: isNotSelectedConnection(id)
? "notSelectedConnection"
: "none";

return (
<div className={containerStyle}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import type { ReactNode } from "react";

import { handleStyling } from "../styles/styling";

export type SelectionVariant = "resource" | "reactflow" | "none";
export type SelectionVariant =
| "resource"
| "reactflow"
| "notSelectedConnection"
| "none";

const containerStyle = css({
position: "relative",
Expand Down Expand Up @@ -44,6 +48,16 @@ export const nodeCardStyle = cva({
reactflow: {
outline: "[4px solid rgba(40, 172, 233, 0.6)]",
},
notSelectedConnection: {
_after: {
content: '""',
position: "absolute",
inset: "0",
pointerEvents: "none",
borderRadius: "[inherit]",
background: "[rgba(255, 255, 255, 0.5)]",
},
},
none: {},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ export const PlaceNode: React.FC<NodeProps<PlaceNodeType>> = ({
isConnectable,
selected,
}: NodeProps<PlaceNodeType>) => {
const { globalMode, isSelected } = use(EditorContext);
const { globalMode, isSelected, isNotSelectedConnection } =
use(EditorContext);
const isSimulateMode = globalMode === "simulate";
const { initialMarking } = use(SimulationContext);
const { currentViewedFrame } = use(PlaybackContext);
Expand All @@ -73,7 +74,9 @@ export const PlaceNode: React.FC<NodeProps<PlaceNodeType>> = ({
? "resource"
: selected
? "reactflow"
: "none";
: isNotSelectedConnection(id)
? "notSelectedConnection"
: "none";

const subtitle = data.dynamicsEnabled ? "Place (Dynamics)" : "Place";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const TransitionNode: React.FC<NodeProps<TransitionNodeType>> = ({
}: NodeProps<TransitionNodeType>) => {
const { label } = data;

const { isSelected } = use(EditorContext);
const { isSelected, isNotSelectedConnection } = use(EditorContext);

// Refs for animated elements
const boxRef = useRef<HTMLDivElement | null>(null);
Expand All @@ -125,7 +125,9 @@ export const TransitionNode: React.FC<NodeProps<TransitionNodeType>> = ({
? "resource"
: selected
? "reactflow"
: "none";
: isNotSelectedConnection(id)
? "notSelectedConnection"
: "none";

const subtitle =
data.lambdaType === "stochastic" ? "Stochastic" : "Predicate";
Expand Down
Loading
Loading