toolbar buttons to select shape

This commit is contained in:
Joeri Exelmans 2025-10-14 18:41:03 +02:00
parent a73d51a31a
commit 5ffa084516
14 changed files with 367 additions and 239 deletions

View file

@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
import { emptyStatechart, Statechart } from "../statecharts/abstract_syntax"; import { emptyStatechart, Statechart } from "../statecharts/abstract_syntax";
import { handleInputEvent, initialize } from "../statecharts/interpreter"; import { handleInputEvent, initialize } from "../statecharts/interpreter";
import { BigStep, BigStepOutput } from "../statecharts/runtime_types"; import { BigStep, BigStepOutput } from "../statecharts/runtime_types";
import { VisualEditor } from "../VisualEditor/VisualEditor"; import { InsertMode, VisualEditor } from "../VisualEditor/VisualEditor";
import { getSimTime, getWallClkDelay, TimeMode } from "../statecharts/time"; import { getSimTime, getWallClkDelay, TimeMode } from "../statecharts/time";
import "../index.css"; import "../index.css";
@ -16,6 +16,8 @@ import { AST } from "./AST";
import { TraceableError } from "../statecharts/parser"; import { TraceableError } from "../statecharts/parser";
export function App() { export function App() {
const [mode, setMode] = useState<InsertMode>("and");
const [ast, setAST] = useState<Statechart>(emptyStatechart); const [ast, setAST] = useState<Statechart>(emptyStatechart);
const [errors, setErrors] = useState<TraceableError[]>([]); const [errors, setErrors] = useState<TraceableError[]>([]);
@ -38,10 +40,10 @@ export function App() {
setTime({kind: "paused", simtime: 0}); setTime({kind: "paused", simtime: 0});
} }
function onRaise(inputEvent: string) { function onRaise(inputEvent: string, param: any) {
if (rt.length>0 && rtIdx!==undefined && ast.inputEvents.has(inputEvent)) { if (rt.length>0 && rtIdx!==undefined && ast.inputEvents.some(e => e.event === inputEvent)) {
const simtime = getSimTime(time, performance.now()); const simtime = getSimTime(time, performance.now());
const nextConfig = handleInputEvent(simtime, {kind: "input", name: inputEvent}, ast, rt[rtIdx]!); const nextConfig = handleInputEvent(simtime, {kind: "input", name: inputEvent, param}, ast, rt[rtIdx]!);
appendNewConfig(inputEvent, simtime, nextConfig); appendNewConfig(inputEvent, simtime, nextConfig);
} }
} }
@ -92,13 +94,13 @@ export function App() {
}}> }}>
<TopPanel <TopPanel
rt={rtIdx === undefined ? undefined : rt[rtIdx]} rt={rtIdx === undefined ? undefined : rt[rtIdx]}
{...{ast, time, setTime, onInit, onClear, onRaise}} {...{ast, time, setTime, onInit, onClear, onRaise, mode, setMode}}
/> />
</Box> </Box>
<Stack direction="row" sx={{height:'calc(100vh - 32px)'}}> <Stack direction="row" sx={{height:'calc(100vh - 32px)'}}>
{/* main */} {/* main */}
<Box sx={{flexGrow:1, overflow:'auto'}}> <Box sx={{flexGrow:1, overflow:'auto'}}>
<VisualEditor {...{ast, setAST, rt: rt.at(rtIdx!), setRT, errors, setErrors}}/> <VisualEditor {...{ast, setAST, rt: rt.at(rtIdx!), setRT, errors, setErrors, mode}}/>
</Box> </Box>
{/* right sidebar */} {/* right sidebar */}
<Box <Box

View file

