clean up code a bit (split of SideBar component and simulator callbacks from App) + fix bug in property checker

This commit is contained in:
Joeri Exelmans 2025-11-08 10:32:28 +01:00
parent 2dd35ab079
commit 9922f8588d
10 changed files with 707 additions and 718 deletions

View file

@ -1,36 +1,20 @@
import "../index.css"; import "../index.css";
import "./App.css"; import "./App.css";
import { Dispatch, ReactElement, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import AddIcon from '@mui/icons-material/Add';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import CachedOutlinedIcon from '@mui/icons-material/CachedOutlined';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import VisibilityIcon from '@mui/icons-material/Visibility';
import { Statechart } from "@/statecharts/abstract_syntax";
import { detectConnections } from "@/statecharts/detect_connections"; import { detectConnections } from "@/statecharts/detect_connections";
import { Conns, coupledExecution, statechartExecution } from "@/statecharts/timed_reactive";
import { RuntimeError } from "../statecharts/interpreter";
import { parseStatechart } from "../statecharts/parser"; import { parseStatechart } from "../statecharts/parser";
import { BigStep, RaisedEvent } from "../statecharts/runtime_types";
import { getSimTime, getWallClkDelay, TimeMode } from "../statecharts/time";
import { BottomPanel } from "./BottomPanel"; import { BottomPanel } from "./BottomPanel";
import { PersistentDetails, PersistentDetailsLocalStorage } from "./PersistentDetails"; import { defaultSideBarState, SideBar, SideBarState } from "./SideBar";
import { digitalWatchPlant } from "./Plant/DigitalWatch/DigitalWatch"; import { InsertMode } from "./TopPanel/InsertModes";
import { dummyPlant } from "./Plant/Dummy/Dummy";
import { microwavePlant } from "./Plant/Microwave/Microwave";
import { Plant } from "./Plant/Plant";
import { trafficLightPlant } from "./Plant/TrafficLight/TrafficLight";
import { RTHistory } from "./RTHistory";
import { ShowAST, ShowInputEvents, ShowInternalEvents, ShowOutputEvents } from "./ShowAST";
import { TopPanel } from "./TopPanel/TopPanel"; import { TopPanel } from "./TopPanel/TopPanel";
import { VisualEditor, VisualEditorState } from "./VisualEditor/VisualEditor"; import { VisualEditor, VisualEditorState } from "./VisualEditor/VisualEditor";
import { checkProperty, PropertyCheckResult } from "./check_property"; import { makeIndividualSetters } from "./makePartialSetter";
import { useEditor } from "./useEditor"; import { useEditor } from "./useEditor";
import { useSimulator } from "./useSimulator";
import { useUrlHashState } from "./useUrlHashState"; import { useUrlHashState } from "./useUrlHashState";
import { plants } from "./plants";
export type EditHistory = { export type EditHistory = {
current: VisualEditorState, current: VisualEditorState,
@ -38,53 +22,22 @@ export type EditHistory = {
future: VisualEditorState[], future: VisualEditorState[],
} }
type UniversalPlantState = {[property: string]: boolean|number}; export type AppState = {
showKeys: boolean,
zoom: number,
insertMode: InsertMode,
} & SideBarState;
const plants: [string, Plant<any, UniversalPlantState>][] = [ const defaultAppState: AppState = {
["dummy", dummyPlant], showKeys: true,
["microwave", microwavePlant as unknown as Plant<any, UniversalPlantState>], zoom: 1,
["digital watch", digitalWatchPlant as unknown as Plant<any, UniversalPlantState>], insertMode: 'and',
["traffic light", trafficLightPlant as unknown as Plant<any, UniversalPlantState>],
]
export type TraceItemError = { ...defaultSideBarState,
cause: BigStepCause, // event name, <init> or <timer>
simtime: number,
error: RuntimeError,
} }
type CoupledState = {
sc: BigStep,
plant: BigStep,
// plantCleanState: {[prop: string]: boolean|number},
};
export type BigStepCause = {
kind: "init",
simtime: 0,
} | {
kind: "input",
simtime: number,
eventName: string,
param?: any,
} | {
kind: "timer",
simtime: number,
};
export type TraceItem =
{ kind: "error" } & TraceItemError
| { kind: "bigstep", simtime: number, cause: BigStepCause, state: CoupledState, outputEvents: RaisedEvent[] };
export type TraceState = {
trace: [TraceItem, ...TraceItem[]], // non-empty
idx: number,
};
export function App() { export function App() {
const [editHistory, setEditHistory] = useState<EditHistory|null>(null); const [editHistory, setEditHistory] = useState<EditHistory|null>(null);
const [trace, setTrace] = useState<TraceState|null>(null);
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
const [modal, setModal] = useState<ReactElement|null>(null); const [modal, setModal] = useState<ReactElement|null>(null);
const {makeCheckPoint, onRedo, onUndo, onRotate} = useEditor(setEditHistory); const {makeCheckPoint, onRedo, onUndo, onRotate} = useEditor(setEditHistory);
@ -94,54 +47,51 @@ export function App() {
setEditHistory(historyState => historyState && ({...historyState, current: cb(historyState.current)})); setEditHistory(historyState => historyState && ({...historyState, current: cb(historyState.current)}));
}, [setEditHistory]); }, [setEditHistory]);
const {
autoConnect,
setAutoConnect,
autoScroll,
setAutoScroll,
plantConns,
setPlantConns,
showKeys,
setShowKeys,
zoom,
setZoom,
insertMode,
setInsertMode,
plantName,
setPlantName,
showConnections,
setShowConnections,
showProperties,
setShowProperties,
showExecutionTrace,
setShowExecutionTrace,
showPlantTrace,
setShowPlantTrace,
properties,
setProperties,
savedTraces,
setSavedTraces,
activeProperty,
setActiveProperty,
} = useUrlHashState(editorState, setEditHistory);
const plant = plants.find(([pn, p]) => pn === plantName)![1];
const refRightSideBar = useRef<HTMLDivElement>(null);
// parse concrete syntax always: // parse concrete syntax always:
const conns = useMemo(() => editorState && detectConnections(editorState), [editorState]); const conns = useMemo(() => editorState && detectConnections(editorState), [editorState]);
const parsed = useMemo(() => editorState && conns && parseStatechart(editorState, conns), [editorState, conns]); const parsed = useMemo(() => editorState && conns && parseStatechart(editorState, conns), [editorState, conns]);
const ast = parsed && parsed[0]; const ast = parsed && parsed[0];
const syntaxErrors = parsed && parsed[1] || [];
const currentTraceItem = trace && trace.trace[trace.idx];
const allErrors = [
...syntaxErrors,
...(currentTraceItem && currentTraceItem.kind === "error") ? [{
message: currentTraceItem.error.message,
shapeUid: currentTraceItem.error.highlight[0],
}] : [],
];
const [appState, setAppState] = useState<AppState>(defaultAppState);
const persist = useUrlHashState<VisualEditorState | AppState & {editorState: VisualEditorState}>(
recoveredState => {
// we support two formats
// @ts-ignore
if (recoveredState.nextID) {
// old format
setEditHistory(() => ({current: recoveredState as VisualEditorState, history: [], future: []}));
}
else {
// new format
// @ts-ignore
if (recoveredState.editorState !== undefined) {
const {editorState, ...appState} = recoveredState as AppState & {editorState: VisualEditorState};
setEditHistory(() => ({current: editorState, history: [], future: []}));
setAppState(() => appState);
}
}
},
);
useEffect(() => {
const timeout = setTimeout(() => {
if (editorState !== null) {
persist({editorState, ...appState});
}
}, 100);
return () => clearTimeout(timeout);
}, [editorState, appState]);
const {
autoScroll,
plantConns,
plantName,
} = appState;
const plant = plants.find(([pn, p]) => pn === plantName)![1];
const refRightSideBar = useRef<HTMLDivElement>(null);
const scrollDownSidebar = useCallback(() => { const scrollDownSidebar = useCallback(() => {
if (autoScroll && refRightSideBar.current) { if (autoScroll && refRightSideBar.current) {
const el = refRightSideBar.current; const el = refRightSideBar.current;
@ -152,221 +102,25 @@ export function App() {
} }
}, [refRightSideBar.current, autoScroll]); }, [refRightSideBar.current, autoScroll]);
// coupled execution const simulator = useSimulator(ast, plant, plantConns, scrollDownSidebar);
const cE = useMemo(() => ast && coupledExecution({
sc: statechartExecution(ast),
plant: plant.execution,
}, {
...plantConns,
...Object.fromEntries(ast.inputEvents.map(({event}) => ["debug."+event, ['sc',event] as [string,string]])),
}), [ast]);
const onInit = useCallback(() => { const setters = makeIndividualSetters(setAppState, Object.keys(appState) as (keyof AppState)[]);
if (cE === null) return;
const metadata = {simtime: 0, cause: {kind: "init" as const, simtime: 0 as const}};
try {
const [outputEvents, state] = cE.initial(); // may throw if initialing the statechart results in a RuntimeError
setTrace({
trace: [{kind: "bigstep", ...metadata, state, outputEvents}],
idx: 0,
});
}
catch (error) {
if (error instanceof RuntimeError) {
setTrace({
trace: [{kind: "error", ...metadata, error}],
idx: 0,
});
}
else {
throw error; // probably a bug in the interpreter
}
}
setTime(time => {
if (time.kind === "paused") {
return {...time, simtime: 0};
}
else {
return {...time, since: {simtime: 0, wallclktime: performance.now()}};
}
});
scrollDownSidebar();
}, [cE, scrollDownSidebar]);
const onClear = useCallback(() => {
setTrace(null);
setTime({kind: "paused", simtime: 0});
}, [setTrace, setTime]);
// raise input event, producing a new runtime configuration (or a runtime error)
const onRaise = (inputEvent: string, param: any) => {
if (cE === null) return;
if (currentTraceItem !== null /*&& ast.inputEvents.some(e => e.event === inputEvent)*/) {
if (currentTraceItem.kind === "bigstep") {
const simtime = getSimTime(time, Math.round(performance.now()));
appendNewConfig(simtime, {kind: "input", simtime, eventName: inputEvent, param}, () => {
return cE.extTransition(simtime, currentTraceItem.state, {kind: "input", name: inputEvent, param});
});
}
}
};
// timer elapse events are triggered by a change of the simulated time (possibly as a scheduled JS event loop timeout)
useEffect(() => {
let timeout: NodeJS.Timeout | undefined;
if (currentTraceItem !== null && cE !== null) {
if (currentTraceItem.kind === "bigstep") {
const nextTimeout = cE?.timeAdvance(currentTraceItem.state);
const raiseTimeEvent = () => {
appendNewConfig(nextTimeout, {kind: "timer", simtime: nextTimeout}, () => {
return cE.intTransition(currentTraceItem.state);
});
}
if (time.kind === "realtime") {
const wallclkDelay = getWallClkDelay(time, nextTimeout, Math.round(performance.now()));
if (wallclkDelay !== Infinity) {
timeout = setTimeout(raiseTimeEvent, wallclkDelay);
}
}
else if (time.kind === "paused") {
if (nextTimeout <= time.simtime) {
raiseTimeEvent();
}
}
}
}
return () => {
if (timeout) clearTimeout(timeout);
}
}, [time, currentTraceItem]); // <-- todo: is this really efficient?
function appendNewConfig(simtime: number, cause: BigStepCause, computeNewState: () => [RaisedEvent[], CoupledState]) {
let newItem: TraceItem;
const metadata = {simtime, cause}
try {
const [outputEvents, state] = computeNewState(); // may throw RuntimeError
newItem = {kind: "bigstep", ...metadata, state, outputEvents};
}
catch (error) {
if (error instanceof RuntimeError) {
newItem = {kind: "error", ...metadata, error};
// also pause the simulation, for dramatic effect:
setTime({kind: "paused", simtime});
}
else {
throw error;
}
}
// @ts-ignore
setTrace(trace => ({
trace: [
...trace!.trace.slice(0, trace!.idx+1), // remove everything after current item
newItem,
],
// idx: 0,
idx: trace!.idx+1,
}));
scrollDownSidebar();
}
const onBack = useCallback(() => {
if (trace !== null) {
setTime(() => {
if (trace !== null) {
return {
kind: "paused",
simtime: trace.trace[trace.idx-1].simtime,
}
}
return { kind: "paused", simtime: 0 };
});
setTrace({
...trace,
idx: trace.idx-1,
});
}
}, [trace]);
const syntaxErrors = parsed && parsed[1] || [];
const currentTraceItem = simulator.trace && simulator.trace.trace[simulator.trace.idx];
const currentBigStep = currentTraceItem && currentTraceItem.kind === "bigstep" && currentTraceItem; const currentBigStep = currentTraceItem && currentTraceItem.kind === "bigstep" && currentTraceItem;
const allErrors = [
...syntaxErrors,
...(currentTraceItem && currentTraceItem.kind === "error") ? [{
message: currentTraceItem.error.message,
shapeUid: currentTraceItem.error.highlight[0],
}] : [],
];
const highlightActive = (currentBigStep && currentBigStep.state.sc.mode) || new Set(); const highlightActive = (currentBigStep && currentBigStep.state.sc.mode) || new Set();
const highlightTransitions = currentBigStep && currentBigStep.state.sc.firedTransitions || []; const highlightTransitions = currentBigStep && currentBigStep.state.sc.firedTransitions || [];
const speed = time.kind === "paused" ? 0 : time.scale;
const plantState = currentBigStep && currentBigStep.state.plant || plant.execution.initial()[1]; const plantState = currentBigStep && currentBigStep.state.plant || plant.execution.initial()[1];
useEffect(() => {
ast && autoConnect && autoDetectConns(ast, plant, setPlantConns);
}, [ast, plant, autoConnect]);
const [propertyResults, setPropertyResults] = useState<PropertyCheckResult[] | null>(null);
const onSaveTrace = () => {
if (trace) {
setSavedTraces(savedTraces => [
...savedTraces,
["untitled", trace.trace.map((item) => item.cause)] as [string, BigStepCause[]],
]);
}
}
const onReplayTrace = (causes: BigStepCause[]) => {
if (cE) {
function run_until(simtime: number) {
while (true) {
const nextTimeout = cE!.timeAdvance(lastState);
if (nextTimeout > simtime) {
break;
}
const [outputEvents, coupledState] = cE!.intTransition(lastState);
lastState = coupledState;
lastSimtime = nextTimeout;
newTrace.push({kind: "bigstep", simtime: nextTimeout, state: coupledState, outputEvents, cause: {kind: "timer", simtime: nextTimeout}});
}
}
const [outputEvents, coupledState] = cE.initial();
const newTrace = [{kind: "bigstep", simtime: 0, state: coupledState, outputEvents, cause: {kind: "init"} as BigStepCause} as TraceItem] as [TraceItem, ...TraceItem[]];
let lastState = coupledState;
let lastSimtime = 0;
for (const cause of causes) {
if (cause.kind === "input") {
run_until(cause.simtime); // <-- just make sure we haven't missed any timers elapsing
// @ts-ignore
const [outputEvents, coupledState] = cE.extTransition(cause.simtime, newTrace.at(-1)!.state, {kind: "input", name: cause.eventName, param: cause.param});
lastState = coupledState;
lastSimtime = cause.simtime;
newTrace.push({kind: "bigstep", simtime: cause.simtime, state: coupledState, outputEvents, cause});
}
else if (cause.kind === "timer") {
run_until(cause.simtime);
}
}
setTrace({trace: newTrace, idx: newTrace.length-1});
setTime({kind: "paused", simtime: lastSimtime});
}
}
// if some properties change, re-evaluate them:
useEffect(() => {
let timeout: NodeJS.Timeout;
if (trace) {
setPropertyResults(null);
timeout = setTimeout(() => {
Promise.all(properties.map((property, i) => {
return checkProperty(plant, property, trace.trace);
}))
.then(results => {
setPropertyResults(results);
})
})
}
return () => clearTimeout(timeout);
}, [properties, trace, plant]);
return <> return <>
{/* Modal dialog */} {/* Modal dialog */}
@ -394,13 +148,13 @@ export function App() {
style={{flex: '0 0 content'}} style={{flex: '0 0 content'}}
> >
{editHistory && <TopPanel {editHistory && <TopPanel
{...{trace, time, setTime, onUndo, onRedo, onRotate, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory}} {...{onUndo, onRedo, onRotate, setModal, editHistory, ...simulator, ...setters, ...appState}}
/>} />}
</div> </div>
{/* Editor */} {/* Editor */}
<div style={{flexGrow: 1, overflow: "auto"}}> <div style={{flexGrow: 1, overflow: "auto"}}>
{editorState && conns && syntaxErrors && {editorState && conns && syntaxErrors &&
<VisualEditor {...{state: editorState, setState: setEditorState, conns, trace, syntaxErrors: allErrors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}}/>} <VisualEditor {...{state: editorState, setState: setEditorState, conns, syntaxErrors: allErrors, highlightActive, highlightTransitions, setModal, makeCheckPoint, ...appState}}/>}
</div> </div>
</div> </div>
@ -413,142 +167,7 @@ export function App() {
maxWidth: 'min(400px, 50vw)', maxWidth: 'min(400px, 50vw)',
}}> }}>
<div className="stackVertical" style={{height:'100%'}}> <div className="stackVertical" style={{height:'100%'}}>
<div <SideBar {...{...appState, refRightSideBar, ast, plantState, ...simulator, ...setters}} />
className={showExecutionTrace ? "shadowBelow" : ""}
style={{flex: '0 0 content', backgroundColor: ''}}
>
{/* State tree */}
<PersistentDetailsLocalStorage localStorageKey="showStateTree" initiallyOpen={true}>
<summary>state tree</summary>
<ul>
{ast && <ShowAST {...{...ast, trace, highlightActive}}/>}
</ul>
</PersistentDetailsLocalStorage>
{/* Input events */}
<PersistentDetailsLocalStorage localStorageKey="showInputEvents" initiallyOpen={true}>
<summary>input events</summary>
{ast && <ShowInputEvents
inputEvents={ast.inputEvents}
onRaise={(e,p) => onRaise("debug."+e,p)}
disabled={trace===null || trace.trace[trace.idx].kind === "error"}
showKeys={showKeys}/>}
</PersistentDetailsLocalStorage>
{/* Internal events */}
<PersistentDetailsLocalStorage localStorageKey="showInternalEvents" initiallyOpen={true}>
<summary>internal events</summary>
{ast && <ShowInternalEvents internalEvents={ast.internalEvents}/>}
</PersistentDetailsLocalStorage>
{/* Output events */}
<PersistentDetailsLocalStorage localStorageKey="showOutputEvents" initiallyOpen={true}>
<summary>output events</summary>
{ast && <ShowOutputEvents outputEvents={ast.outputEvents}/>}
</PersistentDetailsLocalStorage>
{/* Plant */}
<PersistentDetailsLocalStorage localStorageKey="showPlant" initiallyOpen={true}>
<summary>plant</summary>
<select
disabled={trace!==null}
value={plantName}
onChange={e => setPlantName(() => e.target.value)}>
{plants.map(([plantName, p]) =>
<option>{plantName}</option>
)}
</select>
<br/>
{/* Render plant */}
{<plant.render state={plant.cleanupState(plantState)} speed={speed}
raiseUIEvent={e => onRaise("plant.ui."+e.name, e.param)}
/>}
</PersistentDetailsLocalStorage>
{/* Connections */}
<PersistentDetails state={showConnections} setState={setShowConnections}>
<summary>connections</summary>
<button title="auto-connect (name-based)" className={autoConnect?"active":""}
onClick={() => setAutoConnect(c => !c)}>
<AutoAwesomeIcon fontSize="small"/>
</button>
{ast && ConnEditor(ast, plant, plantConns, setPlantConns)}
</PersistentDetails>
{/* Properties */}
<details open={showProperties} onToggle={e => setShowProperties(e.newState === "open")}>
<summary>properties</summary>
{plant && <div>
available signals:
&nbsp;
{plant.signals.join(', ')}
</div>}
{properties.map((property, i) => {
const result = propertyResults && propertyResults[i];
let violated = null, propertyError = null;
if (result) {
violated = result[0] && result[0].length > 0 && !result[0][0].satisfied;
propertyError = result[1];
}
return <div style={{width:'100%'}} key={i} className="toolbar">
<div className={"status" + (violated === null ? "" : (violated ? " violated" : " satisfied"))}></div>
<button title="see in trace (below)" className={activeProperty === i ? "active" : ""} onClick={() => setActiveProperty(i)}>
<VisibilityIcon fontSize="small"/>
</button>
<input type="text" style={{width:'calc(100% - 90px)'}} value={property} onChange={e => setProperties(properties => properties.toSpliced(i, 1, e.target.value))}/>
<button title="delete this property" onClick={() => setProperties(properties => properties.toSpliced(i, 1))}>
<DeleteOutlineIcon fontSize="small"/>
</button>
{propertyError && <div style={{color: 'var(--error-color)'}}>{propertyError}</div>}
</div>;
})}
<div className="toolbar">
<button title="add property" onClick={() => setProperties(properties => [...properties, ""])}>
<AddIcon fontSize="small"/> add property
</button>
</div>
</details>
{/* Traces */}
<details open={showExecutionTrace} onToggle={e => setShowExecutionTrace(e.newState === "open")}><summary>execution trace</summary>
<div>
{savedTraces.map((savedTrace, i) =>
<div key={i} className="toolbar">
<button title="replay trace (may give a different result if you changed your model since recording the trace because only input and timer events are recorded)" onClick={() => onReplayTrace(savedTrace[1])}>
<CachedOutlinedIcon fontSize="small"/>
</button>
&nbsp;
<span style={{display:'inline-block', width: 26, fontSize: 9}}>{(Math.floor(savedTrace[1].at(-1)!.simtime/1000))}s</span>
<span style={{display:'inline-block', width: 22, fontSize: 9}}>({savedTrace[1].length})</span>
&nbsp;
<input title="name of the trace (only for humans - names don't have to be unique or anything)" type="text" value={savedTrace[0]} style={{width: 'calc(100% - 124px)'}} onChange={e => setSavedTraces(savedTraces => savedTraces.toSpliced(i, 1, [e.target.value, savedTraces[i][1]]))}/>
<button title="forget trace" onClick={() => setSavedTraces(savedTraces => savedTraces.toSpliced(i, 1))}>
<DeleteOutlineIcon fontSize="small"/>
</button>
</div>
)}
</div>
<div className="toolbar">
<input id="checkbox-show-plant-items" type="checkbox" checked={showPlantTrace} onChange={e => setShowPlantTrace(e.target.checked)}/>
<label title="plant steps are steps where only the state of the plant changed" htmlFor="checkbox-show-plant-items">show plant steps</label>
<input id="checkbox-autoscroll" type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)}/>
<label title="automatically scroll down event trace when new events occur" htmlFor="checkbox-autoscroll">auto-scroll</label>
&emsp;
<button title="save current trace" disabled={trace === null} onClick={() => onSaveTrace()}>
<SaveOutlinedIcon fontSize="small"/> save trace
</button>
</div>
</details>
</div>
{/* We cheat a bit, and render the execution trace depending on whether the <details> above is 'open' or not, rather than putting it as a child of the <details>. We do this because only then can we get the execution trace to scroll without the rest scrolling as well. */}
{showExecutionTrace &&
<div style={{
flexGrow:1,
overflow:'auto',
minHeight: '50vh',
// minHeight: '75%', // <-- allows us to always scroll down the sidebar far enough such that the execution history is enough in view
}}>
<div ref={refRightSideBar}>
{ast && <RTHistory {...{ast, trace, setTrace, setTime, showPlantTrace,
propertyTrace: propertyResults && propertyResults[activeProperty] && propertyResults[activeProperty][0] || []}}/>}
</div>
</div>}
<div style={{flex: '0 0 content'}}>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -561,83 +180,5 @@ export function App() {
</>; </>;
} }
function autoDetectConns(ast: Statechart, plant: Plant<any, any>, setPlantConns: Dispatch<SetStateAction<Conns>>) {
for (const {event: a} of plant.uiEvents) {
for (const {event: b} of plant.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.ui.'+a]: ['plant', b]}));
break;
}
}
for (const {event: b} of ast.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.ui.'+a]: ['sc', b]}));
}
}
}
for (const a of ast.outputEvents) {
for (const {event: b} of plant.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['sc.'+a]: ['plant', b]}));
}
}
}
for (const {event: a} of plant.outputEvents) {
for (const {event: b} of ast.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.'+a]: ['sc', b]}));
}
}
}
}
function ConnEditor(ast: Statechart, plant: Plant<any, any>, plantConns: Conns, setPlantConns: Dispatch<SetStateAction<Conns>>) {
const plantInputs = <>{plant.inputEvents.map(e => <option key={'plant.'+e.event} value={'plant.'+e.event}>plant.{e.event}</option>)}</>
const scInputs = <>{ast.inputEvents.map(e => <option key={'sc.'+e.event} value={'sc.'+e.event}>sc.{e.event}</option>)}</>;
return <>
{/* SC output events can go to Plant */}
{[...ast.outputEvents].map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-sc-${e}`} style={{width:'50%'}}>sc.{e}&nbsp;&nbsp;</label>
<select id={`select-dst-sc-${e}`}
style={{width:'50%'}}
value={plantConns['sc.'+e]?.join('.')}
// @ts-ignore
onChange={domEvent => setPlantConns(conns => ({...conns, [`sc.${e}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))}))}>
<option key="none" value=""></option>
{plantInputs}
</select>
</div>)}
{/* Plant output events can go to Statechart */}
{[...plant.outputEvents.map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-plant-${e.event}`} style={{width:'50%'}}>plant.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-${e.event}`}
style={{width:'50%'}}
value={plantConns['plant.'+e.event]?.join('.')}
// @ts-ignore
onChange={(domEvent => setPlantConns(conns => ({...conns, [`plant.${e.event}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))})))}>
<option key="none" value=""></option>
{scInputs}
</select>
</div>)]}
{/* Plant UI events typically go to the Plant */}
{plant.uiEvents.map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-plant-ui-${e.event}`} style={{width:'50%', color: 'grey'}}>ui.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-ui-${e.event}`}
style={{width:'50%'}}
value={plantConns['plant.ui.'+e.event]?.join('.')}
// @ts-ignore
onChange={domEvent => setPlantConns(conns => ({...conns, [`plant.ui.${e.event}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))}))}>
<option key="none" value=""></option>
{scInputs}
{plantInputs}
</select>
</div>)}
</>;
}
export default App; export default App;

