editable connections sc <-> plant

This commit is contained in:
Joeri Exelmans 2025-10-30 17:14:57 +01:00
parent e27d3c4c88
commit 8ac5a730cc
28 changed files with 1191 additions and 1016 deletions

View file

@ -1,11 +1,10 @@
import "../index.css";
import "./App.css";
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Dispatch, ReactElement, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { emptyState } from "@/statecharts/concrete_syntax";
import { detectConnections } from "@/statecharts/detect_connections";
import { Conns, coupledExecution, EventDestination, exposeStatechartInputs, statechartExecution } from "@/statecharts/timed_reactive";
import { Conns, coupledExecution, EventDestination, statechartExecution, TimedReactive } from "@/statecharts/timed_reactive";
import { RuntimeError } from "../statecharts/interpreter";
import { parseStatechart } from "../statecharts/parser";
import { BigStep, RaisedEvent } from "../statecharts/runtime_types";
@ -13,17 +12,17 @@ import { getSimTime, getWallClkDelay, TimeMode } from "../statecharts/time";
import { BottomPanel } from "./BottomPanel";
import { usePersistentState } from "./persistent_state";
import { PersistentDetails } from "./PersistentDetails";
import { DummyPlant } from "./Plant/Dummy/Dummy";
import { MicrowavePlant } from "./Plant/Microwave/Microwave";
import { autoConnect, exposePlantInputs, Plant } from "./Plant/Plant";
import { dummyPlant } from "./Plant/Dummy/Dummy";
import { microwavePlant } from "./Plant/Microwave/Microwave";
import { Plant } from "./Plant/Plant";
import { RTHistory } from "./RTHistory";
import { ShowAST, ShowInputEvents, ShowInternalEvents, ShowOutputEvents } from "./ShowAST";
import { TopPanel } from "./TopPanel/TopPanel";
import { getKeyHandler } from "./VisualEditor/shortcut_handler";
import { InsertMode, VisualEditor, VisualEditorState } from "./VisualEditor/VisualEditor";
import { addV2D, rotateLine90CCW, rotateLine90CW, rotatePoint90CCW, rotatePoint90CW, rotateRect90CCW, rotateRect90CW, scaleV2D, subtractV2D, Vec2D } from "@/util/geometry";
import { HISTORY_RADIUS } from "./parameters";
import { DigitalWatchPlant } from "./Plant/DigitalWatch/DigitalWatch";
import { VisualEditor, VisualEditorState } from "./VisualEditor/VisualEditor";
import { digitalWatchPlant } from "./Plant/DigitalWatch/DigitalWatch";
import { useEditor as useEditor } from "./useEditor";
import { InsertMode } from "./TopPanel/InsertModes";
import { Statechart } from "@/statecharts/abstract_syntax";
export type EditHistory = {
current: VisualEditorState,
@ -32,9 +31,9 @@ export type EditHistory = {
}
const plants: [string, Plant<any>][] = [
["dummy", DummyPlant],
["microwave", MicrowavePlant],
["digital watch", DigitalWatchPlant],
["dummy", dummyPlant],
["microwave", microwavePlant],
["digital watch", digitalWatchPlant],
]
export type TraceItemError = {
@ -58,25 +57,8 @@ export type TraceState = {
idx: number,
}; // <-- null if there is no trace
// function getPlantState<T>(plant: Plant<T>, trace: TraceItem[], idx: number): T | null {
// if (idx === -1) {
// return plant.initial;
// }
// let plantState = getPlantState(plant, trace, idx-1);
// if (plantState !== null) {
// const currentConfig = trace[idx];
// if (currentConfig.kind === "bigstep") {
// for (const o of currentConfig.outputEvents) {
// plantState = plant.reduce(o, plantState);
// }
// }
// return plantState;
// }
// return null;
// }
export function App() {
const [insertMode, setInsertMode] = useState<InsertMode>("and");
const [insertMode, setInsertMode] = usePersistentState<InsertMode>("insertMode", "and");
const [editHistory, setEditHistory] = useState<EditHistory|null>(null);
const [trace, setTrace] = useState<TraceState|null>(null);
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
@ -86,6 +68,10 @@ export function App() {
const [zoom, setZoom] = usePersistentState("zoom", 1);
const [showKeys, setShowKeys] = usePersistentState("shortcuts", true);
const [autoScroll, setAutoScroll] = usePersistentState("autoScroll", true);
const [autoConnect, setAutoConnect] = usePersistentState("autoConnect", true);
const [plantConns, setPlantConns] = usePersistentState<Conns>("plantConns", {});
const plant = plants.find(([pn, p]) => pn === plantName)![1];
const editorState = editHistory && editHistory.current;
@ -93,63 +79,6 @@ export function App() {
setEditHistory(historyState => historyState && ({...historyState, current: cb(historyState.current)}));
}, [setEditHistory]);
// recover editor state from URL - we need an effect here because decompression is asynchronous
useEffect(() => {
console.log('recovering state...');
const compressedState = window.location.hash.slice(1);
if (compressedState.length === 0) {
// empty URL hash
console.log("no state to recover");
setEditHistory(() => ({current: emptyState, history: [], future: []}));
return;
}
let compressedBuffer;
try {
compressedBuffer = Uint8Array.fromBase64(compressedState); // may throw
} catch (e) {
// probably invalid base64
console.error("failed to recover state:", e);
setEditHistory(() => ({current: emptyState, history: [], future: []}));
return;
}
const ds = new DecompressionStream("deflate");
const writer = ds.writable.getWriter();
writer.write(compressedBuffer).catch(() => {}); // any promise rejections will be detected when we try to read
writer.close().catch(() => {});
new Response(ds.readable).arrayBuffer()
.then(decompressedBuffer => {
const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer));
setEditHistory(() => ({current: recoveredState, history: [], future: []}));
})
.catch(e => {
// any other error: invalid JSON, or decompression failed.
console.error("failed to recover state:", e);
setEditHistory({current: emptyState, history: [], future: []});
});
}, []);
// save editor state in URL
useEffect(() => {
const timeout = setTimeout(() => {
if (editorState === null) {
window.location.hash = "#";
return;
}
const serializedState = JSON.stringify(editorState);
const stateBuffer = new TextEncoder().encode(serializedState);
const cs = new CompressionStream("deflate");
const writer = cs.writable.getWriter();
writer.write(stateBuffer);
writer.close();
// todo: cancel this promise handler when concurrently starting another compression job
new Response(cs.readable).arrayBuffer().then(compressedStateBuffer => {
const compressedStateString = new Uint8Array(compressedStateBuffer).toBase64();
window.location.hash = "#"+compressedStateString;
});
}, 100);
return () => clearTimeout(timeout);
}, [editorState]);
const refRightSideBar = useRef<HTMLDivElement>(null);
// parse concrete syntax always:
@ -166,177 +95,42 @@ export function App() {
}] : [],
]
// append editor state to undo history
const makeCheckPoint = useCallback(() => {
setEditHistory(historyState => historyState && ({
...historyState,
history: [...historyState.history, historyState.current],
future: [],
}));
}, [setEditHistory]);
const onUndo = useCallback(() => {
setEditHistory(historyState => {
if (historyState === null) return null;
if (historyState.history.length === 0) {
return historyState; // no change
}
return {
current: historyState.history.at(-1)!,
history: historyState.history.slice(0,-1),
future: [...historyState.future, historyState.current],
}
})
}, [setEditHistory]);
const onRedo = useCallback(() => {
setEditHistory(historyState => {
if (historyState === null) return null;
if (historyState.future.length === 0) {
return historyState; // no change
}
return {
current: historyState.future.at(-1)!,
history: [...historyState.history, historyState.current],
future: historyState.future.slice(0,-1),
}
});
}, [setEditHistory]);
const onRotate = useCallback((direction: "ccw" | "cw") => {
makeCheckPoint();
setEditHistory(historyState => {
if (historyState === null) return null;
const selection = historyState.current.selection;
if (selection.length === 0) {
return historyState;
}
// determine bounding box... in a convoluted manner
let minX = -Infinity, minY = -Infinity, maxX = Infinity, maxY = Infinity;
function addPointToBBox({x,y}: Vec2D) {
minX = Math.max(minX, x);
minY = Math.max(minY, y);
maxX = Math.min(maxX, x);
maxY = Math.min(maxY, y);
}
for (const rt of historyState.current.rountangles) {
if (selection.some(s => s.uid === rt.uid)) {
addPointToBBox(rt.topLeft);
addPointToBBox(addV2D(rt.topLeft, rt.size));
}
}
for (const d of historyState.current.diamonds) {
if (selection.some(s => s.uid === d.uid)) {
addPointToBBox(d.topLeft);
addPointToBBox(addV2D(d.topLeft, d.size));
}
}
for (const arr of historyState.current.arrows) {
if (selection.some(s => s.uid === arr.uid)) {
addPointToBBox(arr.start);
addPointToBBox(arr.end);
}
}
for (const txt of historyState.current.texts) {
if (selection.some(s => s.uid === txt.uid)) {
addPointToBBox(txt.topLeft);
}
}
const historySize = {x: HISTORY_RADIUS, y: HISTORY_RADIUS};
for (const h of historyState.current.history) {
if (selection.some(s => s.uid === h.uid)) {
addPointToBBox(h.topLeft);
addPointToBBox(addV2D(h.topLeft, scaleV2D(historySize, 2)));
}
}
const center: Vec2D = {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
};
const mapIfSelected = (shape: {uid: string}, cb: (shape:any)=>any) => {
if (selection.some(s => s.uid === shape.uid)) {
return cb(shape);
}
else {
return shape;
}
}
return {
...historyState,
current: {
...historyState.current,
rountangles: historyState.current.rountangles.map(rt => mapIfSelected(rt, rt => {
return {
...rt,
...(direction === "ccw"
? rotateRect90CCW(rt, center)
: rotateRect90CW(rt, center)),
}
})),
arrows: historyState.current.arrows.map(arr => mapIfSelected(arr, arr => {
return {
...arr,
...(direction === "ccw"
? rotateLine90CCW(arr, center)
: rotateLine90CW(arr, center)),
};
})),
diamonds: historyState.current.diamonds.map(d => mapIfSelected(d, d => {
return {
...d,
...(direction === "ccw"
? rotateRect90CCW(d, center)
: rotateRect90CW(d, center)),
};
})),
texts: historyState.current.texts.map(txt => mapIfSelected(txt, txt => {
return {
...txt,
topLeft: (direction === "ccw"
? rotatePoint90CCW(txt.topLeft, center)
: rotatePoint90CW(txt.topLeft, center)),
};
})),
history: historyState.current.history.map(h => mapIfSelected(h, h => {
return {
...h,
topLeft: (direction === "ccw"
? subtractV2D(rotatePoint90CCW(addV2D(h.topLeft, historySize), center), historySize)
: subtractV2D(rotatePoint90CW(addV2D(h.topLeft, historySize), center), historySize)
),
};
})),
},
}
})
}, [setEditHistory]);
const {makeCheckPoint, onRedo, onUndo, onRotate} = useEditor(editorState, setEditHistory);
const scrollDownSidebar = useCallback(() => {
if (refRightSideBar.current) {
if (autoScroll && refRightSideBar.current) {
const el = refRightSideBar.current;
// hack: we want to scroll to the new element, but we have to wait until it is rendered...
setTimeout(() => {
el.scrollIntoView({block: "end", behavior: "smooth"});
}, 50);
}
}, [refRightSideBar.current]);
}, [refRightSideBar.current, autoScroll]);
const plantConns = ast && ({
inputEvents: {
...exposeStatechartInputs(ast, "sc", (eventName: string) => "DEBUG_"+eventName),
...exposePlantInputs(plant, "plant", (eventName: string) => "PLANT_UI_"+eventName),
},
outputEvents: autoConnect(ast, "sc", plant, "plant"),
}) as Conns;
// const plantConns = ast && ({
// inputEvents: {
// // all SC inputs are directly triggerable from outside
// ...exposeStatechartInputs(ast, "sc", (eventName: string) => "debug."+eventName),
// ...Object.fromEntries(plant.uiEvents.map(e => {
// const globalName = "PLANT_UI_"+e.event;
// if (plant.inputEvents.some(f => f.event === e.event)) {
// return [globalName, {kind: "model", model: 'plant', eventName: e.event}];
// }
// if (ast.inputEvents.some(f => f.event === e.event)) {
// return [globalName, {kind: "model", model: 'sc', eventName: e.event}];
// }
// }).filter(entry => entry !== undefined)),
// },
// outputEvents: {}, //autoConnect(ast, "sc", plant, "plant"),
// }) as Conns;
const cE = useMemo(() => ast && coupledExecution({
sc: statechartExecution(ast),
plant: plant.execution,
}, plantConns!), [ast]);
}, {
...plantConns,
...Object.fromEntries(ast.inputEvents.map(({event}) => ["debug."+event, ['sc',event] as [string,string]])),
}), [ast]);
const onInit = useCallback(() => {
if (cE === null) return;
@ -448,7 +242,7 @@ export function App() {
scrollDownSidebar();
}
function onBack() {
const onBack = useCallback(() => {
if (trace !== null) {
setTime(() => {
if (trace !== null) {
@ -464,22 +258,7 @@ export function App() {
idx: trace.idx-1,
});
}
}
useEffect(() => {
console.log("Welcome to StateBuddy!");
() => {
console.log("Goodbye!");
}
}, []);
useEffect(() => {
const onKeyDown = getKeyHandler(setInsertMode);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, []);
}, [trace]);
const currentBigStep = currentTraceItem && currentTraceItem.kind === "bigstep" && currentTraceItem;
const highlightActive = (currentBigStep && currentBigStep.state.sc.mode) || new Set();
@ -490,6 +269,12 @@ export function App() {
const speed = time.kind === "paused" ? 0 : time.scale;
const plantState = currentBigStep && currentBigStep.state.plant || plant.execution.initial()[1];
useEffect(() => {
ast && autoConnect && autoDetectConns(ast, plant, setPlantConns);
}, [ast, plant, autoConnect]);
return <>
{/* Modal dialog */}
@ -523,7 +308,7 @@ export function App() {
{/* Editor */}
<div style={{flexGrow: 1, overflow: "auto"}}>
{editorState && conns && syntaxErrors &&
<VisualEditor {...{state: editorState, setState: setEditorState, conns, trace, setTrace, syntaxErrors: allErrors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}}/>}
<VisualEditor {...{state: editorState, setState: setEditorState, conns, trace, syntaxErrors: allErrors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}}/>}
</div>
</div>
@ -550,7 +335,7 @@ export function App() {
<summary>input events</summary>
{ast && <ShowInputEvents
inputEvents={ast.inputEvents}
onRaise={(e,p) => onRaise("DEBUG_"+e,p)}
onRaise={(e,p) => onRaise("debug."+e,p)}
disabled={trace===null || trace.trace[trace.idx].kind === "error"}
showKeys={showKeys}/>}
</PersistentDetails>
@ -572,13 +357,25 @@ export function App() {
<option>{plantName}</option>
)}
</select>
{plantConns && <ShowConns {...plantConns} />}
{currentBigStep && <plant.render state={currentBigStep.state.plant} speed={speed}
raiseInput={e => onRaise("PLANT_UI_"+e.name, e.param)}
{/* Render plant */}
{<plant.render state={plantState} speed={speed}
raiseUIEvent={e => onRaise("plant.ui."+e.name, e.param)}
/>}
</PersistentDetails>
<PersistentDetails localStorageKey="showConnEditor" initiallyOpen={false}>
<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>
<details open={showExecutionTrace} onToggle={e => setShowExecutionTrace(e.newState === "open")}><summary>execution trace</summary>
<input id="checkbox-show-plant-items" type="checkbox" checked={showPlantTrace} onChange={e => setShowPlantTrace(e.target.checked)}/><label htmlFor="checkbox-show-plant-items">show plant steps</label>
<input id="checkbox-show-plant-items" type="checkbox" checked={showPlantTrace} onChange={e => setShowPlantTrace(e.target.checked)}/>
<label htmlFor="checkbox-show-plant-items">show plant steps</label>
<input id="checkbox-autoscroll" type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)}/>
<label htmlFor="checkbox-autoscroll">auto-scroll</label>
</details>
</div>
@ -622,11 +419,85 @@ function ShowEventDestination(dst: EventDestination) {
function ShowConns({inputEvents, outputEvents}: Conns) {
return <div>
<div style={{color: "grey"}}>
{/* <div style={{color: "grey"}}>
{Object.entries(inputEvents).map(([eventName, destination]) => <div>{eventName} &#x2192; <ShowEventDestination {...destination}/></div>)}
</div>
{Object.entries(outputEvents).map(([modelName, mapping]) => <>{Object.entries(mapping).map(([eventName, destination]) => <div>{modelName}.{eventName} &#x2192; <ShowEventDestination {...destination}/></div>)}</>)}
{Object.entries(outputEvents).map(([modelName, mapping]) => <>{Object.entries(mapping).map(([eventName, destination]) => <div>{modelName}.{eventName} &#x2192; <ShowEventDestination {...destination}/></div>)}</>)} */}
</div>;
}
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
function autoDetectConns(ast: Statechart, plant: Plant<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>, 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 <>
{/* Plant UI events can go to SC or to Plant */}
{plant.uiEvents.map(e => <div>
<label htmlFor={`select-dst-plant-ui-${e.event}`}>ui.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-ui-${e.event}`}
value={plantConns['plant.ui.'+e.event]?.join('.')}
onChange={domEvent => setPlantConns(conns => ({...conns, [`plant.ui.${e.event}`]: domEvent.target.value.split('.') as [string,string]}))}>
<option key="none" value=""></option>
{scInputs}
{plantInputs}
</select>
</div>)}
{/* SC output events can go to Plant */}
{[...ast.outputEvents].map(e => <div>
<label htmlFor={`select-dst-sc-${e}`}>sc.{e}&nbsp;&nbsp;</label>
<select id={`select-dst-sc-${e}`}
value={plantConns['sc.'+e]?.join('.')}
onChange={domEvent => setPlantConns(conns => ({...conns, [`sc.${e}`]: 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>
<label htmlFor={`select-dst-plant-${e.event}`}>plant.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-${e.event}`}
value={plantConns['plant.'+e.event]?.join('.')}
onChange={(domEvent => setPlantConns(conns => ({...conns, [`plant.${e.event}`]: domEvent.target.value.split('.') as [string,string]})))}>
<option key="none" value=""></option>
{scInputs}
</select>
</div>)]}
</>;
}
export default App;

View file

@ -6,6 +6,5 @@
.bottom {
border-top: 1px lightgrey solid;
background-color: lightyellow;
/* background-color: rgb(255, 251, 244); */
background-color: rgb(255, 249, 235);
}

View file

@ -2,10 +2,9 @@ import { useAudioContext } from "@/App/useAudioContext";
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
import { detectConnections } from "@/statecharts/detect_connections";
import { parseStatechart } from "@/statecharts/parser";
import { BigStep, RT_Statechart } from "@/statecharts/runtime_types";
import { statechartExecution } from "@/statecharts/timed_reactive";
import { RT_Statechart } from "@/statecharts/runtime_types";
import { useEffect } from "react";
import { Plant, PlantRenderProps } from "../Plant";
import { makeStatechartPlant, PlantRenderProps } from "../Plant";
import dwatchConcreteSyntax from "./model.json";
import sndBeep from "./beep.wav";
@ -24,12 +23,12 @@ if (dwatchErrors.length > 0) {
const twoDigits = (n: number) => ("0"+n.toString()).slice(-2);
export function DigitalWatch({state, speed, raiseInput}: PlantRenderProps<RT_Statechart>) {
const displayingTime = state.mode.has("265");
const displayingAlarm = state.mode.has("266");
const displayingChrono = state.mode.has("264");
export function DigitalWatch({state, speed, raiseUIEvent}: PlantRenderProps<RT_Statechart>) {
const displayingTime = state.mode.has("625");
const displayingAlarm = state.mode.has("626");
const displayingChrono = state.mode.has("624");
const lightOn = state.mode.has("389");
const lightOn = state.mode.has("630");
const alarm = state.environment.get("alarm");
@ -43,9 +42,9 @@ export function DigitalWatch({state, speed, raiseInput}: PlantRenderProps<RT_Sta
const cs = state.environment.get("cs");
const chs = state.environment.get("chs");
const hideH = state.mode.has("268");
const hideM = state.mode.has("271");
const hideS = state.mode.has("267");
const hideH = state.mode.has("628");
const hideM = state.mode.has("633");
const hideS = state.mode.has("627");
// console.log({cm,cs,chs});
@ -64,7 +63,7 @@ export function DigitalWatch({state, speed, raiseInput}: PlantRenderProps<RT_Sta
preloadAudio(sndBeep);
const beep = state.mode.has("270");
const beep = state.mode.has("632");
useEffect(() => {
if (beep) {
@ -88,20 +87,20 @@ export function DigitalWatch({state, speed, raiseInput}: PlantRenderProps<RT_Sta
<text x="111" y="126" dominantBaseline="middle" textAnchor="middle" fontFamily="digital-font" fontSize={28} style={{whiteSpace:'preserve'}}>{hhmmss}</text>
<rect className="watchButtonHelper" x={0} y={54} width={24} height={24}
onMouseDown={() => raiseInput({name: "topLeftPressed"})}
onMouseUp={() => raiseInput({name: "topLeftReleased"})}
onMouseDown={() => raiseUIEvent({name: "topLeftPressed"})}
onMouseUp={() => raiseUIEvent({name: "topLeftReleased"})}
/>
<rect className="watchButtonHelper" x={198} y={54} width={24} height={24}
onMouseDown={() => raiseInput({name: "topRightPressed"})}
onMouseUp={() => raiseInput({name: "topRightReleased"})}
onMouseDown={() => raiseUIEvent({name: "topRightPressed"})}
onMouseUp={() => raiseUIEvent({name: "topRightReleased"})}
/>
<rect className="watchButtonHelper" x={0} y={154} width={24} height={24}
onMouseDown={() => raiseInput({name: "bottomLeftPressed"})}
onMouseUp={() => raiseInput({name: "bottomLeftReleased"})}
onMouseDown={() => raiseUIEvent({name: "bottomLeftPressed"})}
onMouseUp={() => raiseUIEvent({name: "bottomLeftReleased"})}
/>
<rect className="watchButtonHelper" x={198} y={154} width={24} height={24}
onMouseDown={() => raiseInput({name: "bottomRightPressed"})}
onMouseUp={() => raiseInput({name: "bottomRightReleased"})}
onMouseDown={() => raiseUIEvent({name: "bottomRightPressed"})}
onMouseUp={() => raiseUIEvent({name: "bottomRightReleased"})}
/>
{alarm &&
@ -111,46 +110,17 @@ export function DigitalWatch({state, speed, raiseInput}: PlantRenderProps<RT_Sta
</>;
}
export const DigitalWatchPlant: Plant<BigStep> = {
inputEvents: [
{ kind: "event", event: "displayTime" },
{ kind: "event", event: "displayChrono" },
{ kind: "event", event: "displayAlarm" },
{ kind: "event", event: "beginEdit" },
{ kind: "event", event: "endEdit" },
{ kind: "event", event: "selectNext" },
{ kind: "event", event: "incSelection" },
{ kind: "event", event: "incTime" },
{ kind: "event", event: "incAlarm" },
{ kind: "event", event: "incChrono" },
{ kind: "event", event: "resetChrono" },
{ kind: "event", event: "lightOn"},
{ kind: "event", event: "lightOff"},
{ kind: "event", event: "setAlarm", paramName: 'alarmOn'},
{ kind: "event", event: "beep", paramName: 'beep'},
// UI events
{ kind: "event", event: "topLeftPressed" },
{ kind: "event", event: "topRightPressed" },
{ kind: "event", event: "bottomRightPressed" },
{ kind: "event", event: "bottomLeftPressed" },
{ kind: "event", event: "topLeftReleased" },
{ kind: "event", event: "topRightReleased" },
{ kind: "event", event: "bottomRightReleased" },
{ kind: "event", event: "bottomLeftReleased" },
],
outputEvents: [
{ kind: "event", event: "alarm" },
{ kind: "event", event: "topLeftPressed" },
{ kind: "event", event: "topRightPressed" },
{ kind: "event", event: "bottomRightPressed" },
{ kind: "event", event: "bottomLeftPressed" },
{ kind: "event", event: "topLeftReleased" },
{ kind: "event", event: "topRightReleased" },
{ kind: "event", event: "bottomRightReleased" },
{ kind: "event", event: "bottomLeftReleased" },
],
execution: statechartExecution(dwatchAbstractSyntax),
export const digitalWatchPlant = makeStatechartPlant({
ast: dwatchAbstractSyntax,
render: DigitalWatch,
}
uiEvents: [
{ kind: "event", event: "topLeftPressed" },
{ kind: "event", event: "topRightPressed" },
{ kind: "event", event: "bottomRightPressed" },
{ kind: "event", event: "bottomLeftPressed" },
{ kind: "event", event: "topLeftReleased" },
{ kind: "event", event: "topRightReleased" },
{ kind: "event", event: "bottomRightReleased" },
{ kind: "event", event: "bottomLeftReleased" },
],
});

File diff suppressed because one or more lines are too long

View file

@ -8,7 +8,8 @@ export const dummyExecution: TimedReactive<null> = {
extTransition: () => [[], null],
};
export const DummyPlant: Plant<null> = {
export const dummyPlant: Plant<null> = {
uiEvents: [],
inputEvents: [],
outputEvents: [],
execution: dummyExecution,

View file

@ -8,14 +8,24 @@ import fontDigital from "../DigitalWatch/digital-font.ttf";
import sndBell from "./bell.wav";
import sndRunning from "./running.wav";
import { BigStep, RaisedEvent, RT_Statechart } from "@/statecharts/runtime_types";
import { RT_Statechart } from "@/statecharts/runtime_types";
import { useEffect } from "react";
import "./Microwave.css";
import { useAudioContext } from "../../useAudioContext";
import { Plant, PlantRenderProps } from "../Plant";
import { statechartExecution } from "@/statecharts/timed_reactive";
import { microwaveAbstractSyntax } from "./model";
import { makeStatechartPlant, PlantRenderProps, StatechartPlantSpec } from "../Plant";
import { detectConnections } from "@/statecharts/detect_connections";
import { parseStatechart } from "@/statecharts/parser";
import microwaveConcreteSyntax from "./model.json";
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
export const [microwaveAbstractSyntax, microwaveErrors] = parseStatechart(microwaveConcreteSyntax as ConcreteSyntax, detectConnections(microwaveConcreteSyntax as ConcreteSyntax));
if (microwaveErrors.length > 0) {
console.log({microwaveErrors});
throw new Error("there were errors parsing microwave plant model. see console.")
}
const imgs = {
@ -37,7 +47,7 @@ const DOOR_Y0 = 68;
const DOOR_WIDTH = 353;
const DOOR_HEIGHT = 217;
export function Microwave({state, speed, raiseInput}: PlantRenderProps<RT_Statechart>) {
export function Microwave({state, speed, raiseUIEvent}: PlantRenderProps<RT_Statechart>) {
const [playSound, preloadAudio] = useAudioContext(speed);
// preload(imgSmallClosedOff, {as: "image"});
@ -48,9 +58,9 @@ export function Microwave({state, speed, raiseInput}: PlantRenderProps<RT_Statec
preloadAudio(sndRunning);
preloadAudio(sndBell);
const bellRinging = state.mode.has("45");
const magnetronRunning = state.mode.has("28");
const doorOpen = state.mode.has("13");
const bellRinging = state.mode.has("12");
const magnetronRunning = state.mode.has("8");
const doorOpen = state.mode.has("7");
const timeDisplay = state.environment.get("timeDisplay");
// a bit hacky: when the bell-state changes to true, we play the bell sound...
@ -80,53 +90,40 @@ export function Microwave({state, speed, raiseInput}: PlantRenderProps<RT_Statec
<image xlinkHref={imgs[doorOpen][magnetronRunning]} width={520} height={348}/>
<rect className="microwaveButtonHelper" x={START_X0} y={START_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
onMouseDown={() => raiseInput({name: "startPressed"})}
onMouseUp={() => raiseInput({name: "startReleased"})}
onMouseDown={() => raiseUIEvent({name: "startPressed"})}
onMouseUp={() => raiseUIEvent({name: "startReleased"})}
/>
<rect className="microwaveButtonHelper" x={STOP_X0} y={STOP_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
onMouseDown={() => raiseInput({name: "stopPressed"})}
onMouseUp={() => raiseInput({name: "stopReleased"})}
onMouseDown={() => raiseUIEvent({name: "stopPressed"})}
onMouseUp={() => raiseUIEvent({name: "stopReleased"})}
/>
<rect className="microwaveButtonHelper" x={INCTIME_X0} y={INCTIME_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
onMouseDown={() => raiseInput({name: "incTimePressed"})}
onMouseUp={() => raiseInput({name: "incTimeReleased"})}
onMouseDown={() => raiseUIEvent({name: "incTimePressed"})}
onMouseUp={() => raiseUIEvent({name: "incTimeReleased"})}
/>
<rect className="microwaveDoorHelper"
x={DOOR_X0} y={DOOR_Y0} width={DOOR_WIDTH} height={DOOR_HEIGHT}
onMouseDown={() => raiseInput({name: "doorMouseDown"})}
onMouseUp={() => raiseInput({name: "doorMouseUp"})}
onMouseDown={() => raiseUIEvent({name: "doorMouseDown"})}
onMouseUp={() => raiseUIEvent({name: "doorMouseUp"})}
/>
<text x={472} y={106} textAnchor="end" fontFamily="digital-font" fontSize={24} fill="lightgreen">{timeDisplay}</text>
</svg>
</>;
}
export const MicrowavePlant: Plant<BigStep> = {
inputEvents: [
// events coming from statechart
{kind: "event", event: "setTimeDisplay", paramName: "t"},
{kind: "event", event: "setMagnetron", paramName: "state"},
{kind: "event", event: "ringBell"},
// events coming from UI:
const microwavePlantSpec: StatechartPlantSpec = {
ast: microwaveAbstractSyntax,
render: Microwave,
uiEvents: [
{kind: "event", event: "doorMouseDown"},
{kind: "event", event: "doorMouseUp"},
{kind: "event", event: "startPressed"},
{kind: "event", event: "stopPressed"},
{kind: "event", event: "incTimePressed"},
{kind: "event", event: "startReleased"},
{kind: "event", event: "stopPressed"},
{kind: "event", event: "stopReleased"},
{kind: "event", event: "incTimePressed"},
{kind: "event", event: "incTimeReleased"},
],
outputEvents: [
{kind: "event", event: "door", paramName: "state"},
{kind: "event", event: "startPressed"},
{kind: "event", event: "stopPressed"},
{kind: "event", event: "incTimePressed"},
{kind: "event", event: "startReleased"},
{kind: "event", event: "stopReleased"},
{kind: "event", event: "incTimeReleased"},
],
execution: statechartExecution(microwaveAbstractSyntax),
render: Microwave,
}
export const microwavePlant = makeStatechartPlant(microwavePlantSpec);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,18 +1,21 @@
import { ReactElement } from "react";
import { Statechart } from "@/statecharts/abstract_syntax";
import { EventTrigger } from "@/statecharts/label_ast";
import { RaisedEvent } from "@/statecharts/runtime_types";
import { Conns, TimedReactive } from "@/statecharts/timed_reactive";
import { BigStep, RaisedEvent, RT_Statechart } from "@/statecharts/runtime_types";
import { statechartExecution, TimedReactive } from "@/statecharts/timed_reactive";
export type PlantRenderProps<StateType> = {
state: StateType,
speed: number,
raiseInput: (e: RaisedEvent) => void,
raiseUIEvent: (e: RaisedEvent) => void,
};
export type Plant<StateType> = {
uiEvents: EventTrigger[];
inputEvents: EventTrigger[];
outputEvents: EventTrigger[];
execution: TimedReactive<StateType>;
render: (props: PlantRenderProps<StateType>) => ReactElement;
}
@ -48,3 +51,19 @@ export function exposePlantInputs(plant: Plant<any>, plantName: string, tfm = (s
}
return inputs
}
export type StatechartPlantSpec = {
uiEvents: EventTrigger[],
ast: Statechart,
render: (props: PlantRenderProps<RT_Statechart>) => ReactElement,
}
export function makeStatechartPlant({uiEvents, ast, render}: StatechartPlantSpec): Plant<BigStep> {
return {
uiEvents,
inputEvents: ast.inputEvents,
outputEvents: [...ast.outputEvents].map(e => ({kind: "event" as const, event: e})),
execution: statechartExecution(ast),
render,
}
}

View file

@ -5,7 +5,6 @@ import { formatTime } from "../util/util";
import { TimeMode, timeTravel } from "../statecharts/time";
import { TraceItem, TraceState } from "./App";
import { Environment } from "@/statecharts/environment";
import { Conns } from "@/statecharts/timed_reactive";
type RTHistoryProps = {
trace: TraceState|null,

View file

@ -1,10 +1,11 @@
import { Dispatch, memo, ReactElement, SetStateAction } from "react";
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect } from "react";
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import { InsertMode } from "@/App/VisualEditor/VisualEditor";
import { HistoryIcon, PseudoStateIcon, RountangleIcon } from "./Icons";
import TrendingFlatIcon from '@mui/icons-material/TrendingFlat';
export type InsertMode = "and" | "or" | "pseudo" | "shallow" | "deep" | "transition" | "text";
const insertModes: [InsertMode, string, ReactElement, ReactElement][] = [
["and", "AND-states", <RountangleIcon kind="and"/>, <kbd>A</kbd>],
["or", "OR-states", <RountangleIcon kind="or"/>, <kbd>O</kbd>],
@ -16,6 +17,47 @@ const insertModes: [InsertMode, string, ReactElement, ReactElement][] = [
];
export const InsertModes = memo(function InsertModes({showKeys, insertMode, setInsertMode}: {showKeys: boolean, insertMode: InsertMode, setInsertMode: Dispatch<SetStateAction<InsertMode>>}) {
const onKeyDown = useCallback((e: KeyboardEvent) => {
// @ts-ignore
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
if (!e.ctrlKey) {
if (e.key === "a") {
e.preventDefault();
setInsertMode("and");
}
if (e.key === "o") {
e.preventDefault();
setInsertMode("or");
}
if (e.key === "p") {
e.preventDefault();
setInsertMode("pseudo");
}
if (e.key === "t") {
e.preventDefault();
setInsertMode("transition");
}
if (e.key === "x") {
e.preventDefault();
setInsertMode("text");
}
if (e.key === "h") {
e.preventDefault();
setInsertMode(oldMode => {
if (oldMode === "shallow") return "deep";
return "shallow";
})
}
}
}, [setInsertMode]);
useEffect(() => {
window.addEventListener("keydown", onKeyDown);
() => window.removeEventListener("keydown", onKeyDown);
}, [onKeyDown]);
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
return <>{insertModes.map(([m, hint, buttonTxt, keyInfo]) => <KeyInfo key={m} keyInfo={keyInfo}>
<button
@ -25,4 +67,4 @@ export const InsertModes = memo(function InsertModes({showKeys, insertMode, setI
onClick={() => setInsertMode(m)}
>{buttonTxt}</button>
</KeyInfo>)}</>;
})
})

View file

@ -0,0 +1,61 @@
import { Dispatch, memo, SetStateAction, useCallback, useEffect } from "react";
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import { setRealtime, TimeMode } from "@/statecharts/time";
export const SpeedControl = memo(function SpeedControl({showKeys, timescale, setTimescale, setTime}: {showKeys: boolean, timescale: number, setTimescale: Dispatch<SetStateAction<number>>, setTime: Dispatch<SetStateAction<TimeMode>>}) {
const onTimeScaleChange = useCallback((newValue: string, wallclktime: number) => {
const asFloat = parseFloat(newValue);
if (Number.isNaN(asFloat)) {
return;
}
const maxed = Math.min(asFloat, 64);
const mined = Math.max(maxed, 1/64);
setTimescale(mined);
setTime(time => {
if (time.kind === "paused") {
return time;
}
else {
return setRealtime(time, mined, wallclktime);
}
});
}, [setTime, setTimescale]);
const onSlower = useCallback(() => {
onTimeScaleChange((timescale/2).toString(), Math.round(performance.now()));
}, [onTimeScaleChange, timescale]);
const onFaster = useCallback(() => {
onTimeScaleChange((timescale*2).toString(), Math.round(performance.now()));
}, [onTimeScaleChange, timescale]);
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (!e.ctrlKey) {
if (e.key === "s") {
e.preventDefault();
onSlower();
}
if (e.key === "f") {
e.preventDefault();
onFaster();
}
}
}, [onSlower, onFaster])
useEffect(() => {
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onKeyDown])
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
return <>
<label htmlFor="number-timescale">speed</label>&nbsp;
<KeyInfo keyInfo={<kbd>S</kbd>}>
<button title="slower" onClick={onSlower}>÷2</button>
</KeyInfo>
<input title="controls how fast the simulation should run in real time mode - larger than 1 means: faster than wall-clock time" id="number-timescale" value={timescale.toFixed(3)} style={{width:40}} readOnly onChange={e => onTimeScaleChange(e.target.value, Math.round(performance.now()))}/>
<KeyInfo keyInfo={<kbd>F</kbd>}>
<button title="faster" onClick={onFaster}>×2</button>
</KeyInfo>
</>
});

View file

@ -1,7 +1,7 @@
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useState } from "react";
import { TimerElapseEvent, Timers } from "../../statecharts/runtime_types";
import { getSimTime, setPaused, setRealtime, TimeMode } from "../../statecharts/time";
import { InsertMode } from "../VisualEditor/VisualEditor";
import { InsertMode } from "./InsertModes";
import { About } from "../Modals/About";
import { EditHistory, TraceState } from "../App";
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
@ -20,6 +20,7 @@ import StopIcon from '@mui/icons-material/Stop';
import { InsertModes } from "./InsertModes";
import { usePersistentState } from "@/App/persistent_state";
import { RotateButtons } from "./RotateButtons";
import { SpeedControl } from "./SpeedControl";
export type TopPanelProps = {
trace: TraceState | null,
@ -79,24 +80,6 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
updateDisplayedTime();
}, [setTime, timescale, updateDisplayedTime]);
const onTimeScaleChange = useCallback((newValue: string, wallclktime: number) => {
const asFloat = parseFloat(newValue);
if (Number.isNaN(asFloat)) {
return;
}
const maxed = Math.min(asFloat, 64);
const mined = Math.max(maxed, 1/64);
setTimescale(mined);
setTime(time => {
if (time.kind === "paused") {
return time;
}
else {
return setRealtime(time, mined, wallclktime);
}
});
}, [setTime, setTimescale]);
// timestamp of next timed transition, in simulated time
const timers: Timers = config?.kind === "bigstep" && config.state.sc.environment.get("_timers") || [];
const nextTimedTransition: [number, TimerElapseEvent] | undefined = timers[0];
@ -115,16 +98,10 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
}
}, [nextTimedTransition, setTime]);
const onSlower = useCallback(() => {
onTimeScaleChange((timescale/2).toString(), Math.round(performance.now()));
}, [onTimeScaleChange, timescale]);
const onFaster = useCallback(() => {
onTimeScaleChange((timescale*2).toString(), Math.round(performance.now()));
}, [onTimeScaleChange, timescale]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
// don't capture keyboard events when focused on an input element:
// @ts-ignore
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
if (!e.ctrlKey) {
@ -143,7 +120,7 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
onClear();
}
if (e.key === "Tab") {
if (trace === null) {
if (config === null) {
onInit();
}
else {
@ -151,14 +128,6 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
}
e.preventDefault();
}
if (e.key === "s") {
e.preventDefault();
onSlower();
}
if (e.key === "f") {
e.preventDefault();
onFaster();
}
if (e.key === "`") {
e.preventDefault();
setShowKeys(show => !show);
@ -168,23 +137,12 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
onBack();
}
}
else {
// ctrl is down
if (e.key === "z") {
e.preventDefault();
onUndo();
}
if (e.key === "Z") {
e.preventDefault();
onRedo();
}
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [trace, config, time, onInit, timescale, onChangePaused, setShowKeys, onUndo, onRedo, onSlower, onFaster, onSkip, onBack, onClear]);
}, [config, time, onInit, onChangePaused, setShowKeys, onSkip, onBack, onClear]);
return <div className="toolbar">
{/* shortcuts / about */}
@ -241,14 +199,7 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
{/* speed */}
<div className="toolbarGroup">
<label htmlFor="number-timescale">speed</label>&nbsp;
<KeyInfo keyInfo={<kbd>S</kbd>}>
<button title="slower" onClick={onSlower}>÷2</button>
</KeyInfo>
<input title="controls how fast the simulation should run in real time mode - larger than 1 means: faster than wall-clock time" id="number-timescale" value={timescale.toFixed(3)} style={{width:40}} readOnly onChange={e => onTimeScaleChange(e.target.value, Math.round(performance.now()))}/>
<KeyInfo keyInfo={<kbd>F</kbd>}>
<button title="faster" onClick={onFaster}>×2</button>
</KeyInfo>
<SpeedControl setTime={setTime} timescale={timescale} setTimescale={setTimescale} showKeys={showKeys} />
&emsp;
</div>

View file

@ -1,10 +1,30 @@
import { memo } from "react";
import { memo, useCallback, useEffect } from "react";
import { KeyInfoHidden, KeyInfoVisible } from "./KeyInfo";
import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo';
export const UndoRedoButtons = memo(function UndoRedoButtons({showKeys, onUndo, onRedo, historyLength, futureLength}: {showKeys: boolean, onUndo: () => void, onRedo: () => void, historyLength: number, futureLength: number}) {
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (e.ctrlKey) {
// ctrl is down
if (e.key === "z") {
e.preventDefault();
onUndo();
}
if (e.key === "Z") {
e.preventDefault();
onRedo();
}
}
}, [onUndo, onRedo]);
useEffect(() => {
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onKeyDown]);
const KeyInfo = showKeys ? KeyInfoVisible : KeyInfoHidden;
return <>
<KeyInfo keyInfo={<><kbd>Ctrl</kbd>+<kbd>Z</kbd></>}>

View file

@ -24,10 +24,12 @@ export const ZoomButtons = memo(function ZoomButtons({showKeys, zoom, setZoom}:
if (e.ctrlKey) {
if (e.key === "+") {
e.preventDefault();
e.stopPropagation();
onZoomIn();
}
if (e.key === "-") {
e.preventDefault();
e.stopPropagation();
onZoomOut();
}
}

View file

@ -1,5 +1,5 @@
import { Diamond, RectSide } from "@/statecharts/concrete_syntax";
import { rountangleMinSize } from "./VisualEditor";
import { rountangleMinSize } from "@/statecharts/concrete_syntax";
import { Vec2D } from "../../util/geometry";
import { RectHelper } from "./RectHelpers";
import { memo } from "react";

View file

@ -1,4 +1,3 @@
import { memo } from "react";
import { RectSide } from "../../statecharts/concrete_syntax";
import { Vec2D } from "../../util/geometry";
import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS } from "../parameters";

View file

@ -2,7 +2,7 @@ import { memo } from "react";
import { Rountangle, RectSide } from "../../statecharts/concrete_syntax";
import { ROUNTANGLE_RADIUS } from "../parameters";
import { RectHelper } from "./RectHelpers";
import { rountangleMinSize } from "./VisualEditor";
import { rountangleMinSize } from "@/statecharts/concrete_syntax";
import { arraysEqual } from "@/util/util";

View file

@ -0,0 +1,14 @@
import { normalizeRect, Rect2D } from "@/util/geometry";
export type SelectingState = Rect2D | null;
export function Selecting(props: SelectingState) {
const normalizedRect = normalizeRect(props!);
return <rect
className="selecting"
x={normalizedRect.topLeft.x}
y={normalizedRect.topLeft.y}
width={normalizedRect.size.x}
height={normalizedRect.size.y}
/>;
}

View file

@ -1,22 +1,23 @@
import { ClipboardEvent, Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
import { Statechart } from "../../statecharts/abstract_syntax";
import { Arrow, ArrowPart, Diamond, History, Rountangle, RectSide, Text } from "../../statecharts/concrete_syntax";
import { parseStatechart, TraceableError } from "../../statecharts/parser";
import { ArcDirection, Rect2D, Vec2D, addV2D, arcDirection, area, isEntirelyWithin, normalizeRect, scaleV2D, subtractV2D, transformLine, transformRect } from "../../util/geometry";
import { MIN_ROUNTANGLE_SIZE } from "../parameters";
import { getBBoxInSvgCoords } from "../../util/svg_helper";
import { ArrowSVG } from "./ArrowSVG";
import { RountangleSVG } from "./RountangleSVG";
import { TextSVG } from "./TextSVG";
import { DiamondSVG } from "./DiamondSVG";
import { HistorySVG } from "./HistorySVG";
import { Connections, detectConnections } from "../../statecharts/detect_connections";
import "./VisualEditor.css";
import { TraceState } from "@/App/App";
import { InsertMode } from "../TopPanel/InsertModes";
import { Mode } from "@/statecharts/runtime_types";
import { arraysEqual, objectsEqual, setsEqual } from "@/util/util";
import { Arrow, ArrowPart, Diamond, History, RectSide, Rountangle, Text } from "../../statecharts/concrete_syntax";
import { Connections } from "../../statecharts/detect_connections";
import { TraceableError } from "../../statecharts/parser";
import { ArcDirection, arcDirection } from "../../util/geometry";
import { ArrowSVG } from "./ArrowSVG";
import { DiamondSVG } from "./DiamondSVG";
import { HistorySVG } from "./HistorySVG";
import { RountangleSVG } from "./RountangleSVG";
import { TextSVG } from "./TextSVG";
import { useCopyPaste } from "./useCopyPaste";
import "./VisualEditor.css";
import { useMouse } from "./useMouse";
import { Selecting } from "./Selection";
export type ConcreteSyntax = {
rountangles: Rountangle[];
@ -31,8 +32,6 @@ export type VisualEditorState = ConcreteSyntax & {
selection: Selection;
};
type SelectingState = Rect2D | null;
export type RountangleSelectable = {
// kind: "rountangle";
parts: RectSide[];
@ -55,9 +54,6 @@ type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | Hist
export type Selection = Selectable[];
export type InsertMode = "and"|"or"|"pseudo"|"shallow"|"deep"|"transition"|"text";
type VisualEditorProps = {
state: VisualEditorState,
setState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
@ -74,17 +70,8 @@ type VisualEditorProps = {
export const VisualEditor = memo(function VisualEditor({state, setState, trace, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}: VisualEditorProps) {
const [dragging, setDragging] = useState(false);
window.setState = setState;
// uid's of selected rountangles
const selection = state.selection || [];
const setSelection = useCallback((cb: (oldSelection: Selection) => Selection) =>
setState(oldState => ({...oldState, selection: cb(oldState.selection)})),[setState]);
// not null while the user is making a selection
const [selectingState, setSelectingState] = useState<SelectingState>(null);
const refSVG = useRef<SVGSVGElement>(null);
@ -102,344 +89,11 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
})
}, [trace && trace.idx]);
const getCurrentPointer = useCallback((e: {pageX: number, pageY: number}) => {
const bbox = refSVG.current!.getBoundingClientRect();
return {
x: (e.pageX - bbox.left)/zoom,
y: (e.pageY - bbox.top)/zoom,
}
}, [refSVG.current, zoom]);
const onMouseDown = useCallback((e: {button: number, target: any, pageX: number, pageY: number}) => {
const currentPointer = getCurrentPointer(e);
if (e.button === 2) {
makeCheckPoint();
// ignore selection, middle mouse button always inserts
setState(state => {
const newID = state.nextID.toString();
if (insertMode === "and" || insertMode === "or") {
// insert rountangle
return {
...state,
rountangles: [...state.rountangles, {
uid: newID,
topLeft: currentPointer,
size: MIN_ROUNTANGLE_SIZE,
kind: insertMode,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["bottom", "right"]}],
};
}
else if (insertMode === "pseudo") {
return {
...state,
diamonds: [...state.diamonds, {
uid: newID,
topLeft: currentPointer,
size: MIN_ROUNTANGLE_SIZE,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["bottom", "right"]}],
};
}
else if (insertMode === "shallow" || insertMode === "deep") {
return {
...state,
history: [...state.history, {
uid: newID,
kind: insertMode,
topLeft: currentPointer,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["history"]}],
}
}
else if (insertMode === "transition") {
return {
...state,
arrows: [...state.arrows, {
uid: newID,
start: currentPointer,
end: currentPointer,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["end"]}],
}
}
else if (insertMode === "text") {
return {
...state,
texts: [...state.texts, {
uid: newID,
text: "// Double-click to edit",
topLeft: currentPointer,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["text"]}],
}
}
throw new Error("unreachable, mode=" + insertMode); // shut up typescript
});
setDragging(true);
return;
}
const {onCopy, onPaste, onCut, deleteSelection} = useCopyPaste(makeCheckPoint, state, setState, selection);
if (e.button === 0) {
// left mouse button on a shape will drag that shape (and everything else that's selected). if the shape under the pointer was not in the selection then the selection is reset to contain only that shape.
const uid = e.target?.dataset.uid;
const parts: string[] = e.target?.dataset.parts?.split(' ').filter((p:string) => p!=="") || [];
if (uid && parts.length > 0) {
makeCheckPoint();
const {onMouseDown, selectionRect} = useMouse(makeCheckPoint, insertMode, zoom, refSVG, state, setState, deleteSelection);
// if the mouse button is pressed outside of the current selection, we reset the selection to whatever shape the mouse is on
let allPartsInSelection = true;
for (const part of parts) {
if (!(selection.find(s => s.uid === uid)?.parts || [] as string[]).includes(part)) {
allPartsInSelection = false;
break;
}
}
if (!allPartsInSelection) {
if (e.target.classList.contains("helper")) {
setSelection(() => [{uid, parts}] as Selection);
}
else {
setDragging(false);
setSelectingState({
topLeft: currentPointer,
size: {x: 0, y: 0},
});
setSelection(() => []);
return;
}
}
// start dragging
setDragging(true);
return;
}
}
// otherwise, just start making a selection
setDragging(false);
setSelectingState({
topLeft: currentPointer,
size: {x: 0, y: 0},
});
setSelection(() => []);
}, [getCurrentPointer, makeCheckPoint, insertMode, selection]);
const onMouseMove = useCallback((e: {pageX: number, pageY: number, movementX: number, movementY: number}) => {
const currentPointer = getCurrentPointer(e);
if (dragging) {
// const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos);
const pointerDelta = {x: e.movementX/zoom, y: e.movementY/zoom};
setState(state => ({
...state,
rountangles: state.rountangles.map(r => {
const parts = state.selection.find(selected => selected.uid === r.uid)?.parts || [];
if (parts.length === 0) {
return r;
}
return {
...r,
...transformRect(r, parts, pointerDelta),
};
})
.toSorted((a,b) => area(b) - area(a)), // sort: smaller rountangles are drawn on top
diamonds: state.diamonds.map(d => {
const parts = state.selection.find(selected => selected.uid === d.uid)?.parts || [];
if (parts.length === 0) {
return d;
}
return {
...d,
...transformRect(d, parts, pointerDelta),
}
}),
history: state.history.map(h => {
const parts = state.selection.find(selected => selected.uid === h.uid)?.parts || [];
if (parts.length === 0) {
return h;
}
return {
...h,
topLeft: addV2D(h.topLeft, pointerDelta),
}
}),
arrows: state.arrows.map(a => {
const parts = state.selection.find(selected => selected.uid === a.uid)?.parts || [];
if (parts.length === 0) {
return a;
}
return {
...a,
...transformLine(a, parts, pointerDelta),
}
}),
texts: state.texts.map(t => {
const parts = state.selection.find(selected => selected.uid === t.uid)?.parts || [];
if (parts.length === 0) {
return t;
}
return {
...t,
topLeft: addV2D(t.topLeft, pointerDelta),
}
}),
}));
setDragging(true);
}
else if (selectingState) {
setSelectingState(ss => {
const selectionSize = subtractV2D(currentPointer, ss!.topLeft);
return {
...ss!,
size: selectionSize,
};
});
}
}, [getCurrentPointer, selectingState, dragging]);
const onMouseUp = useCallback((e: {target: any, pageX: number, pageY: number}) => {
if (dragging) {
setDragging(false);
// do not persist sizes smaller than 40x40
setState(state => {
return {
...state,
rountangles: state.rountangles.map(r => ({
...r,
size: rountangleMinSize(r.size),
})),
diamonds: state.diamonds.map(d => ({
...d,
size: rountangleMinSize(d.size),
}))
};
});
}
if (selectingState) {
if (selectingState.size.x === 0 && selectingState.size.y === 0) {
const uid = e.target?.dataset.uid;
if (uid) {
const parts = e.target?.dataset.parts.split(' ').filter((p: string) => p!=="");
if (uid) {
setSelection(() => [{
uid,
parts,
}]);
}
}
}
else {
// we were making a selection
const normalizedSS = normalizeRect(selectingState);
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
const shapesInSelection = shapes.filter(el => {
const bbox = getBBoxInSvgCoords(el, refSVG.current!);
const scaledBBox = {
topLeft: scaleV2D(bbox.topLeft, 1/zoom),
size: scaleV2D(bbox.size, 1/zoom),
}
return isEntirelyWithin(scaledBBox, normalizedSS);
}).filter(el => !el.classList.contains("corner"));
const uidToParts = new Map();
for (const shape of shapesInSelection) {
const uid = shape.dataset.uid;
if (uid) {
const parts: Set<string> = uidToParts.get(uid) || new Set();
for (const part of shape.dataset.parts?.split(' ') || []) {
parts.add(part);
}
uidToParts.set(uid, parts);
}
}
setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({
uid,
parts: [...parts],
})));
}
}
setSelectingState(null); // no longer making a selection
}, [dragging, selectingState, refSVG.current]);
const deleteSelection = useCallback(() => {
setState(state => ({
...state,
rountangles: state.rountangles.filter(r => !state.selection.some(rs => rs.uid === r.uid)),
diamonds: state.diamonds.filter(d => !state.selection.some(ds => ds.uid === d.uid)),
history: state.history.filter(h => !state.selection.some(hs => hs.uid === h.uid)),
arrows: state.arrows.filter(a => !state.selection.some(as => as.uid === a.uid)),
texts: state.texts.filter(t => !state.selection.some(ts => ts.uid === t.uid)),
selection: [],
}));
}, [setState]);
const onKeyDown = useCallback((e: KeyboardEvent) => {
// don't capture keyboard events when focused on an input element:
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
if (e.key === "Delete") {
// delete selection
makeCheckPoint();
deleteSelection();
}
if (e.key === "o") {
// selected states become OR-states
setState(state => ({
...state,
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "or"}) : r),
}));
}
if (e.key === "a") {
// selected states become AND-states
setState(state => ({
...state,
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "and"}) : r),
}));
}
// if (e.key === "p") {
// // selected states become pseudo-states
// setSelection(selection => {
// setState(state => ({
// ...state,
// rountangles: state.rountangles.map(r => selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "pseudo"}) : r),
// }));
// return selection;
// });
// }
if (e.ctrlKey) {
if (e.key === "a") {
e.preventDefault();
setDragging(false);
setState(state => ({
...state,
// @ts-ignore
selection: [
...state.rountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
...state.diamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
...state.arrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
...state.texts.map(t => ({uid: t.uid, parts: ["text"]})),
]
}))
}
}
}, [makeCheckPoint, deleteSelection, setState, setDragging]);
useEffect(() => {
// mousemove and mouseup are global event handlers so they keep working when pointer is outside of browser window
window.addEventListener("mouseup", onMouseUp);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [selectingState, dragging]);
// for visual feedback, when selecting/moving one thing, we also highlight (in green) all the things that belong to the thing we selected.
const sidesToHighlight: {[key: string]: RectSide[]} = {};
@ -487,101 +141,6 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
}
}
const onPaste = useCallback((e: ClipboardEvent) => {
const data = e.clipboardData?.getData("text/plain");
if (data) {
let parsed;
try {
parsed = JSON.parse(data);
}
catch (e) {
return;
}
// const offset = {x: 40, y: 40};
const offset = {x: 0, y: 0};
setState(state => {
let nextID = state.nextID;
const copiedRountangles: Rountangle[] = parsed.rountangles.map((r: Rountangle) => ({
...r,
uid: (nextID++).toString(),
topLeft: addV2D(r.topLeft, offset),
} as Rountangle));
const copiedDiamonds: Diamond[] = parsed.diamonds.map((r: Diamond) => ({
...r,
uid: (nextID++).toString(),
topLeft: addV2D(r.topLeft, offset),
} as Diamond));
const copiedArrows: Arrow[] = parsed.arrows.map((a: Arrow) => ({
...a,
uid: (nextID++).toString(),
start: addV2D(a.start, offset),
end: addV2D(a.end, offset),
} as Arrow));
const copiedTexts: Text[] = parsed.texts.map((t: Text) => ({
...t,
uid: (nextID++).toString(),
topLeft: addV2D(t.topLeft, offset),
} as Text));
const copiedHistories: History[] = parsed.history.map((h: History) => ({
...h,
uid: (nextID++).toString(),
topLeft: addV2D(h.topLeft, offset),
}))
// @ts-ignore
const newSelection: Selection = [
...copiedRountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
...copiedDiamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
...copiedArrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
...copiedTexts.map(t => ({uid: t.uid, parts: ["text"]})),
...copiedHistories.map(h => ({uid: h.uid, parts: ["history"]})),
];
return {
...state,
rountangles: [...state.rountangles, ...copiedRountangles],
diamonds: [...state.diamonds, ...copiedDiamonds],
arrows: [...state.arrows, ...copiedArrows],
texts: [...state.texts, ...copiedTexts],
history: [...state.history, ...copiedHistories],
nextID: nextID,
selection: newSelection,
};
});
// copyInternal(newSelection, e); // doesn't work
e.preventDefault();
}
}, [setState]);
const copyInternal = useCallback((state: VisualEditorState, selection: Selection, e: ClipboardEvent) => {
const uidsToCopy = new Set(selection.map(shape => shape.uid));
const rountanglesToCopy = state.rountangles.filter(r => uidsToCopy.has(r.uid));
const diamondsToCopy = state.diamonds.filter(d => uidsToCopy.has(d.uid));
const historiesToCopy = state.history.filter(h => uidsToCopy.has(h.uid));
const arrowsToCopy = state.arrows.filter(a => uidsToCopy.has(a.uid));
const textsToCopy = state.texts.filter(t => uidsToCopy.has(t.uid));
e.clipboardData?.setData("text/plain", JSON.stringify({
rountangles: rountanglesToCopy,
diamonds: diamondsToCopy,
history: historiesToCopy,
arrows: arrowsToCopy,
texts: textsToCopy,
}));
}, []);
const onCopy = useCallback((e: ClipboardEvent) => {
if (selection.length > 0) {
e.preventDefault();
copyInternal(state, selection, e);
}
}, [state, selection]);
const onCut = useCallback((e: ClipboardEvent) => {
if (selection.length > 0) {
copyInternal(state, selection, e);
deleteSelection();
e.preventDefault();
}
}, [state, selection]);
const onEditText = useCallback((text: Text, newText: string) => {
if (newText === "") {
// delete text node
@ -616,7 +175,7 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
const size = 4000*zoom;
return <svg width={size} height={size}
className={"svgCanvas"+(active.has("root")?" active":"")+(dragging ? " dragging" : "")}
className={"svgCanvas"+(active.has("root")?" active":"")/*+(dragging ? " dragging" : "")*/}
onMouseDown={onMouseDown}
onContextMenu={e => e.preventDefault()}
ref={refSVG}
@ -689,20 +248,10 @@ export const VisualEditor = memo(function VisualEditor({state, setState, trace,
<Texts texts={state.texts} {...{selection, textsToHighlight, errors, onEditText, setModal}}/>
{selectingState && <Selecting {...selectingState} />}
{selectionRect}
</svg>;
});
export function rountangleMinSize(size: Vec2D): Vec2D {
if (size.x >= 40 && size.y >= 40) {
return size;
}
return {
x: Math.max(40, size.x),
y: Math.max(40, size.y),
};
}
const Rountangles = memo(function Rountangles({rountangles, selection, sidesToHighlight, rountanglesToHighlight, errors, highlightActive}: {rountangles: Rountangle[], selection: Selection, sidesToHighlight: {[key: string]: RectSide[]}, rountanglesToHighlight: {[key: string]: boolean}, errors: TraceableError[], highlightActive: Mode}) {
return <>{rountangles.map(rountangle => {
return <RountangleSVG
@ -765,13 +314,3 @@ const Texts = memo(function Texts({texts, selection, textsToHighlight, errors, o
&& p.setModal === n.setModal;
});
export function Selecting(props: SelectingState) {
const normalizedRect = normalizeRect(props!);
return <rect
className="selecting"
x={normalizedRect.topLeft.x}
y={normalizedRect.topLeft.y}
width={normalizedRect.size.x}
height={normalizedRect.size.y}
/>;
}

View file

@ -1,33 +0,0 @@
import { Dispatch, SetStateAction } from "react";
import { InsertMode } from "./VisualEditor";
export function getKeyHandler(setMode: Dispatch<SetStateAction<InsertMode>>) {
return function onKeyDown(e: KeyboardEvent) {
// don't capture keyboard events when focused on an input element:
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
if (!e.ctrlKey) {
if (e.key === "a") {
setMode("and");
}
if (e.key === "o") {
setMode("or");
}
if (e.key === "p") {
setMode("pseudo");
}
if (e.key === "t") {
setMode("transition");
}
if (e.key === "x") {
setMode("text");
}
if (e.key === "h") {
setMode(oldMode => {
if (oldMode === "shallow") return "deep";
return "shallow";
})
}
}
}
}

View file

@ -0,0 +1,135 @@
import { Arrow, Diamond, Rountangle, Text, History } from "@/statecharts/concrete_syntax";
import { ClipboardEvent, Dispatch, SetStateAction, useCallback, useEffect } from "react";
import { Selection, VisualEditorState } from "./VisualEditor";
import { addV2D } from "@/util/geometry";
// const offset = {x: 40, y: 40};
const offset = {x: 0, y: 0};
export function useCopyPaste(makeCheckPoint: () => void, state: VisualEditorState, setState: Dispatch<(v:VisualEditorState) => VisualEditorState>, selection: Selection) {
const onPaste = useCallback((e: ClipboardEvent) => {
const data = e.clipboardData?.getData("text/plain");
if (data) {
try {
const parsed = JSON.parse(data);
setState(state => {
try {
let nextID = state.nextID;
const copiedRountangles: Rountangle[] = parsed.rountangles.map((r: Rountangle) => ({
...r,
uid: (nextID++).toString(),
topLeft: addV2D(r.topLeft, offset),
} as Rountangle));
const copiedDiamonds: Diamond[] = parsed.diamonds.map((r: Diamond) => ({
...r,
uid: (nextID++).toString(),
topLeft: addV2D(r.topLeft, offset),
} as Diamond));
const copiedArrows: Arrow[] = parsed.arrows.map((a: Arrow) => ({
...a,
uid: (nextID++).toString(),
start: addV2D(a.start, offset),
end: addV2D(a.end, offset),
} as Arrow));
const copiedTexts: Text[] = parsed.texts.map((t: Text) => ({
...t,
uid: (nextID++).toString(),
topLeft: addV2D(t.topLeft, offset),
} as Text));
const copiedHistories: History[] = parsed.history.map((h: History) => ({
...h,
uid: (nextID++).toString(),
topLeft: addV2D(h.topLeft, offset),
}))
// @ts-ignore
const newSelection: Selection = [
...copiedRountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
...copiedDiamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
...copiedArrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
...copiedTexts.map(t => ({uid: t.uid, parts: ["text"]})),
...copiedHistories.map(h => ({uid: h.uid, parts: ["history"]})),
];
makeCheckPoint();
return {
...state,
rountangles: [...state.rountangles, ...copiedRountangles],
diamonds: [...state.diamonds, ...copiedDiamonds],
arrows: [...state.arrows, ...copiedArrows],
texts: [...state.texts, ...copiedTexts],
history: [...state.history, ...copiedHistories],
nextID: nextID,
selection: newSelection,
};
}
catch (e) {
console.warn("error pasting data. most likely you're tying to paste nonsense. ", e);
return state;
}
});
}
catch (e) {
console.warn("error pasting data. most likely you're tying to paste nonsense. ", e);
}
e.preventDefault();
}
}, [setState]);
const copyInternal = useCallback((state: VisualEditorState, selection: Selection, e: ClipboardEvent) => {
const uidsToCopy = new Set(selection.map(shape => shape.uid));
const rountanglesToCopy = state.rountangles.filter(r => uidsToCopy.has(r.uid));
const diamondsToCopy = state.diamonds.filter(d => uidsToCopy.has(d.uid));
const historiesToCopy = state.history.filter(h => uidsToCopy.has(h.uid));
const arrowsToCopy = state.arrows.filter(a => uidsToCopy.has(a.uid));
const textsToCopy = state.texts.filter(t => uidsToCopy.has(t.uid));
e.clipboardData?.setData("text/plain", JSON.stringify({
rountangles: rountanglesToCopy,
diamonds: diamondsToCopy,
history: historiesToCopy,
arrows: arrowsToCopy,
texts: textsToCopy,
}));
}, []);
const onCopy = useCallback((e: ClipboardEvent) => {
if (selection.length > 0) {
e.preventDefault();
copyInternal(state, selection, e);
}
}, [state, selection]);
const onCut = useCallback((e: ClipboardEvent) => {
if (selection.length > 0) {
copyInternal(state, selection, e);
deleteSelection();
e.preventDefault();
}
}, [state, selection]);
const deleteSelection = useCallback(() => {
setState(state => ({
...state,
rountangles: state.rountangles.filter(r => !state.selection.some(rs => rs.uid === r.uid)),
diamonds: state.diamonds.filter(d => !state.selection.some(ds => ds.uid === d.uid)),
history: state.history.filter(h => !state.selection.some(hs => hs.uid === h.uid)),
arrows: state.arrows.filter(a => !state.selection.some(as => as.uid === a.uid)),
texts: state.texts.filter(t => !state.selection.some(ts => ts.uid === t.uid)),
selection: [],
}));
}, [setState]);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Delete") {
// delete selection
makeCheckPoint();
deleteSelection();
e.preventDefault();
}
}
useEffect(() => {
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
})
return {onCopy, onPaste, onCut, deleteSelection};
}

View file

@ -0,0 +1,344 @@
import { rountangleMinSize } from "@/statecharts/concrete_syntax";
import { addV2D, area, isEntirelyWithin, normalizeRect, scaleV2D, subtractV2D, transformLine, transformRect } from "@/util/geometry";
import { getBBoxInSvgCoords } from "@/util/svg_helper";
import { Dispatch, useCallback, useEffect, useState } from "react";
import { MIN_ROUNTANGLE_SIZE } from "../parameters";
import { InsertMode } from "../TopPanel/InsertModes";
import { Selecting, SelectingState } from "./Selection";
import { Selection, VisualEditorState } from "./VisualEditor";
export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoom: number, refSVG: {current: SVGSVGElement|null}, state: VisualEditorState, setState: Dispatch<(v: VisualEditorState) => VisualEditorState>, deleteSelection: () => void) {
const [dragging, setDragging] = useState(false);
// not null while the user is making a selection
const [selectingState, setSelectingState] = useState<SelectingState>(null);
const selection = state.selection;
const setSelection = useCallback((cb: (oldSelection: Selection) => Selection) =>
setState(oldState => ({...oldState, selection: cb(oldState.selection)})),[setState]);
const getCurrentPointer = useCallback((e: {pageX: number, pageY: number}) => {
const bbox = refSVG.current!.getBoundingClientRect();
return {
x: (e.pageX - bbox.left)/zoom,
y: (e.pageY - bbox.top)/zoom,
}
}, [refSVG.current, zoom]);
const onMouseDown = useCallback((e: {button: number, target: any, pageX: number, pageY: number}) => {
const currentPointer = getCurrentPointer(e);
if (e.button === 2) {
makeCheckPoint();
// ignore selection, middle mouse button always inserts
setState(state => {
const newID = state.nextID.toString();
if (insertMode === "and" || insertMode === "or") {
// insert rountangle
return {
...state,
rountangles: [...state.rountangles, {
uid: newID,
topLeft: currentPointer,
size: MIN_ROUNTANGLE_SIZE,
kind: insertMode,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["bottom", "right"]}],
};
}
else if (insertMode === "pseudo") {
return {
...state,
diamonds: [...state.diamonds, {
uid: newID,
topLeft: currentPointer,
size: MIN_ROUNTANGLE_SIZE,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["bottom", "right"]}],
};
}
else if (insertMode === "shallow" || insertMode === "deep") {
return {
...state,
history: [...state.history, {
uid: newID,
kind: insertMode,
topLeft: currentPointer,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["history"]}],
}
}
else if (insertMode === "transition") {
return {
...state,
arrows: [...state.arrows, {
uid: newID,
start: currentPointer,
end: currentPointer,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["end"]}],
}
}
else if (insertMode === "text") {
return {
...state,
texts: [...state.texts, {
uid: newID,
text: "// Double-click to edit",
topLeft: currentPointer,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["text"]}],
}
}
throw new Error("unreachable, mode=" + insertMode); // shut up typescript
});
setDragging(true);
return;
}
if (e.button === 0) {
// left mouse button on a shape will drag that shape (and everything else that's selected). if the shape under the pointer was not in the selection then the selection is reset to contain only that shape.
const uid = e.target?.dataset.uid;
const parts: string[] = e.target?.dataset.parts?.split(' ').filter((p:string) => p!=="") || [];
if (uid && parts.length > 0) {
makeCheckPoint();
// if the mouse button is pressed outside of the current selection, we reset the selection to whatever shape the mouse is on
let allPartsInSelection = true;
for (const part of parts) {
if (!(selection.find(s => s.uid === uid)?.parts || [] as string[]).includes(part)) {
allPartsInSelection = false;
break;
}
}
if (!allPartsInSelection) {
if (e.target.classList.contains("helper")) {
setSelection(() => [{uid, parts}] as Selection);
}
else {
setDragging(false);
setSelectingState({
topLeft: currentPointer,
size: {x: 0, y: 0},
});
setSelection(() => []);
return;
}
}
// start dragging
setDragging(true);
return;
}
}
// otherwise, just start making a selection
setDragging(false);
setSelectingState({
topLeft: currentPointer,
size: {x: 0, y: 0},
});
setSelection(() => []);
}, [getCurrentPointer, makeCheckPoint, insertMode, selection]);
const onMouseMove = useCallback((e: {pageX: number, pageY: number, movementX: number, movementY: number}) => {
const currentPointer = getCurrentPointer(e);
if (dragging) {
// const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos);
const pointerDelta = {x: e.movementX/zoom, y: e.movementY/zoom};
setState(state => ({
...state,
rountangles: state.rountangles.map(r => {
const parts = state.selection.find(selected => selected.uid === r.uid)?.parts || [];
if (parts.length === 0) {
return r;
}
return {
...r,
...transformRect(r, parts, pointerDelta),
};
})
.toSorted((a,b) => area(b) - area(a)), // sort: smaller rountangles are drawn on top
diamonds: state.diamonds.map(d => {
const parts = state.selection.find(selected => selected.uid === d.uid)?.parts || [];
if (parts.length === 0) {
return d;
}
return {
...d,
...transformRect(d, parts, pointerDelta),
}
}),
history: state.history.map(h => {
const parts = state.selection.find(selected => selected.uid === h.uid)?.parts || [];
if (parts.length === 0) {
return h;
}
return {
...h,
topLeft: addV2D(h.topLeft, pointerDelta),
}
}),
arrows: state.arrows.map(a => {
const parts = state.selection.find(selected => selected.uid === a.uid)?.parts || [];
if (parts.length === 0) {
return a;
}
return {
...a,
...transformLine(a, parts, pointerDelta),
}
}),
texts: state.texts.map(t => {
const parts = state.selection.find(selected => selected.uid === t.uid)?.parts || [];
if (parts.length === 0) {
return t;
}
return {
...t,
topLeft: addV2D(t.topLeft, pointerDelta),
}
}),
}));
setDragging(true);
}
else if (selectingState) {
setSelectingState(ss => {
const selectionSize = subtractV2D(currentPointer, ss!.topLeft);
return {
...ss!,
size: selectionSize,
};
});
}
}, [getCurrentPointer, selectingState, dragging]);
const onMouseUp = useCallback((e: {target: any, pageX: number, pageY: number}) => {
if (dragging) {
setDragging(false);
// do not persist sizes smaller than 40x40
setState(state => {
return {
...state,
rountangles: state.rountangles.map(r => ({
...r,
size: rountangleMinSize(r.size),
})),
diamonds: state.diamonds.map(d => ({
...d,
size: rountangleMinSize(d.size),
}))
};
});
}
if (selectingState) {
if (selectingState.size.x === 0 && selectingState.size.y === 0) {
const uid = e.target?.dataset.uid;
if (uid) {
const parts = e.target?.dataset.parts.split(' ').filter((p: string) => p!=="");
if (uid) {
setSelection(() => [{
uid,
parts,
}]);
}
}
}
else {
// we were making a selection
const normalizedSS = normalizeRect(selectingState);
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
const shapesInSelection = shapes.filter(el => {
const bbox = getBBoxInSvgCoords(el, refSVG.current!);
const scaledBBox = {
topLeft: scaleV2D(bbox.topLeft, 1/zoom),
size: scaleV2D(bbox.size, 1/zoom),
}
return isEntirelyWithin(scaledBBox, normalizedSS);
}).filter(el => !el.classList.contains("corner"));
const uidToParts = new Map();
for (const shape of shapesInSelection) {
const uid = shape.dataset.uid;
if (uid) {
const parts: Set<string> = uidToParts.get(uid) || new Set();
for (const part of shape.dataset.parts?.split(' ') || []) {
parts.add(part);
}
uidToParts.set(uid, parts);
}
}
setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({
uid,
parts: [...parts],
})));
}
}
setSelectingState(null); // no longer making a selection
}, [dragging, selectingState, refSVG.current]);
const onKeyDown = useCallback((e: KeyboardEvent) => {
// don't capture keyboard events when focused on an input element:
// @ts-ignore
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target?.tagName)) return;
if (e.key === "o") {
// selected states become OR-states
setState(state => ({
...state,
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "or"}) : r),
}));
}
if (e.key === "a") {
// selected states become AND-states
setState(state => ({
...state,
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "and"}) : r),
}));
}
// if (e.key === "p") {
// // selected states become pseudo-states
// setSelection(selection => {
// setState(state => ({
// ...state,
// rountangles: state.rountangles.map(r => selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "pseudo"}) : r),
// }));
// return selection;
// });
// }
if (e.ctrlKey) {
if (e.key === "a") {
e.preventDefault();
setDragging(false);
setState(state => ({
...state,
// @ts-ignore
selection: [
...state.rountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
...state.diamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
...state.arrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
...state.texts.map(t => ({uid: t.uid, parts: ["text"]})),
]
}))
}
}
}, [makeCheckPoint, deleteSelection, setState, setDragging]);
useEffect(() => {
// mousemove and mouseup are global event handlers so they keep working when pointer is outside of browser window
window.addEventListener("mouseup", onMouseUp);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("keydown", onKeyDown);
};
}, [selectingState, dragging]);
return {onMouseDown, selectionRect: selectingState && <Selecting {...selectingState} />};
}

225
src/App/useEditor.ts Normal file
View file

@ -0,0 +1,225 @@
import { addV2D, rotateLine90CCW, rotateLine90CW, rotatePoint90CCW, rotatePoint90CW, rotateRect90CCW, rotateRect90CW, scaleV2D, subtractV2D, Vec2D } from "@/util/geometry";
import { HISTORY_RADIUS } from "./parameters";
import { Dispatch, SetStateAction, useCallback, useEffect } from "react";
import { EditHistory } from "./App";
import { VisualEditorState } from "./VisualEditor/VisualEditor";
import { emptyState } from "@/statecharts/concrete_syntax";
export function useEditor(editorState: VisualEditorState | null, setEditHistory: Dispatch<SetStateAction<EditHistory|null>>) {
useEffect(() => {
console.log("Welcome to StateBuddy!");
() => {
console.log("Goodbye!");
}
}, []);
// recover editor state from URL - we need an effect here because decompression is asynchronous
useEffect(() => {
console.log('recovering state...');
const compressedState = window.location.hash.slice(1);
if (compressedState.length === 0) {
// empty URL hash
console.log("no state to recover");
setEditHistory(() => ({current: emptyState, history: [], future: []}));
return;
}
let compressedBuffer;
try {
compressedBuffer = Uint8Array.fromBase64(compressedState); // may throw
} catch (e) {
// probably invalid base64
console.error("failed to recover state:", e);
setEditHistory(() => ({current: emptyState, history: [], future: []}));
return;
}
const ds = new DecompressionStream("deflate");
const writer = ds.writable.getWriter();
writer.write(compressedBuffer).catch(() => {}); // any promise rejections will be detected when we try to read
writer.close().catch(() => {});
new Response(ds.readable).arrayBuffer()
.then(decompressedBuffer => {
const recoveredState = JSON.parse(new TextDecoder().decode(decompressedBuffer));
setEditHistory(() => ({current: recoveredState, history: [], future: []}));
})
.catch(e => {
// any other error: invalid JSON, or decompression failed.
console.error("failed to recover state:", e);
setEditHistory({current: emptyState, history: [], future: []});
});
}, []);
// save editor state in URL
useEffect(() => {
const timeout = setTimeout(() => {
if (editorState === null) {
window.location.hash = "#";
return;
}
const serializedState = JSON.stringify(editorState);
const stateBuffer = new TextEncoder().encode(serializedState);
const cs = new CompressionStream("deflate");
const writer = cs.writable.getWriter();
writer.write(stateBuffer);
writer.close();
// todo: cancel this promise handler when concurrently starting another compression job
new Response(cs.readable).arrayBuffer().then(compressedStateBuffer => {
const compressedStateString = new Uint8Array(compressedStateBuffer).toBase64();
window.location.hash = "#"+compressedStateString;
});
}, 100);
return () => clearTimeout(timeout);
}, [editorState]);
// append editor state to undo history
const makeCheckPoint = useCallback(() => {
setEditHistory(historyState => historyState && ({
...historyState,
history: [...historyState.history, historyState.current],
future: [],
}));
}, [setEditHistory]);
const onUndo = useCallback(() => {
setEditHistory(historyState => {
if (historyState === null) return null;
if (historyState.history.length === 0) {
return historyState; // no change
}
return {
current: historyState.history.at(-1)!,
history: historyState.history.slice(0,-1),
future: [...historyState.future, historyState.current],
}
})
}, [setEditHistory]);
const onRedo = useCallback(() => {
setEditHistory(historyState => {
if (historyState === null) return null;
if (historyState.future.length === 0) {
return historyState; // no change
}
return {
current: historyState.future.at(-1)!,
history: [...historyState.history, historyState.current],
future: historyState.future.slice(0,-1),
}
});
}, [setEditHistory]);
const onRotate = useCallback((direction: "ccw" | "cw") => {
makeCheckPoint();
setEditHistory(historyState => {
if (historyState === null) return null;
const selection = historyState.current.selection;
if (selection.length === 0) {
return historyState;
}
// determine bounding box... in a convoluted manner
let minX = -Infinity, minY = -Infinity, maxX = Infinity, maxY = Infinity;
function addPointToBBox({x,y}: Vec2D) {
minX = Math.max(minX, x);
minY = Math.max(minY, y);
maxX = Math.min(maxX, x);
maxY = Math.min(maxY, y);
}
for (const rt of historyState.current.rountangles) {
if (selection.some(s => s.uid === rt.uid)) {
addPointToBBox(rt.topLeft);
addPointToBBox(addV2D(rt.topLeft, rt.size));
}
}
for (const d of historyState.current.diamonds) {
if (selection.some(s => s.uid === d.uid)) {
addPointToBBox(d.topLeft);
addPointToBBox(addV2D(d.topLeft, d.size));
}
}
for (const arr of historyState.current.arrows) {
if (selection.some(s => s.uid === arr.uid)) {
addPointToBBox(arr.start);
addPointToBBox(arr.end);
}
}
for (const txt of historyState.current.texts) {
if (selection.some(s => s.uid === txt.uid)) {
addPointToBBox(txt.topLeft);
}
}
const historySize = {x: HISTORY_RADIUS, y: HISTORY_RADIUS};
for (const h of historyState.current.history) {
if (selection.some(s => s.uid === h.uid)) {
addPointToBBox(h.topLeft);
addPointToBBox(addV2D(h.topLeft, scaleV2D(historySize, 2)));
}
}
const center: Vec2D = {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
};
const mapIfSelected = (shape: {uid: string}, cb: (shape:any)=>any) => {
if (selection.some(s => s.uid === shape.uid)) {
return cb(shape);
}
else {
return shape;
}
}
return {
...historyState,
current: {
...historyState.current,
rountangles: historyState.current.rountangles.map(rt => mapIfSelected(rt, rt => {
return {
...rt,
...(direction === "ccw"
? rotateRect90CCW(rt, center)
: rotateRect90CW(rt, center)),
}
})),
arrows: historyState.current.arrows.map(arr => mapIfSelected(arr, arr => {
return {
...arr,
...(direction === "ccw"
? rotateLine90CCW(arr, center)
: rotateLine90CW(arr, center)),
};
})),
diamonds: historyState.current.diamonds.map(d => mapIfSelected(d, d => {
return {
...d,
...(direction === "ccw"
? rotateRect90CCW(d, center)
: rotateRect90CW(d, center)),
};
})),
texts: historyState.current.texts.map(txt => mapIfSelected(txt, txt => {
return {
...txt,
topLeft: (direction === "ccw"
? rotatePoint90CCW(txt.topLeft, center)
: rotatePoint90CW(txt.topLeft, center)),
};
})),
history: historyState.current.history.map(h => mapIfSelected(h, h => {
return {
...h,
topLeft: (direction === "ccw"
? subtractV2D(rotatePoint90CCW(addV2D(h.topLeft, historySize), center), historySize)
: subtractV2D(rotatePoint90CW(addV2D(h.topLeft, historySize), center), historySize)
),
};
})),
},
}
})
}, [setEditHistory]);
return {makeCheckPoint, onUndo, onRedo, onRotate};
}

3
src/App/useSimulator.ts Normal file
View file

@ -0,0 +1,3 @@
export function useSimulator() {
}

View file

@ -1,5 +1,5 @@
import { Rect2D, Vec2D, Line2D, euclideanDistance, intersectLines, isWithin, lineBBox, subtractV2D } from "../util/geometry";
import { ARROW_SNAP_THRESHOLD, HISTORY_RADIUS, TEXT_SNAP_THRESHOLD } from "../App/parameters";
import { ARROW_SNAP_THRESHOLD, HISTORY_RADIUS, ROUNTANGLE_RADIUS, TEXT_SNAP_THRESHOLD } from "../App/parameters";
import { VisualEditorState } from "../App/VisualEditor/VisualEditor";
import { sides } from "@/util/geometry";
@ -123,3 +123,14 @@ export function findNearestHistory(point: Vec2D, candidates: History[]): History
}
return best;
}
export function rountangleMinSize(size: Vec2D): Vec2D {
const minSize = ROUNTANGLE_RADIUS * 2;
if (size.x >= minSize && size.y >= minSize) {
return size;
}
return {
x: Math.max(minSize, size.x),
y: Math.max(minSize, size.y),
};
}

View file

@ -1,6 +1,6 @@
import { AbstractState, computeArena, computePath, ConcreteState, getDescendants, HistoryState, isOverlapping, OrState, StableState, Statechart, stateDescription, Transition, transitionDescription, TransitionSrcTgt } from "./abstract_syntax";
import { evalExpr } from "./actionlang_interpreter";
import { Environment, FlatEnvironment, ScopedEnvironment } from "./environment";
import { Environment, FlatEnvironment } from "./environment";
import { Action, EventTrigger, TransitionLabel } from "./label_ast";
import { BigStep, initialRaised, Mode, RaisedEvents, RT_Event, RT_History, RT_Statechart, TimerElapseEvent, Timers } from "./runtime_types";

View file

@ -11,6 +11,9 @@ export type TimedReactive<RT_Config> = {
timeAdvance: (c: RT_Config) => number,
intTransition: (c: RT_Config) => [RaisedEvent[], RT_Config],
extTransition: (simtime: number, c: RT_Config, e: InputEvent) => [RaisedEvent[], RT_Config],
// inputEvents: string[],
// outputEvents: string[],
}
export function statechartExecution(ast: Statechart): TimedReactive<BigStep> {
@ -32,7 +35,10 @@ export function statechartExecution(ast: Statechart): TimedReactive<BigStep> {
extTransition: (simtime: number, c: RT_Statechart, e: InputEvent) => {
const {outputEvents, ...rest} = handleInputEvent(simtime, e, ast, c);
return [outputEvents, {outputEvents, ...rest}];
}
},
// inputEvents: ast.inputEvents.map(e => e.event),
// outputEvents: [...ast.outputEvents],
}
}
@ -53,24 +59,24 @@ export type OutputDestination = {
// kind: "nowhere",
// };
export function exposeStatechartInputsOutputs(ast: Statechart, model: string): Conns {
return {
// all the coupled execution's input events become input events for the statechart
inputEvents: exposeStatechartInputs(ast, model),
outputEvents: exposeStatechartOutputs(ast, model),
}
}
// export function exposeStatechartInputsOutputs(ast: Statechart, model: string): Conns {
// return {
// // all the coupled execution's input events become input events for the statechart
// inputEvents: exposeStatechartInputs(ast, model),
// outputEvents: exposeStatechartOutputs(ast, model),
// }
// }
export function exposeStatechartInputs(ast: Statechart, model: string, tfm = (s: string) => s): {[eventName: string]: ModelDestination} {
return Object.fromEntries(ast.inputEvents.map(e => [tfm(e.event), {kind: "model", model, eventName: e.event}]));
}
// export function exposeStatechartInputs(ast: Statechart, model: string, tfm = (s: string) => s): {[eventName: string]: ModelDestination} {
// return Object.fromEntries(ast.inputEvents.map(e => [tfm(e.event), {kind: "model", model, eventName: e.event}]));
// }
export function exposeStatechartOutputs(ast: Statechart, model: string): {[modelName: string]: {[eventName: string]: EventDestination}} {
return {
// all the statechart's output events become output events of our coupled execution
[model]: Object.fromEntries([...ast.outputEvents].map(e => [e, {kind: "output", model, eventName: e}])),
};
}
// export function exposeStatechartOutputs(ast: Statechart, model: string): {[modelName: string]: {[eventName: string]: EventDestination}} {
// return {
// // all the statechart's output events become output events of our coupled execution
// [model]: Object.fromEntries([...ast.outputEvents].map(e => [e, {kind: "output", model, eventName: e}])),
// };
// }
// export function hideStatechartOutputs(ast: Statechart, model: string) {
// return {
@ -78,15 +84,23 @@ export function exposeStatechartOutputs(ast: Statechart, model: string): {[model
// }
// }
export type Conns = {
// inputs coming from outside are routed to the right models
inputEvents: {[eventName: string]: ModelDestination},
// export type Conns = {
// // inputs coming from outside are routed to the right models
// inputEvents: {[eventName: string]: ModelDestination},
// outputs coming from the models are routed to other models or to outside
outputEvents: {[modelName: string]: {[eventName: string]: EventDestination}},
}
// // outputs coming from the models are routed to other models or to outside
// outputEvents: {[modelName: string]: {[eventName: string]: EventDestination}},
// }
export function coupledExecution<T extends {[name: string]: any}>(models: {[name in keyof T]: TimedReactive<T[name]>}, conns: Conns): TimedReactive<T> {
// maps source to target. e.g.:
// {
// "sc.incTime": ["plant", "incTime"],
// "DEBUG_topRightClicked": ["sc", "topRightClicked"],
// }
export type Conns = {[eventName: string]: [string|null, string]};
export function coupledExecution<T extends {[name: string]: any}>(models: {[name in keyof T]: TimedReactive<T[name]>}, conns: Conns, /*inputEvents: string[], outputEvents: []*/): TimedReactive<T> {
function makeModelExtTransition(simtime: number, c: T, model: string, e: InputEvent) {
const [outputEvents, newConfig] = models[model].extTransition(simtime, c[model], e);
@ -100,33 +114,33 @@ export function coupledExecution<T extends {[name: string]: any}>(models: {[name
function processOutputs(simtime: number, events: RaisedEvent[], model: string, c: T): [RaisedEvent[], T] {
if (events.length > 0) {
const [event, ...rest] = events;
const destination = conns.outputEvents[model]?.[event.name];
const destination = conns[model+'.'+event.name];
if (destination === undefined) {
// ignore
console.log(`${model}.${event.name} goes nowhere`);
return processOutputs(simtime, rest, model, c);
}
if (destination.kind === "model") {
const [destinationModel, destinationEventName] = destination;
if (destinationModel !== null) {
// output event is input for another model
console.log(`${model}.${event.name} goes to ${destination.model}.${destination.eventName}`);
console.log(`${model}.${event.name} goes to ${destinationModel}.${destinationEventName}`);
const inputEvent = {
kind: "input" as const,
name: destination.eventName,
name: destinationEventName,
param: event.param,
};
const [outputEvents, newConfig] = makeModelExtTransition(simtime, c, destination.model, inputEvent);
const [outputEvents, newConfig] = makeModelExtTransition(simtime, c, destinationModel, inputEvent);
// proceed with 'rest':
const [restOutputEvents, newConfig2] = processOutputs(simtime, rest, model, newConfig);
return [[...outputEvents, ...restOutputEvents], newConfig2];
}
else if (destination.kind === "output") {
// kind === "output"
console.log(`${model}.${event.name} becomes ^${destination.eventName}`);
else {
// event is output event of our coupled execution
console.log(`${model}.${event.name} becomes ^${destinationEventName}`);
const [outputEvents, newConfig] = processOutputs(simtime, rest, model, c);
return [[event, ...outputEvents], newConfig];
}
throw new Error("unreachable");
}
else {
return [[], c];
@ -146,7 +160,6 @@ export function coupledExecution<T extends {[name: string]: any}>(models: {[name
// @ts-ignore
state[modelName] = modelState;
}
console.log({state});
// 2. handle all output events (models' outputs may be inputs for each other)
let finalOutputs = [];
for (const [modelName, outputEvents] of allOutputs) {
@ -175,21 +188,28 @@ export function coupledExecution<T extends {[name: string]: any}>(models: {[name
throw new Error("cannot make intTransition - timeAdvance is infinity");
},
extTransition: (simtime, c, e) => {
if (!Object.hasOwn(conns.inputEvents, e.name)) {
if (!Object.hasOwn(conns, e.name)) {
console.warn('input event', e.name, 'goes to nowhere');
return [[], c];
}
else {
const {model, eventName} = conns.inputEvents[e.name];
console.log('input event', e.name, 'goes to', `${model}.${eventName}`);
const inputEvent: InputEvent = {
kind: "input",
name: eventName,
param: e.param,
};
return makeModelExtTransition(simtime, c, model, inputEvent);
const [model, eventName] = conns[e.name];
if (model !== null) {
console.log('input event', e.name, 'goes to', `${model}.${eventName}`);
const inputEvent: InputEvent = {
kind: "input",
name: eventName,
param: e.param,
};
return makeModelExtTransition(simtime, c, model, inputEvent);
}
else {
throw new Error("not implemented: input event becoming output event right away.")
}
}
},
// inputEvents,
// outputEvents,
}
}