@ -1,4 +1,4 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react"; import { Dispatch, ReactElement, SetStateAction, useEffect, useState } from "react";
import { BigStep, TimerElapseEvent, Timers } from "../statecharts/runtime_types"; import { BigStep, TimerElapseEvent, Timers } from "../statecharts/runtime_types";
import { getSimTime, setPaused, setRealtime, TimeMode } from "../statecharts/time"; import { getSimTime, setPaused, setRealtime, TimeMode } from "../statecharts/time";
import { Statechart } from "../statecharts/abstract_syntax"; import { Statechart } from "../statecharts/abstract_syntax";
@ -9,7 +9,11 @@ import PauseIcon from '@mui/icons-material/Pause';
import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import BoltIcon from '@mui/icons-material/Bolt'; import BoltIcon from '@mui/icons-material/Bolt';
import SkipNextIcon from '@mui/icons-material/SkipNext'; import SkipNextIcon from '@mui/icons-material/SkipNext';
import TrendingFlatIcon from '@mui/icons-material/TrendingFlat';
import { formatTime } from "./util"; import { formatTime } from "./util";
import { InsertMode } from "../VisualEditor/VisualEditor";
import { DiamondShape } from "../VisualEditor/RountangleSVG";
export type TopPanelProps = { export type TopPanelProps = {
rt?: BigStep, rt?: BigStep,
@ -17,11 +21,32 @@ export type TopPanelProps = {
setTime: Dispatch<SetStateAction<TimeMode>>, setTime: Dispatch<SetStateAction<TimeMode>>,
onInit: () => void, onInit: () => void,
onClear: () => void, onClear: () => void,
onRaise: (e: string) => void, onRaise: (e: string, p: any) => void,
ast: Statechart, ast: Statechart,
mode: InsertMode,
setMode: Dispatch<SetStateAction<InsertMode>>,
} }
export function TopPanel({rt, time, setTime, onInit, onClear, onRaise, ast}: TopPanelProps) { function RountangleIcon(props: {kind: string}) {
return <svg width={20} height={20}>
<rect rx={7} ry={7}
x={1} y={1}
width={18} height={18}
className={`rountangle ${props.kind}`}
style={props.kind === "or" ? {strokeDasharray: '3 2'}: {}}
/>
</svg>;
}
function PseudoStateIcon(props: {}) {
return <svg width={20} height={20}>
<g transform="translate(2,1)">
<DiamondShape geometry={{topLeft:{x:0,y:0}, size:{x:16,y:18}}} extraAttrs={{className: 'rountangle pseudo'}}/>
</g>
</svg>;
}
export function TopPanel({rt, time, setTime, onInit, onClear, onRaise, ast, mode, setMode}: TopPanelProps) {
const [displayTime, setDisplayTime] = useState("0.000"); const [displayTime, setDisplayTime] = useState("0.000");
const [timescale, setTimescale] = useState(1); const [timescale, setTimescale] = useState(1);
@ -74,7 +99,22 @@ export function TopPanel({rt, time, setTime, onInit, onClear, onRaise, ast}: Top
const timers: Timers = (rt?.environment.get("_timers") || []); const timers: Timers = (rt?.environment.get("_timers") || []);
const nextTimedTransition: [number, TimerElapseEvent] | undefined = timers[0]; const nextTimedTransition: [number, TimerElapseEvent] | undefined = timers[0];
return <div className="toolbar"> return <>
<div className="toolbar">
{([
["and", <RountangleIcon kind="and"/>],
["or", <RountangleIcon kind="or"/>],
["pseudo", <PseudoStateIcon/>],
["transition", <TrendingFlatIcon fontSize="small"/>],
["text", <>T</>],
] as [InsertMode, ReactElement][]).map(([m, buttonTxt]) =>
<button
disabled={mode===m}
onClick={() => setMode(m)}
>{buttonTxt}</button>)}
</div>
&emsp;
<div className="toolbar">
<button title="(re)initialize simulation" onClick={onInit} ><CachedIcon fontSize="small"/></button> <button title="(re)initialize simulation" onClick={onInit} ><CachedIcon fontSize="small"/></button>
<button title="clear the simulation" onClick={onClear} disabled={!rt}><ClearIcon fontSize="small"/></button> <button title="clear the simulation" onClick={onClear} disabled={!rt}><ClearIcon fontSize="small"/></button>
@ -83,6 +123,11 @@ export function TopPanel({rt, time, setTime, onInit, onClear, onRaise, ast}: Top
<button title="pause the simulation" disabled={!rt || time.kind==="paused"} onClick={() => onChangePaused(true, performance.now())}><PauseIcon fontSize="small"/></button> <button title="pause the simulation" disabled={!rt || time.kind==="paused"} onClick={() => onChangePaused(true, performance.now())}><PauseIcon fontSize="small"/></button>
<button title="run the simulation in real time" disabled={!rt || time.kind==="realtime"} onClick={() => onChangePaused(false, performance.now())}><PlayArrowIcon fontSize="small"/></button> <button title="run the simulation in real time" disabled={!rt || time.kind==="realtime"} onClick={() => onChangePaused(false, performance.now())}><PlayArrowIcon fontSize="small"/></button>
{/* <ToggleButtonGroup value={time.kind} exclusive onChange={(_,newValue) => onChangePaused(newValue==="paused", performance.now())} size="small">
<ToggleButton disableRipple value="paused" disabled={!rt}><PauseIcon/></ToggleButton>
<ToggleButton disableRipple value="realtime" disabled={!rt}><PlayArrowIcon/></ToggleButton>
</ToggleButtonGroup> */}
&emsp; &emsp;
<label htmlFor="number-timescale">timescale</label>&nbsp; <label htmlFor="number-timescale">timescale</label>&nbsp;
@ -92,19 +137,6 @@ export function TopPanel({rt, time, setTime, onInit, onClear, onRaise, ast}: Top
&emsp; &emsp;
{ast.inputEvents &&
<>
{[...ast.inputEvents].map(event => <button title={`raise input event '${event}'`} disabled={!rt} onClick={() => onRaise(event)}><BoltIcon fontSize="small"/> {event}</button>)}
&emsp;</>
}
{/* <ToggleButtonGroup value={time.kind} exclusive onChange={(_,newValue) => onChangePaused(newValue==="paused", performance.now())} size="small">
<ToggleButton disableRipple value="paused" disabled={!rt}><PauseIcon/></ToggleButton>
<ToggleButton disableRipple value="realtime" disabled={!rt}><PlayArrowIcon/></ToggleButton>
</ToggleButtonGroup> */}
&emsp;
<label htmlFor="time">time (s)</label>&nbsp; <label htmlFor="time">time (s)</label>&nbsp;
<input title="the current simulated time" id="time" disabled={!rt} value={displayTime} readOnly={true} className="readonlyTextBox" /> <input title="the current simulated time" id="time" disabled={!rt} value={displayTime} readOnly={true} className="readonlyTextBox" />
@ -123,5 +155,29 @@ export function TopPanel({rt, time, setTime, onInit, onClear, onRaise, ast}: Top
} }
}); });
}}><SkipNextIcon fontSize="small"/></button> }}><SkipNextIcon fontSize="small"/></button>
</div>;
{ast.inputEvents &&
<>
{ast.inputEvents.map(({event, paramName}) =>
<>&emsp;<button title={`raise input event '${event}'`} disabled={!rt} onClick={() => {
const param = document.getElementById(`input-${event}-param`)?.value;
let paramParsed;
try {
if (param) {
paramParsed = JSON.parse(param); // may throw
}
}
catch (e) {
alert("invalid json");
return;
}
onRaise(event, paramParsed);
}}>
<BoltIcon fontSize="small"/>
{event}
</button>{paramName && <><input id={`input-${event}-param`} style={{width: 20}} placeholder={paramName}/></>}</>)}
</>
}
</div></>;
} }

