From 7f0b7febe03b8fe516f7afba4717b337fa037763 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 22 Dec 2025 20:54:39 +0100 Subject: [PATCH 01/16] Update Broken Machines example to have some global parameters --- .../petrinaut/src/examples/broken-machines.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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", + }, + ], }, }; From 771da533f148066851c4063322f375a26f436c31 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 22 Dec 2025 23:09:43 +0100 Subject: [PATCH 02/16] Update SIR Model to be cleaner and have parameters --- .../petrinaut/src/examples/sir-model.ts | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) 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", + }, + ], }, }; From f6725f0f7f1ea03fb84785afd94498addb974251 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 23 Dec 2025 00:12:30 +0100 Subject: [PATCH 03/16] PARTIAL: Simulation Timeline Subview --- libs/@hashintel/petrinaut/src/constants/ui.ts | 5 + .../petrinaut/src/state/editor-store.ts | 5 +- .../views/Editor/panels/BottomPanel/panel.tsx | 41 +- .../Editor/subviews/simulation-timeline.tsx | 378 ++++++++++++++++++ 4 files changed, 421 insertions(+), 8 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx diff --git a/libs/@hashintel/petrinaut/src/constants/ui.ts b/libs/@hashintel/petrinaut/src/constants/ui.ts index 3f3166b0b29..3e06c82b221 100644 --- a/libs/@hashintel/petrinaut/src/constants/ui.ts +++ b/libs/@hashintel/petrinaut/src/constants/ui.ts @@ -8,6 +8,7 @@ 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) @@ -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/state/editor-store.ts b/libs/@hashintel/petrinaut/src/state/editor-store.ts index a9a60eacdb8..ba4f8155aba 100644 --- a/libs/@hashintel/petrinaut/src/state/editor-store.ts +++ b/libs/@hashintel/petrinaut/src/state/editor-store.ts @@ -14,7 +14,10 @@ export type DraggingStateByNodeId = Record< type EditorGlobalMode = "edit" | "simulate"; type EditorEditionMode = "select" | "pan" | "add-place" | "add-transition"; -export type BottomPanelTab = "diagnostics" | "simulation-settings"; +export type BottomPanelTab = + | "diagnostics" + | "simulation-settings" + | "simulation-timeline"; export type EditorState = { globalMode: EditorGlobalMode; 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 31f62fedce4..f603ae3fc19 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..bc5368209c2 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx @@ -0,0 +1,378 @@ +import { css } from "@hashintel/ds-helpers/css"; +import { useCallback, useMemo, useRef } 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]", +}); + +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); + + // 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 */} + {compartmentData.map((data) => ( + + ))} + + {/* Playhead indicator */} + + + +
+ + {/* Legend */} +
+ {compartmentData.map((data) => ( +
+
+ {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, +}; From 993f6c756ee59f2b96f5448c09b23dc33476476d Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 23 Dec 2025 10:44:27 +0100 Subject: [PATCH 04/16] Fix Parameters injection in Simulation --- .../core/simulation/build-simulation.test.ts | 4 ++ .../src/core/simulation/build-simulation.ts | 66 ++++++++++------- .../simulation/compute-next-frame.test.ts | 3 + .../petrinaut/src/core/types/simulation.ts | 8 ++- .../petrinaut/src/state/simulation-store.ts | 72 +++++++++---------- 5 files changed, 87 insertions(+), 66 deletions(-) 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/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; From aa258717d45c2d91b846484c47402d7b673be796 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 23 Dec 2025 15:22:54 +0100 Subject: [PATCH 05/16] Simulation Timeline: Places: Hover to highlight and click to toggle --- .../Editor/subviews/simulation-timeline.tsx | 118 ++++++++++++++---- 1 file changed, 96 insertions(+), 22 deletions(-) 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 bc5368209c2..6fcc8e05fcc 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx @@ -1,5 +1,5 @@ import { css } from "@hashintel/ds-helpers/css"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import type { SubView } from "../../../components/sub-view/types"; import { useSDCPNContext } from "../../../state/sdcpn-provider"; @@ -38,6 +38,12 @@ const legendItemStyle = css({ display: "flex", alignItems: "center", gap: "[4px]", + cursor: "pointer", + userSelect: "none", + transition: "[opacity 0.15s ease]", + _hover: { + opacity: 1, + }, }); const legendColorStyle = css({ @@ -111,6 +117,23 @@ const CompartmentTimeSeries: React.FC = () => { 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) { @@ -307,19 +330,41 @@ const CompartmentTimeSeries: React.FC = () => { vectorEffect="non-scaling-stroke" /> - {/* Data lines */} - {compartmentData.map((data) => ( - - ))} + {/* 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) => ( -
+ {compartmentData.map((data) => { + const isHidden = hiddenPlaces.has(data.placeId); + const isHovered = hoveredPlaceId === data.placeId; + const isDimmed = hoveredPlaceId && !isHovered; + + return (
- {data.placeName} -
- ))} + key={data.placeId} + role="button" + tabIndex={0} + className={legendItemStyle} + onClick={() => 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} +
+ ); + })}
); From 5f2136cd62e47c13533167121b8ba2a715e1ff29 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 9 Jan 2026 00:17:51 +0100 Subject: [PATCH 06/16] Refactor Simulation Timeline: Remove unused styles and simplify no data display --- .../Editor/subviews/simulation-timeline.tsx | 66 +++---------------- 1 file changed, 10 insertions(+), 56 deletions(-) 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 6fcc8e05fcc..f3ba3898aae 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx @@ -52,31 +52,6 @@ const legendColorStyle = css({ 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 @@ -103,12 +78,11 @@ interface CompartmentData { const CompartmentTimeSeries: React.FC = () => { const simulation = useSimulationStore((state) => state.simulation); const currentlyViewedFrame = useSimulationStore( - (state) => state.currentlyViewedFrame + (state) => state.currentlyViewedFrame, ); const setCurrentlyViewedFrame = useSimulationStore( - (state) => state.setCurrentlyViewedFrame + (state) => state.setCurrentlyViewedFrame, ); - const dt = useSimulationStore((state) => state.dt); const { petriNetDefinition: { places, types }, @@ -176,7 +150,7 @@ const CompartmentTimeSeries: React.FC = () => { const totalFrames = simulation.frames.length; const maxValue = Math.max( 1, - ...compartmentData.flatMap((data) => data.values) + ...compartmentData.flatMap((data) => data.values), ); // Add some padding to max value for visual breathing room @@ -210,7 +184,7 @@ const CompartmentTimeSeries: React.FC = () => { setCurrentlyViewedFrame(frameIndex); }, - [chartMetrics, setCurrentlyViewedFrame] + [chartMetrics, setCurrentlyViewedFrame], ); const handleMouseDown = useCallback( @@ -218,7 +192,7 @@ const CompartmentTimeSeries: React.FC = () => { isDraggingRef.current = true; handleScrub(event); }, - [handleScrub] + [handleScrub], ); const handleMouseMove = useCallback( @@ -227,7 +201,7 @@ const CompartmentTimeSeries: React.FC = () => { handleScrub(event); } }, - [handleScrub] + [handleScrub], ); const handleMouseUp = useCallback(() => { @@ -253,41 +227,21 @@ const CompartmentTimeSeries: React.FC = () => { return `M ${points.join(" L ")}`; }, - [chartMetrics] + [chartMetrics], ); if (!simulation || compartmentData.length === 0 || !chartMetrics) { return (
-
- No simulation data available -
+ + 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 */}
Date: Fri, 9 Jan 2026 00:54:59 +0100 Subject: [PATCH 07/16] Playhead Indicator to Simulation Timeline: HTML overlay instead of SVG --- .../Editor/subviews/simulation-timeline.tsx | 55 +++++++++++++------ 1 file changed, 37 insertions(+), 18 deletions(-) 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 f3ba3898aae..a8f7d21464a 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx @@ -19,6 +19,32 @@ const chartContainerStyle = css({ cursor: "pointer", }); +const playheadStyle = css({ + position: "absolute", + top: "[0]", + bottom: "[0]", + width: "[1px]", + pointerEvents: "none", + display: "flex", + flexDirection: "column", + alignItems: "center", +}); + +const playheadLineStyle = css({ + flex: "[1]", + width: "[1.5px]", + background: "[#333]", +}); + +const playheadArrowStyle = css({ + width: "[0]", + height: "[0]", + borderLeft: "[5px solid transparent]", + borderRight: "[5px solid transparent]", + borderTop: "[7px solid #333]", + marginBottom: "[-1px]", +}); + const svgStyle = css({ width: "[100%]", height: "[100%]", @@ -319,25 +345,18 @@ const CompartmentTimeSeries: React.FC = () => { strokeLinecap="round" /> ))} - - {/* Playhead indicator */} - - + + {/* Playhead indicator (HTML overlay) */} +
+
+
+
{/* Legend */} From 8cfc1040434b3abac18d6ce75df2dc6d110a5078 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 9 Jan 2026 02:19:37 +0100 Subject: [PATCH 08/16] Enhance Simulation Timeline: Introduce Timeline Chart Type Selector and refactor chart rendering logic for improved interactivity and maintainability. --- .../src/components/segment-group.tsx | 90 ++- .../petrinaut/src/state/editor-store.ts | 15 + .../Editor/subviews/simulation-timeline.tsx | 672 +++++++++++++----- 3 files changed, 586 insertions(+), 191 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/segment-group.tsx b/libs/@hashintel/petrinaut/src/components/segment-group.tsx index 451da7efc20..3dc213c255d 100644 --- a/libs/@hashintel/petrinaut/src/components/segment-group.tsx +++ b/libs/@hashintel/petrinaut/src/components/segment-group.tsx @@ -1,44 +1,85 @@ import { SegmentGroup as ArkSegmentGroup } from "@ark-ui/react/segment-group"; -import { css, cva } from "@hashintel/ds-helpers/css"; +import { cva } from "@hashintel/ds-helpers/css"; -const containerStyle = css({ - display: "flex", - backgroundColor: "core.gray.20", - borderRadius: "radius.8", - gap: "spacing.1", - position: "relative", - padding: "[4px]", +const containerStyle = cva({ + base: { + display: "flex", + backgroundColor: "core.gray.20", + gap: "spacing.1", + position: "relative", + }, + variants: { + size: { + md: { + borderRadius: "[12px]", + padding: "[4px]", + }, + sm: { + borderRadius: "[12px]", + padding: "[2px]", + }, + }, + }, + defaultVariants: { + size: "md", + }, }); -const indicatorStyle = css({ - backgroundColor: "core.gray.90", - borderRadius: "radius.6", - position: "absolute", - transition: "[all 0.2s ease]", - width: "var(--width)", - height: "var(--height)", - left: "var(--left)", - top: "var(--top)", +const indicatorStyle = cva({ + base: { + backgroundColor: "core.gray.90", + position: "absolute", + transition: "[all 0.2s ease]", + width: "var(--width)", + height: "var(--height)", + left: "var(--left)", + top: "var(--top)", + }, + variants: { + size: { + md: { + borderRadius: "[8px]", + }, + sm: { + borderRadius: "[10px]", + }, + }, + }, + defaultVariants: { + size: "md", + }, }); const itemStyle = cva({ base: { flex: "1", - fontSize: "[13px]", fontWeight: 500, textAlign: "center", cursor: "pointer", - borderRadius: "radius.6", transition: "[all 0.2s ease]", position: "relative", zIndex: 1, - padding: "[4px 6px]", }, variants: { isSelected: { true: { color: "core.gray.10" }, false: { color: "core.gray.70" }, }, + size: { + md: { + fontSize: "[13px]", + borderRadius: "radius.6", + padding: "[4px 6px]", + }, + sm: { + fontSize: "[11px]", + borderRadius: "radius.4", + padding: "[2px 6px]", + }, + }, + }, + defaultVariants: { + size: "md", }, }); @@ -51,12 +92,15 @@ interface SegmentGroupProps { value: string; options: SegmentOption[]; onChange: (value: string) => void; + /** Size variant. Defaults to "md". */ + size?: "md" | "sm"; } export const SegmentGroup: React.FC = ({ value, options, onChange, + size = "md", }) => { return ( = ({ } }} > -
- +
+ {options.map((option) => ( {option.label} diff --git a/libs/@hashintel/petrinaut/src/state/editor-store.ts b/libs/@hashintel/petrinaut/src/state/editor-store.ts index ba4f8155aba..b07a4fce962 100644 --- a/libs/@hashintel/petrinaut/src/state/editor-store.ts +++ b/libs/@hashintel/petrinaut/src/state/editor-store.ts @@ -19,6 +19,8 @@ export type BottomPanelTab = | "simulation-settings" | "simulation-timeline"; +export type TimelineChartType = "run" | "stacked"; + export type EditorState = { globalMode: EditorGlobalMode; setGlobalMode: (mode: EditorGlobalMode) => void; @@ -64,6 +66,10 @@ export type EditorState = { ) => void; resetDraggingState: () => void; + // Timeline chart type (run chart vs stacked area chart) + timelineChartType: TimelineChartType; + setTimelineChartType: (chartType: TimelineChartType) => void; + __reinitialize: () => void; }; @@ -191,6 +197,14 @@ export function createEditorStore() { resetDraggingState: () => set({ draggingStateByNodeId: {} }, false, "resetDraggingState"), + // Timeline chart type + timelineChartType: "run", + setTimelineChartType: (chartType) => + set({ timelineChartType: chartType }, false, { + type: "setTimelineChartType", + chartType, + }), + __reinitialize: () => { set( { @@ -205,6 +219,7 @@ export function createEditorStore() { selectedResourceId: null, selectedItemIds: new Set(), draggingStateByNodeId: {}, + timelineChartType: "run", }, false, { type: "initializeEditorStore" }, 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 a8f7d21464a..b250bd28327 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx @@ -1,7 +1,10 @@ import { css } from "@hashintel/ds-helpers/css"; import { useCallback, useMemo, useRef, useState } from "react"; +import { SegmentGroup } from "../../../components/segment-group"; import type { SubView } from "../../../components/sub-view/types"; +import { useEditorStore } from "../../../state/editor-provider"; +import type { TimelineChartType } from "../../../state/editor-store"; import { useSDCPNContext } from "../../../state/sdcpn-provider"; import { useSimulationStore } from "../../../state/simulation-provider"; @@ -90,6 +93,28 @@ const DEFAULT_COLORS = [ "#84cc16", // lime ]; +const CHART_TYPE_OPTIONS = [ + { value: "run", label: "Run" }, + { value: "stacked", label: "Stacked" }, +]; + +/** + * Header action component that renders a chart type selector. + */ +const TimelineChartTypeSelector: React.FC = () => { + const chartType = useEditorStore((state) => state.timelineChartType); + const setChartType = useEditorStore((state) => state.setTimelineChartType); + + return ( + setChartType(value as TimelineChartType)} + size="sm" + /> + ); +}; + interface CompartmentData { placeId: string; placeName: string; @@ -98,44 +123,23 @@ interface CompartmentData { } /** - * CompartmentTimeSeries displays a line chart showing token counts over time. - * Clicking/dragging on the chart scrubs through frames. + * Shared legend state interface for chart components. */ -const CompartmentTimeSeries: React.FC = () => { - const simulation = useSimulationStore((state) => state.simulation); - const currentlyViewedFrame = useSimulationStore( - (state) => state.currentlyViewedFrame, - ); - const setCurrentlyViewedFrame = useSimulationStore( - (state) => state.setCurrentlyViewedFrame, - ); +interface LegendState { + hiddenPlaces: Set; + hoveredPlaceId: string | null; +} +/** + * Hook to extract compartment data from simulation frames. + */ +const useCompartmentData = (): CompartmentData[] => { + const simulation = useSimulationStore((state) => state.simulation); 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[] => { + return useMemo((): CompartmentData[] => { if (!simulation || simulation.frames.length === 0) { return []; } @@ -166,6 +170,111 @@ const CompartmentTimeSeries: React.FC = () => { }; }); }, [simulation, places, types]); +}; + +/** + * Shared playhead indicator component for timeline charts. + */ +const PlayheadIndicator: React.FC<{ totalFrames: number }> = ({ + totalFrames, +}) => { + const currentlyViewedFrame = useSimulationStore( + (state) => state.currentlyViewedFrame, + ); + + return ( +
+
+
+
+ ); +}; + +/** + * Shared legend component for timeline charts. + */ +const TimelineLegend: React.FC<{ + compartmentData: CompartmentData[]; + hiddenPlaces: Set; + hoveredPlaceId: string | null; + onToggleVisibility: (placeId: string) => void; + onHover: (placeId: string | null) => void; +}> = ({ + compartmentData, + hiddenPlaces, + hoveredPlaceId, + onToggleVisibility, + onHover, +}) => ( +
+ {compartmentData.map((data) => { + const isHidden = hiddenPlaces.has(data.placeId); + const isHovered = hoveredPlaceId === data.placeId; + const isDimmed = hoveredPlaceId && !isHovered; + + return ( +
onToggleVisibility(data.placeId)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onToggleVisibility(data.placeId); + } + }} + onMouseEnter={() => onHover(data.placeId)} + onMouseLeave={() => onHover(null)} + onFocus={() => onHover(data.placeId)} + onBlur={() => onHover(null)} + style={{ + opacity: isHidden ? 0.4 : isDimmed ? 0.6 : 1, + textDecoration: isHidden ? "line-through" : "none", + }} + > +
+ {data.placeName} +
+ ); + })} +
+); + +interface ChartProps { + compartmentData: CompartmentData[]; + legendState: LegendState; +} + +/** + * CompartmentTimeSeries displays a line chart showing token counts over time. + * Clicking/dragging on the chart scrubs through frames. + */ +const CompartmentTimeSeries: React.FC = ({ + compartmentData, + legendState, +}) => { + const simulation = useSimulationStore((state) => state.simulation); + const setCurrentlyViewedFrame = useSimulationStore( + (state) => state.setCurrentlyViewedFrame, + ); + + const chartRef = useRef(null); + const isDraggingRef = useRef(false); + + const { hiddenPlaces, hoveredPlaceId } = legendState; // Calculate chart dimensions and scales const chartMetrics = useMemo(() => { @@ -257,6 +366,351 @@ const CompartmentTimeSeries: React.FC = () => { ); if (!simulation || compartmentData.length === 0 || !chartMetrics) { + return null; + } + + return ( + + {/* 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) => ( + + ))} + + ); +}; + +/** + * StackedAreaChart displays a stacked area chart showing token counts over time. + * Each place's tokens are stacked on top of each other to show the total distribution. + * Clicking/dragging on the chart scrubs through frames. + */ +const StackedAreaChart: React.FC = ({ + compartmentData, + legendState, +}) => { + const simulation = useSimulationStore((state) => state.simulation); + const setCurrentlyViewedFrame = useSimulationStore( + (state) => state.setCurrentlyViewedFrame, + ); + + const chartRef = useRef(null); + const isDraggingRef = useRef(false); + + const { hiddenPlaces, hoveredPlaceId } = legendState; + + // Filter visible compartment data + const visibleCompartmentData = useMemo(() => { + return compartmentData.filter((data) => !hiddenPlaces.has(data.placeId)); + }, [compartmentData, hiddenPlaces]); + + // Calculate stacked values and chart metrics + const { stackedData, chartMetrics } = useMemo(() => { + if (visibleCompartmentData.length === 0 || !simulation) { + return { stackedData: [], chartMetrics: null }; + } + + const totalFrames = simulation.frames.length; + + // Calculate stacked values: for each frame, accumulate the values + // stackedData[i] contains { placeId, color, baseValues[], topValues[] } + const stacked: Array<{ + placeId: string; + placeName: string; + color: string; + baseValues: number[]; + topValues: number[]; + }> = []; + + // Track cumulative values at each frame + const cumulativeAtFrame: number[] = new Array(totalFrames).fill(0); + + for (const data of visibleCompartmentData) { + const baseValues: number[] = [...cumulativeAtFrame]; + const topValues: number[] = data.values.map((value, frameIdx) => { + const newCumulative = (cumulativeAtFrame[frameIdx] ?? 0) + value; + cumulativeAtFrame[frameIdx] = newCumulative; + return newCumulative; + }); + + stacked.push({ + placeId: data.placeId, + placeName: data.placeName, + color: data.color, + baseValues, + topValues, + }); + } + + // Find the max stacked value for scaling + const maxValue = Math.max(1, ...cumulativeAtFrame); + const yMax = Math.ceil(maxValue * 1.1); + + return { + stackedData: stacked, + chartMetrics: { + totalFrames, + maxValue, + yMax, + xScale: (frameIndex: number, width: number) => + (frameIndex / Math.max(1, totalFrames - 1)) * width, + yScale: (value: number, height: number) => + height - (value / yMax) * height, + }, + }; + }, [visibleCompartmentData, 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 stacked area + const generateAreaPath = useCallback( + ( + baseValues: number[], + topValues: number[], + width: number, + height: number, + ) => { + if (!chartMetrics || topValues.length === 0) { + return ""; + } + + // Build the path: top line forward, then bottom line backward + const topPoints = topValues.map((value, index) => { + const x = chartMetrics.xScale(index, width); + const y = chartMetrics.yScale(value, height); + return `${x},${y}`; + }); + + const basePoints = baseValues + .map((value, index) => { + const x = chartMetrics.xScale(index, width); + const y = chartMetrics.yScale(value, height); + return `${x},${y}`; + }) + .reverse(); + + return `M ${topPoints.join(" L ")} L ${basePoints.join(" L ")} Z`; + }, + [chartMetrics], + ); + + if (!simulation || compartmentData.length === 0 || !chartMetrics) { + return null; + } + + return ( + + {/* Background grid lines */} + + + + + {/* Stacked areas - render from bottom to top */} + {stackedData.map((data) => { + const isHovered = hoveredPlaceId === data.placeId; + const isDimmed = hoveredPlaceId && !isHovered; + + return ( + + ); + })} + + ); +}; + +/** + * SimulationTimelineContent displays timeline information for the running simulation. + * Shows a compartment time-series chart with interactive scrubbing. + */ +const SimulationTimelineContent: React.FC = () => { + const chartType = useEditorStore((state) => state.timelineChartType); + const simulation = useSimulationStore((state) => state.simulation); + const compartmentData = useCompartmentData(); + + // Shared legend state - persists across chart type switches + const [hiddenPlaces, setHiddenPlaces] = useState>(new Set()); + const [hoveredPlaceId, setHoveredPlaceId] = useState(null); + + const legendState: LegendState = useMemo( + () => ({ hiddenPlaces, hoveredPlaceId }), + [hiddenPlaces, hoveredPlaceId], + ); + + // 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; + }); + }, []); + + const handleHover = useCallback((placeId: string | null) => { + setHoveredPlaceId(placeId); + }, []); + + const totalFrames = simulation?.frames.length ?? 0; + + if (compartmentData.length === 0 || totalFrames === 0) { return (
@@ -268,150 +722,31 @@ const CompartmentTimeSeries: React.FC = () => { return (
- {/* 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 (HTML overlay) */} -
-
-
-
-
- - {/* 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. @@ -422,4 +757,5 @@ export const simulationTimelineSubView: SubView = { tooltip: "View the simulation timeline with compartment time-series. Click/drag to scrub through frames.", component: SimulationTimelineContent, + renderHeaderAction: () => , }; From bbd432ca889559c24d93b2d375199180a39915a4 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 9 Jan 2026 02:37:16 +0100 Subject: [PATCH 09/16] Update D3 Dependencies and Enhance Y-Axis Scaling in Simulation Timeline: Added d3-array and d3-scale packages, implemented Y-axis scale configuration, and integrated Y-axis component for improved chart rendering. --- libs/@hashintel/petrinaut/package.json | 4 + .../Editor/subviews/simulation-timeline.tsx | 183 ++++++++++++++---- yarn.lock | 76 +++++++- 3 files changed, 229 insertions(+), 34 deletions(-) diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index e404617aa10..d411b048b8b 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -35,6 +35,8 @@ "@mantine/hooks": "8.3.5", "@monaco-editor/react": "4.8.0-rc.3", "@mui/material": "5.18.0", + "d3-array": "3.2.4", + "d3-scale": "4.0.2", "elkjs": "0.11.0", "monaco-editor": "0.55.1", "react-icons": "5.5.0", @@ -49,6 +51,8 @@ "@local/eslint": "0.0.0-private", "@pandacss/dev": "1.4.3", "@types/babel__standalone": "7.1.9", + "@types/d3-array": "3.2.2", + "@types/d3-scale": "4.0.9", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "5.0.4", 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 b250bd28327..765f2b3cff1 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx @@ -1,4 +1,5 @@ import { css } from "@hashintel/ds-helpers/css"; +import { scaleLinear } from "d3-scale"; import { useCallback, useMemo, useRef, useState } from "react"; import { SegmentGroup } from "../../../components/segment-group"; @@ -15,9 +16,34 @@ const containerStyle = css({ gap: "[8px]", }); -const chartContainerStyle = css({ +const chartRowStyle = css({ + display: "flex", flex: "[1]", minHeight: "[60px]", + gap: "[4px]", +}); + +const yAxisStyle = css({ + position: "relative", + display: "flex", + flexDirection: "column", + alignItems: "flex-end", + fontSize: "[10px]", + color: "[#666]", + paddingRight: "[4px]", + minWidth: "[32px]", + userSelect: "none", +}); + +const yAxisTickStyle = css({ + position: "absolute", + right: "[4px]", + lineHeight: "[1]", + transform: "translateY(-50%)", +}); + +const chartContainerStyle = css({ + flex: "[1]", position: "relative", cursor: "pointer", }); @@ -172,6 +198,101 @@ const useCompartmentData = (): CompartmentData[] => { }, [simulation, places, types]); }; +/** + * Represents the Y-axis scale configuration. + */ +interface YAxisScale { + /** The maximum value for the Y-axis (after applying .nice()) */ + yMax: number; + /** Tick values to display on the Y-axis */ + ticks: number[]; + /** Convert a data value to a percentage (0-100) for SVG positioning */ + toPercent: (value: number) => number; +} + +/** + * Computes a nice Y-axis scale using D3's scale utilities. + * Returns tick values that are round numbers appropriate for the data range. + */ +const useYAxisScale = ( + compartmentData: CompartmentData[], + chartType: TimelineChartType, + hiddenPlaces: Set, +): YAxisScale => { + return useMemo(() => { + if (compartmentData.length === 0) { + return { + yMax: 10, + ticks: [0, 5, 10], + toPercent: (value: number) => 100 - (value / 10) * 100, + }; + } + + // Filter to visible data + const visibleData = compartmentData.filter( + (item) => !hiddenPlaces.has(item.placeId), + ); + + let maxValue: number; + + if (chartType === "stacked") { + // For stacked chart, calculate the maximum cumulative value + if (visibleData.length === 0) { + maxValue = 1; + } else { + const frameCount = visibleData[0]?.values.length ?? 0; + let maxCumulative = 0; + for (let frameIdx = 0; frameIdx < frameCount; frameIdx++) { + let cumulative = 0; + for (const data of visibleData) { + cumulative += data.values[frameIdx] ?? 0; + } + maxCumulative = Math.max(maxCumulative, cumulative); + } + maxValue = maxCumulative; + } + } else { + // For run chart, find the maximum individual value + maxValue = Math.max(1, ...visibleData.flatMap((item) => item.values)); + } + + // Use D3 to create a nice scale + const scale = scaleLinear().domain([0, maxValue]).nice(); + const niceDomain = scale.domain(); + const yMax = niceDomain[1] ?? maxValue; + + // Get tick values (aim for 3-5 ticks based on the range) + const ticks = scale.ticks(4); + + return { + yMax, + ticks, + toPercent: (value: number) => 100 - (value / yMax) * 100, + }; + }, [compartmentData, chartType, hiddenPlaces]); +}; + +/** + * Y-axis component that displays tick labels. + */ +const YAxis: React.FC<{ scale: YAxisScale }> = ({ scale }) => { + return ( +
+ {scale.ticks.map((tick) => ( + + {tick} + + ))} +
+ ); +}; + /** * Shared playhead indicator component for timeline charts. */ @@ -256,6 +377,7 @@ const TimelineLegend: React.FC<{ interface ChartProps { compartmentData: CompartmentData[]; legendState: LegendState; + yAxisScale: YAxisScale; } /** @@ -265,6 +387,7 @@ interface ChartProps { const CompartmentTimeSeries: React.FC = ({ compartmentData, legendState, + yAxisScale, }) => { const simulation = useSimulationStore((state) => state.simulation); const setCurrentlyViewedFrame = useSimulationStore( @@ -283,24 +406,15 @@ const CompartmentTimeSeries: React.FC = ({ } 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, + height - (value / yAxisScale.yMax) * height, }; - }, [compartmentData, simulation]); + }, [compartmentData, simulation, yAxisScale.yMax]); // Handle mouse interaction for scrubbing const handleScrub = useCallback( @@ -457,6 +571,7 @@ const CompartmentTimeSeries: React.FC = ({ const StackedAreaChart: React.FC = ({ compartmentData, legendState, + yAxisScale, }) => { const simulation = useSimulationStore((state) => state.simulation); const setCurrentlyViewedFrame = useSimulationStore( @@ -511,23 +626,17 @@ const StackedAreaChart: React.FC = ({ }); } - // Find the max stacked value for scaling - const maxValue = Math.max(1, ...cumulativeAtFrame); - const yMax = Math.ceil(maxValue * 1.1); - return { stackedData: stacked, chartMetrics: { totalFrames, - maxValue, - yMax, xScale: (frameIndex: number, width: number) => (frameIndex / Math.max(1, totalFrames - 1)) * width, yScale: (value: number, height: number) => - height - (value / yMax) * height, + height - (value / yAxisScale.yMax) * height, }, }; - }, [visibleCompartmentData, simulation]); + }, [visibleCompartmentData, simulation, yAxisScale.yMax]); // Handle mouse interaction for scrubbing const handleScrub = useCallback( @@ -691,6 +800,9 @@ const SimulationTimelineContent: React.FC = () => { [hiddenPlaces, hoveredPlaceId], ); + // Compute Y-axis scale based on data and chart type + const yAxisScale = useYAxisScale(compartmentData, chartType, hiddenPlaces); + // Toggle visibility handler const togglePlaceVisibility = useCallback((placeId: string) => { setHiddenPlaces((prev) => { @@ -722,19 +834,24 @@ const SimulationTimelineContent: React.FC = () => { return (
-
- {chartType === "stacked" ? ( - - ) : ( - - )} - +
+ +
+ {chartType === "stacked" ? ( + + ) : ( + + )} + +
Date: Fri, 9 Jan 2026 02:37:59 +0100 Subject: [PATCH 10/16] Update yarn.lock --- yarn.lock | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/yarn.lock b/yarn.lock index 996d2a66896..492009c2850 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19344,14 +19344,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-array@npm:*": - version: 3.2.1 - resolution: "@types/d3-array@npm:3.2.1" - checksum: 10c0/38bf2c778451f4b79ec81a2288cb4312fe3d6449ecdf562970cc339b60f280f31c93a024c7ff512607795e79d3beb0cbda123bb07010167bce32927f71364bca - languageName: node - linkType: hard - -"@types/d3-array@npm:3.2.2": +"@types/d3-array@npm:*, @types/d3-array@npm:3.2.2": version: 3.2.2 resolution: "@types/d3-array@npm:3.2.2" checksum: 10c0/6137cb97302f8a4f18ca22c0560c585cfcb823f276b23d89f2c0c005d72697ec13bca671c08e68b4b0cabd622e3f0e91782ee221580d6774074050be96dd7028 @@ -19520,16 +19513,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-scale@npm:*": - version: 4.0.8 - resolution: "@types/d3-scale@npm:4.0.8" - dependencies: - "@types/d3-time": "npm:*" - checksum: 10c0/57de90e4016f640b83cb960b7e3a0ab3ed02e720898840ddc5105264ffcfea73336161442fdc91895377c2d2f91904d637282f16852b8535b77e15a761c8e99e - languageName: node - linkType: hard - -"@types/d3-scale@npm:4.0.9": +"@types/d3-scale@npm:*, @types/d3-scale@npm:4.0.9": version: 4.0.9 resolution: "@types/d3-scale@npm:4.0.9" dependencies: From d9ff655d8bb6b060337cc87084cb9643c1cf4fa9 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 9 Jan 2026 02:59:20 +0100 Subject: [PATCH 11/16] Implement Tooltip Functionality in Simulation Timeline: Added tooltip component for displaying token counts on hover, enhanced mouse interaction for path highlighting, and updated state management for improved user experience. --- .../Editor/subviews/simulation-timeline.tsx | 377 ++++++++++++++++-- 1 file changed, 334 insertions(+), 43 deletions(-) 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 765f2b3cff1..1d4f436c125 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx @@ -80,6 +80,40 @@ const svgStyle = css({ display: "block", }); +const tooltipStyle = css({ + position: "fixed", + pointerEvents: "none", + backgroundColor: "[rgba(0, 0, 0, 0.85)]", + color: "[white]", + padding: "[6px 10px]", + borderRadius: "[6px]", + fontSize: "[11px]", + lineHeight: "[1.4]", + zIndex: "[1000]", + whiteSpace: "nowrap", + boxShadow: "[0 2px 8px rgba(0, 0, 0, 0.25)]", + transform: "translate(-50%, -100%)", + marginTop: "[-8px]", +}); + +const tooltipLabelStyle = css({ + display: "flex", + alignItems: "center", + gap: "[6px]", +}); + +const tooltipColorDotStyle = css({ + width: "[8px]", + height: "[8px]", + borderRadius: "[50%]", + flexShrink: "[0]", +}); + +const tooltipValueStyle = css({ + fontWeight: "[600]", + marginLeft: "[4px]", +}); + const legendContainerStyle = css({ display: "flex", flexWrap: "wrap", @@ -156,6 +190,20 @@ interface LegendState { hoveredPlaceId: string | null; } +/** + * Tooltip state for displaying token counts on hover. + */ +interface TooltipState { + visible: boolean; + x: number; + y: number; + placeName: string; + color: string; + value: number; + frameIndex: number; + time: number; +} + /** * Hook to extract compartment data from simulation frames. */ @@ -293,6 +341,42 @@ const YAxis: React.FC<{ scale: YAxisScale }> = ({ scale }) => { ); }; +/** + * Tooltip component for displaying token count on hover. + */ +const ChartTooltip: React.FC<{ tooltip: TooltipState | null }> = ({ + tooltip, +}) => { + if (!tooltip?.visible) { + return null; + } + + return ( +
+
+
+ {tooltip.placeName} + {tooltip.value} +
+
+ {tooltip.time.toFixed(3)}s +
+
+ Frame {tooltip.frameIndex} +
+
+ ); +}; + /** * Shared playhead indicator component for timeline charts. */ @@ -378,6 +462,8 @@ interface ChartProps { compartmentData: CompartmentData[]; legendState: LegendState; yAxisScale: YAxisScale; + onTooltipChange: (tooltip: TooltipState | null) => void; + onPlaceHover: (placeId: string | null) => void; } /** @@ -388,6 +474,8 @@ const CompartmentTimeSeries: React.FC = ({ compartmentData, legendState, yAxisScale, + onTooltipChange, + onPlaceHover, }) => { const simulation = useSimulationStore((state) => state.simulation); const setCurrentlyViewedFrame = useSimulationStore( @@ -397,8 +485,16 @@ const CompartmentTimeSeries: React.FC = ({ const chartRef = useRef(null); const isDraggingRef = useRef(false); + // Track locally hovered place (from SVG path hover) + const [localHoveredPlaceId, setLocalHoveredPlaceId] = useState( + null, + ); + const { hiddenPlaces, hoveredPlaceId } = legendState; + // Use local hover if available, otherwise fall back to legend hover + const activeHoveredPlaceId = localHoveredPlaceId ?? hoveredPlaceId; + // Calculate chart dimensions and scales const chartMetrics = useMemo(() => { if (compartmentData.length === 0 || !simulation) { @@ -416,26 +512,94 @@ const CompartmentTimeSeries: React.FC = ({ }; }, [compartmentData, simulation, yAxisScale.yMax]); - // Handle mouse interaction for scrubbing - const handleScrub = useCallback( + // Calculate frame index from mouse position + const getFrameFromEvent = useCallback( (event: React.MouseEvent) => { if (!chartRef.current || !chartMetrics) { - return; + return null; } 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)); + return Math.round(progress * (chartMetrics.totalFrames - 1)); + }, + [chartMetrics], + ); + + // Handle mouse interaction for scrubbing + const handleScrub = useCallback( + (event: React.MouseEvent) => { + const frameIndex = getFrameFromEvent(event); + if (frameIndex !== null) { + setCurrentlyViewedFrame(frameIndex); + } + }, + [getFrameFromEvent, setCurrentlyViewedFrame], + ); + + // Update tooltip based on mouse position and hovered place + const updateTooltip = useCallback( + (event: React.MouseEvent) => { + if (!localHoveredPlaceId || !simulation) { + onTooltipChange(null); + return; + } + + const frameIndex = getFrameFromEvent(event); + if (frameIndex === null) { + onTooltipChange(null); + return; + } + + const placeData = compartmentData.find( + (data) => data.placeId === localHoveredPlaceId, + ); + if (!placeData || hiddenPlaces.has(localHoveredPlaceId)) { + onTooltipChange(null); + return; + } + + const value = placeData.values[frameIndex] ?? 0; + const time = simulation.frames[frameIndex]?.time ?? 0; + + onTooltipChange({ + visible: true, + x: event.clientX, + y: event.clientY, + placeName: placeData.placeName, + color: placeData.color, + value, + frameIndex, + time, + }); + }, + [ + compartmentData, + hiddenPlaces, + localHoveredPlaceId, + simulation, + getFrameFromEvent, + onTooltipChange, + ], + ); - setCurrentlyViewedFrame(frameIndex); + // Handle path hover + const handlePathMouseEnter = useCallback( + (placeId: string) => { + setLocalHoveredPlaceId(placeId); + onPlaceHover(placeId); }, - [chartMetrics, setCurrentlyViewedFrame], + [onPlaceHover], ); + const handlePathMouseLeave = useCallback(() => { + setLocalHoveredPlaceId(null); + onPlaceHover(null); + }, [onPlaceHover]); + const handleMouseDown = useCallback( (event: React.MouseEvent) => { isDraggingRef.current = true; @@ -449,8 +613,9 @@ const CompartmentTimeSeries: React.FC = ({ if (isDraggingRef.current) { handleScrub(event); } + updateTooltip(event); }, - [handleScrub], + [handleScrub, updateTooltip], ); const handleMouseUp = useCallback(() => { @@ -459,7 +624,8 @@ const CompartmentTimeSeries: React.FC = ({ const handleMouseLeave = useCallback(() => { isDraggingRef.current = false; - }, []); + onTooltipChange(null); + }, [onTooltipChange]); // Generate SVG path for a data series const generatePath = useCallback( @@ -527,37 +693,67 @@ const CompartmentTimeSeries: React.FC = ({ {/* Data lines - render non-hovered first, then hovered on top */} {compartmentData .filter((data) => !hiddenPlaces.has(data.placeId)) - .filter((data) => data.placeId !== hoveredPlaceId) + .filter((data) => data.placeId !== activeHoveredPlaceId) .map((data) => ( - - ))} - {/* Render hovered line on top */} - {hoveredPlaceId && - !hiddenPlaces.has(hoveredPlaceId) && - compartmentData - .filter((data) => data.placeId === hoveredPlaceId) - .map((data) => ( + + {/* Visible line */} + {/* Invisible hit area for easier hovering */} + handlePathMouseEnter(data.placeId)} + onMouseLeave={handlePathMouseLeave} /> + + ))} + {/* Render hovered line on top */} + {activeHoveredPlaceId && + !hiddenPlaces.has(activeHoveredPlaceId) && + compartmentData + .filter((data) => data.placeId === activeHoveredPlaceId) + .map((data) => ( + + {/* Visible line */} + + {/* Invisible hit area */} + handlePathMouseEnter(data.placeId)} + onMouseLeave={handlePathMouseLeave} + /> + ))} ); @@ -572,6 +768,8 @@ const StackedAreaChart: React.FC = ({ compartmentData, legendState, yAxisScale, + onTooltipChange, + onPlaceHover, }) => { const simulation = useSimulationStore((state) => state.simulation); const setCurrentlyViewedFrame = useSimulationStore( @@ -581,8 +779,16 @@ const StackedAreaChart: React.FC = ({ const chartRef = useRef(null); const isDraggingRef = useRef(false); + // Track locally hovered place (from SVG path hover) + const [localHoveredPlaceId, setLocalHoveredPlaceId] = useState( + null, + ); + const { hiddenPlaces, hoveredPlaceId } = legendState; + // Use local hover if available, otherwise fall back to legend hover + const activeHoveredPlaceId = localHoveredPlaceId ?? hoveredPlaceId; + // Filter visible compartment data const visibleCompartmentData = useMemo(() => { return compartmentData.filter((data) => !hiddenPlaces.has(data.placeId)); @@ -638,26 +844,95 @@ const StackedAreaChart: React.FC = ({ }; }, [visibleCompartmentData, simulation, yAxisScale.yMax]); - // Handle mouse interaction for scrubbing - const handleScrub = useCallback( + // Calculate frame index from mouse position + const getFrameFromEvent = useCallback( (event: React.MouseEvent) => { if (!chartRef.current || !chartMetrics) { - return; + return null; } 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)); + return Math.round(progress * (chartMetrics.totalFrames - 1)); + }, + [chartMetrics], + ); - setCurrentlyViewedFrame(frameIndex); + // Handle mouse interaction for scrubbing + const handleScrub = useCallback( + (event: React.MouseEvent) => { + const frameIndex = getFrameFromEvent(event); + if (frameIndex !== null) { + setCurrentlyViewedFrame(frameIndex); + } }, - [chartMetrics, setCurrentlyViewedFrame], + [getFrameFromEvent, setCurrentlyViewedFrame], ); + // Update tooltip based on mouse position and hovered place + const updateTooltip = useCallback( + (event: React.MouseEvent) => { + if (!localHoveredPlaceId || !simulation) { + onTooltipChange(null); + return; + } + + const frameIndex = getFrameFromEvent(event); + if (frameIndex === null) { + onTooltipChange(null); + return; + } + + // For stacked chart, get the original (non-stacked) value + const placeData = compartmentData.find( + (data) => data.placeId === localHoveredPlaceId, + ); + if (!placeData || hiddenPlaces.has(localHoveredPlaceId)) { + onTooltipChange(null); + return; + } + + const value = placeData.values[frameIndex] ?? 0; + const time = simulation.frames[frameIndex]?.time ?? 0; + + onTooltipChange({ + visible: true, + x: event.clientX, + y: event.clientY, + placeName: placeData.placeName, + color: placeData.color, + value, + frameIndex, + time, + }); + }, + [ + compartmentData, + hiddenPlaces, + localHoveredPlaceId, + simulation, + getFrameFromEvent, + onTooltipChange, + ], + ); + + // Handle path hover + const handlePathMouseEnter = useCallback( + (placeId: string) => { + setLocalHoveredPlaceId(placeId); + onPlaceHover(placeId); + }, + [onPlaceHover], + ); + + const handlePathMouseLeave = useCallback(() => { + setLocalHoveredPlaceId(null); + onPlaceHover(null); + }, [onPlaceHover]); + const handleMouseDown = useCallback( (event: React.MouseEvent) => { isDraggingRef.current = true; @@ -671,8 +946,9 @@ const StackedAreaChart: React.FC = ({ if (isDraggingRef.current) { handleScrub(event); } + updateTooltip(event); }, - [handleScrub], + [handleScrub, updateTooltip], ); const handleMouseUp = useCallback(() => { @@ -681,7 +957,8 @@ const StackedAreaChart: React.FC = ({ const handleMouseLeave = useCallback(() => { isDraggingRef.current = false; - }, []); + onTooltipChange(null); + }, [onTooltipChange]); // Generate SVG path for a stacked area const generateAreaPath = useCallback( @@ -762,8 +1039,8 @@ const StackedAreaChart: React.FC = ({ {/* Stacked areas - render from bottom to top */} {stackedData.map((data) => { - const isHovered = hoveredPlaceId === data.placeId; - const isDimmed = hoveredPlaceId && !isHovered; + const isHovered = activeHoveredPlaceId === data.placeId; + const isDimmed = activeHoveredPlaceId && !isHovered; return ( = ({ strokeWidth="0.5" vectorEffect="non-scaling-stroke" opacity={isDimmed ? 0.3 : isHovered ? 1 : 0.7} - style={{ transition: "opacity 0.15s ease" }} + style={{ transition: "opacity 0.15s ease", cursor: "pointer" }} + onMouseEnter={() => handlePathMouseEnter(data.placeId)} + onMouseLeave={handlePathMouseLeave} /> ); })} @@ -795,6 +1074,9 @@ const SimulationTimelineContent: React.FC = () => { const [hiddenPlaces, setHiddenPlaces] = useState>(new Set()); const [hoveredPlaceId, setHoveredPlaceId] = useState(null); + // Tooltip state + const [tooltip, setTooltip] = useState(null); + const legendState: LegendState = useMemo( () => ({ hiddenPlaces, hoveredPlaceId }), [hiddenPlaces, hoveredPlaceId], @@ -820,6 +1102,10 @@ const SimulationTimelineContent: React.FC = () => { setHoveredPlaceId(placeId); }, []); + const handleTooltipChange = useCallback((newTooltip: TooltipState | null) => { + setTooltip(newTooltip); + }, []); + const totalFrames = simulation?.frames.length ?? 0; if (compartmentData.length === 0 || totalFrames === 0) { @@ -842,12 +1128,16 @@ const SimulationTimelineContent: React.FC = () => { compartmentData={compartmentData} legendState={legendState} yAxisScale={yAxisScale} + onTooltipChange={handleTooltipChange} + onPlaceHover={handleHover} /> ) : ( )} @@ -860,6 +1150,7 @@ const SimulationTimelineContent: React.FC = () => { onToggleVisibility={togglePlaceVisibility} onHover={handleHover} /> +
); }; From fe16268537b26e9b53b32e650cce048dff207f86 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 9 Jan 2026 03:19:23 +0100 Subject: [PATCH 12/16] Format --- .../src/core/simulation/build-simulation.ts | 28 ++++---- .../petrinaut/src/core/types/simulation.ts | 6 +- .../petrinaut/src/state/simulation-store.ts | 64 +++++++++---------- .../views/Editor/panels/BottomPanel/panel.tsx | 2 +- 4 files changed, 50 insertions(+), 50 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.ts b/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.ts index 668f9c55888..ef558cb31ad 100644 --- a/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.ts +++ b/libs/@hashintel/petrinaut/src/core/simulation/build-simulation.ts @@ -20,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; @@ -28,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; @@ -66,7 +66,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { // 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])); @@ -75,14 +75,14 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { const defaultParameterValues = deriveDefaultParameterValues(sdcpn.parameters); const parameterValues = mergeParameterValues( inputParameterValues, - defaultParameterValues + 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`, ); } } @@ -94,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}`, ); } } @@ -108,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; @@ -120,7 +120,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { try { const fn = compileUserCode<[Record[], ParameterValues]>( code, - "Dynamics" + "Dynamics", ); differentialEquationFns.set(place.id, fn as DifferentialEquationFn); } catch (error) { @@ -128,7 +128,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { `Failed to compile differential equation for place \`${ place.name }\`:\n\n${error instanceof Error ? error.message : String(error)}`, - place.id + place.id, ); } } @@ -146,7 +146,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { `Failed to compile Lambda function for transition \`${ transition.name }\`:\n\n${error instanceof Error ? error.message : String(error)}`, - transition.id + transition.id, ); } } @@ -166,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; } @@ -181,7 +181,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { `Failed to compile transition kernel for transition \`${ transition.name }\`:\n\n${error instanceof Error ? error.message : String(error)}`, - transition.id + transition.id, ); } } @@ -238,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/types/simulation.ts b/libs/@hashintel/petrinaut/src/core/types/simulation.ts index fa3316381c0..06a67a567e7 100644 --- a/libs/@hashintel/petrinaut/src/core/types/simulation.ts +++ b/libs/@hashintel/petrinaut/src/core/types/simulation.ts @@ -4,17 +4,17 @@ 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 = { diff --git a/libs/@hashintel/petrinaut/src/state/simulation-store.ts b/libs/@hashintel/petrinaut/src/state/simulation-store.ts index d17ff7ec76d..9c9e2323b80 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 @@ -117,7 +117,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { return { initialMarking: newMarking }; }, false, - { type: "setInitialMarking", placeId, marking } + { type: "setInitialMarking", placeId, marking }, ), setParameterValue: (parameterId, value) => @@ -129,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 }), @@ -139,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) @@ -151,7 +151,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { return { parameterValues }; }, false, - "initializeParameterValuesFromDefaults" + "initializeParameterValuesFromDefaults", ), initialize: ({ seed, dt }) => @@ -160,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.", ); } @@ -177,7 +177,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { ? firstDiagnostic.messageText : ts.flattenDiagnosticMessageText( firstDiagnostic.messageText, - "\n" + "\n", ); return { @@ -221,7 +221,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { } }, false, - "initialize" + "initialize", ), step: () => @@ -229,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.", ); } @@ -272,7 +272,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { } }, false, - "step" + "step", ), run: () => @@ -280,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.", ); } @@ -319,7 +319,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { set( { _runTimeoutId: timeoutId }, false, - "run:scheduleNext" + "run:scheduleNext", ); } catch { // Error is already handled by step() @@ -329,7 +329,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { const initialTimeoutId = setTimeout( executeStep, - 20 + 20, ) as unknown as number; return { state: "Running", _runTimeoutId: initialTimeoutId }; } @@ -337,7 +337,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { return { state: "Running" }; }, false, - "run" + "run", ), pause: () => @@ -354,7 +354,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { }; }, false, - "pause" + "pause", ), reset: () => @@ -368,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 @@ -389,7 +389,7 @@ export function createSimulationStore(getSDCPN: () => { sdcpn: SDCPN }) { }; }, false, - "reset" + "reset", ), setState: (newState) => @@ -398,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) => @@ -425,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: () => { @@ -452,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 f603ae3fc19..5d967f3dfee 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx @@ -71,7 +71,7 @@ const closeButtonStyle = css({ export const BottomPanel: React.FC = () => { const isOpen = useEditorStore((state) => state.isBottomPanelOpen); const setBottomPanelOpen = useEditorStore( - (state) => state.setBottomPanelOpen + (state) => state.setBottomPanelOpen, ); const isLeftSidebarOpen = useEditorStore((state) => state.isLeftSidebarOpen); const leftSidebarWidth = useEditorStore((state) => state.leftSidebarWidth); From 92364a9bbc341f0a77282033028c9e1a469efd83 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 9 Jan 2026 03:24:40 +0100 Subject: [PATCH 13/16] Add changeset --- .changeset/four-bugs-collect.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/four-bugs-collect.md diff --git a/.changeset/four-bugs-collect.md b/.changeset/four-bugs-collect.md new file mode 100644 index 00000000000..ebc713b0af2 --- /dev/null +++ b/.changeset/four-bugs-collect.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Add Simulation Timeline Visualizer From c3062f21ea639b3709129a8c11dcc8f183f1ca17 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 9 Jan 2026 03:26:54 +0100 Subject: [PATCH 14/16] Update Y-Axis Scaling Logic in Simulation Timeline: Ensure maxValue is at least 1 to prevent invalid scaling in charts. --- .../petrinaut/src/views/Editor/subviews/simulation-timeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1d4f436c125..46d2c09e39f 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-timeline.tsx @@ -297,7 +297,7 @@ const useYAxisScale = ( } maxCumulative = Math.max(maxCumulative, cumulative); } - maxValue = maxCumulative; + maxValue = Math.max(1, maxCumulative); } } else { // For run chart, find the maximum individual value From 064b81188d23cd6a916ef32b5b1e2891d1331a40 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 9 Jan 2026 03:28:38 +0100 Subject: [PATCH 15/16] Enhance Bottom Panel Behavior: Update simulation state handling to automatically switch to diagnostics tab when simulation stops, improving user experience during transitions. --- .../src/views/Editor/panels/BottomPanel/panel.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 5d967f3dfee..82eda5a1e96 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx @@ -96,7 +96,8 @@ export const BottomPanel: React.FC = () => { ? [...BOTTOM_PANEL_SUBVIEWS, ...SIMULATION_ONLY_SUBVIEWS] : BOTTOM_PANEL_SUBVIEWS; - // Automatically open bottom panel and switch to timeline when simulation starts + // Automatically open bottom panel and switch to timeline when simulation starts, + // and fall back to diagnostics when simulation stops useEffect(() => { const wasActive = prevSimulationActiveRef.current; prevSimulationActiveRef.current = isSimulationActive; @@ -106,7 +107,17 @@ export const BottomPanel: React.FC = () => { setBottomPanelOpen(true); setActiveTab("simulation-timeline"); } - }, [isSimulationActive, setBottomPanelOpen, setActiveTab]); + + // Simulation just stopped (transition from active to inactive) + // If the current tab is simulation-only, fall back to diagnostics + if ( + !isSimulationActive && + wasActive && + activeTab === "simulation-timeline" + ) { + setActiveTab("diagnostics"); + } + }, [isSimulationActive, setBottomPanelOpen, setActiveTab, activeTab]); // Handler for tab change that casts string to BottomPanelTab const handleTabChange = useCallback( From 0658fe8eb5038c812dec525efed4c17519c50e2c Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 9 Jan 2026 13:40:01 +0100 Subject: [PATCH 16/16] Update SegmentGroup styles --- .../petrinaut/src/components/segment-group.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/segment-group.tsx b/libs/@hashintel/petrinaut/src/components/segment-group.tsx index 3dc213c255d..737b07c1bc0 100644 --- a/libs/@hashintel/petrinaut/src/components/segment-group.tsx +++ b/libs/@hashintel/petrinaut/src/components/segment-group.tsx @@ -11,12 +11,12 @@ const containerStyle = cva({ variants: { size: { md: { - borderRadius: "[12px]", + borderRadius: "[18px]", padding: "[4px]", }, sm: { borderRadius: "[12px]", - padding: "[2px]", + padding: "[3px]", }, }, }, @@ -38,7 +38,7 @@ const indicatorStyle = cva({ variants: { size: { md: { - borderRadius: "[8px]", + borderRadius: "[14px]", }, sm: { borderRadius: "[10px]", @@ -69,12 +69,12 @@ const itemStyle = cva({ md: { fontSize: "[13px]", borderRadius: "radius.6", - padding: "[4px 6px]", + padding: "[4px 8px]", }, sm: { fontSize: "[11px]", borderRadius: "radius.4", - padding: "[2px 6px]", + padding: "[1px 8px]", }, }, },