diff --git a/libs/@hashintel/petrinaut/src/constants/ui.ts b/libs/@hashintel/petrinaut/src/constants/ui.ts index 2816ef61e75..7ad2e1f4ad0 100644 --- a/libs/@hashintel/petrinaut/src/constants/ui.ts +++ b/libs/@hashintel/petrinaut/src/constants/ui.ts @@ -8,13 +8,14 @@ import { differentialEquationsListSubView } from "../views/Editor/subviews/diffe import { nodesListSubView } from "../views/Editor/subviews/nodes-list"; import { parametersListSubView } from "../views/Editor/subviews/parameters-list"; import { simulationSettingsSubView } from "../views/Editor/subviews/simulation-settings"; +import { simulationTimelineSubView } from "../views/Editor/subviews/simulation-timeline"; import { typesListSubView } from "../views/Editor/subviews/types-list"; // Panel margin (spacing around panels) -export const PANEL_MARGIN = 10; +export const PANEL_MARGIN = 8; // Resize handle -export const RESIZE_HANDLE_SIZE = 20; +export const RESIZE_HANDLE_SIZE = 16; export const RESIZE_HANDLE_OFFSET = -Math.floor(RESIZE_HANDLE_SIZE / 2); // Left Sidebar @@ -43,7 +44,11 @@ export const LEFT_SIDEBAR_SUBVIEWS: SubView[] = [ nodesListSubView, ]; +// Base subviews always visible in the bottom panel export const BOTTOM_PANEL_SUBVIEWS: SubView[] = [ diagnosticsSubView, simulationSettingsSubView, ]; + +// Subviews only visible when simulation is running/paused +export const SIMULATION_ONLY_SUBVIEWS: SubView[] = [simulationTimelineSubView]; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.test.ts index 66281076df7..959618e81c7 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.test.ts @@ -50,6 +50,7 @@ describe("buildSimulation", () => { }, ], ]), + parameterValues: {}, seed: 42, dt: 0.1, }; @@ -196,6 +197,7 @@ describe("buildSimulation", () => { ], // p3 has no initial tokens ]), + parameterValues: {}, seed: 123, dt: 0.05, }; @@ -305,6 +307,7 @@ describe("buildSimulation", () => { }, ], ]), + parameterValues: {}, seed: 42, dt: 0.1, }; @@ -360,6 +363,7 @@ describe("buildSimulation", () => { }, ], ]), + parameterValues: {}, seed: 42, dt: 0.1, }; diff --git a/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.ts b/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.ts index c8dd4ce8caa..668f9c55888 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.ts @@ -1,4 +1,7 @@ -import { deriveDefaultParameterValues } from "../../hooks/use-default-parameter-values"; +import { + deriveDefaultParameterValues, + mergeParameterValues, +} from "../../hooks/use-default-parameter-values"; import { SDCPNItemError } from "../errors"; import type { DifferentialEquationFn, @@ -17,7 +20,7 @@ import { compileUserCode } from "./compile-user-code"; */ function getPlaceDimensions( place: SimulationInput["sdcpn"]["places"][0], - sdcpn: SimulationInput["sdcpn"], + sdcpn: SimulationInput["sdcpn"] ): number { if (!place.colorId) { return 0; @@ -25,7 +28,7 @@ function getPlaceDimensions( const type = sdcpn.types.find((tp) => tp.id === place.colorId); if (!type) { throw new Error( - `Type with ID ${place.colorId} referenced by place ${place.id} does not exist in SDCPN`, + `Type with ID ${place.colorId} referenced by place ${place.id} does not exist in SDCPN` ); } return type.elements.length; @@ -52,23 +55,34 @@ function getPlaceDimensions( * @throws {Error} if user code fails to compile */ export function buildSimulation(input: SimulationInput): SimulationInstance { - const { sdcpn, initialMarking, seed, dt } = input; + const { + sdcpn, + initialMarking, + parameterValues: inputParameterValues, + seed, + dt, + } = input; // Build maps for quick lookup const placesMap = new Map(sdcpn.places.map((place) => [place.id, place])); const transitionsMap = new Map( - sdcpn.transitions.map((transition) => [transition.id, transition]), + sdcpn.transitions.map((transition) => [transition.id, transition]) ); const typesMap = new Map(sdcpn.types.map((type) => [type.id, type])); - // Build parameter values from SDCPN parameters using the shared utility - const parameterValues = deriveDefaultParameterValues(sdcpn.parameters); + // Build parameter values: merge input values with SDCPN defaults + // Input values (from simulation store) take precedence over defaults + const defaultParameterValues = deriveDefaultParameterValues(sdcpn.parameters); + const parameterValues = mergeParameterValues( + inputParameterValues, + defaultParameterValues + ); // Validate that all places in initialMarking exist in SDCPN for (const placeId of initialMarking.keys()) { if (!placesMap.has(placeId)) { throw new Error( - `Place with ID ${placeId} in initialMarking does not exist in SDCPN`, + `Place with ID ${placeId} in initialMarking does not exist in SDCPN` ); } } @@ -80,7 +94,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { const expectedSize = dimensions * marking.count; if (marking.values.length !== expectedSize) { throw new Error( - `Token dimension mismatch for place ${placeId}. Expected ${expectedSize} values (${dimensions} dimensions × ${marking.count} tokens), got ${marking.values.length}`, + `Token dimension mismatch for place ${placeId}. Expected ${expectedSize} values (${dimensions} dimensions × ${marking.count} tokens), got ${marking.values.length}` ); } } @@ -94,11 +108,11 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { } const differentialEquation = sdcpn.differentialEquations.find( - (de) => de.id === place.differentialEquationId, + (de) => de.id === place.differentialEquationId ); if (!differentialEquation) { throw new Error( - `Differential equation with ID ${place.differentialEquationId} referenced by place ${place.id} does not exist in SDCPN`, + `Differential equation with ID ${place.differentialEquationId} referenced by place ${place.id} does not exist in SDCPN` ); } const { code } = differentialEquation; @@ -106,15 +120,15 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { try { const fn = compileUserCode<[Record[], ParameterValues]>( code, - "Dynamics", + "Dynamics" ); differentialEquationFns.set(place.id, fn as DifferentialEquationFn); } catch (error) { throw new SDCPNItemError( - `Failed to compile differential equation for place \`${place.name}\`:\n\n${ - error instanceof Error ? error.message : String(error) - }`, - place.id, + `Failed to compile differential equation for place \`${ + place.name + }\`:\n\n${error instanceof Error ? error.message : String(error)}`, + place.id ); } } @@ -129,10 +143,10 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { lambdaFns.set(transition.id, fn as LambdaFn); } catch (error) { throw new SDCPNItemError( - `Failed to compile Lambda function for transition \`${transition.name}\`:\n\n${ - error instanceof Error ? error.message : String(error) - }`, - transition.id, + `Failed to compile Lambda function for transition \`${ + transition.name + }\`:\n\n${error instanceof Error ? error.message : String(error)}`, + transition.id ); } } @@ -152,7 +166,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { // without typed output places (they don't need to generate token data) transitionKernelFns.set( transition.id, - (() => ({})) as TransitionKernelFn, + (() => ({})) as TransitionKernelFn ); continue; } @@ -164,10 +178,10 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { transitionKernelFns.set(transition.id, fn as TransitionKernelFn); } catch (error) { throw new SDCPNItemError( - `Failed to compile transition kernel for transition \`${transition.name}\`:\n\n${ - error instanceof Error ? error.message : String(error) - }`, - transition.id, + `Failed to compile transition kernel for transition \`${ + transition.name + }\`:\n\n${error instanceof Error ? error.message : String(error)}`, + transition.id ); } } @@ -224,7 +238,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { instance: transition, timeSinceLastFiring: 0, }, - ]), + ]) ); // Create the simulation instance (without frames initially) diff --git a/libs/@hashintel/petrinaut/src/core/simulation/compute-next-frame.test.ts b/libs/@hashintel/petrinaut/src/core/simulation/compute-next-frame.test.ts index ea1d36c0183..db73e4cc32f 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/compute-next-frame.test.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/compute-next-frame.test.ts @@ -65,6 +65,7 @@ describe("computeNextFrame", () => { const simulation = buildSimulation({ sdcpn, initialMarking, + parameterValues: {}, seed: 42, dt: 0.1, }); @@ -114,6 +115,7 @@ describe("computeNextFrame", () => { const simulation = buildSimulation({ sdcpn, initialMarking, + parameterValues: {}, seed: 42, dt: 0.1, }); @@ -167,6 +169,7 @@ describe("computeNextFrame", () => { const simulation = buildSimulation({ sdcpn, initialMarking, + parameterValues: {}, seed: 42, dt: 0.1, }); diff --git a/libs/@hashintel/petrinaut/src/core/types/simulation.ts b/libs/@hashintel/petrinaut/src/core/types/simulation.ts index 817d28af7bb..fa3316381c0 100644 --- a/libs/@hashintel/petrinaut/src/core/types/simulation.ts +++ b/libs/@hashintel/petrinaut/src/core/types/simulation.ts @@ -4,22 +4,24 @@ export type ParameterValues = Record; export type DifferentialEquationFn = ( tokens: Record[], - parameters: ParameterValues, + parameters: ParameterValues ) => Record[]; export type LambdaFn = ( tokenValues: Record[]>, - parameters: ParameterValues, + parameters: ParameterValues ) => number | boolean; export type TransitionKernelFn = ( tokenValues: Record[]>, - parameters: ParameterValues, + 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; }; diff --git a/libs/@hashintel/petrinaut/src/examples/broken-machines.ts b/libs/@hashintel/petrinaut/src/examples/broken-machines.ts index eb7d487342b..39f5d6026bd 100644 --- a/libs/@hashintel/petrinaut/src/examples/broken-machines.ts +++ b/libs/@hashintel/petrinaut/src/examples/broken-machines.ts @@ -361,13 +361,13 @@ export const productionMachines: { title: string; petriNetDefinition: SDCPN } = id: "5bfea547-faaf-4626-8662-6400d07c049e", name: "Reparation Dynamics", colorId: "type__1762560152725", - code: '// This function defines the differential equation for the place of type "Machine".\n// The function receives the current tokens in all places and the parameters.\n// It should return the derivative of the token value in this place.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ machine_damage_ratio }) => {\n // ...Do some computation with input token here if needed\n\n return {\n machine_damage_ratio: -1 / 3\n };\n });\n});', + code: '// This function defines the differential equation for the place of type "Machine".\n// The function receives the current tokens in all places and the parameters.\n// It should return the derivative of the token value in this place.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ machine_damage_ratio }) => {\n // ...Do some computation with input token here if needed\n\n return {\n machine_damage_ratio: -parameters.damage_reparation_per_second\n };\n });\n});', }, { id: "ca26e5e2-0373-46a9-920e-a6eacadd92e8", name: "Production Dynamics", colorId: "type__1762560154179", - code: '// This function defines the differential equation for the place of type "Machine Producing Product".\n// The function receives the current tokens in all places and the parameters.\n// It should return the derivative of the token value in this place.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ machine_damage_ratio, transformation_progress }) => {\n // ...Do some computation with input token here if needed\n\n return {\n machine_damage_ratio: 1 / 1000,\n transformation_progress: 1 / 3\n };\n });\n});', + code: '// This function defines the differential equation for the place of type "Machine Producing Product".\n// The function receives the current tokens in all places and the parameters.\n// It should return the derivative of the token value in this place.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ machine_damage_ratio, transformation_progress }) => {\n // ...Do some computation with input token here if needed\n\n return {\n machine_damage_ratio: parameters.damage_per_second,\n transformation_progress: 1 / 3\n };\n });\n});', }, { id: "887245c3-183c-4dac-a1aa-d602d21b6450", @@ -376,6 +376,21 @@ export const productionMachines: { title: string; petriNetDefinition: SDCPN } = code: '// This function defines the differential equation for the place of type "Technician".\n// The function receives the current tokens in all places and the parameters.\n// It should return the derivative of the token value in this place.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ distance_to_site }) => {\n // ...Do some computation with input token here if needed\n\n return {\n distance_to_site: -1\n };\n });\n});', }, ], - parameters: [], + parameters: [ + { + id: "param__damage_per_second", + name: "Damage Per Second", + variableName: "damage_per_second", + type: "real", + defaultValue: "0.001", + }, + { + id: "param__damage_reparation_per_second", + name: "Damage Reparation Per Second", + variableName: "damage_reparation_per_second", + type: "real", + defaultValue: "0.333", + }, + ], }, }; diff --git a/libs/@hashintel/petrinaut/src/examples/sir-model.ts b/libs/@hashintel/petrinaut/src/examples/sir-model.ts index 491060db958..1e30195d589 100644 --- a/libs/@hashintel/petrinaut/src/examples/sir-model.ts +++ b/libs/@hashintel/petrinaut/src/examples/sir-model.ts @@ -10,8 +10,8 @@ export const sirModel: { title: string; petriNetDefinition: SDCPN } = { colorId: null, dynamicsEnabled: false, differentialEquationId: null, - x: -300, - y: 0, + x: -375, + y: 135, width: 130, height: 130, }, @@ -21,8 +21,8 @@ export const sirModel: { title: string; petriNetDefinition: SDCPN } = { colorId: null, dynamicsEnabled: false, differentialEquationId: null, - x: 0, - y: 0, + x: -195, + y: 285, width: 130, height: 130, }, @@ -32,8 +32,8 @@ export const sirModel: { title: string; petriNetDefinition: SDCPN } = { colorId: null, dynamicsEnabled: false, differentialEquationId: null, - x: 300, - y: 0, + x: 315, + y: 120, width: 130, height: 130, }, @@ -59,11 +59,12 @@ export const sirModel: { title: string; petriNetDefinition: SDCPN } = { }, ], lambdaType: "stochastic", - lambdaCode: "export default Lambda(() => 0.3)", + lambdaCode: + "export default Lambda((tokens, parameters) => parameters.infection_rate)", transitionKernelCode: "export default TransitionKernel(() => {\n return {\n Infected: [{}, {}],\n };\n});", - x: -150, - y: 0, + x: -165, + y: 75, width: 160, height: 80, }, @@ -83,17 +84,33 @@ export const sirModel: { title: string; petriNetDefinition: SDCPN } = { }, ], lambdaType: "stochastic", - lambdaCode: "export default Lambda(() => 0.1)", + lambdaCode: + "export default Lambda((tokens, parameters) => parameters.recovery_rate)", transitionKernelCode: "export default TransitionKernel(() => {\n return {\n Recovered: [{}],\n };\n});", - x: 150, - y: 0, + x: 75, + y: 225, width: 160, height: 80, }, ], types: [], differentialEquations: [], - parameters: [], + parameters: [ + { + id: "param__infection_rate", + name: "Infection Rate", + variableName: "infection_rate", + type: "real", + defaultValue: "3", + }, + { + id: "param__recovery_rate", + name: "Recovery Rate", + variableName: "recovery_rate", + type: "real", + defaultValue: "1", + }, + ], }, }; diff --git a/libs/@hashintel/petrinaut/src/state/editor-store.ts b/libs/@hashintel/petrinaut/src/state/editor-store.ts index 669b440e198..ba4f8155aba 100644 --- a/libs/@hashintel/petrinaut/src/state/editor-store.ts +++ b/libs/@hashintel/petrinaut/src/state/editor-store.ts @@ -17,7 +17,7 @@ type EditorEditionMode = "select" | "pan" | "add-place" | "add-transition"; export type BottomPanelTab = | "diagnostics" | "simulation-settings" - | "parameters"; + | "simulation-timeline"; export type EditorState = { globalMode: EditorGlobalMode; diff --git a/libs/@hashintel/petrinaut/src/state/simulation-store.ts b/libs/@hashintel/petrinaut/src/state/simulation-store.ts index 5df77f8709b..d17ff7ec76d 100644 --- a/libs/@hashintel/petrinaut/src/state/simulation-store.ts +++ b/libs/@hashintel/petrinaut/src/state/simulation-store.ts @@ -54,7 +54,7 @@ export type SimulationStoreState = { // Set initial marking for a specific place setInitialMarking: ( placeId: string, - marking: { values: Float64Array; count: number }, + marking: { values: Float64Array; count: number } ) => void; // Set a parameter value @@ -67,10 +67,7 @@ export type SimulationStoreState = { initializeParameterValuesFromDefaults: () => void; // Initialize the simulation with seed and dt (uses stored initialMarking) - initialize: (params: { - seed: number; - dt: number; - }) => void; + initialize: (params: { seed: number; dt: number }) => void; // Advance the simulation by one frame step: () => void; @@ -120,7 +117,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { return { initialMarking: newMarking }; }, false, - { type: "setInitialMarking", placeId, marking }, + { type: "setInitialMarking", placeId, marking } ), setParameterValue: (parameterId, value) => @@ -132,7 +129,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { }, }), false, - { type: "setParameterValue", parameterId, value }, + { type: "setParameterValue", parameterId, value } ), setDt: (dt) => set({ dt }, false, { type: "setDt", dt }), @@ -142,7 +139,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { () => { const { sdcpn } = getSDCPN(); const defaultValues = deriveDefaultParameterValues( - sdcpn.parameters, + sdcpn.parameters ); // Convert to string format for storage (matching the parameterValues type) @@ -154,7 +151,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { return { parameterValues }; }, false, - "initializeParameterValuesFromDefaults", + "initializeParameterValuesFromDefaults" ), initialize: ({ seed, dt }) => @@ -163,7 +160,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { // Prevent initialization if already running if (state.state === "Running") { throw new Error( - "Cannot initialize simulation while it is running. Please reset first.", + "Cannot initialize simulation while it is running. Please reset first." ); } @@ -180,7 +177,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { ? firstDiagnostic.messageText : ts.flattenDiagnosticMessageText( firstDiagnostic.messageText, - "\n", + "\n" ); return { @@ -191,10 +188,11 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { }; } - // Build the simulation instance using stored initialMarking + // Build the simulation instance using stored initialMarking and parameterValues const simulationInstance = buildSimulation({ sdcpn, initialMarking: state.initialMarking, + parameterValues: state.parameterValues, seed, dt, }); @@ -223,7 +221,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { } }, false, - "initialize", + "initialize" ), step: () => @@ -231,19 +229,19 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { (state) => { if (!state.simulation) { throw new Error( - "Cannot step simulation: No simulation initialized. Call initialize() first.", + "Cannot step simulation: No simulation initialized. Call initialize() first." ); } if (state.state === "Error") { throw new Error( - "Cannot step simulation: Simulation is in error state. Please reset.", + "Cannot step simulation: Simulation is in error state. Please reset." ); } if (state.state === "Complete") { throw new Error( - "Cannot step simulation: Simulation is complete. Please reset to run again.", + "Cannot step simulation: Simulation is complete. Please reset to run again." ); } @@ -274,7 +272,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { } }, false, - "step", + "step" ), run: () => @@ -282,19 +280,19 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { (state) => { if (!state.simulation) { throw new Error( - "Cannot run simulation: No simulation initialized. Call initialize() first.", + "Cannot run simulation: No simulation initialized. Call initialize() first." ); } if (state.state === "Error") { throw new Error( - "Cannot run simulation: Simulation is in error state. Please reset.", + "Cannot run simulation: Simulation is in error state. Please reset." ); } if (state.state === "Complete") { throw new Error( - "Cannot run simulation: Simulation is complete. Please reset to run again.", + "Cannot run simulation: Simulation is complete. Please reset to run again." ); } @@ -321,7 +319,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { set( { _runTimeoutId: timeoutId }, false, - "run:scheduleNext", + "run:scheduleNext" ); } catch { // Error is already handled by step() @@ -331,7 +329,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { const initialTimeoutId = setTimeout( executeStep, - 20, + 20 ) as unknown as number; return { state: "Running", _runTimeoutId: initialTimeoutId }; } @@ -339,7 +337,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { return { state: "Running" }; }, false, - "run", + "run" ), pause: () => @@ -356,7 +354,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { }; }, false, - "pause", + "pause" ), reset: () => @@ -370,7 +368,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { // Get default parameter values from SDCPN const { sdcpn } = getSDCPN(); const defaultValues = deriveDefaultParameterValues( - sdcpn.parameters, + sdcpn.parameters ); // Convert to string format for storage @@ -391,7 +389,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { }; }, false, - "reset", + "reset" ), setState: (newState) => @@ -400,26 +398,26 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { // Validate state transitions if (!state.simulation && newState !== "NotRun") { throw new Error( - "Cannot change state: No simulation initialized.", + "Cannot change state: No simulation initialized." ); } if (state.state === "Error" && newState === "Running") { throw new Error( - "Cannot start simulation: Simulation is in error state. Please reset.", + "Cannot start simulation: Simulation is in error state. Please reset." ); } if (state.state === "Complete" && newState === "Running") { throw new Error( - "Cannot start simulation: Simulation is complete. Please reset.", + "Cannot start simulation: Simulation is complete. Please reset." ); } return { state: newState }; }, false, - { type: "setState", newState }, + { type: "setState", newState } ), setCurrentlyViewedFrame: (frameIndex) => @@ -427,20 +425,20 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { (state) => { if (!state.simulation) { throw new Error( - "Cannot set viewed frame: No simulation initialized.", + "Cannot set viewed frame: No simulation initialized." ); } const totalFrames = state.simulation.frames.length; const clampedIndex = Math.max( 0, - Math.min(frameIndex, totalFrames - 1), + Math.min(frameIndex, totalFrames - 1) ); return { currentlyViewedFrame: clampedIndex }; }, false, - { type: "setCurrentlyViewedFrame", frameIndex }, + { type: "setCurrentlyViewedFrame", frameIndex } ), __reinitialize: () => { @@ -454,13 +452,13 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { _runTimeoutId: null, }, false, - "reinitialize", + "reinitialize" ); }, // for some reason 'create' doesn't raise an error if a function in the type is missing - }) satisfies SimulationStoreState, - { name: "Simulation Store" }, - ), + } satisfies SimulationStoreState), + { name: "Simulation Store" } + ) ); return store; 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 fc6fae2f24c..67380a6600b 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx @@ -1,5 +1,5 @@ import { css } from "@hashintel/ds-helpers/css"; -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { FaXmark } from "react-icons/fa6"; import { GlassPanel } from "../../../../components/glass-panel"; @@ -13,9 +13,11 @@ import { MAX_BOTTOM_PANEL_HEIGHT, MIN_BOTTOM_PANEL_HEIGHT, PANEL_MARGIN, + SIMULATION_ONLY_SUBVIEWS, } from "../../../../constants/ui"; import { useEditorStore } from "../../../../state/editor-provider"; import type { BottomPanelTab } from "../../../../state/editor-store"; +import { useSimulationStore } from "../../../../state/simulation-provider"; const glassPanelBaseStyle = css({ position: "fixed", @@ -68,6 +70,9 @@ const closeButtonStyle = css({ */ export const BottomPanel: React.FC = () => { const isOpen = useEditorStore((state) => state.isBottomPanelOpen); + const setBottomPanelOpen = useEditorStore( + (state) => state.setBottomPanelOpen + ); const isLeftSidebarOpen = useEditorStore((state) => state.isLeftSidebarOpen); const leftSidebarWidth = useEditorStore((state) => state.leftSidebarWidth); const panelHeight = useEditorStore((state) => state.bottomPanelHeight); @@ -78,6 +83,31 @@ export const BottomPanel: React.FC = () => { const setActiveTab = useEditorStore((state) => state.setActiveBottomPanelTab); const toggleBottomPanel = useEditorStore((state) => state.toggleBottomPanel); + // Simulation state for conditional subviews + const simulationState = useSimulationStore((state) => state.state); + const isSimulationActive = + simulationState === "Running" || simulationState === "Paused"; + + // Track previous simulation state to detect when simulation starts + const prevSimulationActiveRef = useRef(isSimulationActive); + + // Dynamically compute subviews based on simulation state + const subViews = isSimulationActive + ? [...BOTTOM_PANEL_SUBVIEWS, ...SIMULATION_ONLY_SUBVIEWS] + : BOTTOM_PANEL_SUBVIEWS; + + // Automatically open bottom panel and switch to timeline when simulation starts + useEffect(() => { + const wasActive = prevSimulationActiveRef.current; + prevSimulationActiveRef.current = isSimulationActive; + + // Simulation just started (transition from inactive to active) + if (isSimulationActive && !wasActive) { + setBottomPanelOpen(true); + setActiveTab("simulation-timeline"); + } + }, [isSimulationActive, setBottomPanelOpen, setActiveTab]); + // Handler for tab change that casts string to BottomPanelTab const handleTabChange = useCallback( (tabId: string) => { @@ -117,13 +147,13 @@ export const BottomPanel: React.FC = () => { {/* Tab Header */}
{/* Scrollable content */} - + ); }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx new file mode 100644 index 00000000000..6fcc8e05fcc --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx @@ -0,0 +1,452 @@ +import { css } from "@hashintel/ds-helpers/css"; +import { useCallback, useMemo, useRef, useState } from "react"; + +import type { SubView } from "../../../components/sub-view/types"; +import { useSDCPNContext } from "../../../state/sdcpn-provider"; +import { useSimulationStore } from "../../../state/simulation-provider"; + +const containerStyle = css({ + display: "flex", + flexDirection: "column", + height: "[100%]", + gap: "[8px]", +}); + +const chartContainerStyle = css({ + flex: "[1]", + minHeight: "[60px]", + position: "relative", + cursor: "pointer", +}); + +const svgStyle = css({ + width: "[100%]", + height: "[100%]", + display: "block", +}); + +const legendContainerStyle = css({ + display: "flex", + flexWrap: "wrap", + gap: "[12px]", + fontSize: "[11px]", + color: "[#666]", + paddingTop: "[4px]", +}); + +const legendItemStyle = css({ + display: "flex", + alignItems: "center", + gap: "[4px]", + cursor: "pointer", + userSelect: "none", + transition: "[opacity 0.15s ease]", + _hover: { + opacity: 1, + }, +}); + +const legendColorStyle = css({ + width: "[10px]", + height: "[10px]", + borderRadius: "[2px]", +}); + +const statsRowStyle = css({ + display: "flex", + alignItems: "center", + gap: "[16px]", + fontSize: "[12px]", + color: "[#666]", +}); + +const statItemStyle = css({ + display: "flex", + alignItems: "center", + gap: "[4px]", +}); + +const statLabelStyle = css({ + fontWeight: 500, + color: "[#999]", +}); + +const statValueStyle = css({ + fontWeight: 600, + color: "[#333]", + fontVariantNumeric: "tabular-nums", +}); + +// Default color palette for places without a specific color +const DEFAULT_COLORS = [ + "#3b82f6", // blue + "#ef4444", // red + "#22c55e", // green + "#f59e0b", // amber + "#8b5cf6", // violet + "#06b6d4", // cyan + "#ec4899", // pink + "#84cc16", // lime +]; + +interface CompartmentData { + placeId: string; + placeName: string; + color: string; + values: number[]; // token count at each frame +} + +/** + * CompartmentTimeSeries displays a line chart showing token counts over time. + * Clicking/dragging on the chart scrubs through frames. + */ +const CompartmentTimeSeries: React.FC = () => { + const simulation = useSimulationStore((state) => state.simulation); + const currentlyViewedFrame = useSimulationStore( + (state) => state.currentlyViewedFrame + ); + const setCurrentlyViewedFrame = useSimulationStore( + (state) => state.setCurrentlyViewedFrame + ); + const dt = useSimulationStore((state) => state.dt); + + const { + petriNetDefinition: { places, types }, + } = useSDCPNContext(); + + const chartRef = useRef(null); + const isDraggingRef = useRef(false); + + // State for legend interactivity + const [hiddenPlaces, setHiddenPlaces] = useState>(new Set()); + const [hoveredPlaceId, setHoveredPlaceId] = useState(null); + + // Toggle visibility handler + const togglePlaceVisibility = useCallback((placeId: string) => { + setHiddenPlaces((prev) => { + const next = new Set(prev); + if (next.has(placeId)) { + next.delete(placeId); + } else { + next.add(placeId); + } + return next; + }); + }, []); + + // Extract compartment data from simulation frames + const compartmentData = useMemo((): CompartmentData[] => { + if (!simulation || simulation.frames.length === 0) { + return []; + } + + // Create a map of place ID to color + const placeColors = new Map(); + for (const [index, place] of places.entries()) { + // Try to get color from the place's token type + const tokenType = types.find((type) => type.id === place.colorId); + const color = + tokenType?.displayColor ?? + DEFAULT_COLORS[index % DEFAULT_COLORS.length]!; + placeColors.set(place.id, color); + } + + // Extract token counts for each place across all frames + return places.map((place) => { + const values = simulation.frames.map((frame) => { + const placeData = frame.places.get(place.id); + return placeData?.count ?? 0; + }); + + return { + placeId: place.id, + placeName: place.name, + color: placeColors.get(place.id) ?? DEFAULT_COLORS[0]!, + values, + }; + }); + }, [simulation, places, types]); + + // Calculate chart dimensions and scales + const chartMetrics = useMemo(() => { + if (compartmentData.length === 0 || !simulation) { + return null; + } + + const totalFrames = simulation.frames.length; + const maxValue = Math.max( + 1, + ...compartmentData.flatMap((data) => data.values) + ); + + // Add some padding to max value for visual breathing room + const yMax = Math.ceil(maxValue * 1.1); + + return { + totalFrames, + maxValue, + yMax, + xScale: (frameIndex: number, width: number) => + (frameIndex / Math.max(1, totalFrames - 1)) * width, + yScale: (value: number, height: number) => + height - (value / yMax) * height, + }; + }, [compartmentData, simulation]); + + // Handle mouse interaction for scrubbing + const handleScrub = useCallback( + (event: React.MouseEvent) => { + if (!chartRef.current || !chartMetrics) { + return; + } + + const rect = chartRef.current.getBoundingClientRect(); + const x = event.clientX - rect.left; + const width = rect.width; + + // Calculate frame index from x position + const progress = Math.max(0, Math.min(1, x / width)); + const frameIndex = Math.round(progress * (chartMetrics.totalFrames - 1)); + + setCurrentlyViewedFrame(frameIndex); + }, + [chartMetrics, setCurrentlyViewedFrame] + ); + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + isDraggingRef.current = true; + handleScrub(event); + }, + [handleScrub] + ); + + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + if (isDraggingRef.current) { + handleScrub(event); + } + }, + [handleScrub] + ); + + const handleMouseUp = useCallback(() => { + isDraggingRef.current = false; + }, []); + + const handleMouseLeave = useCallback(() => { + isDraggingRef.current = false; + }, []); + + // Generate SVG path for a data series + const generatePath = useCallback( + (values: number[], width: number, height: number) => { + if (!chartMetrics || values.length === 0) { + return ""; + } + + const points = values.map((value, index) => { + const x = chartMetrics.xScale(index, width); + const y = chartMetrics.yScale(value, height); + return `${x},${y}`; + }); + + return `M ${points.join(" L ")}`; + }, + [chartMetrics] + ); + + if (!simulation || compartmentData.length === 0 || !chartMetrics) { + return ( +
+
+ No simulation data available +
+
+ ); + } + + const totalFrames = chartMetrics.totalFrames; + const currentTime = currentlyViewedFrame * dt; + const totalTime = (totalFrames - 1) * dt; + + return ( +
+ {/* Stats row */} +
+
+ Frame: + + {currentlyViewedFrame} / {totalFrames - 1} + +
+
+ Time: + + {currentTime.toFixed(3)}s / {totalTime.toFixed(3)}s + +
+
+ + {/* Chart */} +
+ + {/* Background grid lines */} + + + + + {/* Data lines - render non-hovered first, then hovered on top */} + {compartmentData + .filter((data) => !hiddenPlaces.has(data.placeId)) + .filter((data) => data.placeId !== hoveredPlaceId) + .map((data) => ( + + ))} + {/* Render hovered line on top */} + {hoveredPlaceId && + !hiddenPlaces.has(hoveredPlaceId) && + compartmentData + .filter((data) => data.placeId === hoveredPlaceId) + .map((data) => ( + + ))} + + {/* Playhead indicator */} + + + +
+ + {/* Legend */} +
+ {compartmentData.map((data) => { + const isHidden = hiddenPlaces.has(data.placeId); + const isHovered = hoveredPlaceId === data.placeId; + const isDimmed = hoveredPlaceId && !isHovered; + + return ( +
togglePlaceVisibility(data.placeId)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + togglePlaceVisibility(data.placeId); + } + }} + onMouseEnter={() => setHoveredPlaceId(data.placeId)} + onMouseLeave={() => setHoveredPlaceId(null)} + onFocus={() => setHoveredPlaceId(data.placeId)} + onBlur={() => setHoveredPlaceId(null)} + style={{ + opacity: isHidden ? 0.4 : isDimmed ? 0.6 : 1, + textDecoration: isHidden ? "line-through" : "none", + }} + > +
+ {data.placeName} +
+ ); + })} +
+
+ ); +}; + +/** + * SimulationTimelineContent displays timeline information for the running simulation. + * Shows a compartment time-series chart with interactive scrubbing. + */ +const SimulationTimelineContent: React.FC = () => { + return ; +}; + +/** + * SubView definition for Simulation Timeline tab. + * This tab is only visible when simulation is running or paused. + */ +export const simulationTimelineSubView: SubView = { + id: "simulation-timeline", + title: "Timeline", + tooltip: + "View the simulation timeline with compartment time-series. Click/drag to scrub through frames.", + component: SimulationTimelineContent, +};