diff --git a/.changeset/rotten-lemons-sparkle.md b/.changeset/rotten-lemons-sparkle.md new file mode 100644 index 00000000000..6b1c6e3b176 --- /dev/null +++ b/.changeset/rotten-lemons-sparkle.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Better animation of Transitions and flow inside Arcs diff --git a/libs/@hashintel/petrinaut/src/core/types/simulation.ts b/libs/@hashintel/petrinaut/src/core/types/simulation.ts deleted file mode 100644 index 06a67a567e7..00000000000 --- a/libs/@hashintel/petrinaut/src/core/types/simulation.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { Color, ID, Place, SDCPN, Transition } from "./sdcpn"; - -export type ParameterValues = Record; - -export type DifferentialEquationFn = ( - tokens: Record[], - parameters: ParameterValues, -) => Record[]; - -export type LambdaFn = ( - tokenValues: Record[]>, - parameters: ParameterValues, -) => number | boolean; - -export type TransitionKernelFn = ( - tokenValues: Record[]>, - parameters: ParameterValues, -) => Record[]>; - -export type SimulationInput = { - sdcpn: SDCPN; - initialMarking: Map; - /** Parameter values from the simulation store (overrides SDCPN defaults) */ - parameterValues: Record; - seed: number; - dt: number; -}; - -export type SimulationInstance = { - places: Map; - transitions: Map; - types: Map; - differentialEquationFns: Map; - lambdaFns: Map; - transitionKernelFns: Map; - parameterValues: ParameterValues; - dt: number; - rngState: number; - frames: SimulationFrame[]; - currentFrameNumber: number; -}; - -export type SimulationFrame = { - simulation: SimulationInstance; - time: number; - places: Map< - ID, - { instance: Place; offset: number; count: number; dimensions: number } - >; - transitions: Map; - /** - * Buffer containing all place values concatenated. - * - * Size: sum of (place.dimensions * place.count) for all places. - * - * Layout: For each place, its tokens are stored contiguously. - * - * Access to a place's token values can be done via the offset and count in the `places` map. - */ - buffer: Float64Array; -}; diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index a014e4fed8d..f177349ef1b 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -13,10 +13,10 @@ import type { } from "./core/types/sdcpn"; import { useMonacoGlobalTypings } from "./hooks/use-monaco-global-typings"; import { NotificationsProvider } from "./notifications/notifications-provider"; +import { SimulationProvider } from "./simulation/provider"; import { CheckerProvider } from "./state/checker-provider"; import { EditorProvider } from "./state/editor-provider"; import { SDCPNProvider } from "./state/sdcpn-provider"; -import { SimulationProvider } from "./state/simulation-provider"; import { EditorView } from "./views/Editor/editor-view"; export type { diff --git a/libs/@hashintel/petrinaut/src/simulation/context.ts b/libs/@hashintel/petrinaut/src/simulation/context.ts new file mode 100644 index 00000000000..573b281af12 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/simulation/context.ts @@ -0,0 +1,252 @@ +import { createContext } from "react"; + +import type { Color, ID, Place, SDCPN, Transition } from "../core/types/sdcpn"; + +/** + * Current state of the simulation lifecycle. + */ +export type SimulationState = + | "NotRun" + | "Running" + | "Complete" + | "Error" + | "Paused"; + +/** + * State of a transition within a simulation frame. + * + * Contains timing information and firing counts for tracking transition behavior + * during simulation execution. + */ +export type SimulationFrameState_Transition = { + /** + * Time elapsed since this transition last fired, in milliseconds. + * Resets to 0 when the transition fires. + */ + timeSinceLastFiringMs: number; + /** + * Whether this transition fired in this specific frame. + * True only during the frame when the firing occurred. + */ + firedInThisFrame: boolean; + /** + * Total cumulative count of times this transition has fired + * since the start of the simulation (frame 0). + */ + firingCount: number; +}; + +// +// Simulation Instance Types +// +// These types define the internal simulation data structures. They are defined +// here to ensure the context module has no dependencies on the simulator module, +// making the context the source of truth for type definitions. +// +// TODO FE-207: This is a temporary solution that leaks implementation details of the +// SDCPN simulator (e.g., compiled function types, buffer layouts) into the +// context module. Ideally, the context should only expose a minimal public +// interface, and these internal types should live in the simulator module. +// This would require refactoring SimulationContextValue to not expose the +// full SimulationInstance, but instead provide accessor methods or a +// simplified public state type. +// + +/** + * Runtime parameter values used during simulation execution. + * Maps parameter names to their resolved numeric or boolean values. + */ +export type ParameterValues = Record; + +/** + * Compiled differential equation function for continuous dynamics. + * Computes the rate of change for tokens in a place with dynamics enabled. + */ +export type DifferentialEquationFn = ( + tokens: Record[], + parameters: ParameterValues, +) => Record[]; + +/** + * Compiled lambda function for transition firing probability. + * Returns a rate (number) for stochastic transitions or a boolean for predicate transitions. + */ +export type LambdaFn = ( + tokenValues: Record[]>, + parameters: ParameterValues, +) => number | boolean; + +/** + * Compiled transition kernel function for token generation. + * Computes the output tokens to create when a transition fires. + */ +export type TransitionKernelFn = ( + tokenValues: Record[]>, + parameters: ParameterValues, +) => Record[]>; + +/** + * Input configuration for building a new simulation instance. + */ +export type SimulationInput = { + /** The SDCPN definition to simulate */ + sdcpn: SDCPN; + /** Initial token distribution across places */ + initialMarking: Map; + /** Parameter values from the simulation store (overrides SDCPN defaults) */ + parameterValues: Record; + /** Random seed for deterministic stochastic behavior */ + seed: number; + /** Time step for simulation advancement */ + dt: number; +}; + +/** + * A running simulation instance with compiled functions and frame history. + * Contains all state needed to execute and advance the simulation. + */ +export type SimulationInstance = { + /** Place definitions indexed by ID */ + places: Map; + /** Transition definitions indexed by ID */ + transitions: Map; + /** Color type definitions indexed by ID */ + types: Map; + /** Compiled differential equation functions indexed by place ID */ + differentialEquationFns: Map; + /** Compiled lambda functions indexed by transition ID */ + lambdaFns: Map; + /** Compiled transition kernel functions indexed by transition ID */ + transitionKernelFns: Map; + /** Resolved parameter values for this simulation run */ + parameterValues: ParameterValues; + /** Time step for simulation advancement */ + dt: number; + /** Current state of the seeded random number generator */ + rngState: number; + /** History of all computed frames */ + frames: SimulationFrame[]; + /** Index of the current frame in the frames array */ + currentFrameNumber: number; +}; + +/** + * A single frame (snapshot) of the simulation state at a point in time. + * Contains the complete token distribution and transition states. + */ +export type SimulationFrame = { + /** Back-reference to the parent simulation instance */ + simulation: SimulationInstance; + /** Simulation time at this frame */ + time: number; + /** Place states with token buffer offsets */ + places: Map< + ID, + { instance: Place; offset: number; count: number; dimensions: number } + >; + /** Transition states with firing information */ + transitions: Map< + ID, + SimulationFrameState_Transition & { instance: Transition } + >; + /** + * Buffer containing all place values concatenated. + * + * Size: sum of (place.dimensions * place.count) for all places. + * + * Layout: For each place, its tokens are stored contiguously. + * + * Access to a place's token values can be done via the offset and count in the `places` map. + */ + buffer: Float64Array; +}; + +/** + * Simplified view of a simulation frame for UI consumption. + * Provides easy access to place and transition states without internal details. + */ +export type SimulationFrameState = { + /** Frame index in the simulation history */ + number: number; + /** Simulation time at this frame */ + time: number; + /** Place states indexed by place ID */ + places: { + [placeId: string]: + | { + /** Number of tokens in the place at the time of the frame. */ + tokenCount: number; + } + | undefined; + }; + /** Transition states indexed by transition ID */ + transitions: { + [transitionId: string]: SimulationFrameState_Transition | undefined; + }; +}; + +/** + * Initial token distribution for starting a simulation. + * Maps place IDs to their initial token values and counts. + */ +export type InitialMarking = Map< + string, + { values: Float64Array; count: number } +>; + +/** + * The combined simulation context containing both state and actions. + */ +export type SimulationContextValue = { + // State values + simulation: SimulationInstance | null; + state: SimulationState; + error: string | null; + errorItemId: string | null; + parameterValues: Record; + initialMarking: InitialMarking; + /** + * The currently viewed simulation frame state. + * Null when no simulation is running or no frames exist. + */ + currentViewedFrame: SimulationFrameState | null; + dt: number; + + // Actions + setInitialMarking: ( + placeId: string, + marking: { values: Float64Array; count: number }, + ) => void; + setParameterValue: (parameterId: string, value: string) => void; + setDt: (dt: number) => void; + initializeParameterValuesFromDefaults: () => void; + initialize: (params: { seed: number; dt: number }) => void; + run: () => void; + pause: () => void; + reset: () => void; + setCurrentViewedFrame: (frameIndex: number) => void; +}; + +const DEFAULT_CONTEXT_VALUE: SimulationContextValue = { + simulation: null, + state: "NotRun", + error: null, + errorItemId: null, + parameterValues: {}, + initialMarking: new Map(), + currentViewedFrame: null, + dt: 0.01, + setInitialMarking: () => {}, + setParameterValue: () => {}, + setDt: () => {}, + initializeParameterValuesFromDefaults: () => {}, + initialize: () => {}, + run: () => {}, + pause: () => {}, + reset: () => {}, + setCurrentViewedFrame: () => {}, +}; + +export const SimulationContext = createContext( + DEFAULT_CONTEXT_VALUE, +); diff --git a/libs/@hashintel/petrinaut/src/state/simulation-provider.tsx b/libs/@hashintel/petrinaut/src/simulation/provider.tsx similarity index 83% rename from libs/@hashintel/petrinaut/src/state/simulation-provider.tsx rename to libs/@hashintel/petrinaut/src/simulation/provider.tsx index a5b12cc8178..1f32b1ac9c7 100644 --- a/libs/@hashintel/petrinaut/src/state/simulation-provider.tsx +++ b/libs/@hashintel/petrinaut/src/simulation/provider.tsx @@ -3,19 +3,20 @@ import ts from "typescript"; import { checkSDCPN } from "../core/checker/checker"; import { SDCPNItemError } from "../core/errors"; -import { buildSimulation } from "../core/simulation/build-simulation"; -import { checkTransitionEnablement } from "../core/simulation/check-transition-enablement"; -import { computeNextFrame } from "../core/simulation/compute-next-frame"; import type { SDCPN } from "../core/types/sdcpn"; import { deriveDefaultParameterValues } from "../hooks/use-default-parameter-values"; import { useNotifications } from "../notifications/notifications-context"; -import { SDCPNContext } from "./sdcpn-context"; +import { SDCPNContext } from "../state/sdcpn-context"; import { type InitialMarking, SimulationContext, type SimulationContextValue, + type SimulationFrameState, type SimulationState, -} from "./simulation-context"; +} from "./context"; +import { buildSimulation } from "./simulator/build-simulation"; +import { checkTransitionEnablement } from "./simulator/check-transition-enablement"; +import { computeNextFrame } from "./simulator/compute-next-frame"; type SimulationStateValues = { simulation: SimulationContextValue["simulation"]; @@ -24,7 +25,8 @@ type SimulationStateValues = { errorItemId: string | null; parameterValues: Record; initialMarking: InitialMarking; - currentlyViewedFrame: number; + /** Internal frame index for tracking which frame is being viewed */ + currentViewedFrameIndex: number | null; dt: number; }; @@ -35,7 +37,7 @@ const initialStateValues: SimulationStateValues = { errorItemId: null, parameterValues: {}, initialMarking: new Map(), - currentlyViewedFrame: 0, + currentViewedFrameIndex: null, dt: 0.01, }; @@ -118,7 +120,7 @@ const useSimulationRunner = ({ state: finalState, error: null, errorItemId: null, - currentlyViewedFrame: simulation?.currentFrameNumber ?? 0, + currentViewedFrameIndex: simulation?.currentFrameNumber ?? 0, })); // Continue the loop if still running @@ -153,6 +155,46 @@ const useSimulationRunner = ({ }, [isRunning, getState, setStateValues]); }; +/** + * Converts a simulation frame to a SimulationFrameState. + */ +function buildFrameState( + simulation: SimulationContextValue["simulation"], + frameIndex: number, +): SimulationFrameState | null { + if (!simulation || simulation.frames.length === 0) { + return null; + } + + const frame = simulation.frames[frameIndex]; + if (!frame) { + return null; + } + + const places: SimulationFrameState["places"] = {}; + for (const [placeId, placeData] of frame.places) { + places[placeId] = { + tokenCount: placeData.count, + }; + } + + const transitions: SimulationFrameState["transitions"] = {}; + for (const [transitionId, transitionData] of frame.transitions) { + transitions[transitionId] = { + timeSinceLastFiringMs: transitionData.timeSinceLastFiringMs, + firedInThisFrame: transitionData.firedInThisFrame, + firingCount: transitionData.firingCount, + }; + } + + return { + number: frameIndex, + time: frame.time, + places, + transitions, + }; +} + /** * Internal component that subscribes to simulation state changes * and shows notifications when appropriate. @@ -312,7 +354,7 @@ export const SimulationProvider: React.FC = ({ state: "Paused", error: null, errorItemId: null, - currentlyViewedFrame: 0, + currentViewedFrameIndex: 0, }; } catch (error) { // eslint-disable-next-line no-console @@ -373,12 +415,12 @@ export const SimulationProvider: React.FC = ({ error: null, errorItemId: null, parameterValues, - currentlyViewedFrame: 0, + currentViewedFrameIndex: null, // Keep initialMarking when resetting - it's configuration, not simulation state })); }; - const setCurrentlyViewedFrame: SimulationContextValue["setCurrentlyViewedFrame"] = + const setCurrentViewedFrame: SimulationContextValue["setCurrentViewedFrame"] = (frameIndex) => { setStateValues((prev) => { if (!prev.simulation) { @@ -390,12 +432,31 @@ export const SimulationProvider: React.FC = ({ const totalFrames = prev.simulation.frames.length; const clampedIndex = Math.max(0, Math.min(frameIndex, totalFrames - 1)); - return { ...prev, currentlyViewedFrame: clampedIndex }; + return { + ...prev, + currentViewedFrameIndex: clampedIndex, + }; }); }; + // Compute the currently viewed frame state + const currentViewedFrame = + stateValues.currentViewedFrameIndex !== null + ? buildFrameState( + stateValues.simulation, + stateValues.currentViewedFrameIndex, + ) + : null; + const contextValue: SimulationContextValue = { - ...stateValues, + simulation: stateValues.simulation, + state: stateValues.state, + error: stateValues.error, + errorItemId: stateValues.errorItemId, + parameterValues: stateValues.parameterValues, + initialMarking: stateValues.initialMarking, + dt: stateValues.dt, + currentViewedFrame, setInitialMarking, setParameterValue, setDt, @@ -404,7 +465,7 @@ export const SimulationProvider: React.FC = ({ run, pause, reset, - setCurrentlyViewedFrame, + setCurrentViewedFrame, }; return ( diff --git a/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.test.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/build-simulation.test.ts similarity index 98% rename from libs/@hashintel/petrinaut/src/core/simulation/build-simulation.test.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/build-simulation.test.ts index 959618e81c7..cd53620bf08 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.test.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/build-simulation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { SimulationInput } from "../types/simulation"; import { buildSimulation } from "./build-simulation"; +import type { SimulationInput } from "./types"; describe("buildSimulation", () => { it("builds a simulation with a single place and initial tokens", () => { @@ -246,8 +246,8 @@ describe("buildSimulation", () => { // Verify transitions exist with initial state expect(frame.transitions.size).toBe(2); - expect(frame.transitions.get("t1")?.timeSinceLastFiring).toBe(0); - expect(frame.transitions.get("t2")?.timeSinceLastFiring).toBe(0); + expect(frame.transitions.get("t1")?.timeSinceLastFiringMs).toBe(0); + expect(frame.transitions.get("t2")?.timeSinceLastFiringMs).toBe(0); // Verify all compiled functions exist expect(simulationInstance.differentialEquationFns.size).toBe(3); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/build-simulation.ts similarity index 98% rename from libs/@hashintel/petrinaut/src/core/simulation/build-simulation.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/build-simulation.ts index ef558cb31ad..9bb5a2ecaeb 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/build-simulation.ts @@ -1,8 +1,9 @@ +import { SDCPNItemError } from "../../core/errors"; import { deriveDefaultParameterValues, mergeParameterValues, } from "../../hooks/use-default-parameter-values"; -import { SDCPNItemError } from "../errors"; +import { compileUserCode } from "./compile-user-code"; import type { DifferentialEquationFn, LambdaFn, @@ -11,8 +12,7 @@ import type { SimulationInput, SimulationInstance, TransitionKernelFn, -} from "../types/simulation"; -import { compileUserCode } from "./compile-user-code"; +} from "./types"; /** * Get the dimensions (number of elements) for a place based on its type. @@ -236,7 +236,9 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { transition.id, { instance: transition, - timeSinceLastFiring: 0, + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, }, ]), ); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/check-transition-enablement.test.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/check-transition-enablement.test.ts similarity index 92% rename from libs/@hashintel/petrinaut/src/core/simulation/check-transition-enablement.test.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/check-transition-enablement.test.ts index 1b231b365cc..ef74c877f4e 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/check-transition-enablement.test.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/check-transition-enablement.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import type { SimulationFrame, SimulationInstance } from "../types/simulation"; import { checkTransitionEnablement, isTransitionStructurallyEnabled, } from "./check-transition-enablement"; +import type { SimulationFrame, SimulationInstance } from "./types"; describe("isTransitionStructurallyEnabled", () => { it("returns true when input place has sufficient tokens", () => { @@ -59,7 +59,9 @@ describe("isTransitionStructurallyEnabled", () => { x: 0, y: 0, }, - timeSinceLastFiring: 0, + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, }, ], ]), @@ -121,7 +123,9 @@ describe("isTransitionStructurallyEnabled", () => { x: 0, y: 0, }, - timeSinceLastFiring: 0, + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, }, ], ]), @@ -183,7 +187,9 @@ describe("isTransitionStructurallyEnabled", () => { x: 0, y: 0, }, - timeSinceLastFiring: 0, + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, }, ], ]), @@ -266,7 +272,9 @@ describe("isTransitionStructurallyEnabled", () => { x: 0, y: 0, }, - timeSinceLastFiring: 0, + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, }, ], ]), @@ -311,7 +319,9 @@ describe("isTransitionStructurallyEnabled", () => { x: 0, y: 0, }, - timeSinceLastFiring: 0, + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, }, ], ]), @@ -392,7 +402,9 @@ describe("checkTransitionEnablement", () => { x: 0, y: 0, }, - timeSinceLastFiring: 0, + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, }, ], [ @@ -409,7 +421,9 @@ describe("checkTransitionEnablement", () => { x: 0, y: 0, }, - timeSinceLastFiring: 0, + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, }, ], ]), @@ -492,7 +506,9 @@ describe("checkTransitionEnablement", () => { x: 0, y: 0, }, - timeSinceLastFiring: 0, + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, }, ], [ @@ -509,7 +525,9 @@ describe("checkTransitionEnablement", () => { x: 0, y: 0, }, - timeSinceLastFiring: 0, + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, }, ], ]), @@ -607,7 +625,9 @@ describe("checkTransitionEnablement", () => { x: 0, y: 0, }, - timeSinceLastFiring: 0, + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, }, ], [ @@ -624,7 +644,9 @@ describe("checkTransitionEnablement", () => { x: 0, y: 0, }, - timeSinceLastFiring: 0, + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, }, ], [ @@ -641,7 +663,9 @@ describe("checkTransitionEnablement", () => { x: 0, y: 0, }, - timeSinceLastFiring: 0, + timeSinceLastFiringMs: 0, + firedInThisFrame: false, + firingCount: 0, }, ], ]), diff --git a/libs/@hashintel/petrinaut/src/core/simulation/check-transition-enablement.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/check-transition-enablement.ts similarity index 98% rename from libs/@hashintel/petrinaut/src/core/simulation/check-transition-enablement.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/check-transition-enablement.ts index 65ca8b1675f..f0267a4388f 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/check-transition-enablement.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/check-transition-enablement.ts @@ -1,4 +1,4 @@ -import type { SimulationFrame } from "../types/simulation"; +import type { SimulationFrame } from "./types"; /** * Result of checking transition enablement for a simulation frame. diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compile-user-code.test.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/compile-user-code.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/compile-user-code.test.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/compile-user-code.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compile-user-code.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/compile-user-code.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/compile-user-code.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/compile-user-code.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compile-visualizer.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/compile-visualizer.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/compile-visualizer.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/compile-visualizer.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compute-next-frame.test.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/compute-next-frame.test.ts similarity index 99% rename from libs/@hashintel/petrinaut/src/core/simulation/compute-next-frame.test.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/compute-next-frame.test.ts index 0a714e10796..dd53ce4560b 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compute-next-frame.test.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/compute-next-frame.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { SDCPN } from "../types/sdcpn"; +import type { SDCPN } from "../../core/types/sdcpn"; import { buildSimulation } from "./build-simulation"; import { computeNextFrame } from "./compute-next-frame"; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compute-next-frame.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/compute-next-frame.ts similarity index 95% rename from libs/@hashintel/petrinaut/src/core/simulation/compute-next-frame.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/compute-next-frame.ts index 7ada1aed265..564d0577b18 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compute-next-frame.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/compute-next-frame.ts @@ -1,6 +1,6 @@ -import type { SimulationInstance } from "../types/simulation"; import { computePlaceNextState } from "./compute-place-next-state"; import { executeTransitions } from "./execute-transitions"; +import type { SimulationInstance } from "./types"; /** * Result of computing the next frame. @@ -164,13 +164,15 @@ export function computeNextFrame( : { ...frameAfterTransitions, time: currentFrame.time + simulation.dt, - // Also update transition timeSinceLastFiring since time advanced + // Also update transition timeSinceLastFiringMs and firedInThisFrame since time advanced transitions: new Map( Array.from(frameAfterTransitions.transitions).map(([id, state]) => [ id, { ...state, - timeSinceLastFiring: state.timeSinceLastFiring + simulation.dt, + timeSinceLastFiringMs: + state.timeSinceLastFiringMs + simulation.dt, + firedInThisFrame: false, }, ]), ), diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compute-place-next-state.test.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/compute-place-next-state.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/compute-place-next-state.test.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/compute-place-next-state.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compute-place-next-state.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/compute-place-next-state.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/compute-place-next-state.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/compute-place-next-state.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compute-possible-transition.test.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/compute-possible-transition.test.ts similarity index 95% rename from libs/@hashintel/petrinaut/src/core/simulation/compute-possible-transition.test.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/compute-possible-transition.test.ts index c6fc5c4c3e6..8f9668a7336 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compute-possible-transition.test.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/compute-possible-transition.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { SimulationFrame, SimulationInstance } from "../types/simulation"; import { computePossibleTransition } from "./compute-possible-transition"; +import type { SimulationFrame, SimulationInstance } from "./types"; describe("computePossibleTransition", () => { it("returns null when transition is not enabled due to insufficient tokens", () => { @@ -57,7 +57,9 @@ describe("computePossibleTransition", () => { x: 100, y: 0, }, - timeSinceLastFiring: 1.0, + timeSinceLastFiringMs: 1.0, + firedInThisFrame: false, + firingCount: 0, }, ], ]), @@ -187,7 +189,9 @@ describe("computePossibleTransition", () => { x: 100, y: 0, }, - timeSinceLastFiring: 1.0, + timeSinceLastFiringMs: 1.0, + firedInThisFrame: false, + firingCount: 0, }, ], ]), diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compute-possible-transition.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/compute-possible-transition.ts similarity index 97% rename from libs/@hashintel/petrinaut/src/core/simulation/compute-possible-transition.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/compute-possible-transition.ts index fb83747f9ca..fbadac4b1c3 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compute-possible-transition.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/compute-possible-transition.ts @@ -1,8 +1,8 @@ -import { SDCPNItemError } from "../errors"; -import type { ID } from "../types/sdcpn"; -import type { SimulationFrame } from "../types/simulation"; +import { SDCPNItemError } from "../../core/errors"; +import type { ID } from "../../core/types/sdcpn"; import { enumerateWeightedMarkingIndicesGenerator } from "./enumerate-weighted-markings"; import { nextRandom } from "./seeded-rng"; +import type { SimulationFrame } from "./types"; type PlaceID = ID; @@ -74,7 +74,7 @@ export function computePossibleTransition( // Generate random number using seeded RNG and update state const [U1, newRngState] = nextRandom(simulation.rngState); - const { timeSinceLastFiring } = transition; + const { timeSinceLastFiringMs } = transition; // TODO: This should acumulate lambda over time, but for now we just consider that lambda is constant per combination. // (just multiply by time since last transition) @@ -170,7 +170,7 @@ export function computePossibleTransition( : 0 : lambdaResult; - const lambdaValue = lambdaNumeric * timeSinceLastFiring; + const lambdaValue = lambdaNumeric * timeSinceLastFiringMs; // Find the first combination of tokens where e^(-lambda) < U1 // We should normally find the minimum for all possibilities, but we try to reduce as much as we can here. diff --git a/libs/@hashintel/petrinaut/src/core/simulation/enumerate-weighted-markings.test.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/enumerate-weighted-markings.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/enumerate-weighted-markings.test.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/enumerate-weighted-markings.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/enumerate-weighted-markings.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/enumerate-weighted-markings.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/enumerate-weighted-markings.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/enumerate-weighted-markings.ts diff --git a/libs/@hashintel/petrinaut/src/core/simulation/execute-transitions.test.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/execute-transitions.test.ts similarity index 91% rename from libs/@hashintel/petrinaut/src/core/simulation/execute-transitions.test.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/execute-transitions.test.ts index dc444d354a6..f7aeadd21ee 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/execute-transitions.test.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/execute-transitions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { SimulationFrame, SimulationInstance } from "../types/simulation"; import { executeTransitions } from "./execute-transitions"; +import type { SimulationFrame, SimulationInstance } from "./types"; describe("executeTransitions", () => { it("returns the original frame when no transitions can fire", () => { @@ -56,7 +56,9 @@ describe("executeTransitions", () => { x: 0, y: 0, }, - timeSinceLastFiring: 1.0, + timeSinceLastFiringMs: 1.0, + firedInThisFrame: false, + firingCount: 0, }, ], ]), @@ -175,7 +177,9 @@ describe("executeTransitions", () => { x: 0, y: 0, }, - timeSinceLastFiring: 1.0, + timeSinceLastFiringMs: 1.0, + firedInThisFrame: false, + firingCount: 0, }, ], ]), @@ -195,8 +199,8 @@ describe("executeTransitions", () => { // Time should be incremented expect(result.time).toBe(0.1); - // Transition that fired should have timeSinceLastFiring reset to 0 - expect(result.transitions.get("t1")?.timeSinceLastFiring).toBe(0); + // Transition that fired should have timeSinceLastFiringMs reset to 0 + expect(result.transitions.get("t1")?.timeSinceLastFiringMs).toBe(0); }); it("executes multiple transitions sequentially with proper token removal between each", () => { @@ -342,7 +346,9 @@ describe("executeTransitions", () => { x: 0, y: 0, }, - timeSinceLastFiring: 1.0, + timeSinceLastFiringMs: 1.0, + firedInThisFrame: false, + firingCount: 0, }, ], [ @@ -359,7 +365,9 @@ describe("executeTransitions", () => { x: 0, y: 0, }, - timeSinceLastFiring: 1.0, + timeSinceLastFiringMs: 1.0, + firedInThisFrame: false, + firingCount: 0, }, ], ]), @@ -378,9 +386,9 @@ describe("executeTransitions", () => { // p3 should have 1 token added by t2 expect(result.places.get("p3")?.count).toBe(1); - // Both transitions should have their timeSinceLastFiring reset - expect(result.transitions.get("t1")?.timeSinceLastFiring).toBe(0); - expect(result.transitions.get("t2")?.timeSinceLastFiring).toBe(0); + // Both transitions should have their timeSinceLastFiringMs reset + expect(result.transitions.get("t1")?.timeSinceLastFiringMs).toBe(0); + expect(result.transitions.get("t2")?.timeSinceLastFiringMs).toBe(0); }); it("handles transitions with multi-dimensional tokens", () => { @@ -499,7 +507,9 @@ describe("executeTransitions", () => { x: 0, y: 0, }, - timeSinceLastFiring: 1.0, + timeSinceLastFiringMs: 1.0, + firedInThisFrame: false, + firingCount: 0, }, ], ]), @@ -517,7 +527,7 @@ describe("executeTransitions", () => { expect(result.buffer[1]).toBe(4.0); }); - it("updates timeSinceLastFiring for transitions that did not fire", () => { + it("updates timeSinceLastFiringMs for transitions that did not fire", () => { const simulation: SimulationInstance = { places: new Map([ [ @@ -631,7 +641,9 @@ describe("executeTransitions", () => { x: 0, y: 0, }, - timeSinceLastFiring: 0.5, + timeSinceLastFiringMs: 0.5, + firedInThisFrame: false, + firingCount: 0, }, ], [ @@ -648,7 +660,9 @@ describe("executeTransitions", () => { x: 0, y: 0, }, - timeSinceLastFiring: 0.3, + timeSinceLastFiringMs: 0.3, + firedInThisFrame: false, + firingCount: 0, }, ], ]), @@ -657,10 +671,10 @@ describe("executeTransitions", () => { const result = executeTransitions(frame); - // t1 should have fired and timeSinceLastFiring reset - expect(result.transitions.get("t1")?.timeSinceLastFiring).toBe(0); + // t1 should have fired and timeSinceLastFiringMs reset + expect(result.transitions.get("t1")?.timeSinceLastFiringMs).toBe(0); - // t2 should not have fired and timeSinceLastFiring incremented by dt - expect(result.transitions.get("t2")?.timeSinceLastFiring).toBe(0.4); + // t2 should not have fired and timeSinceLastFiringMs incremented by dt + expect(result.transitions.get("t2")?.timeSinceLastFiringMs).toBe(0.4); }); }); diff --git a/libs/@hashintel/petrinaut/src/core/simulation/execute-transitions.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/execute-transitions.ts similarity index 92% rename from libs/@hashintel/petrinaut/src/core/simulation/execute-transitions.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/execute-transitions.ts index 0ca61901d13..3c76fc9129b 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/execute-transitions.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/execute-transitions.ts @@ -1,7 +1,7 @@ -import type { ID } from "../types/sdcpn"; -import type { SimulationFrame } from "../types/simulation"; +import type { ID } from "../../core/types/sdcpn"; import { computePossibleTransition } from "./compute-possible-transition"; import { removeTokensFromSimulationFrame } from "./remove-tokens-from-simulation-frame"; +import type { SimulationFrame } from "./types"; type PlaceID = ID; @@ -134,7 +134,7 @@ export function executeTransitions(frame: SimulationFrame): SimulationFrame { // Map to accumulate all tokens to add: PlaceID -> array of token values const tokensToAdd = new Map(); - // Keep track of which transitions fired for updating timeSinceLastFiring + // Keep track of which transitions fired for updating timeSinceLastFiringMs const transitionsFired = new Set(); // Start with the current frame and update it as transitions fire @@ -187,21 +187,24 @@ export function executeTransitions(frame: SimulationFrame): SimulationFrame { // Add all new tokens at once const newFrame = addTokensToSimulationFrame(currentFrame, tokensToAdd); - // Update transition timeSinceLastFiring + // Update transition timeSinceLastFiringMs, firedInThisFrame, and firingCount const newTransitions = new Map(newFrame.transitions); for (const [transitionId, transitionState] of newFrame.transitions) { if (transitionsFired.has(transitionId)) { - // Reset time since last firing for transitions that fired + // Reset time since last firing and increment firing count for transitions that fired newTransitions.set(transitionId, { ...transitionState, - timeSinceLastFiring: 0, + timeSinceLastFiringMs: 0, + firedInThisFrame: true, + firingCount: transitionState.firingCount + 1, }); } else { // Increment time for transitions that didn't fire newTransitions.set(transitionId, { ...transitionState, - timeSinceLastFiring: - transitionState.timeSinceLastFiring + frame.simulation.dt, + timeSinceLastFiringMs: + transitionState.timeSinceLastFiringMs + frame.simulation.dt, + firedInThisFrame: false, }); } } diff --git a/libs/@hashintel/petrinaut/src/core/simulation/remove-tokens-from-simulation-frame.test.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/remove-tokens-from-simulation-frame.test.ts similarity index 99% rename from libs/@hashintel/petrinaut/src/core/simulation/remove-tokens-from-simulation-frame.test.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/remove-tokens-from-simulation-frame.test.ts index 67101ce7b3f..bc1ca28b000 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/remove-tokens-from-simulation-frame.test.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/remove-tokens-from-simulation-frame.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { SimulationFrame, SimulationInstance } from "../types/simulation"; import { removeTokensFromSimulationFrame } from "./remove-tokens-from-simulation-frame"; +import type { SimulationFrame, SimulationInstance } from "./types"; describe("removeTokensFromSimulationFrame", () => { it("throws error when place ID is not found", () => { diff --git a/libs/@hashintel/petrinaut/src/core/simulation/remove-tokens-from-simulation-frame.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/remove-tokens-from-simulation-frame.ts similarity index 98% rename from libs/@hashintel/petrinaut/src/core/simulation/remove-tokens-from-simulation-frame.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/remove-tokens-from-simulation-frame.ts index 7c08d63dc2a..1303fa2a7df 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/remove-tokens-from-simulation-frame.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/remove-tokens-from-simulation-frame.ts @@ -1,4 +1,4 @@ -import type { SimulationFrame } from "../types/simulation"; +import type { SimulationFrame } from "./types"; /** * Removes tokens from multiple places in the simulation frame. diff --git a/libs/@hashintel/petrinaut/src/core/simulation/seeded-rng.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/seeded-rng.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/simulation/seeded-rng.ts rename to libs/@hashintel/petrinaut/src/simulation/simulator/seeded-rng.ts diff --git a/libs/@hashintel/petrinaut/src/simulation/simulator/types.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/types.ts new file mode 100644 index 00000000000..7d6025913f4 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/types.ts @@ -0,0 +1,17 @@ +/** + * Re-export simulation types from the context module. + * + * All simulation-related types are defined in the context module to ensure + * the context is the source of truth. This file re-exports them for + * convenient access within the simulator module. + */ +export type { + DifferentialEquationFn, + LambdaFn, + ParameterValues, + SimulationFrame, + SimulationFrameState_Transition, + SimulationInput, + SimulationInstance, + TransitionKernelFn, +} from "../context"; diff --git a/libs/@hashintel/petrinaut/src/state/simulation-context.ts b/libs/@hashintel/petrinaut/src/state/simulation-context.ts deleted file mode 100644 index 82922c482f5..00000000000 --- a/libs/@hashintel/petrinaut/src/state/simulation-context.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { createContext } from "react"; - -import type { SimulationInstance } from "../core/types/simulation"; - -export type SimulationState = - | "NotRun" - | "Running" - | "Complete" - | "Error" - | "Paused"; - -export type InitialMarking = Map< - string, - { values: Float64Array; count: number } ->; - -/** - * The combined simulation context containing both state and actions. - */ -export type SimulationContextValue = { - // State values - simulation: SimulationInstance | null; - state: SimulationState; - error: string | null; - errorItemId: string | null; - parameterValues: Record; - initialMarking: InitialMarking; - currentlyViewedFrame: number; - dt: number; - - // Actions - setInitialMarking: ( - placeId: string, - marking: { values: Float64Array; count: number }, - ) => void; - setParameterValue: (parameterId: string, value: string) => void; - setDt: (dt: number) => void; - initializeParameterValuesFromDefaults: () => void; - initialize: (params: { seed: number; dt: number }) => void; - run: () => void; - pause: () => void; - reset: () => void; - setCurrentlyViewedFrame: (frameIndex: number) => void; -}; - -const DEFAULT_CONTEXT_VALUE: SimulationContextValue = { - simulation: null, - state: "NotRun", - error: null, - errorItemId: null, - parameterValues: {}, - initialMarking: new Map(), - currentlyViewedFrame: 0, - dt: 0.01, - setInitialMarking: () => {}, - setParameterValue: () => {}, - setDt: () => {}, - initializeParameterValuesFromDefaults: () => {}, - initialize: () => {}, - run: () => {}, - pause: () => {}, - reset: () => {}, - setCurrentlyViewedFrame: () => {}, -}; - -export const SimulationContext = createContext( - DEFAULT_CONTEXT_VALUE, -); diff --git a/libs/@hashintel/petrinaut/src/state/types-for-editor-to-remove.ts b/libs/@hashintel/petrinaut/src/state/types-for-editor-to-remove.ts deleted file mode 100644 index 94783f0a1c2..00000000000 --- a/libs/@hashintel/petrinaut/src/state/types-for-editor-to-remove.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Edge, Node } from "reactflow"; - -// -// These types are React-Flow specific, and should not appear in the global state. -// Instead we should only use them in SDCPNView and related components/mappers. -// - -export type ArcData = { - tokenWeights: { - [tokenTypeId: string]: number | undefined; - }; -}; - -export type ArcType = Omit, "style">; - -export type PlaceNodeData = { - label: string; - type: "place"; - dynamicsEnabled: boolean; - hasColorType: boolean; - typeColor?: string; // Color code from the type, if assigned -}; - -export type TransitionNodeData = { - label: string; - /** - * Although a reactflow {@link Node} has a 'type' field, the library types don't discriminate on this field in all methods, - * so we add our own discriminating field here to make it easier to narrow between Transition and Place nodes. - */ - type: "transition"; - lambdaType: "predicate" | "stochastic"; -}; - -export type TransitionNodeType = Omit< - Node, - "selected" | "dragging" ->; - -export type PetriNetDefinitionObject = { - arcs: ArcType[]; - nodes: NodeType[]; -}; - -export type NodeData = PlaceNodeData | TransitionNodeData; - -export type NodeType = Node; diff --git a/libs/@hashintel/petrinaut/src/state/use-is-read-only.ts b/libs/@hashintel/petrinaut/src/state/use-is-read-only.ts index 656dbfd1c4e..09c87863f16 100644 --- a/libs/@hashintel/petrinaut/src/state/use-is-read-only.ts +++ b/libs/@hashintel/petrinaut/src/state/use-is-read-only.ts @@ -1,7 +1,7 @@ import { use } from "react"; +import { SimulationContext } from "../simulation/context"; import { EditorContext } from "./editor-context"; -import { SimulationContext } from "./simulation-context"; /** * Hook that determines if the editor is in read-only mode. diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/simulation-controls.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/simulation-controls.tsx index 5c16ea124cb..733ef06b4d6 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/simulation-controls.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/simulation-controls.tsx @@ -7,27 +7,37 @@ import { } from "react-icons/io"; import { MdRotateLeft } from "react-icons/md"; +import { SimulationContext } from "../../../../simulation/context"; import { EditorContext } from "../../../../state/editor-context"; -import { SimulationContext } from "../../../../state/simulation-context"; import { ToolbarButton } from "./toolbar-button"; const frameInfoStyle = css({ display: "flex", flexDirection: "column", alignItems: "center", - fontSize: "[11px]", + fontSize: "[10px]", color: "gray.60", fontWeight: "medium", lineHeight: "[1]", - minWidth: "[80px]", + width: "[90px]", + fontVariantNumeric: "tabular-nums", + overflow: "hidden", + whiteSpace: "nowrap", }); const elapsedTimeStyle = css({ - fontSize: "[10px]", + fontSize: "[9px]", color: "gray.50", marginTop: "[2px]", }); +const frameIndexStyle = css({ + fontSize: "[11px]", + color: "gray.50", + letterSpacing: "[-0.2px]", + marginTop: "[1px]", +}); + const sliderStyle = css({ width: "[400px]", height: "[4px]", @@ -73,8 +83,8 @@ export const SimulationControls: React.FC = ({ run, pause, dt, - currentlyViewedFrame, - setCurrentlyViewedFrame, + currentViewedFrame, + setCurrentViewedFrame, } = use(SimulationContext); const { setBottomPanelOpen, setActiveBottomPanelTab } = use(EditorContext); @@ -90,7 +100,8 @@ export const SimulationControls: React.FC = ({ const hasSimulation = simulation !== null; const isRunning = simulationState === "Running"; const isComplete = simulationState === "Complete"; - const elapsedTime = simulation ? currentlyViewedFrame * simulation.dt : 0; + const frameIndex = currentViewedFrame?.number ?? 0; + const elapsedTime = currentViewedFrame?.time ?? 0; const getPlayPauseTooltip = () => { if (isDisabled) { @@ -177,8 +188,8 @@ export const SimulationControls: React.FC = ({ <>
Frame
-
- {currentlyViewedFrame + 1} / {totalFrames} +
+ {frameIndex + 1} / {totalFrames}
{elapsedTime.toFixed(3)}s
@@ -187,10 +198,10 @@ export const SimulationControls: React.FC = ({ type="range" min="0" max={Math.max(0, totalFrames - 1)} - value={currentlyViewedFrame} + value={frameIndex} disabled={isDisabled} onChange={(event) => - setCurrentlyViewedFrame(Number(event.target.value)) + setCurrentViewedFrame(Number(event.target.value)) } className={sliderStyle} /> diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx index 74ca4f22c75..f529c3ddeee 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx @@ -15,11 +15,11 @@ import { PANEL_MARGIN, SIMULATION_ONLY_SUBVIEWS, } from "../../../../constants/ui"; +import { SimulationContext } from "../../../../simulation/context"; import { type BottomPanelTab, EditorContext, } from "../../../../state/editor-context"; -import { SimulationContext } from "../../../../state/simulation-context"; const glassPanelBaseStyle = css({ position: "fixed", diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/initial-state-editor.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/initial-state-editor.tsx index 3e2f45cfc0a..f203d0120b4 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/initial-state-editor.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/initial-state-editor.tsx @@ -2,7 +2,7 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { use, useEffect, useRef, useState } from "react"; import type { Color } from "../../../../core/types/sdcpn"; -import { SimulationContext } from "../../../../state/simulation-context"; +import { SimulationContext } from "../../../../simulation/context"; const wrapperStyle = css({ display: "flex", @@ -254,15 +254,12 @@ export const InitialStateEditor: React.FC = ({ const internalResize = useResizable(250); const { height, isResizing, containerRef, startResize } = internalResize; - const { - initialMarking, - setInitialMarking, - simulation, - currentlyViewedFrame, - } = use(SimulationContext); + const { initialMarking, setInitialMarking, simulation, currentViewedFrame } = + use(SimulationContext); // Determine if we should show current simulation state or initial marking const hasSimulation = simulation !== null && simulation.frames.length > 0; + const frameIndex = currentViewedFrame?.number ?? 0; // Get current marking for this place - either from simulation frame or initial marking const getCurrentMarkingData = (): { @@ -270,8 +267,8 @@ export const InitialStateEditor: React.FC = ({ count: number; } | null => { if (hasSimulation) { - // Get from currently viewed frame - const currentFrame = simulation.frames[currentlyViewedFrame]; + // Get from currently viewed frame (need raw frame for buffer access) + const currentFrame = simulation.frames[frameIndex]; if (!currentFrame) { return null; } diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx index d55e5ffeff8..62d8875fcac 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx @@ -25,9 +25,9 @@ import type { DifferentialEquation, Place, } from "../../../../core/types/sdcpn"; +import { SimulationContext } from "../../../../simulation/context"; import { EditorContext } from "../../../../state/editor-context"; import { SDCPNContext } from "../../../../state/sdcpn-context"; -import { SimulationContext } from "../../../../state/simulation-context"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; import { placeInitialStateSubView } from "../../subviews/place-initial-state"; import { placeVisualizerOutputSubView } from "../../subviews/place-visualizer-output"; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/differential-equations-list.tsx index e9ac3ee4ed3..e9f152fc316 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/differential-equations-list.tsx @@ -4,9 +4,9 @@ import { v4 as uuidv4 } from "uuid"; import type { SubView } from "../../../components/sub-view/types"; import { DEFAULT_DIFFERENTIAL_EQUATION_CODE } from "../../../core/default-codes"; +import { SimulationContext } from "../../../simulation/context"; import { EditorContext } from "../../../state/editor-context"; import { SDCPNContext } from "../../../state/sdcpn-context"; -import { SimulationContext } from "../../../state/simulation-context"; const listContainerStyle = css({ display: "flex", diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/parameters-list.tsx index c7722e28718..7de447c256a 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/parameters-list.tsx @@ -3,9 +3,9 @@ import { use } from "react"; import { v4 as uuidv4 } from "uuid"; import type { SubView } from "../../../components/sub-view/types"; +import { SimulationContext } from "../../../simulation/context"; import { EditorContext } from "../../../state/editor-context"; import { SDCPNContext } from "../../../state/sdcpn-context"; -import { SimulationContext } from "../../../state/simulation-context"; const addButtonStyle = css({ display: "flex", diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/place-initial-state.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/place-initial-state.tsx index 454fe0bb2b4..60f71c98329 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/place-initial-state.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/place-initial-state.tsx @@ -3,7 +3,7 @@ import { use } from "react"; import { TbTrash } from "react-icons/tb"; import type { SubView } from "../../../components/sub-view/types"; -import { SimulationContext } from "../../../state/simulation-context"; +import { SimulationContext } from "../../../simulation/context"; import { InitialStateEditor } from "../panels/PropertiesPanel/initial-state-editor"; import { usePlacePropertiesContext } from "../panels/PropertiesPanel/place-properties-context"; @@ -101,23 +101,20 @@ const ClearStateHeaderAction: React.FC = () => { const PlaceInitialStateContent: React.FC = () => { const { place, placeType } = usePlacePropertiesContext(); - const { - simulation, - initialMarking, - setInitialMarking, - currentlyViewedFrame, - } = use(SimulationContext); + const { simulation, initialMarking, setInitialMarking, currentViewedFrame } = + use(SimulationContext); // Determine if simulation is running (has frames) const hasSimulationFrames = simulation !== null && simulation.frames.length > 0; + const frameIndex = currentViewedFrame?.number ?? 0; // If no type or type has 0 dimensions, show simple number input if (!placeType || placeType.elements.length === 0) { // Get token count from simulation frame or initial marking let currentTokenCount = 0; if (hasSimulationFrames) { - const currentFrame = simulation.frames[currentlyViewedFrame]; + const currentFrame = simulation.frames[frameIndex]; if (currentFrame) { const placeState = currentFrame.places.get(place.id); currentTokenCount = placeState?.count ?? 0; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/place-visualizer-output.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/place-visualizer-output.tsx index 89e52eb134d..d44cb4c9057 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/place-visualizer-output.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/place-visualizer-output.tsx @@ -2,12 +2,12 @@ import { css } from "@hashintel/ds-helpers/css"; import { use, useMemo } from "react"; import type { SubView } from "../../../components/sub-view/types"; -import { compileVisualizer } from "../../../core/simulation/compile-visualizer"; import { mergeParameterValues, useDefaultParameterValues, } from "../../../hooks/use-default-parameter-values"; -import { SimulationContext } from "../../../state/simulation-context"; +import { SimulationContext } from "../../../simulation/context"; +import { compileVisualizer } from "../../../simulation/simulator/compile-visualizer"; import { usePlacePropertiesContext } from "../panels/PropertiesPanel/place-properties-context"; import { VisualizerErrorBoundary } from "../panels/PropertiesPanel/visualizer-error-boundary"; @@ -28,7 +28,7 @@ const visualizerErrorStyle = css({ const PlaceVisualizerOutputContent: React.FC = () => { const { place, placeType } = usePlacePropertiesContext(); - const { simulation, initialMarking, parameterValues, currentlyViewedFrame } = + const { simulation, initialMarking, parameterValues, currentViewedFrame } = use(SimulationContext); // Get default parameter values from SDCPN definition @@ -64,11 +64,12 @@ const PlaceVisualizerOutputContent: React.FC = () => { const dimensions = placeType.elements.length; const tokens: Record[] = []; let parameters: Record = {}; + const frameIndex = currentViewedFrame?.number ?? 0; // Check if we have simulation frames or use initial marking if (simulation && simulation.frames.length > 0) { - // Use currently viewed simulation frame - const currentFrame = simulation.frames[currentlyViewedFrame]; + // Use currently viewed simulation frame (need raw frame for buffer access) + const currentFrame = simulation.frames[frameIndex]; if (!currentFrame) { return (
No frame data available
diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-settings.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-settings.tsx index 16be75d145f..0da56136e17 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-settings.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-settings.tsx @@ -4,9 +4,9 @@ import { TbArrowRight } from "react-icons/tb"; import type { SubView } from "../../../components/sub-view/types"; import { InfoIconTooltip } from "../../../components/tooltip"; +import { SimulationContext } from "../../../simulation/context"; import { EditorContext } from "../../../state/editor-context"; import { SDCPNContext } from "../../../state/sdcpn-context"; -import { SimulationContext } from "../../../state/simulation-context"; const containerStyle = css({ display: "flex", diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx index 6e7fa5a28fc..bcdc5ca4c33 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx @@ -4,12 +4,12 @@ import { use, useCallback, useMemo, useRef, useState } from "react"; import { SegmentGroup } from "../../../components/segment-group"; import type { SubView } from "../../../components/sub-view/types"; +import { SimulationContext } from "../../../simulation/context"; import { EditorContext, type TimelineChartType, } from "../../../state/editor-context"; import { SDCPNContext } from "../../../state/sdcpn-context"; -import { SimulationContext } from "../../../state/simulation-context"; /** * Computes the maximum value from an array using a selector function. @@ -421,13 +421,14 @@ const ChartTooltip: React.FC<{ tooltip: TooltipState | null }> = ({ const PlayheadIndicator: React.FC<{ totalFrames: number }> = ({ totalFrames, }) => { - const { currentlyViewedFrame } = use(SimulationContext); + const { currentViewedFrame } = use(SimulationContext); + const frameIndex = currentViewedFrame?.number ?? 0; return (
@@ -513,7 +514,7 @@ const CompartmentTimeSeries: React.FC = ({ onTooltipChange, onPlaceHover, }) => { - const { simulation, setCurrentlyViewedFrame } = use(SimulationContext); + const { simulation, setCurrentViewedFrame } = use(SimulationContext); const chartRef = useRef(null); const isDraggingRef = useRef(false); @@ -567,10 +568,10 @@ const CompartmentTimeSeries: React.FC = ({ (event: React.MouseEvent) => { const frameIndex = getFrameFromEvent(event); if (frameIndex !== null) { - setCurrentlyViewedFrame(frameIndex); + setCurrentViewedFrame(frameIndex); } }, - [getFrameFromEvent, setCurrentlyViewedFrame], + [getFrameFromEvent, setCurrentViewedFrame], ); // Update tooltip based on mouse position and hovered place @@ -804,7 +805,7 @@ const StackedAreaChart: React.FC = ({ onTooltipChange, onPlaceHover, }) => { - const { simulation, setCurrentlyViewedFrame } = use(SimulationContext); + const { simulation, setCurrentViewedFrame } = use(SimulationContext); const chartRef = useRef(null); const isDraggingRef = useRef(false); @@ -896,10 +897,10 @@ const StackedAreaChart: React.FC = ({ (event: React.MouseEvent) => { const frameIndex = getFrameFromEvent(event); if (frameIndex !== null) { - setCurrentlyViewedFrame(frameIndex); + setCurrentViewedFrame(frameIndex); } }, - [getFrameFromEvent, setCurrentlyViewedFrame], + [getFrameFromEvent, setCurrentViewedFrame], ); // Update tooltip based on mouse position and hovered place diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/types-list.tsx index ccade70a3ed..6adf4dd58b4 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/types-list.tsx @@ -2,9 +2,9 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { use } from "react"; import type { SubView } from "../../../components/sub-view/types"; +import { SimulationContext } from "../../../simulation/context"; import { EditorContext } from "../../../state/editor-context"; import { SDCPNContext } from "../../../state/sdcpn-context"; -import { SimulationContext } from "../../../state/simulation-context"; const listContainerStyle = css({ display: "flex", diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/components/arc.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/components/arc.tsx index b45b4fea108..1d93759249f 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/components/arc.tsx +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/components/arc.tsx @@ -1,8 +1,101 @@ import { css } from "@hashintel/ds-helpers/css"; -import { type CSSProperties, use } from "react"; +import { type CSSProperties, use, useEffect, useRef } from "react"; import { BaseEdge, type EdgeProps, getBezierPath } from "reactflow"; import { EditorContext } from "../../../state/editor-context"; +import { useFiringDelta } from "../hooks/use-firing-delta"; +import type { ArcData } from "../reactflow-types"; + +const BASE_STROKE_WIDTH = 2; +const ANIMATION_DURATION_MS = 300; + +type AnimationState = { + animation: Animation; + startTime: number; + transitionsAnimating: number; +}; + +/** + * Hook to animate stroke width when firing delta changes. + * Creates a "pulse" effect on the arc when transitions fire. + * + * The animation accumulates: if a new firing occurs while an animation is + * in progress, we calculate the remaining stroke width from the previous + * animation and add it to the new one for smooth visual continuity. + */ +function useFiringAnimation( + pathRef: React.RefObject, + firingDelta: number | null, + weight: number, +): void { + const animationStateRef = useRef(null); + + useEffect(() => { + // Only start a new animation when there's an actual firing (delta > 0) + if (firingDelta === null || firingDelta <= 0 || pathRef.current === null) { + return; + } + + let transitionsToAnimate = firingDelta; + + // Calculate remaining transitions from previous animation (if any) + if (animationStateRef.current) { + const { animation, startTime, transitionsAnimating } = + animationStateRef.current; + const elapsed = performance.now() - startTime; + const progress = Math.min(elapsed / ANIMATION_DURATION_MS, 1); + + // Remaining transitions decrease linearly as animation progresses + const remainingTransitions = transitionsAnimating * (1 - progress); + transitionsToAnimate += remainingTransitions; + + // Cancel the previous animation + animation.cancel(); + } + + const path = pathRef.current; + + // Stroke width based on total transitions + const peakStrokeWidth = + BASE_STROKE_WIDTH + Math.log(1 + transitionsToAnimate) * 3 * weight; + + const animation = path.animate( + [ + { strokeWidth: `${peakStrokeWidth}px` }, + { strokeWidth: `${BASE_STROKE_WIDTH}px` }, + ], + { + duration: ANIMATION_DURATION_MS, + easing: "ease-out", + fill: "forwards", + }, + ); + + // Store total transitions for calculating remaining stroke width later + animationStateRef.current = { + animation, + startTime: performance.now(), + transitionsAnimating: transitionsToAnimate, + }; + + // Clean up animation reference when it finishes + animation.onfinish = () => { + if (animationStateRef.current?.animation === animation) { + animationStateRef.current = null; + } + }; + }, [firingDelta, pathRef, weight]); + + // Cancel animation on unmount + useEffect(() => { + return () => { + if (animationStateRef.current) { + animationStateRef.current.animation.cancel(); + animationStateRef.current = null; + } + }; + }, []); +} const selectionIndicatorStyle: CSSProperties = { stroke: "rgba(249, 115, 22, 0.4)", @@ -23,12 +116,6 @@ const weightTextStyle = css({ pointerEvents: "none", }); -interface ArcData { - tokenWeights: { - [tokenTypeId: string]: number; - }; -} - export const Arc: React.FC> = ({ id, sourceX, @@ -47,6 +134,15 @@ export const Arc: React.FC> = ({ // Check if this arc is selected by its ID const selected = selectedItemIds.has(id); + // Track firing count delta for simulation visualization + const firingDelta = useFiringDelta(data?.frame?.firingCount ?? null); + + // Ref for the main arc path to animate stroke width + const arcPathRef = useRef(null); + + // Animate stroke width when firing delta changes (scaled by arc weight) + useFiringAnimation(arcPathRef, firingDelta, data?.weight ?? 1); + const [arcPath, labelX, labelY] = getBezierPath({ sourceX, sourceY, @@ -67,52 +163,57 @@ export const Arc: React.FC> = ({ /> )} - {/* Main edge with original style */} + {/* Main edge with marker - using BaseEdge for proper interaction handling */} - {/* Weight label - only show for weights > 1 */} + {/* Animated overlay path for firing visualization (no marker) */} + + + {/* Labels container */} - {Object.entries(data?.tokenWeights ?? {}) - .filter(([_, weight]) => weight > 1) - .map(([_tokenTypeId, weight], index, nonZeroWeights) => { - const yOffset = (index - (nonZeroWeights.length - 1) / 2) * 24; - - return ( - - {/* White background for readability */} - - {/* Multiplication symbol (grayed out) */} - - × - - {/* Weight number */} - - {weight} - - - ); - })} + {/* Weight label - always show for weights > 1 */} + {data && data.weight > 1 ? ( + + {/* White background for readability */} + + {/* Multiplication symbol (grayed out) */} + + × + + {/* Weight number */} + + {data.weight} + + + ) : null} ); diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/components/place-node.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/components/place-node.tsx index d28470d40e6..53990b0a75f 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/components/place-node.tsx +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/components/place-node.tsx @@ -5,9 +5,9 @@ import { Handle, type NodeProps, Position } from "reactflow"; import { hexToHsl } from "../../../lib/hsl-color"; import { splitPascalCase } from "../../../lib/split-pascal-case"; +import { SimulationContext } from "../../../simulation/context"; import { EditorContext } from "../../../state/editor-context"; -import { SimulationContext } from "../../../state/simulation-context"; -import type { PlaceNodeData } from "../../../state/types-for-editor-to-remove"; +import type { PlaceNodeData } from "../reactflow-types"; import { handleStyling } from "../styles/styling"; const containerStyle = css({ @@ -98,10 +98,12 @@ const tokenCountBadgeStyle = css({ justifyContent: "center", color: "[white]", backgroundColor: "[black]", - width: "[27px]", - height: "[27px]", - borderRadius: "[50%]", + minWidth: "[26px]", + height: "[26px]", + borderRadius: "[13px]", + padding: "[0 6px]", fontWeight: "semibold", + fontVariantNumeric: "tabular-nums", }); export const PlaceNode: React.FC> = ({ @@ -112,18 +114,13 @@ export const PlaceNode: React.FC> = ({ }: NodeProps) => { const { globalMode, selectedResourceId } = use(EditorContext); const isSimulateMode = globalMode === "simulate"; - const { simulation, currentlyViewedFrame, initialMarking } = - use(SimulationContext); + const { currentViewedFrame, initialMarking } = use(SimulationContext); // Get token count from the currently viewed frame or initial marking let tokenCount: number | null = null; - if (simulation && simulation.frames.length > 0) { - const frame = simulation.frames[currentlyViewedFrame]; - const placeData = frame?.places.get(id); - if (placeData) { - tokenCount = placeData.count; - } - } else if (isSimulateMode && !simulation) { + if (currentViewedFrame) { + tokenCount = currentViewedFrame.places[id]?.tokenCount ?? null; + } else if (isSimulateMode) { // In simulate mode but no simulation running - show initial marking const marking = initialMarking.get(id); tokenCount = marking?.count ?? 0; diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/components/transition-node.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/components/transition-node.tsx index 8eb6f5692e5..e6ae166e2d1 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/components/transition-node.tsx +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/components/transition-node.tsx @@ -1,13 +1,15 @@ import { css, cva } from "@hashintel/ds-helpers/css"; -import { use } from "react"; +import { use, useEffect, useRef } from "react"; import { TbBolt, TbLambda } from "react-icons/tb"; import { Handle, type NodeProps, Position } from "reactflow"; import { EditorContext } from "../../../state/editor-context"; -import { SimulationContext } from "../../../state/simulation-context"; -import type { TransitionNodeData } from "../../../state/types-for-editor-to-remove"; +import { useFiringDelta } from "../hooks/use-firing-delta"; +import type { TransitionNodeData } from "../reactflow-types"; import { handleStyling } from "../styles/styling"; +const FIRING_ANIMATION_DURATION_MS = 300; + const containerStyle = css({ position: "relative", background: "[transparent]", @@ -30,7 +32,7 @@ const transitionBoxStyle = cva({ boxSizing: "border-box", position: "relative", cursor: "default", - transition: "[all 0.2s ease]", + transition: "[outline 0.2s ease, border-color 0.2s ease]", outline: "[0px solid rgba(75, 126, 156, 0)]", _hover: { borderColor: "gray.70", @@ -50,20 +52,9 @@ const transitionBoxStyle = cva({ }, none: {}, }, - fired: { - true: { - background: "yellow.20/70", - boxShadow: "0 0 6px 1px rgba(255, 132, 0, 0.59)", - transition: "[background 0s, box-shadow 0s, outline 0.3s]", - }, - false: { - transition: "[background 0.3s, box-shadow 0.3s, outline 0.3s]", - }, - }, }, defaultVariants: { selection: "none", - fired: false, }, }); @@ -90,37 +81,76 @@ const labelStyle = css({ textAlign: "center", }); -const firingIndicatorStyle = cva({ - base: { - position: "absolute", - bottom: "[8px]", - left: "[0px]", - width: "[100%]", - display: "flex", - alignItems: "center", - justifyContent: "center", - fontSize: "[20px]", - color: "yellow.60", - }, - variants: { - fired: { - true: { - opacity: "[1]", - transform: "scale(1)", - transition: "[opacity 0s, transform 0s]", +const firingIndicatorStyle = css({ + position: "absolute", + bottom: "[8px]", + left: "[0px]", + width: "[100%]", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: "[20px]", + color: "yellow.60", + // Initial state: hidden + opacity: "[0]", + transform: "scale(0.5)", +}); + +/** + * Hook to animate the transition box and lightning bolt when firing. + * Uses Web Animations API for smooth, programmatic control. + */ +function useFiringAnimation( + boxRef: React.RefObject, + boltRef: React.RefObject, + firingDelta: number | null, +): void { + useEffect(() => { + // Only animate when there's an actual firing (delta > 0) + if (firingDelta === null || firingDelta <= 0) { + return; + } + + const box = boxRef.current; + const bolt = boltRef.current; + + if (!box || !bolt) { + return; + } + + // Animate the box: flash yellow background and glow + box.animate( + [ + { + background: "rgba(255, 224, 132, 0.7)", + boxShadow: "0 0 6px 1px rgba(255, 132, 0, 0.59)", + }, + { + background: "rgb(247, 247, 247)", + boxShadow: "0 0 0 0 rgba(255, 132, 0, 0)", + }, + ], + { + duration: FIRING_ANIMATION_DURATION_MS, + easing: "ease-out", + fill: "forwards", }, - false: { - opacity: "[0]", - transform: "scale(0.5)", - transition: - "[opacity 1s cubic-bezier(0.4, 0, 0.2, 1), transform 1s cubic-bezier(0,-1.41,.17,.9)]", + ); + + // Animate the lightning bolt: appear then fade out + bolt.animate( + [ + { opacity: 1, transform: "scale(1)" }, + { opacity: 0, transform: "scale(0.5)" }, + ], + { + duration: FIRING_ANIMATION_DURATION_MS * 3, + easing: "cubic-bezier(0.4, 0, 0.2, 1)", + fill: "forwards", }, - }, - }, - defaultVariants: { - fired: false, - }, -}); + ); + }, [firingDelta, boxRef, boltRef]); +} export const TransitionNode: React.FC> = ({ id, @@ -131,19 +161,16 @@ export const TransitionNode: React.FC> = ({ const { label } = data; const { selectedResourceId } = use(EditorContext); - const { simulation, currentlyViewedFrame } = use(SimulationContext); - - // Check if this transition just fired (time since last fire is zero) - let justFired = false; - if (simulation && simulation.frames.length > 0) { - const frame = simulation.frames[currentlyViewedFrame]; - const transitionData = frame?.transitions.get(id); - // Ugly hack: check if currentlyViewedFrame is greater than 0 to avoid showing the transition as fired on the first frame. - // This will be fixed when define proper simulation state interface. - if (transitionData && currentlyViewedFrame > 0) { - justFired = transitionData.timeSinceLastFiring === 0; - } - } + + // Refs for animated elements + const boxRef = useRef(null); + const boltRef = useRef(null); + + // Track firing count delta for simulation visualization + const firingDelta = useFiringDelta(data.frame?.firingCount ?? null); + + // Animate when firing occurs + useFiringAnimation(boxRef, boltRef, firingDelta); // Determine selection state const isSelectedByResource = selectedResourceId === id; @@ -162,9 +189,9 @@ export const TransitionNode: React.FC> = ({ style={handleStyling} />
{data.lambdaType === "stochastic" && ( @@ -175,7 +202,7 @@ export const TransitionNode: React.FC> = ({
{label}
-
+
diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-firing-delta.ts b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-firing-delta.ts new file mode 100644 index 00000000000..aacc4ea2f1d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-firing-delta.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef } from "react"; + +/** + * Hook to track the previous firingCount and compute the delta. + */ +export function useFiringDelta(firingCount: number | null): number | null { + const prevFiringCountRef = useRef(null); + + useEffect(() => { + if (firingCount !== null) { + prevFiringCountRef.current = firingCount; + } + }, [firingCount]); + + if (firingCount === null) { + return null; + } + + // On first render (ref not yet initialized) or no change, return null + // This prevents triggering a large "fake" animation when mounting + // while viewing a later frame + if ( + prevFiringCountRef.current === null || + firingCount === prevFiringCountRef.current + ) { + return null; + } + + const delta = firingCount - prevFiringCountRef.current; + + // Ignore negative deltas (e.g., when scrubbing backwards) + return delta > 0 ? delta : null; +} diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts index d8a1a7de56e..4af48ecf669 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-sdcpn-to-react-flow.ts @@ -1,14 +1,14 @@ import { use } from "react"; import { MarkerType } from "reactflow"; -import type { SDCPN } from "../../../core/types/sdcpn"; import { hexToHsl } from "../../../lib/hsl-color"; +import { SimulationContext } from "../../../simulation/context"; import { EditorContext } from "../../../state/editor-context"; -import { generateArcId } from "../../../state/sdcpn-context"; +import { generateArcId, SDCPNContext } from "../../../state/sdcpn-context"; import type { NodeType, - PetriNetDefinitionObject, -} from "../../../state/types-for-editor-to-remove"; + PetrinautReactFlowDefinitionObject, +} from "../reactflow-types"; /** * Converts SDCPN state to ReactFlow format (nodes and edges), and combines @@ -18,21 +18,22 @@ import type { * - Converting SDCPN places/transitions/arcs to ReactFlow nodes/edges * - Folding in the dragging state for proper rendering during drag operations * - * @param sdcpn - The SDCPN state to convert * @returns An object with nodes (including dragging state) and arcs for ReactFlow */ -export function useSdcpnToReactFlow(sdcpn: SDCPN): PetriNetDefinitionObject { +export function useSdcpnToReactFlow(): PetrinautReactFlowDefinitionObject { + const { petriNetDefinition } = use(SDCPNContext); const { draggingStateByNodeId, selectedItemIds } = use(EditorContext); + const { currentViewedFrame } = use(SimulationContext); const nodes: NodeType[] = []; // Create place nodes - for (const place of sdcpn.places) { + for (const place of petriNetDefinition.places) { const draggingState = draggingStateByNodeId[place.id]; // Check if place has a type with at least one dimension (element) const placeType = place.colorId - ? sdcpn.types.find((type) => type.id === place.colorId) + ? petriNetDefinition.types.find((type) => type.id === place.colorId) : null; const hasColorType = !!(placeType && placeType.elements.length > 0); @@ -57,7 +58,7 @@ export function useSdcpnToReactFlow(sdcpn: SDCPN): PetriNetDefinitionObject { } // Create transition nodes - for (const transition of sdcpn.transitions) { + for (const transition of petriNetDefinition.transitions) { const draggingState = draggingStateByNodeId[transition.id]; nodes.push({ @@ -74,6 +75,7 @@ export function useSdcpnToReactFlow(sdcpn: SDCPN): PetriNetDefinitionObject { label: transition.name, type: "transition", lambdaType: transition.lambdaType, + frame: currentViewedFrame?.transitions[transition.id] ?? null, }, }); } @@ -81,7 +83,7 @@ export function useSdcpnToReactFlow(sdcpn: SDCPN): PetriNetDefinitionObject { // Create arcs from input and output arcs const arcs = []; - for (const transition of sdcpn.transitions) { + for (const transition of petriNetDefinition.transitions) { // Input arcs (from places to transition) for (const inputArc of transition.inputArcs) { const arcId = generateArcId({ @@ -90,9 +92,11 @@ export function useSdcpnToReactFlow(sdcpn: SDCPN): PetriNetDefinitionObject { }); // Get the place to determine type color - const place = sdcpn.places.find((pl) => pl.id === inputArc.placeId); + const place = petriNetDefinition.places.find( + (pl) => pl.id === inputArc.placeId, + ); const placeType = place?.colorId - ? sdcpn.types.find((type) => type.id === place.colorId) + ? petriNetDefinition.types.find((type) => type.id === place.colorId) : null; const arcColor = placeType?.displayColor ? hexToHsl(placeType.displayColor).lighten(-15).saturate(-30).css(1) @@ -115,9 +119,8 @@ export function useSdcpnToReactFlow(sdcpn: SDCPN): PetriNetDefinitionObject { strokeWidth: 2, }, data: { - tokenWeights: { - default: inputArc.weight, - }, + weight: inputArc.weight, + frame: currentViewedFrame?.transitions[transition.id] ?? null, }, }); } @@ -130,9 +133,11 @@ export function useSdcpnToReactFlow(sdcpn: SDCPN): PetriNetDefinitionObject { }); // Get the place to determine type color - const place = sdcpn.places.find((pl) => pl.id === outputArc.placeId); + const place = petriNetDefinition.places.find( + (pl) => pl.id === outputArc.placeId, + ); const placeType = place?.colorId - ? sdcpn.types.find((type) => type.id === place.colorId) + ? petriNetDefinition.types.find((type) => type.id === place.colorId) : null; const arcColor = placeType?.displayColor ? hexToHsl(placeType.displayColor).lighten(-15).saturate(-30).css(1) @@ -155,9 +160,8 @@ export function useSdcpnToReactFlow(sdcpn: SDCPN): PetriNetDefinitionObject { strokeWidth: 2, }, data: { - tokenWeights: { - default: outputArc.weight, - }, + weight: outputArc.weight, + frame: currentViewedFrame?.transitions[transition.id] ?? null, }, }); } diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/reactflow-types.ts b/libs/@hashintel/petrinaut/src/views/SDCPN/reactflow-types.ts new file mode 100644 index 00000000000..71695cd9267 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/reactflow-types.ts @@ -0,0 +1,63 @@ +import type { Edge, Node, ReactFlowInstance } from "reactflow"; + +import type { SimulationFrameState_Transition } from "../../simulation/context"; + +// +// Specific types for ReactFlow nodes, arcs, and instance. +// Serve for mapping between Petrinaut Contexts and ReactFlow. +// + +export type ArcData = { + weight: number; + /** + * State of the transition connected to this arc in the current simulation frame. + * Null when no simulation is running. + */ + frame: SimulationFrameState_Transition | null; +}; + +export type ArcType = Omit, "style">; + +export type PlaceNodeData = { + label: string; + type: "place"; + dynamicsEnabled: boolean; + hasColorType: boolean; + typeColor?: string; // Color code from the type, if assigned +}; + +export type PlaceNodeType = Node; + +export type TransitionNodeData = { + label: string; + /** + * Although a reactflow {@link Node} has a 'type' field, the library types don't discriminate on this field in all methods, + * so we add our own discriminating field here to make it easier to narrow between Transition and Place nodes. + */ + type: "transition"; + lambdaType: "predicate" | "stochastic"; + /** + * State of this transition in the current simulation frame. + * Null when no simulation is running. + */ + frame: SimulationFrameState_Transition | null; +}; + +export type TransitionNodeType = Node; + +export type NodeData = PlaceNodeData | TransitionNodeData; + +export type NodeType = TransitionNodeType | PlaceNodeType; + +/** + * Object containing the nodes and arcs for the ReactFlow instance. + */ +export type PetrinautReactFlowDefinitionObject = { + arcs: ArcType[]; + nodes: NodeType[]; +}; + +/** + * ReactFlow instance type for Petrinaut. + */ +export type PetrinautReactFlowInstance = ReactFlowInstance; diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx index 1452ca1cb3e..1ab40da6c44 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx @@ -10,15 +10,19 @@ import { DEFAULT_TRANSITION_KERNEL_CODE, generateDefaultLambdaCode, } from "../../core/default-codes"; +import { SimulationContext } from "../../simulation/context"; import { EditorContext } from "../../state/editor-context"; import { SDCPNContext } from "../../state/sdcpn-context"; -import { SimulationContext } from "../../state/simulation-context"; -import type { ArcData, NodeData } from "../../state/types-for-editor-to-remove"; import { Arc } from "./components/arc"; import { PlaceNode } from "./components/place-node"; import { TransitionNode } from "./components/transition-node"; import { useApplyNodeChanges } from "./hooks/use-apply-node-changes"; import { useSdcpnToReactFlow } from "./hooks/use-sdcpn-to-react-flow"; +import type { + ArcData, + NodeData, + PetrinautReactFlowInstance, +} from "./reactflow-types"; import { nodeDimensions } from "./styles/styling"; const SNAP_GRID_SIZE = 15; @@ -43,19 +47,16 @@ const canvasContainerStyle = css({ /** * SDCPNView is responsible for rendering the SDCPN using ReactFlow. - * It reads from sdcpn-store and editor-store, and handles all ReactFlow interactions. + * It reads from SDCPNContext and EditorContext, and handles all ReactFlow interactions. */ export const SDCPNView: React.FC = () => { const canvasContainer = useRef(null); - const [reactFlowInstance, setReactFlowInstance] = useState | null>(null); + const [reactFlowInstance, setReactFlowInstance] = + useState(null); // SDCPN store const { petriNetId, - petriNetDefinition, addPlace, addTransition, addArc, @@ -63,13 +64,6 @@ export const SDCPNView: React.FC = () => { readonly, } = use(SDCPNContext); - // Hook for applying node changes - const applyNodeChanges = useApplyNodeChanges(); - - // Convert SDCPN to ReactFlow format with dragging state - const { nodes, arcs } = useSdcpnToReactFlow(petriNetDefinition); - - // Editor state const { globalMode: mode, editionMode, @@ -82,11 +76,15 @@ export const SDCPNView: React.FC = () => { const { state: simulationState } = use(SimulationContext); + // Hook for applying node changes + const applyNodeChanges = useApplyNodeChanges(); + + // Convert SDCPN to ReactFlow format with dragging state + const { nodes, arcs } = useSdcpnToReactFlow(); + // Center viewport on SDCPN load useEffect(() => { - if (reactFlowInstance) { - reactFlowInstance.fitView({ padding: 0.4, minZoom: 0.4, maxZoom: 1.1 }); - } + reactFlowInstance?.fitView({ padding: 0.4, minZoom: 0.4, maxZoom: 1.1 }); }, [reactFlowInstance, petriNetId]); // Readonly if in simulate mode, simulation is running/paused, or readonly has been provided by external consumer.