diff --git a/.changeset/petrinaut-preview-quick-simulation.md b/.changeset/petrinaut-preview-quick-simulation.md new file mode 100644 index 00000000000..26bd2ce8539 --- /dev/null +++ b/.changeset/petrinaut-preview-quick-simulation.md @@ -0,0 +1,8 @@ +--- +"@hashintel/petrinaut": patch +--- + +Add an optional Quick Simulation mode to `PetrinautPreview`: hosts supply +precompiled HIR artifacts and bounded run settings, and the preview gains +scenario configuration, compact playback controls, and an expandable timeline +reusing the editor's simulation components. diff --git a/libs/@hashintel/petrinaut/docs/preview.md b/libs/@hashintel/petrinaut/docs/preview.md index de7f5a82660..9663104ca1d 100644 --- a/libs/@hashintel/petrinaut/docs/preview.md +++ b/libs/@hashintel/petrinaut/docs/preview.md @@ -21,6 +21,31 @@ the canvas so the canvas remains usable. Use the compact net selector to move between the root net and its subnets. The canvas, selection, and inspector update together when you change nets. +## Quick Simulation + +Some embeds include Quick Simulation. Open **Quick Simulation** in the header +to choose one of the model's named scenarios and adjust the parameters the +embed makes available. The first scenario is selected when the embed does not +specify a valid one. There is no "No scenario" option in this surface. + +The selected scenario's initial marking appears on the shared canvas before a +run starts. Press **Play** in the compact bar at the bottom to start the run; +the preview never starts it automatically. The same bar lets you pause, reset, +choose from the playback speeds allowed by the embed, and scrub through the +frames that have been produced. + +As soon as frames arrive, the compact bar expands upward to show a small +timeline. The timeline follows playback, lets you hover to inspect a series, +and supports clicking or dragging to scrub the canvas to another frame. It +collapses again when you reset the simulation. This expansion is animated when +Petrinaut animations are enabled and reduced-motion is not requested. + +Quick Simulation uses a fixed time step and time horizon selected by the embed. +It intentionally does not expose the full Simulate workspace, timeline-series +configuration, metric authoring, or controls for changing those run settings. +Scenario parameters are limited to the safe ranges chosen for that embedded +example. + ## Navigation and embedding The application hosting `PetrinautPreview` owns navigation. It can reflect the @@ -37,5 +62,6 @@ markup, content-security policy, sandbox permissions, and any other embedding or security headers. The preview intentionally omits source code, editing tools, mode and document -management controls, experiments, optimizations, and the AI assistant. Use the -full Petrinaut interface when those workflows are needed. +management controls, experiments, optimizations, and the AI assistant. Quick +Simulation is available only when the embed supplies it. Use the full Petrinaut +interface when the omitted workflows are needed. diff --git a/libs/@hashintel/petrinaut/src/preview.ts b/libs/@hashintel/petrinaut/src/preview.ts index 4d8c2627925..7c3758ec213 100644 --- a/libs/@hashintel/petrinaut/src/preview.ts +++ b/libs/@hashintel/petrinaut/src/preview.ts @@ -8,5 +8,6 @@ export type { PetrinautPreviewNavigationState } from "./ui/preview/navigation-adapter"; export { PetrinautPreview } from "./ui/preview/petrinaut-preview"; export type { PetrinautPreviewProps } from "./ui/preview/petrinaut-preview"; +export type { PetrinautPreviewQuickSimulation } from "./ui/preview/quick-simulation"; export type { PetrinautNavigationController } from "./react/navigation"; export type { ViewportAction } from "./ui/types/viewport-action"; diff --git a/libs/@hashintel/petrinaut/src/react/index.ts b/libs/@hashintel/petrinaut/src/react/index.ts index 645e8aff767..7a63fe159aa 100644 --- a/libs/@hashintel/petrinaut/src/react/index.ts +++ b/libs/@hashintel/petrinaut/src/react/index.ts @@ -100,6 +100,11 @@ export type { NotificationTone, } from "./notifications/context"; export { NotificationsProvider } from "./notifications/provider"; +export { SimulationProvider } from "./simulation/provider"; +export type { + SimulationCompiler, + SimulationProviderProps, +} from "./simulation/provider"; // --- Error tracker DI --- export { ErrorTrackerContext } from "./error-tracker-context"; diff --git a/libs/@hashintel/petrinaut/src/react/petrinaut-provider-layers.tsx b/libs/@hashintel/petrinaut/src/react/petrinaut-provider-layers.tsx index e45863d974e..2fd68cd56eb 100644 --- a/libs/@hashintel/petrinaut/src/react/petrinaut-provider-layers.tsx +++ b/libs/@hashintel/petrinaut/src/react/petrinaut-provider-layers.tsx @@ -12,7 +12,7 @@ import { UndoRedoContext } from "./state/undo-redo-context"; import { UserSettingsProvider } from "./state/user-settings-provider"; import { useHandleHistoryAsUndoRedo } from "./use-handle-history-as-undo-redo"; -import type { Petrinaut } from "@hashintel/petrinaut-core"; +import type { Petrinaut, PlaybackSpeed } from "@hashintel/petrinaut-core"; import type { ReactNode } from "react"; export type PetrinautDocumentProviderProps = { @@ -57,6 +57,7 @@ export const PetrinautDocumentProvider: React.FC< export type PetrinautCanvasProviderProps = { children: ReactNode; + initialPlaybackSpeed?: PlaybackSpeed; }; /** @@ -66,8 +67,8 @@ export type PetrinautCanvasProviderProps = { */ export const PetrinautCanvasProvider: React.FC< PetrinautCanvasProviderProps -> = ({ children }) => ( - +> = ({ children, initialPlaybackSpeed }) => ( + {children} diff --git a/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx index 214baa37342..61d67800e35 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx @@ -13,6 +13,7 @@ import { import { DEFAULT_COMPUTE_MODE, PlaybackContext, + type PlaybackSpeed, type PlaybackContextValue, } from "./context"; import { PlaybackProvider } from "./provider"; @@ -129,13 +130,15 @@ const PlaybackContextConsumer = ({ // Component wrapper for testing - defined outside to avoid closure issues with React Compiler const TestWrapper = ({ simContext, + initialSpeed, onContextValue, }: { simContext: SimulationContextValue; + initialSpeed?: PlaybackSpeed; onContextValue: (value: PlaybackContextValue) => void; }) => ( - + @@ -145,7 +148,10 @@ const TestWrapper = ({ * Renders the PlaybackProvider with a mock SimulationContext and returns * a function to get the current PlaybackContext value. */ -function renderPlaybackProvider(simulationContext: SimulationContextValue): { +function renderPlaybackProvider( + simulationContext: SimulationContextValue, + initialSpeed?: PlaybackSpeed, +): { getPlaybackValue: () => PlaybackContextValue; renderResult: RenderResult; rerender: (newSimulationContext: SimulationContextValue) => void; @@ -159,6 +165,7 @@ function renderPlaybackProvider(simulationContext: SimulationContextValue): { const renderResult = render( , ); @@ -170,6 +177,7 @@ function renderPlaybackProvider(simulationContext: SimulationContextValue): { renderResult.rerender( , ); @@ -227,6 +235,16 @@ describe("PlaybackProvider", () => { expect(playbackValue.isComputeAvailable).toBe(true); }); + it("should honor a host-provided initial playback speed", () => { + const simulationContext = createMockSimulationContext(); + const { getPlaybackValue } = renderPlaybackProvider( + simulationContext, + 10, + ); + + expect(getPlaybackValue().playbackSpeed).toBe(10); + }); + it("should have viewOnly available when there are frames", () => { const simulationContext = createMockSimulationContext( { diff --git a/libs/@hashintel/petrinaut/src/react/playback/provider.tsx b/libs/@hashintel/petrinaut/src/react/playback/provider.tsx index b629dbf35e7..828c93e529f 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/playback/provider.tsx @@ -62,10 +62,13 @@ function toComputePlayMode(mode: PlayMode): ComputePlayMode { return mode; } -type PlaybackProviderProps = React.PropsWithChildren; +type PlaybackProviderProps = React.PropsWithChildren<{ + initialSpeed?: PlaybackSpeed; +}>; export const PlaybackProvider: React.FC = ({ children, + initialSpeed, }) => { const { dt, @@ -84,7 +87,10 @@ export const PlaybackProvider: React.FC = ({ // Pure timing model lives in /core. The provider drives ticks via rAF and // coordinates simulation lifecycle (init / run / pause / ack / backpressure). const [playback] = useState(() => - createPlayback({ mode: playMode }), + createPlayback({ + mode: playMode, + ...(initialSpeed === undefined ? {} : { speed: initialSpeed }), + }), ); // Playback only owns in-memory state; its rAF and store subscriptions are // cleaned up by their respective effects. Disposing this handle from an diff --git a/libs/@hashintel/petrinaut/src/react/simulation/provider.test.ts b/libs/@hashintel/petrinaut/src/react/simulation/provider.test.ts new file mode 100644 index 00000000000..944eba45def --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/simulation/provider.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vitest"; + +import { getEffectiveSelectedScenarioId } from "./provider"; + +import type { Scenario } from "@hashintel/petrinaut-core"; + +const scenarios = [{ id: "first" }, { id: "second" }] as Scenario[]; + +describe("effective simulation scenario", () => { + test("preserves the full editor's explicit no-scenario selection", () => { + expect(getEffectiveSelectedScenarioId(scenarios, null)).toBeNull(); + }); + + test("defaults missing and stale selections to the first scenario", () => { + expect(getEffectiveSelectedScenarioId(scenarios, undefined)).toBe("first"); + expect(getEffectiveSelectedScenarioId(scenarios, "stale")).toBe("first"); + }); + + test("defaults an explicit no-scenario selection when one is required", () => { + expect(getEffectiveSelectedScenarioId(scenarios, null, true)).toBe("first"); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx index c602a3adba4..e81f46dc2fc 100644 --- a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx @@ -10,9 +10,15 @@ import { compileScenario, synthesizeAdHocScenario, type AdHocScenarioState, + type AdHocSynthesisContext, + type HirCompileResult, + type PetrinautExtensionSettings, type ReadableStore, type Scenario, type ScenarioCompilationError, + type ScenarioHir, + type ScenarioLoweringInput, + type SDCPN, type Simulation, type SimulationState as CoreSimulationState, type WorkerFactory, @@ -80,6 +86,8 @@ function getScenarioParameterDefaults( } function createInitialStateValues(options?: { + dt?: number; + maxTime?: number | null; selectedScenarioId?: string | null; }): SimulationStateValues { return { @@ -88,16 +96,17 @@ function createInitialStateValues(options?: { selectedScenarioId: options?.selectedScenarioId, scenarioParameterValues: {}, adHocScenario: null, - dt: 0.01, - maxTime: null, + dt: options?.dt ?? 0.01, + maxTime: options?.maxTime ?? null, }; } -function getEffectiveSelectedScenarioId( +export function getEffectiveSelectedScenarioId( scenarios: readonly Scenario[] | undefined, selectedScenarioId: string | null | undefined, + requireScenario = false, ): string | null { - if (selectedScenarioId === null) { + if (selectedScenarioId === null && !requireScenario) { return null; } @@ -157,7 +166,22 @@ function mapCoreState(status: CoreSimulationState | null): SimulationState { } } -type SimulationProviderProps = React.PropsWithChildren<{ +/** Compilation surface consumed by simulation without coupling it to an LSP. */ +export type SimulationCompiler = { + requestHirArtifacts: ( + sdcpn: SDCPN, + extensions?: PetrinautExtensionSettings, + ) => Promise; + requestScenarioHir: ( + scenario: ScenarioLoweringInput, + /** The net context an `adhoc` initial state synthesizes against. */ + adHocContext?: AdHocSynthesisContext, + /** Lets a host with precompiled artifacts look the scenario up. */ + scenarioId?: string, + ) => Promise; +}; + +export type SimulationProviderProps = React.PropsWithChildren<{ /** * Factory that produces the simulation worker. Hosts can plug in their own * worker bundling — e.g. via Vite's `?worker` directive against the package @@ -169,16 +193,37 @@ type SimulationProviderProps = React.PropsWithChildren<{ * blob URL (e.g. some production builds against the dist output). */ workerFactory?: WorkerFactory; + /** + * Optional compiler override. The Preview supplies build-time artifacts so + * it can reuse the simulation/playback providers without mounting an LSP. + */ + compiler?: SimulationCompiler; + initialConfiguration?: { + dt?: number; + maxTime?: number | null; + }; + /** + * Require one of the model's named scenarios. Missing, explicit-none, and + * stale selections resolve to the first scenario and are normalized back + * through controlled navigation. The full editor keeps this disabled so + * its existing "No scenario" workflow is unchanged. + */ + requireScenario?: boolean; }>; export const SimulationProvider: React.FC = ({ children, + compiler, + initialConfiguration, + requireScenario = false, workerFactory, }) => { const sdcpnContext = use(SDCPNContext); - const { requestHirArtifacts, requestScenarioHir } = use( - LanguageClientContext, - ); + const languageClient = use(LanguageClientContext); + // The language client satisfies the compiler surface as is: it ignores the + // scenario id that hosts with precompiled artifacts key their lookups by. + const activeCompiler: SimulationCompiler = compiler ?? languageClient; + const { requestHirArtifacts, requestScenarioHir } = activeCompiler; const navigation = usePetrinautNavigation(); const { extensions, petriNetDefinition } = sdcpnContext; const { addNotification } = use(NotificationsContext); @@ -194,11 +239,13 @@ export const SimulationProvider: React.FC = ({ const effectiveSelectedScenarioId = getEffectiveSelectedScenarioId( petriNetDefinition.scenarios, requestedScenarioId, + requireScenario, ); // Configuration state (not managed by the simulation handle) const [stateValues, setStateValues] = useState(() => createInitialStateValues({ + ...initialConfiguration, selectedScenarioId: navigation.state.scenarioId, }), ); @@ -486,6 +533,7 @@ export const SimulationProvider: React.FC = ({ places: sdcpn.places, types: sdcpn.types, }, + scenarioToCompile.scenario.id, ); if (initializationGenerationRef.current !== generation) { return; @@ -653,17 +701,31 @@ export const SimulationProvider: React.FC = ({ const simulationState = mapCoreState(simulation ? coreStatus : null); const totalFrames = frameSummary.count; useEffect(() => { - if ( + const shouldNormalizeRequiredScenario = + requireScenario && + effectiveSelectedScenarioId !== null && + requestedScenarioId !== effectiveSelectedScenarioId; + const shouldNormalizeStaleOptionalScenario = + !requireScenario && requestedScenarioId !== undefined && requestedScenarioId !== null && - requestedScenarioId !== effectiveSelectedScenarioId + requestedScenarioId !== effectiveSelectedScenarioId; + + if ( + shouldNormalizeRequiredScenario || + shouldNormalizeStaleOptionalScenario ) { navigation.navigate( { scenarioId: effectiveSelectedScenarioId }, { cause: "normalization", action: "scenario" }, ); } - }, [effectiveSelectedScenarioId, navigation, requestedScenarioId]); + }, [ + effectiveSelectedScenarioId, + navigation, + requestedScenarioId, + requireScenario, + ]); const effectiveScenarioParameterValues = stateValues.selectedScenarioId === undefined || stateValues.selectedScenarioId === effectiveSelectedScenarioId @@ -703,12 +765,17 @@ export const SimulationProvider: React.FC = ({ const scenarioHirState = useScenarioHir( selectedScenario ?? (adHocSynthesized?.ok ? adHocSynthesized.scenario : undefined), - // A persisted ad-hoc scenario synthesizes in the worker against the - // net context; the quick-sim definition was synthesized above already. { - netParameters: extensions.parameters ? petriNetDefinition.parameters : [], - places: petriNetDefinition.places, - types: petriNetDefinition.types, + requestScenarioHir, + // A persisted ad-hoc scenario synthesizes in the worker against the + // net context; the quick-sim definition was synthesized above already. + adHocContext: { + netParameters: extensions.parameters + ? petriNetDefinition.parameters + : [], + places: petriNetDefinition.places, + types: petriNetDefinition.types, + }, }, ); diff --git a/libs/@hashintel/petrinaut/src/react/simulation/use-scenario-hir.ts b/libs/@hashintel/petrinaut/src/react/simulation/use-scenario-hir.ts index f0faad75517..e6fb60167d4 100644 --- a/libs/@hashintel/petrinaut/src/react/simulation/use-scenario-hir.ts +++ b/libs/@hashintel/petrinaut/src/react/simulation/use-scenario-hir.ts @@ -84,6 +84,16 @@ const loweringKey = ( const PENDING: ScenarioHirState = { hir: null, error: null }; +/** + * Lowers one scenario. The language client is the default; a host with + * precompiled artifacts supplies its own and looks the scenario up by id. + */ +export type ScenarioHirRequest = ( + input: ScenarioLoweringInput, + adHocContext?: AdHocSynthesisContext, + scenarioId?: string, +) => Promise; + /** * Lowers a scenario's expressions and code-mode body to HIR via the language * worker (where the TypeScript compiler lives), so `compileScenario` can @@ -94,10 +104,16 @@ const PENDING: ScenarioHirState = { hir: null, error: null }; */ export function useScenarioHir( scenario: Scenario | undefined, - /** The net context an `adhoc` initial state synthesizes against. */ - adHocContext?: AdHocSynthesisContext, + options?: { + requestScenarioHir?: ScenarioHirRequest; + /** The net context an `adhoc` initial state synthesizes against. */ + adHocContext?: AdHocSynthesisContext; + }, ): ScenarioHirState { - const { requestScenarioHir } = use(LanguageClientContext); + const languageClient = use(LanguageClientContext); + const requestScenarioHir: ScenarioHirRequest = + options?.requestScenarioHir ?? languageClient.requestScenarioHir; + const adHocContext = options?.adHocContext; const key = scenario ? loweringKey(scenario, adHocContext) : null; const [entry, setEntry] = useState<{ @@ -111,7 +127,7 @@ export function useScenarioHir( } let cancelled = false; const payload = JSON.parse(key) as LoweringPayload; - requestScenarioHir(payload.scenario, payload.adHocContext) + requestScenarioHir(payload.scenario, payload.adHocContext, scenario?.id) .then((hir) => { if (!cancelled) { setEntry({ key, state: { hir, error: null } }); @@ -131,7 +147,7 @@ export function useScenarioHir( return () => { cancelled = true; }; - }, [key, requestScenarioHir]); + }, [key, requestScenarioHir, scenario?.id]); if (key === null) { return PENDING; diff --git a/libs/@hashintel/petrinaut/src/ui/preview/navigation-adapter.test.ts b/libs/@hashintel/petrinaut/src/ui/preview/navigation-adapter.test.ts index 1d38ee8cb67..e3788ce8488 100644 --- a/libs/@hashintel/petrinaut/src/ui/preview/navigation-adapter.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/preview/navigation-adapter.test.ts @@ -31,6 +31,26 @@ describe("Preview navigation adapter", () => { }); }); + test("pins Quick Simulation Preview to simulate mode", () => { + const controller: PetrinautNavigationController = + { + state: { + scenarioId: undefined, + subnetId: null, + selection: [], + }, + onNavigate: vi.fn(), + }; + + expect( + createPreviewNavigationAdapter(controller, "simulate").state, + ).toEqual({ + ...defaultPetrinautNavigationState, + mode: "simulate", + simulateView: "scenarios", + }); + }); + test("projects full-state updaters back onto the Preview contract", () => { const onNavigate = vi.fn< diff --git a/libs/@hashintel/petrinaut/src/ui/preview/navigation-adapter.ts b/libs/@hashintel/petrinaut/src/ui/preview/navigation-adapter.ts index 82309615587..59169ed8e81 100644 --- a/libs/@hashintel/petrinaut/src/ui/preview/navigation-adapter.ts +++ b/libs/@hashintel/petrinaut/src/ui/preview/navigation-adapter.ts @@ -12,9 +12,10 @@ export type PetrinautPreviewNavigationState = Pick< const toFullNavigationState = ( state: Readonly, + mode: "edit" | "simulate", ): PetrinautNavigationState => ({ ...defaultPetrinautNavigationState, - mode: "edit", + mode, simulateView: "scenarios", scenarioId: state.scenarioId, subnetId: state.subnetId, @@ -32,12 +33,14 @@ export const toPreviewNavigationState = ( /** Adapts Preview's deliberately smaller URL contract to the shared providers. */ export const createPreviewNavigationAdapter = ( controller: PetrinautNavigationController, + mode: "edit" | "simulate" = "edit", ): PetrinautNavigationController => ({ - state: toFullNavigationState(controller.state), + state: toFullNavigationState(controller.state, mode), historyPolicy: controller.historyPolicy, onNavigate: (update, options) => { controller.onNavigate( - (state) => toPreviewNavigationState(update(toFullNavigationState(state))), + (state) => + toPreviewNavigationState(update(toFullNavigationState(state, mode))), options, ); }, diff --git a/libs/@hashintel/petrinaut/src/ui/preview/petrinaut-preview.tsx b/libs/@hashintel/petrinaut/src/ui/preview/petrinaut-preview.tsx index 13a9a58b614..2bc1863999a 100644 --- a/libs/@hashintel/petrinaut/src/ui/preview/petrinaut-preview.tsx +++ b/libs/@hashintel/petrinaut/src/ui/preview/petrinaut-preview.tsx @@ -29,10 +29,12 @@ import { PetrinautNavigationProvider, type PetrinautNavigationController, } from "../../react/navigation"; +import { NotificationsProvider } from "../../react/notifications/provider"; import { PetrinautCanvasProvider, PetrinautDocumentProvider, } from "../../react/petrinaut-provider-layers"; +import { SimulationProvider } from "../../react/simulation/provider"; import { SDCPNView } from "../views/SDCPN/sdcpn-view"; import { PetrinautPresentationProvider } from "../views/shared/presentation-context"; import { @@ -40,7 +42,17 @@ import { type PetrinautPreviewNavigationState, } from "./navigation-adapter"; import { PreviewNetNavigation } from "./preview-net-navigation"; +import { + PreviewSimulationConfiguration, + PreviewSimulationPlaybackControls, +} from "./preview-quick-simulation-controls"; import { PreviewPropertiesPanel } from "./properties-panel"; +import { + createPreviewSimulationCompiler, + resolvePreviewPlaybackOptions, + type PetrinautPreviewQuickSimulation, + validatePreviewQuickSimulation, +} from "./quick-simulation"; import type { NetManagement } from "../../react/net-management-context"; import type { ViewportAction } from "../types/viewport-action"; @@ -103,6 +115,9 @@ const previewBadgeStyle = css({ fontWeight: "semibold", textTransform: "uppercase", letterSpacing: "[0.4px]", + "@media (max-width: 480px)": { + display: "none", + }, }); // The canvas and the docked inspector share the row; narrow viewports stack @@ -136,6 +151,11 @@ export type PetrinautPreviewProps = { * router; Preview itself does not depend on a router implementation. */ navigation?: PetrinautNavigationController; + /** + * Optional build-time artifacts and bounded controls for running the + * model's named scenarios without mounting Petrinaut's language tooling. + */ + quickSimulation?: PetrinautPreviewQuickSimulation; /** Host actions displayed alongside the canvas zoom controls. */ viewportActions?: ViewportAction[]; }; @@ -145,17 +165,23 @@ export type PetrinautPreviewProps = { * * The component creates a history-free read-only document and renders the * exact same {@link SDCPNView} as the editor. It intentionally mounts neither - * Monaco/LSP nor experiments, optimizations, AI, or simulation controls. + * Monaco/LSP nor experiments, optimizations, or AI. Hosts may opt into a + * bounded Quick Simulation surface by supplying precompiled artifacts. */ export const PetrinautPreview: FunctionComponent = ({ definition, documentId, navigation, + quickSimulation, title = "Petrinaut model", viewportActions, }) => { const generatedDocumentId = useId(); const portalContainerRef = useRef(null); + const hasQuickSimulation = quickSimulation !== undefined; + if (quickSimulation) { + validatePreviewQuickSimulation(definition, quickSimulation); + } const handle = useMemo( () => createJsonDocHandle({ @@ -171,8 +197,37 @@ export const PetrinautPreview: FunctionComponent = ({ [handle], ); const navigationAdapter = useMemo( - () => (navigation ? createPreviewNavigationAdapter(navigation) : undefined), - [navigation], + () => + navigation + ? createPreviewNavigationAdapter( + navigation, + hasQuickSimulation ? "simulate" : "edit", + ) + : undefined, + [hasQuickSimulation, navigation], + ); + const hirArtifacts = quickSimulation?.hirArtifacts; + const scenarioHirById = quickSimulation?.scenarioHirById; + // SimulationProvider's lowering effect keys on compiler function identity, + // so retain the adapter while the host's immutable artifacts are unchanged. + const simulationCompiler = useMemo( + () => + hirArtifacts && scenarioHirById + ? createPreviewSimulationCompiler({ hirArtifacts, scenarioHirById }) + : undefined, + [hirArtifacts, scenarioHirById], + ); + const allowedPlaybackSpeeds = quickSimulation?.allowedPlaybackSpeeds; + const defaultPlaybackSpeed = quickSimulation?.defaultPlaybackSpeed; + const playbackOptions = useMemo( + () => + hasQuickSimulation + ? resolvePreviewPlaybackOptions({ + allowedPlaybackSpeeds, + defaultPlaybackSpeed, + }) + : undefined, + [allowedPlaybackSpeeds, defaultPlaybackSpeed, hasQuickSimulation], ); const netManagement = useMemo( () => ({ @@ -187,41 +242,74 @@ export const PetrinautPreview: FunctionComponent = ({ useEffect(() => () => instance.dispose(), [instance]); + const canvas = ( + +
+
+ + + {title} + + {quickSimulation && ( + + )} + View only +
+
+
+ + {quickSimulation && ( + + )} +
+ +
+
+
+ ); + return ( - -
-
- - - {title} - - View only -
-
-
- -
- -
-
-
+ {quickSimulation && simulationCompiler ? ( + + + {canvas} + + + ) : ( + canvas + )}
diff --git a/libs/@hashintel/petrinaut/src/ui/preview/preview-quick-simulation-controls.test.tsx b/libs/@hashintel/petrinaut/src/ui/preview/preview-quick-simulation-controls.test.tsx new file mode 100644 index 00000000000..38cf5a370c6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/preview/preview-quick-simulation-controls.test.tsx @@ -0,0 +1,76 @@ +/** + * @vitest-environment jsdom + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + emptyExecutionFrameSource, + ExecutionFrameSourceContext, +} from "../../react/execution-frame/context"; +import { PreviewSimulationPlaybackControls } from "./preview-quick-simulation-controls"; + +vi.mock("../views/Editor/components/BottomBar/simulation-controls", () => ({ + SimulationControls: () =>
Simulation controls
, +})); + +vi.mock( + "../views/Editor/panels/BottomPanel/subviews/simulation-timeline/content", + () => ({ + SimulationTimeline: ({ showLegend }: { showLegend?: boolean }) => ( +
Compact simulation timeline
+ ), + }), +); + +const renderPlaybackControls = (totalFrames: number) => + render( + + + , + ); + +describe("PreviewSimulationPlaybackControls", () => { + it("expands for frames and collapses again after reset", () => { + const { container, rerender } = renderPlaybackControls(0); + const panel = screen.getByRole("region", { + name: "Simulation playback", + }); + const timelineReveal = container.querySelector("[data-preview-timeline]")!; + + expect(panel.getAttribute("data-state")).toBe("collapsed"); + expect(timelineReveal.getAttribute("aria-hidden")).toBe("true"); + const timeline = screen.getByText("Compact simulation timeline"); + + rerender( + + + , + ); + + expect(panel.getAttribute("data-state")).toBe("expanded"); + expect(timelineReveal.getAttribute("aria-hidden")).toBe("false"); + expect(screen.getByText("Compact simulation timeline")).toBe(timeline); + expect( + screen + .getByText("Compact simulation timeline") + .getAttribute("data-show-legend"), + ).toBe("false"); + + rerender( + + + , + ); + + expect(panel.getAttribute("data-state")).toBe("collapsed"); + expect(timelineReveal.getAttribute("aria-hidden")).toBe("true"); + expect(screen.getByText("Compact simulation timeline")).toBe(timeline); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/preview/preview-quick-simulation-controls.tsx b/libs/@hashintel/petrinaut/src/ui/preview/preview-quick-simulation-controls.tsx new file mode 100644 index 00000000000..9da957b9021 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/preview/preview-quick-simulation-controls.tsx @@ -0,0 +1,192 @@ +import { use, useRef, useState } from "react"; + +import { Button, Icon, Popover } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { ExecutionFrameSourceContext } from "../../react/execution-frame/context"; +import { SimulationContext } from "../../react/simulation/context"; +import { ActiveNetContext } from "../../react/state/active-net-context"; +import { UserSettingsContext } from "../../react/state/user-settings-context"; +import { SimulationControls } from "../views/Editor/components/BottomBar/simulation-controls"; +import { SimulationTimeline } from "../views/Editor/panels/BottomPanel/subviews/simulation-timeline/content"; +import { SimulationScenarioControls } from "../views/shared/simulation-scenario-controls"; + +import type { PetrinautPreviewQuickSimulation } from "./quick-simulation"; + +const configurationPopoverStyle = css({ + width: "[min(380px, calc(100vw - 24px))]", +}); + +const configurationBodyStyle = css({ + height: "[min(420px, calc(100vh - 96px))]", + minHeight: "[180px]", + padding: "3", +}); + +const playbackPositionStyle = css({ + position: "absolute", + left: "[50%]", + bottom: "2", + zIndex: "[calc(var(--z-index-sticky) + 1)]", + transform: "translateX(-50%)", + // Hug the controls while collapsed; only an expanded timeline needs width. + width: "[max-content]", + maxWidth: "[calc(100% - 16px)]", + // Lets supporting browsers animate the max-content <-> full-width switch. + interpolateSize: "[allow-keywords]", + padding: "0.5", + overflow: "hidden", + // A flat bordered box like the editor's panels: square, opaque, no shadow. + borderWidth: "thin", + borderColor: "neutral.s40", + backgroundColor: "neutral.s00", + "&[data-expanded='true']": { + width: "[calc(100% - 16px)]", + maxWidth: "[720px]", + }, + "&[data-animated='true']": { + transition: "[width 180ms ease-in-out, max-width 180ms ease-in-out]", + "@media (prefers-reduced-motion: reduce)": { + transition: "[none]", + }, + }, +}); + +const playbackControlsScrollStyle = css({ + width: "full", + minWidth: "0", + overflowX: "auto", +}); + +const playbackControlsRowStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "1", + width: "[max-content]", + minWidth: "full", +}); + +const timelineRevealStyle = css({ + display: "grid", + gridTemplateRows: "[0fr]", + opacity: "0", + pointerEvents: "none", + "&[data-expanded='true']": { + gridTemplateRows: "[1fr]", + opacity: "1", + pointerEvents: "auto", + }, + "&[data-animated='true']": { + transition: + "[grid-template-rows 180ms ease-in-out, opacity 140ms ease-in-out]", + "@media (prefers-reduced-motion: reduce)": { + transition: "[none]", + }, + }, +}); + +const timelineClipStyle = css({ + minHeight: "0", + overflow: "hidden", +}); + +const timelineStyle = css({ + height: "[clamp(60px, 20vh, 116px)]", + minHeight: "0", + marginTop: "0.5", + paddingTop: "0.5", + borderTopWidth: "thin", + borderColor: "neutral.bd.subtle", +}); + +const configurationLabelStyle = css({ + "@media (max-width: 420px)": { + display: "none", + }, +}); + +export const PreviewSimulationConfiguration = ({ + parameterBounds, +}: Pick) => { + const triggerRef = useRef(null); + const [open, setOpen] = useState(false); + + return ( + <> + + {open && ( + setOpen(false)} + > + + + + + + + + )} + + ); +}; + +export const PreviewSimulationPlaybackControls = ({ + allowedPlaybackSpeeds, +}: Pick) => { + const { activeSubnetId } = use(ActiveNetContext); + const { scenarioCompilationErrors } = use(SimulationContext); + const { totalFrames } = use(ExecutionFrameSourceContext); + const { showAnimations } = use(UserSettingsContext); + const expanded = totalFrames > 0; + + return ( +
+
+
+ +
+
+
+
+
+ +
+
+
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/preview/quick-simulation.test.ts b/libs/@hashintel/petrinaut/src/ui/preview/quick-simulation.test.ts new file mode 100644 index 00000000000..ad3886009f1 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/preview/quick-simulation.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "vitest"; + +import { + createPreviewSimulationCompiler, + resolvePreviewPlaybackOptions, + validatePreviewQuickSimulation, +} from "./quick-simulation"; + +import type { HirArtifacts, ScenarioHir } from "@hashintel/petrinaut-core"; + +const hirArtifacts: HirArtifacts = { + version: 4, + fingerprint: "0123456789abcdef", + dynamics: {}, + lambdas: {}, + kernels: {}, + metrics: {}, +}; + +const scenarioHir: ScenarioHir = { + version: 1, + parameterOverrides: {}, + placeExpressions: {}, +}; + +describe("Preview Quick Simulation compiler", () => { + test("returns the supplied immutable net and scenario artifacts", async () => { + const compiler = createPreviewSimulationCompiler({ + hirArtifacts, + scenarioHirById: { scenario: scenarioHir }, + }); + + await expect(compiler.requestHirArtifacts({} as never)).resolves.toEqual({ + artifacts: hirArtifacts, + failures: [], + }); + await expect( + compiler.requestScenarioHir( + { + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }, + undefined, + "scenario", + ), + ).resolves.toBe(scenarioHir); + }); + + test("rejects missing and unknown named-scenario artifacts", async () => { + const compiler = createPreviewSimulationCompiler({ + hirArtifacts, + scenarioHirById: {}, + }); + const input = { + parameterOverrides: {}, + initialState: { type: "per_place" as const, content: {} }, + }; + + await expect(compiler.requestScenarioHir(input)).rejects.toThrow( + "requires a named scenario", + ); + await expect( + compiler.requestScenarioHir(input, undefined, "missing"), + ).rejects.toThrow('No precompiled scenario HIR is available for "missing"'); + }); +}); + +describe("Preview Quick Simulation playback options", () => { + test("defaults to the first host-allowed speed", () => { + expect( + resolvePreviewPlaybackOptions({ allowedPlaybackSpeeds: [5, 10] }), + ).toEqual({ + allowedPlaybackSpeeds: [5, 10], + defaultPlaybackSpeed: 5, + }); + }); + + test("honors an allowed explicit default", () => { + expect( + resolvePreviewPlaybackOptions({ + allowedPlaybackSpeeds: [2, 5, 10], + defaultPlaybackSpeed: 10, + }).defaultPlaybackSpeed, + ).toBe(10); + }); + + test("rejects empty options and disallowed defaults", () => { + expect(() => + resolvePreviewPlaybackOptions({ allowedPlaybackSpeeds: [] }), + ).toThrow("at least one allowed playback speed"); + expect(() => + resolvePreviewPlaybackOptions({ + allowedPlaybackSpeeds: [1, 2], + defaultPlaybackSpeed: 5, + }), + ).toThrow("default playback speed (5) must be allowed"); + }); +}); + +describe("Preview Quick Simulation model validation", () => { + test("requires at least one named scenario", () => { + expect(() => + validatePreviewQuickSimulation( + { scenarios: [] }, + { scenarioHirById: {} }, + ), + ).toThrow("requires at least one named scenario"); + }); + + test("requires precompiled HIR for every declared scenario", () => { + const declaredScenarios = [ + { id: "covered" }, + { id: "missing" }, + ] as NonNullable< + Parameters[0]["scenarios"] + >; + + expect(() => + validatePreviewQuickSimulation( + { scenarios: declaredScenarios }, + { scenarioHirById: { covered: scenarioHir } }, + ), + ).toThrow("missing precompiled HIR for: missing"); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/preview/quick-simulation.ts b/libs/@hashintel/petrinaut/src/ui/preview/quick-simulation.ts new file mode 100644 index 00000000000..2862efc1421 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/preview/quick-simulation.ts @@ -0,0 +1,131 @@ +import { + PLAYBACK_SPEEDS, + type HirArtifacts, + type PlaybackSpeed, + type ScenarioHir, + type SDCPN, + type WorkerFactory, +} from "@hashintel/petrinaut-core"; + +import type { SimulationCompiler } from "../../react/simulation/provider"; +import type { SimulationParameterBoundsByIdentifier } from "../views/shared/simulation-parameter-bounds"; + +/** Build-time simulation inputs supplied by an embed host. */ +export type PetrinautPreviewQuickSimulation = { + /** HIR compiled for the exact immutable definition passed to Preview. */ + hirArtifacts: HirArtifacts; + /** Pre-lowered HIR for every named scenario exposed by the definition. */ + scenarioHirById: Readonly>; + /** + * Simulation step size, in seconds. Preview does not expose this; when + * omitted, the simulation provider's default applies. + */ + dt?: number; + /** + * Simulation horizon, in seconds. Preview does not expose this; when + * omitted, the simulation runs until paused. + */ + maxTime?: number; + /** Optional host-specific simulation worker constructor. */ + workerFactory?: WorkerFactory; + /** Speeds offered by Preview's compact playback menu. */ + allowedPlaybackSpeeds?: readonly PlaybackSpeed[]; + /** Initial playback speed. Must be one of `allowedPlaybackSpeeds`. */ + defaultPlaybackSpeed?: PlaybackSpeed; + /** Safe UI bounds for scenario parameters, keyed by identifier. */ + parameterBounds?: SimulationParameterBoundsByIdentifier; +}; + +export type PreviewPlaybackOptions = { + allowedPlaybackSpeeds: readonly PlaybackSpeed[]; + defaultPlaybackSpeed: PlaybackSpeed; +}; + +/** Resolve and validate the compact playback menu's host-owned policy. */ +export const resolvePreviewPlaybackOptions = ( + quickSimulation: Pick< + PetrinautPreviewQuickSimulation, + "allowedPlaybackSpeeds" | "defaultPlaybackSpeed" + >, +): PreviewPlaybackOptions => { + if (quickSimulation.allowedPlaybackSpeeds?.length === 0) { + throw new Error( + "Preview Quick Simulation requires at least one allowed playback speed", + ); + } + + const allowedPlaybackSpeeds = + quickSimulation.allowedPlaybackSpeeds ?? PLAYBACK_SPEEDS; + const defaultPlaybackSpeed = + quickSimulation.defaultPlaybackSpeed ?? allowedPlaybackSpeeds[0] ?? 1; + + if (!allowedPlaybackSpeeds.includes(defaultPlaybackSpeed)) { + throw new Error( + `Preview Quick Simulation default playback speed (${defaultPlaybackSpeed}) must be allowed`, + ); + } + + return { allowedPlaybackSpeeds, defaultPlaybackSpeed }; +}; + +/** Fail fast when build-time artifacts cannot cover the model's scenarios. */ +export const validatePreviewQuickSimulation = ( + definition: Pick, + quickSimulation: Pick, +): void => { + const scenarios = definition.scenarios ?? []; + if (scenarios.length === 0) { + throw new Error( + "Preview Quick Simulation requires at least one named scenario", + ); + } + + const missingScenarioIds = scenarios + .map(({ id }) => id) + .filter( + (scenarioId) => + !Object.hasOwn(quickSimulation.scenarioHirById, scenarioId), + ); + if (missingScenarioIds.length > 0) { + throw new Error( + `Preview Quick Simulation is missing precompiled HIR for: ${missingScenarioIds.join( + ", ", + )}`, + ); + } +}; + +/** + * Adapt immutable, build-time artifacts to the compiler seam shared with the + * full editor. No language worker is mounted in Preview. + */ +export const createPreviewSimulationCompiler = ( + quickSimulation: Pick< + PetrinautPreviewQuickSimulation, + "hirArtifacts" | "scenarioHirById" + >, +): SimulationCompiler => ({ + requestHirArtifacts: () => + Promise.resolve({ artifacts: quickSimulation.hirArtifacts, failures: [] }), + requestScenarioHir: (_scenario, _adHocContext, scenarioId) => { + if (!scenarioId) { + return Promise.reject( + new Error("Preview Quick Simulation requires a named scenario"), + ); + } + + const scenarioHir = Object.hasOwn( + quickSimulation.scenarioHirById, + scenarioId, + ) + ? quickSimulation.scenarioHirById[scenarioId] + : undefined; + return scenarioHir + ? Promise.resolve(scenarioHir) + : Promise.reject( + new Error( + `No precompiled scenario HIR is available for "${scenarioId}"`, + ), + ); + }, +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/content.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/content.tsx new file mode 100644 index 00000000000..b8c7603efc1 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/content.tsx @@ -0,0 +1,59 @@ +import { use } from "react"; + +import { ExecutionFrameSourceContext } from "../../../../../../../react/execution-frame/context"; +import { EditorContext } from "../../../../../../../react/state/editor-context"; +import { UPlotChart } from "./chart"; +import { TimelineLegend } from "./legend"; +import { chartAreaStyle, containerStyle } from "./styles"; +import { useStreamingData } from "./use-streaming-data"; + +export const SimulationTimeline: React.FC<{ + showLegend?: boolean; +}> = ({ showLegend = true }) => { + const { + hiddenTimelineSeriesIds: hiddenSeries, + setHiddenTimelineSeriesIds: setHiddenSeries, + timelineChartType: chartType, + } = use(EditorContext); + const source = use(ExecutionFrameSourceContext); + const { store, metricError } = useStreamingData(source); + + if (metricError) { + return ( +
+ {metricError} +
+ ); + } + + if (store.length === 0 || source.totalFrames === 0) { + return ( +
+ + No simulation data available + +
+ ); + } + + return ( +
+ + {showLegend && store.series.length > 1 && ( + + )} +
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/main.tsx index d7087526ff0..95fd607558d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/main.tsx @@ -1,70 +1,14 @@ -import { use } from "react"; - -import { ExecutionFrameSourceContext } from "../../../../../../../react/execution-frame/context"; -import { EditorContext } from "../../../../../../../react/state/editor-context"; -import { UPlotChart } from "./chart"; +import { SimulationTimeline } from "./content"; import { TimelineHeaderActions } from "./header"; -import { TimelineLegend } from "./legend"; -import { chartAreaStyle, containerStyle } from "./styles"; -import { useStreamingData } from "./use-streaming-data"; import type { SubView } from "../../../../../../components/sub-view/types"; -const SimulationTimelineContent: React.FC = () => { - const { - hiddenTimelineSeriesIds: hiddenSeries, - setHiddenTimelineSeriesIds: setHiddenSeries, - timelineChartType: chartType, - } = use(EditorContext); - const source = use(ExecutionFrameSourceContext); - const { store, metricError } = useStreamingData(source); - - if (metricError) { - return ( -
- {metricError} -
- ); - } - - if (store.length === 0 || source.totalFrames === 0) { - return ( -
- - No simulation data available - -
- ); - } - - return ( -
- - {store.series.length > 1 && ( - - )} -
- ); -}; - 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, + component: SimulationTimeline, renderHeaderAction: () => , noPadding: true, }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.tsx index 883fedcab22..72330b709ed 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.tsx @@ -102,7 +102,7 @@ export const ExperimentScenarioRun: React.FC = ({ values, onValuesChange, }) => { - const hirState = useScenarioHir(scenario, context); + const hirState = useScenarioHir(scenario, { adHocContext: context }); const [computedOpen, setComputedOpen] = useState(false); // `seededFrom` is the persisted scenario object the variables came from: // saving an edit to it replaces the object, so the drawer reseeds to the