View file

@ -0,0 +1,53 @@
import { Arrow } from "../statecharts/concrete_syntax";
import { ArcDirection, euclideanDistance } from "./geometry";
import { CORNER_HELPER_RADIUS } from "./parameters";
export function ArrowSVG(props: { arrow: Arrow; selected: string[]; errors: string[]; highlight: boolean; arc: ArcDirection; }) {
const { start, end, uid } = props.arrow;
const radius = euclideanDistance(start, end) / 1.6;
const largeArc = "1";
const arcOrLine = props.arc === "no" ? "L" :
`A ${radius} ${radius} 0 ${largeArc} ${props.arc === "ccw" ? "0" : "1"}`;
return <g>
<path
className={"arrow"
+ (props.selected.length === 2 ? " selected" : "")
+ (props.errors.length > 0 ? " error" : "")
+ (props.highlight ? " highlight" : "")}
markerEnd='url(#arrowEnd)'
d={`M ${start.x} ${start.y}
${arcOrLine}
${end.x} ${end.y}`}
data-uid={uid}
data-parts="start end" />
{props.errors.length > 0 && <text className="error" x={(start.x + end.x) / 2 + 5} y={(start.y + end.y) / 2} data-uid={uid} data-parts="start end">{props.errors.join(' ')}</text>}
<path
className="pathHelper"
// markerEnd='url(#arrowEnd)'
d={`M ${start.x} ${start.y}
${arcOrLine}
${end.x} ${end.y}`}
data-uid={uid}
data-parts="start end" />
<circle
className={"circleHelper"
+ (props.selected.includes("start") ? " selected" : "")}
cx={start.x}
cy={start.y}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="start" />
<circle
className={"circleHelper"
+ (props.selected.includes("end") ? " selected" : "")}
cx={end.x}
cy={end.y}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="end" />
</g>;
}

View file