331
src/App/SideBar.tsx Normal file
View file

@ -0,0 +1,331 @@
import AddIcon from '@mui/icons-material/Add';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import CachedOutlinedIcon from '@mui/icons-material/CachedOutlined';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import VisibilityIcon from '@mui/icons-material/Visibility';
import { Conns } from '@/statecharts/timed_reactive';
import { Dispatch, Ref, SetStateAction, useEffect, useRef, useState } from 'react';
import { Statechart } from '@/statecharts/abstract_syntax';
import { ShowAST, ShowInputEvents, ShowInternalEvents, ShowOutputEvents } from './ShowAST';
import { Plant } from './Plant/Plant';
import { checkProperty, PropertyCheckResult } from './check_property';
import { Setters } from './makePartialSetter';
import { RTHistory } from './RTHistory';
import { BigStepCause, TraceState } from './useSimulator';
import { plants, UniversalPlantState } from './plants';
import { TimeMode } from '@/statecharts/time';
import { PersistentDetails } from './PersistentDetails';
type SavedTraces = [string, BigStepCause[]][];
export type SideBarState = {
showStateTree: boolean,
showInputEvents: boolean,
showInternalEvents: boolean,
showOutputEvents: boolean,
showPlant: boolean,
showConnections: boolean,
showProperties: boolean,
showExecutionTrace: boolean,
plantName: string,
plantConns: Conns,
autoConnect: boolean,
properties: string[],
activeProperty: number,
savedTraces: SavedTraces,
autoScroll: boolean,
showPlantTrace: boolean,
};
export const defaultSideBarState = {
showStateTree: false,
showInputEvents: true,
showInternalEvents: true,
showOutputEvents: true,
showPlant: false,
showConnections: false,
showProperties: false,
showExecutionTrace: true,
plantName: 'dummy',
plantConns: {},
autoConnect: true,
properties: [],
activeProperty: 0,
savedTraces: [],
autoScroll: false,
showPlantTrace: false,
};
type SideBarProps = SideBarState & {
refRightSideBar: Ref<HTMLDivElement>,
ast: Statechart | null,
plant: Plant<any, UniversalPlantState>,
// setSavedTraces: Dispatch<SetStateAction<SavedTraces>>,
trace: TraceState|null,
setTrace: Dispatch<SetStateAction<TraceState|null>>,
plantState: UniversalPlantState,
onRaise: (inputEvent: string, param: any) => void,
onReplayTrace: (causes: BigStepCause[]) => void,
setTime: Dispatch<SetStateAction<TimeMode>>,
time: TimeMode,
} & Setters<SideBarState>;
export function SideBar({showExecutionTrace, showConnections, plantName, showPlantTrace, showProperties, activeProperty, autoConnect, autoScroll, plantConns, properties, savedTraces, refRightSideBar, ast, plant, setSavedTraces, trace, setTrace, setProperties, setShowPlantTrace, setActiveProperty, setPlantConns, setPlantName, setAutoConnect, setShowProperties, setAutoScroll, time, plantState, onReplayTrace, onRaise, setTime, setShowConnections, setShowExecutionTrace, showPlant, setShowPlant, showOutputEvents, setShowOutputEvents, setShowInternalEvents, showInternalEvents, setShowInputEvents, setShowStateTree, showInputEvents, showStateTree}: SideBarProps) {
const [propertyResults, setPropertyResults] = useState<PropertyCheckResult[] | null>(null);
const speed = time.kind === "paused" ? 0 : time.scale;
const onSaveTrace = () => {
if (trace) {
setSavedTraces(savedTraces => [
...savedTraces,
["untitled", trace.trace.map((item) => item.cause)] as [string, BigStepCause[]],
]);
}
}
// if some properties change, re-evaluate them:
useEffect(() => {
let timeout: NodeJS.Timeout;
if (trace) {
setPropertyResults(null);
timeout = setTimeout(() => {
Promise.all(properties.map((property, i) => {
return checkProperty(plant, property, trace.trace);
}))
.then(results => {
setPropertyResults(results);
})
})
}
return () => clearTimeout(timeout);
}, [properties, trace, plant]);
// whenever the ast, the plant or 'autoconnect' option changes, detect connections:
useEffect(() => {
if (ast && autoConnect) {
autoDetectConns(ast, plant, setPlantConns);
}
}, [ast, plant, autoConnect]);
return <>
<div
className={showExecutionTrace ? "shadowBelow" : ""}
style={{flex: '0 0 content', backgroundColor: ''}}
>
{/* State tree */}
<PersistentDetails state={showStateTree} setState={setShowStateTree}>
<summary>state tree</summary>
<ul>
{ast && <ShowAST {...{...ast, trace, highlightActive: new Set()}}/>}
</ul>
</PersistentDetails>
{/* Input events */}
<PersistentDetails state={showInputEvents} setState={setShowInputEvents}>
<summary>input events</summary>
{ast && <ShowInputEvents
inputEvents={ast.inputEvents}
onRaise={(e,p) => onRaise("debug."+e,p)}
disabled={trace===null || trace.trace[trace.idx].kind === "error"}
showKeys={true}/>}
</PersistentDetails>
{/* Internal events */}
<PersistentDetails state={showInternalEvents} setState={setShowInternalEvents}>
<summary>internal events</summary>
{ast && <ShowInternalEvents internalEvents={ast.internalEvents}/>}
</PersistentDetails>
{/* Output events */}
<PersistentDetails state={showOutputEvents} setState={setShowOutputEvents}>
<summary>output events</summary>
{ast && <ShowOutputEvents outputEvents={ast.outputEvents}/>}
</PersistentDetails>
{/* Plant */}
<PersistentDetails state={showPlant} setState={setShowPlant}>
<summary>plant</summary>
<select
disabled={trace!==null}
value={plantName}
onChange={e => setPlantName(() => e.target.value)}>
{plants.map(([plantName, p]) =>
<option>{plantName}</option>
)}
</select>
<br/>
{/* Render plant */}
{<plant.render state={plant.cleanupState(plantState)} speed={speed}
raiseUIEvent={e => onRaise("plant.ui."+e.name, e.param)}
/>}
</PersistentDetails>
{/* Connections */}
<PersistentDetails state={showConnections} setState={setShowConnections}>
<summary>connections</summary>
<button title="auto-connect (name-based)" className={autoConnect?"active":""}
onClick={() => setAutoConnect(c => !c)}>
<AutoAwesomeIcon fontSize="small"/>
</button>
{ast && ConnEditor(ast, plant, plantConns, setPlantConns)}
</PersistentDetails>
{/* Properties */}
<details open={showProperties} onToggle={e => setShowProperties(e.newState === "open")}>
<summary>properties</summary>
{plant && <div>
available signals:
&nbsp;
{plant.signals.join(', ')}
</div>}
{properties.map((property, i) => {
const result = propertyResults && propertyResults[i];
let violated = null, propertyError = null;
if (result) {
violated = result[0] && result[0].length > 0 && !result[0][0].satisfied;
propertyError = result[1];
}
return <div style={{width:'100%'}} key={i} className="toolbar">
<div className={"status" + (violated === null ? "" : (violated ? " violated" : " satisfied"))}></div>
<button title="see in trace (below)" className={activeProperty === i ? "active" : ""} onClick={() => setActiveProperty(i)}>
<VisibilityIcon fontSize="small"/>
</button>
<input type="text" style={{width:'calc(100% - 90px)'}} value={property} onChange={e => setProperties(properties => properties.toSpliced(i, 1, e.target.value))}/>
<button title="delete this property" onClick={() => setProperties(properties => properties.toSpliced(i, 1))}>
<DeleteOutlineIcon fontSize="small"/>
</button>
{propertyError && <div style={{color: 'var(--error-color)'}}>{propertyError}</div>}
</div>;
})}
<div className="toolbar">
<button title="add property" onClick={() => setProperties(properties => [...properties, ""])}>
<AddIcon fontSize="small"/> add property
</button>
</div>
</details>
{/* Traces */}
<details open={showExecutionTrace} onToggle={e => setShowExecutionTrace(e.newState === "open")}><summary>execution trace</summary>
<div>
{savedTraces.map((savedTrace, i) =>
<div key={i} className="toolbar">
<button title="replay trace (may give a different result if you changed your model since recording the trace because only input and timer events are recorded)" onClick={() => onReplayTrace(savedTrace[1])}>
<CachedOutlinedIcon fontSize="small"/>
</button>
&nbsp;
<span style={{display:'inline-block', width: 26, fontSize: 9}}>{(Math.floor(savedTrace[1].at(-1)!.simtime/1000))}s</span>
<span style={{display:'inline-block', width: 22, fontSize: 9}}>({savedTrace[1].length})</span>
&nbsp;
<input title="name of the trace (only for humans - names don't have to be unique or anything)" type="text" value={savedTrace[0]} style={{width: 'calc(100% - 124px)'}} onChange={e => setSavedTraces(savedTraces => savedTraces.toSpliced(i, 1, [e.target.value, savedTraces[i][1]]))}/>
<button title="forget trace" onClick={() => setSavedTraces(savedTraces => savedTraces.toSpliced(i, 1))}>
<DeleteOutlineIcon fontSize="small"/>
</button>
</div>
)}
</div>
<div className="toolbar">
<input id="checkbox-show-plant-items" type="checkbox" checked={showPlantTrace} onChange={e => setShowPlantTrace(e.target.checked)}/>
<label title="plant steps are steps where only the state of the plant changed" htmlFor="checkbox-show-plant-items">show plant steps</label>
<input id="checkbox-autoscroll" type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)}/>
<label title="automatically scroll down event trace when new events occur" htmlFor="checkbox-autoscroll">auto-scroll</label>
&emsp;
<button title="save current trace" disabled={trace === null} onClick={() => onSaveTrace()}>
<SaveOutlinedIcon fontSize="small"/> save trace
</button>
</div>
</details>
</div>
{/* We cheat a bit, and render the execution trace depending on whether the <details> above is 'open' or not, rather than putting it as a child of the <details>. We do this because only then can we get the execution trace to scroll without the rest scrolling as well. */}
{showExecutionTrace &&
<div style={{
flexGrow:1,
overflow:'auto',
minHeight: '50vh',
// minHeight: '75%', // <-- allows us to always scroll down the sidebar far enough such that the execution history is enough in view
}}>
<div ref={refRightSideBar}>
{ast && <RTHistory {...{ast, trace, setTrace, setTime, showPlantTrace,
propertyTrace: propertyResults && propertyResults[activeProperty] && propertyResults[activeProperty][0] || []}}/>}
</div>
</div>}
</>;
}
function autoDetectConns(ast: Statechart, plant: Plant<any, any>, setPlantConns: Dispatch<SetStateAction<Conns>>) {
for (const {event: a} of plant.uiEvents) {
for (const {event: b} of plant.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.ui.'+a]: ['plant', b]}));
break;
}
}
for (const {event: b} of ast.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.ui.'+a]: ['sc', b]}));
}
}
}
for (const a of ast.outputEvents) {
for (const {event: b} of plant.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['sc.'+a]: ['plant', b]}));
}
}
}
for (const {event: a} of plant.outputEvents) {
for (const {event: b} of ast.inputEvents) {
if (a === b) {
setPlantConns(conns => ({...conns, ['plant.'+a]: ['sc', b]}));
}
}
}
}
function ConnEditor(ast: Statechart, plant: Plant<any, any>, plantConns: Conns, setPlantConns: Dispatch<SetStateAction<Conns>>) {
const plantInputs = <>{plant.inputEvents.map(e => <option key={'plant.'+e.event} value={'plant.'+e.event}>plant.{e.event}</option>)}</>
const scInputs = <>{ast.inputEvents.map(e => <option key={'sc.'+e.event} value={'sc.'+e.event}>sc.{e.event}</option>)}</>;
return <>
{/* SC output events can go to Plant */}
{[...ast.outputEvents].map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-sc-${e}`} style={{width:'50%'}}>sc.{e}&nbsp;&nbsp;</label>
<select id={`select-dst-sc-${e}`}
style={{width:'50%'}}
value={plantConns['sc.'+e]?.join('.')}
// @ts-ignore
onChange={domEvent => setPlantConns(conns => ({...conns, [`sc.${e}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))}))}>
<option key="none" value=""></option>
{plantInputs}
</select>
</div>)}
{/* Plant output events can go to Statechart */}
{[...plant.outputEvents.map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-plant-${e.event}`} style={{width:'50%'}}>plant.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-${e.event}`}
style={{width:'50%'}}
value={plantConns['plant.'+e.event]?.join('.')}
// @ts-ignore
onChange={(domEvent => setPlantConns(conns => ({...conns, [`plant.${e.event}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))})))}>
<option key="none" value=""></option>
{scInputs}
</select>
</div>)]}
{/* Plant UI events typically go to the Plant */}
{plant.uiEvents.map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-plant-ui-${e.event}`} style={{width:'50%', color: 'grey'}}>ui.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-ui-${e.event}`}
style={{width:'50%'}}
value={plantConns['plant.ui.'+e.event]?.join('.')}
// @ts-ignore
onChange={domEvent => setPlantConns(conns => ({...conns, [`plant.ui.${e.event}`]: (domEvent.target.value === "" ? undefined : (domEvent.target.value.split('.') as [string,string]))}))}>
<option key="none" value=""></option>
{scInputs}
{plantInputs}
</select>
</div>)}
</>;
}

View file

@ -3,7 +3,7 @@ import { TimerElapseEvent, Timers } from "../../statecharts/runtime_types";
import { getSimTime, setPaused, setRealtime, TimeMode } from "../../statecharts/time"; import { getSimTime, setPaused, setRealtime, TimeMode } from "../../statecharts/time";
import { InsertMode } from "./InsertModes"; import { InsertMode } from "./InsertModes";
import { About } from "../Modals/About"; import { About } from "../Modals/About";
import { EditHistory, TraceState } from "../App"; import { EditHistory } from "../App";
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo"; import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import { UndoRedoButtons } from "./UndoRedoButtons"; import { UndoRedoButtons } from "./UndoRedoButtons";
import { ZoomButtons } from "./ZoomButtons"; import { ZoomButtons } from "./ZoomButtons";
@ -21,6 +21,7 @@ import { InsertModes } from "./InsertModes";
import { usePersistentState } from "@/App/persistent_state"; import { usePersistentState } from "@/App/persistent_state";
import { RotateButtons } from "./RotateButtons"; import { RotateButtons } from "./RotateButtons";
import { SpeedControl } from "./SpeedControl"; import { SpeedControl } from "./SpeedControl";
import { TraceState } from "../useSimulator";
export type TopPanelProps = { export type TopPanelProps = {
trace: TraceState | null, trace: TraceState | null,

View file

@ -1,6 +1,5 @@
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useRef, useState } from "react"; import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
import { TraceState } from "@/App/App";
import { InsertMode } from "../TopPanel/InsertModes"; import { InsertMode } from "../TopPanel/InsertModes";
import { Mode } from "@/statecharts/runtime_types"; import { Mode } from "@/statecharts/runtime_types";
import { arraysEqual, objectsEqual, setsEqual } from "@/util/util"; import { arraysEqual, objectsEqual, setsEqual } from "@/util/util";
@ -17,7 +16,6 @@ import { useCopyPaste } from "./useCopyPaste";
import "./VisualEditor.css"; import "./VisualEditor.css";
import { useMouse } from "./useMouse"; import { useMouse } from "./useMouse";
import { Selecting } from "./Selection";
export type ConcreteSyntax = { export type ConcreteSyntax = {
rountangles: Rountangle[]; rountangles: Rountangle[];
@ -59,7 +57,8 @@ type VisualEditorProps = {
setState: Dispatch<(v:VisualEditorState) => VisualEditorState>, setState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
conns: Connections, conns: Connections,
syntaxErrors: TraceableError[], syntaxErrors: TraceableError[],
trace: TraceState | null, // trace: TraceState | null,
// activeStates: Set<string>,
insertMode: InsertMode, insertMode: InsertMode,
highlightActive: Set<string>, highlightActive: Set<string>,
highlightTransitions: string[], highlightTransitions: string[],
@ -68,7 +67,7 @@ type VisualEditorProps = {
zoom: number; zoom: number;
}; };
export const VisualEditor = memo(function VisualEditor({state, setState, trace, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}: VisualEditorProps) { export const VisualEditor = memo(function VisualEditor({state, setState, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}: VisualEditorProps) {
// uid's of selected rountangles // uid's of selected rountangles
const selection = state.selection || []; const selection = state.selection || [];
@ -87,7 +86,7 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
}) })
}); });
}) })
}, [trace && trace.idx]); }, [highlightTransitions]);
const {onCopy, onPaste, onCut, deleteSelection} = useCopyPaste(makeCheckPoint, state, setState, selection); const {onCopy, onPaste, onCut, deleteSelection} = useCopyPaste(makeCheckPoint, state, setState, selection);
@ -167,15 +166,12 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
} }
}, [setState]); }, [setState]);
// @ts-ignore
const active = trace && trace.trace[trace.idx].mode || new Set();
const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message); const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message);
const size = 4000*zoom; const size = 4000*zoom;
return <svg width={size} height={size} return <svg width={size} height={size}
className={"svgCanvas"+(active.has("root")?" active":"")/*+(dragging ? " dragging" : "")*/} className={"svgCanvas"+(highlightActive.has("root")?" active":"")/*+(dragging ? " dragging" : "")*/}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onContextMenu={e => e.preventDefault()} onContextMenu={e => e.preventDefault()}
ref={refSVG} ref={refSVG}

View file

@ -1,6 +1,6 @@
import { RT_Statechart } from "@/statecharts/runtime_types"; import { RT_Statechart } from "@/statecharts/runtime_types";
import { TraceItem } from "./App";
import { Plant } from "./Plant/Plant"; import { Plant } from "./Plant/Plant";
import { TraceItem } from "./useSimulator";
// const endpoint = "http://localhost:15478/check_property"; // const endpoint = "http://localhost:15478/check_property";
const endpoint = "https://deemz.org/apis/mtl-aas/check_property"; const endpoint = "https://deemz.org/apis/mtl-aas/check_property";
@ -37,8 +37,8 @@ export async function checkProperty(plant: Plant<RT_Statechart, any>, property:
}, [] as {simtime: number, state: any}[]); }, [] as {simtime: number, state: any}[]);
let traces = { let traces = {
'true': [0, true] as [number, any], 'true': [[0, true] as [number, any]],
'false': [0, false] as [number, any], 'false': [[0, false] as [number, any]],
} as {[key: string]: [number, any][]}; } as {[key: string]: [number, any][]};
for (const {simtime, state} of cleanPlantStates) { for (const {simtime, state} of cleanPlantStates) {
for (const [key, value] of Object.entries(state)) { for (const [key, value] of Object.entries(state)) {

View file

@ -0,0 +1,36 @@
import { Dispatch, SetStateAction, useCallback, useMemo } from "react";
export function makePartialSetter<T, K extends keyof T>(fullSetter: Dispatch<SetStateAction<T>>, key: K): Dispatch<SetStateAction<T[typeof key]>> {
return (newValueOrCallback: T[K] | ((newValue: T[K]) => T[K])) => {
fullSetter(oldFullValue => {
if (typeof newValueOrCallback === 'function') {
return {
...oldFullValue,
[key]: (newValueOrCallback as (newValue: T[K]) => T[K])(oldFullValue[key] as T[K]),
}
}
return {
...oldFullValue,
[key]: newValueOrCallback as T[K],
}
})
};
}
export type Setters<T extends {[key: string]: any}> = {
[K in keyof T as `set${Capitalize<Extract<K, string>>}`]: Dispatch<SetStateAction<T[K]>>;
}
export function makeIndividualSetters<T extends {[key: string]: any}>(
fullSetter: Dispatch<SetStateAction<T>>,
keys: (keyof T)[],
): Setters<T> {
// @ts-ignore
return useMemo(() =>
// @ts-ignore
Object.fromEntries(keys.map((key: string) => {
return [`set${key.charAt(0).toUpperCase()}${key.slice(1)}`, makePartialSetter(fullSetter, key)];
})),
[fullSetter]
);
}

14
src/App/plants.ts Normal file
View file

@ -0,0 +1,14 @@
import { digitalWatchPlant } from "./Plant/DigitalWatch/DigitalWatch";
import { dummyPlant } from "./Plant/Dummy/Dummy";
import { microwavePlant } from "./Plant/Microwave/Microwave";
import { Plant } from "./Plant/Plant";
import { trafficLightPlant } from "./Plant/TrafficLight/TrafficLight";
export type UniversalPlantState = {[property: string]: boolean|number};
export const plants: [string, Plant<any, UniversalPlantState>][] = [
["dummy", dummyPlant],
["microwave", microwavePlant as unknown as Plant<any, UniversalPlantState>],
["digital watch", digitalWatchPlant as unknown as Plant<any, UniversalPlantState>],
["traffic light", trafficLightPlant as unknown as Plant<any, UniversalPlantState>],
];

View file

@ -1,3 +1,223 @@
export function useSimulator() { import { Statechart } from "@/statecharts/abstract_syntax";
import { RuntimeError } from "@/statecharts/interpreter";
import { BigStep, RaisedEvent } from "@/statecharts/runtime_types";
import { Conns, coupledExecution, statechartExecution } from "@/statecharts/timed_reactive";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Plant } from "./Plant/Plant";
import { getSimTime, getWallClkDelay, TimeMode } from "@/statecharts/time";
import { UniversalPlantState } from "./plants";
type CoupledState = {
sc: BigStep,
plant: BigStep,
};
export type TraceItemError = {
cause: BigStepCause, // event name, <init> or <timer>
simtime: number,
error: RuntimeError,
}
export type BigStepCause = {
kind: "init",
simtime: 0,
} | {
kind: "input",
simtime: number,
eventName: string,
param?: any,
} | {
kind: "timer",
simtime: number,
};
export type TraceItem =
{ kind: "error" } & TraceItemError
| { kind: "bigstep", simtime: number, cause: BigStepCause, state: CoupledState, outputEvents: RaisedEvent[] };
export type TraceState = {
trace: [TraceItem, ...TraceItem[]], // non-empty
idx: number,
};
export function useSimulator(ast: Statechart|null, plant: Plant<any, UniversalPlantState>, plantConns: Conns, onStep: () => void) {
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
const [trace, setTrace] = useState<TraceState|null>(null);
const currentTraceItem = trace && trace.trace[trace.idx];
// coupled execution
const cE = useMemo(() => ast && coupledExecution({
sc: statechartExecution(ast),
plant: plant.execution,
}, {
...plantConns,
...Object.fromEntries(ast.inputEvents.map(({event}) => ["debug."+event, ['sc',event] as [string,string]])),
}), [ast]);
const onInit = useCallback(() => {
if (cE === null) return;
const metadata = {simtime: 0, cause: {kind: "init" as const, simtime: 0 as const}};
try {
const [outputEvents, state] = cE.initial(); // may throw if initialing the statechart results in a RuntimeError
setTrace({
trace: [{kind: "bigstep", ...metadata, state, outputEvents}],
idx: 0,
});
}
catch (error) {
if (error instanceof RuntimeError) {
setTrace({
trace: [{kind: "error", ...metadata, error}],
idx: 0,
});
}
else {
throw error; // probably a bug in the interpreter
}
}
setTime(time => {
if (time.kind === "paused") {
return {...time, simtime: 0};
}
else {
return {...time, since: {simtime: 0, wallclktime: performance.now()}};
}
});
onStep();
}, [cE, onStep]);
const onClear = useCallback(() => {
setTrace(null);
setTime({kind: "paused", simtime: 0});
}, [setTrace, setTime]);
// raise input event, producing a new runtime configuration (or a runtime error)
const onRaise = (inputEvent: string, param: any) => {
if (cE === null) return;
if (currentTraceItem !== null /*&& ast.inputEvents.some(e => e.event === inputEvent)*/) {
if (currentTraceItem.kind === "bigstep") {
const simtime = getSimTime(time, Math.round(performance.now()));
appendNewConfig(simtime, {kind: "input", simtime, eventName: inputEvent, param}, () => {
return cE.extTransition(simtime, currentTraceItem.state, {kind: "input", name: inputEvent, param});
});
}
}
};
// timer elapse events are triggered by a change of the simulated time (possibly as a scheduled JS event loop timeout)
useEffect(() => {
let timeout: NodeJS.Timeout | undefined;
if (currentTraceItem !== null && cE !== null) {
if (currentTraceItem.kind === "bigstep") {
const nextTimeout = cE?.timeAdvance(currentTraceItem.state);
const raiseTimeEvent = () => {
appendNewConfig(nextTimeout, {kind: "timer", simtime: nextTimeout}, () => {
return cE.intTransition(currentTraceItem.state);
});
}
if (time.kind === "realtime") {
const wallclkDelay = getWallClkDelay(time, nextTimeout, Math.round(performance.now()));
if (wallclkDelay !== Infinity) {
timeout = setTimeout(raiseTimeEvent, wallclkDelay);
}
}
else if (time.kind === "paused") {
if (nextTimeout <= time.simtime) {
raiseTimeEvent();
}
}
}
}
return () => {
if (timeout) clearTimeout(timeout);
}
}, [time, currentTraceItem]); // <-- todo: is this really efficient?
function appendNewConfig(simtime: number, cause: BigStepCause, computeNewState: () => [RaisedEvent[], CoupledState]) {
let newItem: TraceItem;
const metadata = {simtime, cause}
try {
const [outputEvents, state] = computeNewState(); // may throw RuntimeError
newItem = {kind: "bigstep", ...metadata, state, outputEvents};
}
catch (error) {
if (error instanceof RuntimeError) {
newItem = {kind: "error", ...metadata, error};
// also pause the simulation, for dramatic effect:
setTime({kind: "paused", simtime});
}
else {
throw error;
}
}
// @ts-ignore
setTrace(trace => ({
trace: [
...trace!.trace.slice(0, trace!.idx+1), // remove everything after current item
newItem,
],
// idx: 0,
idx: trace!.idx+1,
}));
onStep();
}
const onBack = useCallback(() => {
if (trace !== null) {
setTime(() => {
if (trace !== null) {
return {
kind: "paused",
simtime: trace.trace[trace.idx-1].simtime,
}
}
return { kind: "paused", simtime: 0 };
});
setTrace({
...trace,
idx: trace.idx-1,
});
}
}, [trace]);
const onReplayTrace = (causes: BigStepCause[]) => {
if (cE) {
function run_until(simtime: number) {
while (true) {
const nextTimeout = cE!.timeAdvance(lastState);
if (nextTimeout > simtime) {
break;
}
const [outputEvents, coupledState] = cE!.intTransition(lastState);
lastState = coupledState;
lastSimtime = nextTimeout;
newTrace.push({kind: "bigstep", simtime: nextTimeout, state: coupledState, outputEvents, cause: {kind: "timer", simtime: nextTimeout}});
}
}
const [outputEvents, coupledState] = cE.initial();
const newTrace = [{kind: "bigstep", simtime: 0, state: coupledState, outputEvents, cause: {kind: "init"} as BigStepCause} as TraceItem] as [TraceItem, ...TraceItem[]];
let lastState = coupledState;
let lastSimtime = 0;
for (const cause of causes) {
if (cause.kind === "input") {
run_until(cause.simtime); // <-- just make sure we haven't missed any timers elapsing
// @ts-ignore
const [outputEvents, coupledState] = cE.extTransition(cause.simtime, newTrace.at(-1)!.state, {kind: "input", name: cause.eventName, param: cause.param});
lastState = coupledState;
lastSimtime = cause.simtime;
newTrace.push({kind: "bigstep", simtime: cause.simtime, state: coupledState, outputEvents, cause});
}
else if (cause.kind === "timer") {
run_until(cause.simtime);
}
}
setTrace({trace: newTrace, idx: newTrace.length-1});
setTime({kind: "paused", simtime: lastSimtime});
}
}
return {trace, setTrace, plant, onInit, onClear, onBack, onRaise, onReplayTrace, time, setTime};
} }

View file

@ -1,29 +1,7 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react"; import { useEffect } from "react";
import { BigStepCause, EditHistory } from "./App";
import { VisualEditorState } from "./VisualEditor/VisualEditor";
import { emptyState } from "@/statecharts/concrete_syntax";
import { InsertMode } from "./TopPanel/InsertModes";
import { Conns } from "@/statecharts/timed_reactive";
export function useUrlHashState(editorState: VisualEditorState | null, setEditHistory: Dispatch<SetStateAction<EditHistory|null>>) {
// i should probably put all these things into a single object, the 'app state'...
const [autoScroll, setAutoScroll] = useState(false);
const [autoConnect, setAutoConnect] = useState(true);
const [plantConns, setPlantConns] = useState<Conns>({});
const [showKeys, setShowKeys] = useState(true);
const [zoom, setZoom] = useState(1);
const [insertMode, setInsertMode] = useState<InsertMode>("and");
const [plantName, setPlantName] = useState("dummy");
const [showConnections, setShowConnections] = useState(false);
const [showProperties, setShowProperties] = useState(false);
const [showExecutionTrace, setShowExecutionTrace] = useState(true);
const [showPlantTrace, setShowPlantTrace] = useState(false);
const [properties, setProperties] = useState<string[]>([]);
const [savedTraces, setSavedTraces] = useState<[string, BigStepCause[]][]>([]);
const [activeProperty, setActiveProperty] = useState<number>(0);
// persist state in URL hash
export function useUrlHashState<T>(recoverCallback: (recoveredState: T) => void): (toPersist: T) => void {
// recover editor state from URL - we need an effect here because decompression is asynchronous // recover editor state from URL - we need an effect here because decompression is asynchronous
useEffect(() => { useEffect(() => {
@ -32,7 +10,7 @@ export function useUrlHashState(editorState: VisualEditorState | null, setEditHi
if (compressedState.length === 0) { if (compressedState.length === 0) {
// empty URL hash // empty URL hash
console.log("no state to recover"); console.log("no state to recover");
setEditHistory(() => ({current: emptyState, history: [], future: []})); // setEditHistory(() => ({current: emptyState, history: [], future: []}));
return; return;
} }
let compressedBuffer; let compressedBuffer;
@ -41,7 +19,7 @@ export function useUrlHashState(editorState: VisualEditorState | null, setEditHi
} catch (e) { } catch (e) {
// probably invalid base64 // probably invalid base64
console.error("failed to recover state:", e); console.error("failed to recover state:", e);
setEditHistory(() => ({current: emptyState, history: [], future: []})); // setEditHistory(() => ({current: emptyState, history: [], future: []}));
return; return;
} }
const ds = new DecompressionStream("deflate"); const ds = new DecompressionStream("deflate");
@ -51,94 +29,17 @@ export function useUrlHashState(editorState: VisualEditorState | null, setEditHi
new Response(ds.readable).arrayBuffer() new Response(ds.readable).arrayBuffer()
.then(decompressedBuffer => { .then(decompressedBuffer => {
const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer)); const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer));
// we support two formats recoverCallback(recoveredState);
if (recoveredState.nextID) {
// old format
setEditHistory(() => ({current: recoveredState, history: [], future: []}));
}
else {
console.log(recoveredState);
// new format
if (recoveredState.editorState !== undefined) {
setEditHistory(() => ({current: recoveredState.editorState, history: [], future: []}));
}
if (recoveredState.plantName !== undefined) {
setPlantName(recoveredState.plantName);
}
if (recoveredState.autoScroll !== undefined) {
setAutoScroll(recoveredState.autoScroll);
}
if (recoveredState.autoConnect !== undefined) {
setAutoConnect(recoveredState.autoConnect);
}
if (recoveredState.plantConns !== undefined) {
setPlantConns(recoveredState.plantConns);
}
if (recoveredState.showKeys !== undefined) {
setShowKeys(recoveredState.showKeys);
}
if (recoveredState.zoom !== undefined) {
setZoom(recoveredState.zoom);
}
if (recoveredState.insertMode !== undefined) {
setInsertMode(recoveredState.insertMode);
}
if (recoveredState.showConnections !== undefined) {
setShowConnections(recoveredState.showConnections);
}
if (recoveredState.showProperties !== undefined) {
setShowProperties(recoveredState.showProperties);
}
if (recoveredState.showExecutionTrace !== undefined) {
setShowExecutionTrace(recoveredState.showExecutionTrace);
}
if (recoveredState.showPlantTrace !== undefined) {
setShowPlantTrace(recoveredState.showPlantTrace);
}
if (recoveredState.properties !== undefined) {
setProperties(recoveredState.properties);
}
if (recoveredState.savedTraces !== undefined) {
setSavedTraces(recoveredState.savedTraces);
}
if (recoveredState.activeProperty !== undefined) {
setActiveProperty(recoveredState.activeProperty);
}
}
}) })
.catch(e => { .catch(e => {
// any other error: invalid JSON, or decompression failed. // any other error: invalid JSON, or decompression failed.
console.error("failed to recover state:", e); console.error("failed to recover state:", e);
setEditHistory({current: emptyState, history: [], future: []}); // setEditHistory({current: emptyState, history: [], future: []});
}); });
}, []); }, []);
// save editor state in URL function persist(state: T) {
useEffect(() => { const serializedState = JSON.stringify(state);
const timeout = setTimeout(() => {
if (editorState === null) {
window.location.hash = "#";
return;
}
const serializedState = JSON.stringify({
autoConnect,
autoScroll,
plantConns,
showKeys,
zoom,
insertMode,
plantName,
editorState,
showConnections,
showProperties,
showExecutionTrace,
showPlantTrace,
properties,
savedTraces,
activeProperty,
});
const stateBuffer = new TextEncoder().encode(serializedState); const stateBuffer = new TextEncoder().encode(serializedState);
const cs = new CompressionStream("deflate"); const cs = new CompressionStream("deflate");
const writer = cs.writable.getWriter(); const writer = cs.writable.getWriter();
@ -149,55 +50,7 @@ export function useUrlHashState(editorState: VisualEditorState | null, setEditHi
const compressedStateString = new Uint8Array(compressedStateBuffer).toBase64(); const compressedStateString = new Uint8Array(compressedStateBuffer).toBase64();
window.location.hash = "#"+compressedStateString; window.location.hash = "#"+compressedStateString;
}); });
}, 100);
return () => clearTimeout(timeout);
}, [
editorState,
autoConnect,
autoScroll,
plantConns,
showKeys,
zoom,
insertMode,
plantName,
showConnections,
showProperties,
showExecutionTrace,
showPlantTrace,
properties,
savedTraces,
activeProperty,
]);
return {
autoConnect,
setAutoConnect,
autoScroll,
setAutoScroll,
plantConns,
setPlantConns,
showKeys,
setShowKeys,
zoom,
setZoom,
insertMode,
setInsertMode,
plantName,
setPlantName,
showConnections,
setShowConnections,
showProperties,
setShowProperties,
showExecutionTrace,
setShowExecutionTrace,
showPlantTrace,
setShowPlantTrace,
properties,
setProperties,
savedTraces,
setSavedTraces,
activeProperty,
setActiveProperty,
} }
return persist;
} }

View file

@ -326,7 +326,6 @@ function attemptSrcState(simtime: number, sourceState: AbstractState, event: RT_
// A fair step is a response to one (input|internal) event, where possibly multiple transitions are made as long as their arenas do not overlap. A reasonably accurate and more intuitive explanation is that every orthogonal region is allowed to fire at most one transition. // A fair step is a response to one (input|internal) event, where possibly multiple transitions are made as long as their arenas do not overlap. A reasonably accurate and more intuitive explanation is that every orthogonal region is allowed to fire at most one transition.
export function fairStep(simtime: number, event: RT_Event, statechart: Statechart, activeParent: StableState, {arenasFired, environment, ...config}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents { export function fairStep(simtime: number, event: RT_Event, statechart: Statechart, activeParent: StableState, {arenasFired, environment, ...config}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents {
console.log('fair step', event, activeParent);
environment = environment.enterScope(activeParent.uid); environment = environment.enterScope(activeParent.uid);
// console.log('fairStep', arenasFired); // console.log('fairStep', arenasFired);
for (const state of activeParent.children) { for (const state of activeParent.children) {
@ -347,7 +346,6 @@ export function fairStep(simtime: number, event: RT_Event, statechart: Statechar
} }
export function handleInputEvent(simtime: number, event: RT_Event, statechart: Statechart, {mode, environment, history}: {mode: Mode, environment: Environment, history: RT_History}): BigStep { export function handleInputEvent(simtime: number, event: RT_Event, statechart: Statechart, {mode, environment, history}: {mode: Mode, environment: Environment, history: RT_History}): BigStep {
console.log('handleInputEvent', event);
let raised = initialRaised; let raised = initialRaised;
({mode, environment, ...raised} = fairStep(simtime, event, statechart, statechart.root, {mode, environment, history, arenasFired: [], ...raised})); ({mode, environment, ...raised} = fairStep(simtime, event, statechart, statechart.root, {mode, environment, history, arenasFired: [], ...raised}));
@ -356,7 +354,6 @@ export function handleInputEvent(simtime: number, event: RT_Event, statechart: S
} }
export function handleInternalEvents(simtime: number, statechart: Statechart, {internalEvents, ...rest}: RT_Statechart & RaisedEvents) { export function handleInternalEvents(simtime: number, statechart: Statechart, {internalEvents, ...rest}: RT_Statechart & RaisedEvents) {
console.log('handleInternalEvents');
while (internalEvents.length > 0) { while (internalEvents.length > 0) {
const [nextEvent, ...remainingEvents] = internalEvents; const [nextEvent, ...remainingEvents] = internalEvents;
({internalEvents, ...rest} = fairStep(simtime, ({internalEvents, ...rest} = fairStep(simtime,