@ -0,0 +1,124 @@
import { Rountangle, RountanglePart } from "../statecharts/concrete_syntax";
import { Rect2D } from "./geometry";
import { ROUNTANGLE_RADIUS, CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS } from "./parameters";
import { rountangleMinSize } from "./VisualEditor";
export function DiamondShape(props: {geometry: Rect2D, extraAttrs: object}) {
const {geometry} = props;
return <polygon
points={`
${geometry.size.x/2} ${0},
${geometry.size.x} ${geometry.size.y/2},
${geometry.size.x/2} ${geometry.size.y},
${0} ${geometry.size.y/2}
`}
{...props.extraAttrs}
/>;
}
export function RountangleSVG(props: { rountangle: Rountangle; selected: string[]; highlight: RountanglePart[]; errors: string[]; active: boolean; }) {
const { topLeft, size, uid } = props.rountangle;
// always draw a rountangle with a minimum size
// during resizing, rountangle can be smaller than this size and even have a negative size, but we don't show it
const minSize = rountangleMinSize(size);
const extraAttrs = {
className: 'rountangle'
+ (props.selected.length === 4 ? " selected" : "")
+ (' ' + props.rountangle.kind)
+ (props.errors.length > 0 ? " error" : "")
+ (props.active ? " active" : ""),
"data-uid": uid,
"data-parts": "left top right bottom",
};
return <g transform={`translate(${topLeft.x} ${topLeft.y})`}>
{props.rountangle.kind === "pseudo" ?
<DiamondShape geometry={props.rountangle} extraAttrs={extraAttrs}/>
: <rect
rx={ROUNTANGLE_RADIUS} ry={ROUNTANGLE_RADIUS}
x={0}
y={0}
width={minSize.x}
height={minSize.y}
{...extraAttrs}
/>
}
{(props.errors.length > 0) &&
<text className="error" x={10} y={40} data-uid={uid} data-parts="left top right bottom">{props.errors.join(' ')}</text>}
<line
className={"lineHelper"
+ (props.selected.includes("top") ? " selected" : "")
+ (props.highlight.includes("top") ? " highlight" : "")}
x1={0}
y1={0}
x2={minSize.x}
y2={0}
data-uid={uid}
data-parts="top" />
<line
className={"lineHelper"
+ (props.selected.includes("right") ? " selected" : "")
+ (props.highlight.includes("right") ? " highlight" : "")}
x1={minSize.x}
y1={0}
x2={minSize.x}
y2={minSize.y}
data-uid={uid}
data-parts="right" />
<line
className={"lineHelper"
+ (props.selected.includes("bottom") ? " selected" : "")
+ (props.highlight.includes("bottom") ? " highlight" : "")}
x1={0}
y1={minSize.y}
x2={minSize.x}
y2={minSize.y}
data-uid={uid}
data-parts="bottom" />
<line
className={"lineHelper"
+ (props.selected.includes("left") ? " selected" : "")
+ (props.highlight.includes("left") ? " highlight" : "")}
x1={0}
y1={0}
x2={0}
y2={minSize.y}
data-uid={uid}
data-parts="left" />
<circle
className="circleHelper corner"
cx={CORNER_HELPER_OFFSET}
cy={CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="top left" />
<circle
className="circleHelper corner"
cx={minSize.x - CORNER_HELPER_OFFSET}
cy={CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="top right" />
<circle
className="circleHelper corner"
cx={minSize.x - CORNER_HELPER_OFFSET}
cy={minSize.y - CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="bottom right" />
<circle
className="circleHelper corner"
cx={CORNER_HELPER_OFFSET}
cy={minSize.y - CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="bottom left" />
<text x={10} y={20}
className="uid"
data-uid={uid}>{uid}</text>
</g>;
}

View file

@ -1,15 +1,17 @@
import * as lz4 from "@nick/lz4"; import * as lz4 from "@nick/lz4";
import { Dispatch, MouseEventHandler, SetStateAction, useEffect, useRef, useState, MouseEvent } from "react"; import { Dispatch, SetStateAction, useEffect, useRef, useState, MouseEvent } from "react";
import { Statechart } from "../statecharts/abstract_syntax"; import { Statechart } from "../statecharts/abstract_syntax";
import { Arrow, ArrowPart, Rountangle, RountanglePart, VisualEditorState, emptyState, findNearestArrow, findNearestRountangleSide, findRountangle } from "../statecharts/concrete_syntax"; import { ArrowPart, RountanglePart, VisualEditorState, emptyState, findNearestArrow, findNearestRountangleSide, findRountangle } from "../statecharts/concrete_syntax";
import { parseStatechart, TraceableError } from "../statecharts/parser"; import { parseStatechart, TraceableError } from "../statecharts/parser";
import { BigStep } from "../statecharts/runtime_types"; import { BigStep } from "../statecharts/runtime_types";
import { ArcDirection, Line2D, Rect2D, Vec2D, addV2D, arcDirection, area, euclideanDistance, getBottomSide, getLeftSide, getRightSide, getTopSide, isEntirelyWithin, normalizeRect, subtractV2D, transformLine, transformRect } from "./geometry"; import { ArcDirection, Line2D, Rect2D, Vec2D, addV2D, arcDirection, area, getBottomSide, getLeftSide, getRightSide, getTopSide, isEntirelyWithin, normalizeRect, subtractV2D, transformLine, transformRect } from "./geometry";
import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS, MIN_ROUNTANGLE_SIZE, ROUNTANGLE_RADIUS } from "./parameters"; import { MIN_ROUNTANGLE_SIZE } from "./parameters";
import { getBBoxInSvgCoords } from "./svg_helper"; import { getBBoxInSvgCoords } from "./svg_helper";
import "./VisualEditor.css"; import "./VisualEditor.css";
import { ArrowSVG } from "./ArrowSVG";
import { RountangleSVG } from "./RountangleSVG";
type DraggingState = { type DraggingState = {
@ -48,14 +50,17 @@ export const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [
["bottom", getBottomSide], ["bottom", getBottomSide],
]; ];
export type InsertMode = "and"|"or"|"pseudo"|"transition"|"text";
type VisualEditorProps = { type VisualEditorProps = {
setAST: Dispatch<SetStateAction<Statechart>>, setAST: Dispatch<SetStateAction<Statechart>>,
rt: BigStep|undefined, rt: BigStep|undefined,
errors: TraceableError[], errors: TraceableError[],
setErrors: Dispatch<SetStateAction<TraceableError[]>>, setErrors: Dispatch<SetStateAction<TraceableError[]>>,
mode: InsertMode,
}; };
export function VisualEditor({setAST, rt, errors, setErrors}: VisualEditorProps) { export function VisualEditor({setAST, rt, errors, setErrors, mode}: VisualEditorProps) {
const [historyState, setHistoryState] = useState<HistoryState>({current: emptyState, history: [], future: []}); const [historyState, setHistoryState] = useState<HistoryState>({current: emptyState, history: [], future: []});
const state = historyState.current; const state = historyState.current;
@ -108,7 +113,6 @@ export function VisualEditor({setAST, rt, errors, setErrors}: VisualEditorProps)
} }
const [dragging, setDragging] = useState<DraggingState>(null); const [dragging, setDragging] = useState<DraggingState>(null);
const [mode, setMode] = useState<"state"|"transition"|"text">("state");
const [showHelp, setShowHelp] = useState<boolean>(false); const [showHelp, setShowHelp] = useState<boolean>(false);
// uid's of selected rountangles // uid's of selected rountangles
@ -165,7 +169,7 @@ export function VisualEditor({setAST, rt, errors, setErrors}: VisualEditorProps)
// ignore selection, middle mouse button always inserts // ignore selection, middle mouse button always inserts
setState(state => { setState(state => {
const newID = state.nextID.toString(); const newID = state.nextID.toString();
if (mode === "state") { if (mode === "and" || mode === "or" || mode === "pseudo") {
// insert rountangle // insert rountangle
setSelection([{uid: newID, parts: ["bottom", "right"]}]); setSelection([{uid: newID, parts: ["bottom", "right"]}]);
return { return {
@ -174,7 +178,7 @@ export function VisualEditor({setAST, rt, errors, setErrors}: VisualEditorProps)
uid: newID, uid: newID,
topLeft: currentPointer, topLeft: currentPointer,
size: MIN_ROUNTANGLE_SIZE, size: MIN_ROUNTANGLE_SIZE,
kind: "and", kind: mode,
}], }],
nextID: state.nextID+1, nextID: state.nextID+1,
}; };
@ -203,7 +207,7 @@ export function VisualEditor({setAST, rt, errors, setErrors}: VisualEditorProps)
nextID: state.nextID+1, nextID: state.nextID+1,
} }
} }
throw new Error("unreachable"); // shut up typescript throw new Error("unreachable, mode=" + mode); // shut up typescript
}); });
setDragging({ setDragging({
lastMousePos: currentPointer, lastMousePos: currentPointer,
@ -377,18 +381,28 @@ export function VisualEditor({setAST, rt, errors, setErrors}: VisualEditorProps)
return selection; return selection;
}); });
} }
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.key === "h") { if (e.key === "h") {
setShowHelp(showHelp => !showHelp); setShowHelp(showHelp => !showHelp);
} }
if (e.key === "s") { // if (e.key === "s") {
setMode("state"); // setMode("state");
} // }
if (e.key === "t") { // if (e.key === "t") {
setMode("transition"); // setMode("transition");
} // }
if (e.key === "x") { // if (e.key === "x") {
setMode("text"); // setMode("text");
} // }
if (e.ctrlKey) { if (e.ctrlKey) {
if (e.key === "z") { if (e.key === "z") {
@ -577,8 +591,8 @@ export function VisualEditor({setAST, rt, errors, setErrors}: VisualEditorProps)
+(textsToHighlight.hasOwnProperty(txt.uid)?" highlight":""), +(textsToHighlight.hasOwnProperty(txt.uid)?" highlight":""),
} }
let textNode; let textNode;
if (err) { if (err?.data?.location) {
const {start,end} = err.data; const {start,end} = err.data.location;
textNode = <><text {...commonProps}> textNode = <><text {...commonProps}>
{txt.text.slice(0, start.offset)} {txt.text.slice(0, start.offset)}
<tspan className="error" data-uid={txt.uid} data-parts="text"> <tspan className="error" data-uid={txt.uid} data-parts="text">
@ -650,7 +664,7 @@ export function VisualEditor({setAST, rt, errors, setErrors}: VisualEditorProps)
</svg>; </svg>;
} }
function rountangleMinSize(size: Vec2D): Vec2D { export function rountangleMinSize(size: Vec2D): Vec2D {
if (size.x >= 40 && size.y >= 40) { if (size.x >= 40 && size.y >= 40) {
return size; return size;
} }
@ -660,173 +674,6 @@ function rountangleMinSize(size: Vec2D): Vec2D {
}; };
} }
export function RountangleSVG(props: {rountangle: Rountangle, selected: string[], highlight: RountanglePart[], errors: string[], active: boolean}) {
const {topLeft, size, uid} = props.rountangle;
// always draw a rountangle with a minimum size
// during resizing, rountangle can be smaller than this size and even have a negative size, but we don't show it
const minSize = rountangleMinSize(size);
return <g transform={`translate(${topLeft.x} ${topLeft.y})`}>
<rect
className={"rountangle"
+(props.selected.length===4?" selected":"")
+((props.rountangle.kind==="or")?" or":"")
+(props.errors.length>0?" error":"")
+(props.active?" active":"")
}
rx={ROUNTANGLE_RADIUS} ry={ROUNTANGLE_RADIUS}
x={0}
y={0}
width={minSize.x}
height={minSize.y}
data-uid={uid}
data-parts="left top right bottom"
/>
{(props.errors.length>0) &&
<text className="error" x={10} y={40} data-uid={uid} data-parts="left top right bottom">{props.errors.join(' ')}</text>}
<line
className={"lineHelper"
+(props.selected.includes("top")?" selected":"")
+(props.highlight.includes("top")?" highlight":"")
}
x1={0}
y1={0}
x2={minSize.x}
y2={0}
data-uid={uid}
data-parts="top"
/>
<line
className={"lineHelper"
+(props.selected.includes("right")?" selected":"")
+(props.highlight.includes("right")?" highlight":"")
}
x1={minSize.x}
y1={0}
x2={minSize.x}
y2={minSize.y}
data-uid={uid}
data-parts="right"
/>
<line
className={"lineHelper"
+(props.selected.includes("bottom")?" selected":"")
+(props.highlight.includes("bottom")?" highlight":"")
}
x1={0}
y1={minSize.y}
x2={minSize.x}
y2={minSize.y}
data-uid={uid}
data-parts="bottom"
/>
<line
className={"lineHelper"
+(props.selected.includes("left")?" selected":"")
+(props.highlight.includes("left")?" highlight":"")
}
x1={0}
y1={0}
x2={0}
y2={minSize.y}
data-uid={uid}
data-parts="left"
/>
<circle
className="circleHelper corner"
cx={CORNER_HELPER_OFFSET}
cy={CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="top left"
/>
<circle
className="circleHelper corner"
cx={minSize.x-CORNER_HELPER_OFFSET}
cy={CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="top right"
/>
<circle
className="circleHelper corner"
cx={minSize.x-CORNER_HELPER_OFFSET}
cy={minSize.y-CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="bottom right"
/>
<circle
className="circleHelper corner"
cx={CORNER_HELPER_OFFSET}
cy={minSize.y-CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="bottom left"
/>
<text x={10} y={20}
className="uid"
data-uid={uid}>{uid}</text>
</g>;
}
export function ArrowSVG(props: {arrow: Arrow, selected: string[], errors: string[], highlight: boolean, arc: ArcDirection}) {
const {start, end, uid} = props.arrow;
const radius = euclideanDistance(start, end)/1.6;
const largeArc = "1";
const arcOrLine = props.arc === "no" ? "L" :
`A ${radius} ${radius} 0 ${largeArc} ${props.arc === "ccw" ? "0" : "1"}`;
return <g>
<path
className={"arrow"
+(props.selected.length===2?" selected":"")
+(props.errors.length>0?" error":"")
+(props.highlight?" highlight":"")
}
markerEnd='url(#arrowEnd)'
d={`M ${start.x} ${start.y}
${arcOrLine}
${end.x} ${end.y}`}
data-uid={uid}
data-parts="start end"
/>
{props.errors.length>0 && <text className="error" x={(start.x+end.x)/2+5} y={(start.y+end.y)/2} data-uid={uid} data-parts="start end">{props.errors.join(' ')}</text>}
<path
className="pathHelper"
// markerEnd='url(#arrowEnd)'
d={`M ${start.x} ${start.y}
${arcOrLine}
${end.x} ${end.y}`}
data-uid={uid}
data-parts="start end"
/>
<circle
className={"circleHelper"
+(props.selected.includes("start")?" selected":"")}
cx={start.x}
cy={start.y}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="start"
/>
<circle
className={"circleHelper"
+(props.selected.includes("end")?" selected":"")}
cx={end.x}
cy={end.y}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="end"
/>
</g>;
}
export function Selecting(props: SelectingState) { export function Selecting(props: SelectingState) {
const normalizedRect = normalizeRect(props!); const normalizedRect = normalizeRect(props!);
return <rect return <rect

View file

View file

@ -1,4 +1,4 @@
import { Action, TransitionLabel } from "./label_ast"; import { Action, EventTrigger, TransitionLabel } from "./label_ast";
export type AbstractState = { export type AbstractState = {
uid: string; uid: string;
@ -37,8 +37,8 @@ export type Statechart = {
variables: Set<string>; variables: Set<string>;
inputEvents: Set<string>; inputEvents: EventTrigger[];
internalEvents: Set<string>; internalEvents: EventTrigger[];
outputEvents: Set<string>; outputEvents: Set<string>;
uid2State: Map<string, ConcreteState>; uid2State: Map<string, ConcreteState>;
@ -60,8 +60,8 @@ export const emptyStatechart: Statechart = {
root: emptyRoot, root: emptyRoot,
transitions: new Map(), transitions: new Map(),
variables: new Set(), variables: new Set(),
inputEvents: new Set(), inputEvents: [],
internalEvents: new Set(), internalEvents: [],
outputEvents: new Set(), outputEvents: new Set(),
uid2State: new Map([["root", emptyRoot]]), uid2State: new Map([["root", emptyRoot]]),
}; };

View file

@ -29,6 +29,7 @@ export function evalExpr(expr: Expression, environment: Environment): any {
else if (expr.kind === "ref") { else if (expr.kind === "ref") {
const found = environment.get(expr.variable); const found = environment.get(expr.variable);
if (found === undefined) { if (found === undefined) {
console.log({environment});
throw new Error(`variable '${expr.variable}' does not exist in environment`); throw new Error(`variable '${expr.variable}' does not exist in environment`);
} }
return found; return found;

View file

@ -1,10 +1,10 @@
import { Rect2D, Vec2D, Line2D, euclideanDistance, intersectLines, isWithin, lineBBox, isEntirelyWithin } from "../VisualEditor/geometry"; import { Rect2D, Vec2D, Line2D, euclideanDistance, intersectLines, isWithin, lineBBox } from "../VisualEditor/geometry";
import { ARROW_SNAP_THRESHOLD, TEXT_SNAP_THRESHOLD } from "../VisualEditor/parameters"; import { ARROW_SNAP_THRESHOLD, TEXT_SNAP_THRESHOLD } from "../VisualEditor/parameters";
import { sides } from "../VisualEditor/VisualEditor"; import { sides } from "../VisualEditor/VisualEditor";
export type Rountangle = { export type Rountangle = {
uid: string; uid: string;
kind: "and" | "or"; kind: "and" | "or" | "pseudo";
} & Rect2D; } & Rect2D;
export type Text = { export type Text = {

View file

@ -238,10 +238,10 @@ export function handleEvent(simtime: number, event: RT_Event, statechart: Statec
if (event.kind === "input" && event.param !== undefined) { if (event.kind === "input" && event.param !== undefined) {
// input events may have a parameter // input events may have a parameter
// *temporarily* add event to environment (dirty!) // *temporarily* add event to environment (dirty!)
oldValue = environment.get(event.param.name); oldValue = environment.get(event.param);
environment = new Map([ environment = new Map([
...environment, ...environment,
[(t.label[0].trigger as EventTrigger).paramName as string, event.param.value], [(t.label[0].trigger as EventTrigger).paramName as string, event.param],
]); ]);
} }
({mode, environment, ...raised} = fireTransition(simtime, t, arena, srcPath, tgtPath, {mode, environment, ...raised})); ({mode, environment, ...raised} = fireTransition(simtime, t, arena, srcPath, tgtPath, {mode, environment, ...raised}));
@ -251,6 +251,7 @@ export function handleEvent(simtime: number, event: RT_Event, statechart: Statec
...environment, ...environment,
[(t.label[0].trigger as EventTrigger).paramName as string, oldValue], [(t.label[0].trigger as EventTrigger).paramName as string, oldValue],
]); ]);
console.log('restored environment:', environment);
} }
arenasFired.add(arena); arenasFired.add(arena);
} }

View file

@ -231,7 +231,7 @@ function peg$parse(input, options) {
}; };
} }
function peg$f1(event, param) { function peg$f1(event, param) {
return {kind: "event", event, param: param ? param[1] : undefined}; return {kind: "event", event, paramName: param ? param[1] : undefined};
} }
function peg$f2(dur) { function peg$f2(dur) {
return {kind: "after", durationMs: dur}; return {kind: "after", durationMs: dur};

View file

@ -1,7 +1,7 @@
import { ConcreteState, OrState, Statechart, Transition } from "./abstract_syntax"; import { ConcreteState, OrState, Statechart, Transition } from "./abstract_syntax";
import { findNearestArrow, findNearestRountangleSide, findRountangle, Rountangle, VisualEditorState } from "./concrete_syntax"; import { findNearestArrow, findNearestRountangleSide, findRountangle, Rountangle, VisualEditorState } from "./concrete_syntax";
import { isEntirelyWithin } from "../VisualEditor/geometry"; import { isEntirelyWithin } from "../VisualEditor/geometry";
import { Action, Expression, ParsedText } from "./label_ast"; import { Action, EventTrigger, Expression, ParsedText } from "./label_ast";
import { parse as parseLabel, SyntaxError } from "./label_parser"; import { parse as parseLabel, SyntaxError } from "./label_parser";
@ -11,6 +11,24 @@ export type TraceableError = {
data?: any; data?: any;
} }
function addEvent(events: EventTrigger[], e: EventTrigger, textUid: string) {
const haveEvent = events.find(({event}) => event === e.event);
if (haveEvent) {
if (haveEvent.paramName !== e.paramName === undefined) {
return [{
shapeUid: textUid,
message: "inconsistent event parameter",
}];
}
return [];
}
else {
events.push(e);
events.sort((a,b) => a.event.localeCompare(b.event));
return [];
}
}
export function parseStatechart(state: VisualEditorState): [Statechart, TraceableError[]] { export function parseStatechart(state: VisualEditorState): [Statechart, TraceableError[]] {
const errors: TraceableError[] = []; const errors: TraceableError[] = [];
@ -144,9 +162,9 @@ export function parseStatechart(state: VisualEditorState): [Statechart, Traceabl
} }
let variables = new Set<string>(); let variables = new Set<string>();
const inputEvents = new Set<string>(); const inputEvents: EventTrigger[] = [];
const internalEvents: EventTrigger[] = [];
const outputEvents = new Set<string>(); const outputEvents = new Set<string>();
const internalEvents = new Set<string>();
// step 3: figure out labels // step 3: figure out labels
@ -176,31 +194,35 @@ export function parseStatechart(state: VisualEditorState): [Statechart, Traceabl
if (belongsToTransition) { if (belongsToTransition) {
// parse as transition label // parse as transition label
belongsToTransition.label.push(parsed); belongsToTransition.label.push(parsed);
// collect events // collect events
// triggers
if (parsed.trigger.kind === "event") { if (parsed.trigger.kind === "event") {
const {event} = parsed.trigger; const {event} = parsed.trigger;
if (event.startsWith("_")) { if (event.startsWith("_")) {
internalEvents.add(event); errors.push(...addEvent(internalEvents, parsed.trigger, parsed.uid));
} }
else { else {
inputEvents.add(event); errors.push(...addEvent(inputEvents, parsed.trigger, parsed.uid));
} }
} }
else if (parsed.trigger.kind === "after") { else if (parsed.trigger.kind === "after") {
belongsToTransition.src.timers.push(parsed.trigger.durationMs); belongsToTransition.src.timers.push(parsed.trigger.durationMs);
belongsToTransition.src.timers.sort(); belongsToTransition.src.timers.sort();
} }
for (const action of parsed.actions) { // // raise-actions
if (action.kind === "raise") { // for (const action of parsed.actions) {
const {event} = action; // if (action.kind === "raise") {
if (event.startsWith("_")) { // const {event} = action;
internalEvents.add(event); // if (event.startsWith("_")) {
} // internalEvents.add(event);
else { // }
outputEvents.add(event); // else {
} // outputEvents.add(event);
} // }
} // }
// }
// collect variables // collect variables
variables = variables variables = variables
.union(findVariables(parsed.guard)); .union(findVariables(parsed.guard));

View file

@ -19,6 +19,28 @@ export type Mode = Set<string>; // set of active states
export type Environment = ReadonlyMap<string, any>; // variable name -> value export type Environment = ReadonlyMap<string, any>; // variable name -> value
// export class Environment {
// env: Map<string, any>[];
// constructor(env = [new Map()]) {
// this.env = env;
// }
// with(key: string, value: any): Environment {
// for (let i=0; i<this.env.length; i++) {
// if (this.env[i].has(key)) {
// return new Environment(this.env.with(i, new Map([
// ...this.env[i].entries(),
// [key, value],
// ])));
// }
// }
// return new Environment(this.env.with(-1, new Map([
// ...this.env[this.env.length-1].entries(),
// [key, value],
// ])));
// }
// }
export type RT_Statechart = { export type RT_Statechart = {
mode: Mode; mode: Mode;
environment: Environment; environment: Environment;

View file

@ -12,7 +12,7 @@ tlabel = _ trigger:trigger _ guard:("[" _ guard _ "]")? _ actions:("/" _ actions
trigger = afterTrigger / entryTrigger / exitTrigger / eventTrigger trigger = afterTrigger / entryTrigger / exitTrigger / eventTrigger
eventTrigger = event:identifier param:("(" identifier ")")? { eventTrigger = event:identifier param:("(" identifier ")")? {
return {kind: "event", event, param: param ? param[1] : undefined}; return {kind: "event", event, paramName: param ? param[1] : undefined};
} }
afterTrigger = "after" _ dur:durationMs { afterTrigger = "after" _ dur:durationMs {