statechart parsing and error reporting working

This commit is contained in:
Joeri Exelmans 2025-10-05 15:28:31 +02:00
parent 2adf902a7f
commit f40e7f60b5
6 changed files with 300 additions and 115 deletions

View file

@ -2,7 +2,7 @@
cursor: crosshair;
}
svg > text {
text {
user-select: none;
}
@ -37,6 +37,9 @@ svg > text {
/* stroke: blue;
stroke-width: 4px; */
}
.rountangle.error {
stroke: rgb(230,0,0);
}
.selected:hover {
cursor: grab;
@ -105,3 +108,10 @@ text:hover {
stroke: green;
stroke-width: 4px;
}
.arrow.error {
stroke: rgb(230,0,0);
}
text.error {
fill: rgb(230,0,0);
}

View file

@ -1,60 +1,21 @@
import { Dispatch, MouseEventHandler, SetStateAction, useEffect, useRef, useState } from "react";
import { Line2D, Rect2D, Vec2D, addV2D, area, euclideanDistance, getBottomSide, getLeftSide, getRightSide, getTopSide, intersectLines, isEntirelyWithin, isWithin, lineBBox, normalizeRect, subtractV2D, transformLine, transformRect } from "./geometry";
import { MouseEventHandler, SetStateAction, useEffect, useRef, useState } from "react";
import { Line2D, Rect2D, Vec2D, addV2D, area, getBottomSide, getLeftSide, getRightSide, getTopSide, isEntirelyWithin, normalizeRect, subtractV2D, transformLine, transformRect } from "./geometry";
import "./VisualEditor.css";
import { getBBoxInSvgCoords } from "./svg_helper";
import { VisualEditorState, Rountangle, emptyState, Arrow, ArrowPart, RountanglePart, findNearestRountangleSide } from "./editor_types";
import { parseStatechart } from "./parser";
import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS, MIN_ROUNTANGLE_SIZE, ROUNTANGLE_RADIUS } from "./parameters";
type Rountangle = {
uid: string;
kind: "and" | "or";
} & Rect2D;
type Text = {
uid: string;
topLeft: Vec2D;
text: string;
};
type Arrow = {
uid: string;
} & Line2D;
type VisualEditorState = {
rountangles: Rountangle[];
texts: Text[];
arrows: Arrow[];
nextID: number;
};
const emptyState = {
rountangles: [], texts: [], arrows: [], nextID: 0,
};
const onOffStateMachine = {
rountangles: [
{ uid: "0", topLeft: {x: 100, y: 100}, size: {x: 100, y: 100}, kind: "and" },
{ uid: "1", topLeft: {x: 100, y: 300}, size: {x: 100, y: 100}, kind: "and" },
],
texts: [],
arrows: [
{ uid: "2", start: {x: 150, y: 200}, end: {x: 160, y: 300} },
],
nextID: 3,
};
type DraggingState = {
lastMousePos: Vec2D;
} | null; // null means: not dragging
type SelectingState = Rect2D | null;
// independently moveable parts of our shapes:
type RountanglePart = "left" | "top" | "right" | "bottom";
type ArrowPart = "start" | "end";
type RountangleSelectable = {
export type RountangleSelectable = {
// kind: "rountangle";
parts: RountanglePart[];
uid: string;
@ -71,44 +32,19 @@ type TextSelectable = {
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable;
type Selection = Selectable[];
const minStateSize = {x: 40, y: 40};
type HistoryState = {
current: VisualEditorState,
history: VisualEditorState[],
future: VisualEditorState[],
}
const threshold = 20;
const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [
export const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [
["left", getLeftSide],
["top", getTopSide],
["right", getRightSide],
["bottom", getBottomSide],
];
function findNearestRountangleSide(arrow: Line2D, arrowPart: "start"|"end", candidates: Rountangle[]): RountangleSelectable | undefined {
let best = Infinity;
let bestSide: undefined | RountangleSelectable;
for (const rountangle of candidates) {
for (const [side, getSide] of sides) {
const asLine = getSide(rountangle);
const intersection = intersectLines(arrow, asLine);
if (intersection !== null) {
const bbox = lineBBox(asLine, threshold);
const dist = euclideanDistance(arrow[arrowPart], intersection);
if (isWithin(arrow[arrowPart], bbox) && dist<best) {
best = dist;
bestSide = {uid: rountangle.uid, parts: [side]};
}
}
}
}
return bestSide;
}
export function VisualEditor() {
const [historyState, setHistoryState] = useState<HistoryState>({current: emptyState, history: [], future: []});
@ -162,10 +98,8 @@ export function VisualEditor() {
}
const [dragging, setDragging] = useState<DraggingState>(null);
const [mode, setMode] = useState<"state"|"transition"|"text">("state");
const [showHelp, setShowHelp] = useState<boolean>(true);
const [showHelp, setShowHelp] = useState<boolean>(false);
// uid's of selected rountangles
const [selection, setSelection] = useState<Selection>([]);
@ -173,17 +107,17 @@ export function VisualEditor() {
// not null while the user is making a selection
const [selectingState, setSelectingState] = useState<SelectingState>(null);
const [errors, setErrors] = useState<[string,string][]>([]);
const refSVG = useRef<SVGSVGElement>(null);
useEffect(() => {
const recoveredState = JSON.parse(window.localStorage.getItem("state") || "null") || emptyState;
setState(recoveredState);
const recoveredState = JSON.parse(window.localStorage.getItem("state") || "null");
if (recoveredState) {
setState(recoveredState);
}
}, []);
// useEffect(() => {
// console.log(`history: ${history.length}, future: ${future.length}`);
// }, [editorState]);
useEffect(() => {
// delay is necessary for 2 reasons:
// 1) it's a hack - prevents us from writing the initial state to localstorage (before having recovered the state that was in localstorage)
@ -191,6 +125,10 @@ export function VisualEditor() {
const timeout = setTimeout(() => {
window.localStorage.setItem("state", JSON.stringify(state));
console.log('saved to localStorage');
const [statechart, errors] = parseStatechart(state);
console.log('statechart: ', statechart, 'errors:', errors);
setErrors(errors);
}, 100);
return () => clearTimeout(timeout);
}, [state]);
@ -212,7 +150,7 @@ export function VisualEditor() {
rountangles: [...state.rountangles, {
uid: newID,
topLeft: currentPointer,
size: minStateSize,
size: MIN_ROUNTANGLE_SIZE,
kind: "and",
}],
nextID: state.nextID+1,
@ -464,17 +402,19 @@ export function VisualEditor() {
if (arrow.uid === selected.uid) {
const rSideStart = findNearestRountangleSide(arrow, "start", state.rountangles);
if (rSideStart) {
sidesToHighlight[rSideStart.uid] = [...(sidesToHighlight[rSideStart.uid] || []), rSideStart.parts[0]];
sidesToHighlight[rSideStart.uid] = [...(sidesToHighlight[rSideStart.uid] || []), rSideStart.part];
}
const rSideEnd = findNearestRountangleSide(arrow, "end", state.rountangles);
if (rSideEnd) {
sidesToHighlight[rSideEnd.uid] = [...(sidesToHighlight[rSideEnd.uid] || []), rSideEnd.parts[0]];
sidesToHighlight[rSideEnd.uid] = [...(sidesToHighlight[rSideEnd.uid] || []), rSideEnd.part];
}
}
}
}
const rootErrors = errors.filter(([uid]) => uid === "root").map(err=>err[1]);
return <svg width="4000px" height="4000px"
className="svgCanvas"
onMouseDown={onMouseDown}
@ -494,17 +434,21 @@ export function VisualEditor() {
</marker>
</defs>
{(rootErrors.length>0) && <text className="error" x={5} y={50}>{rootErrors.join(' ')}</text>}
{state.rountangles.map(rountangle => <RountangleSVG
key={rountangle.uid}
rountangle={rountangle}
selected={selection.find(r => r.uid === rountangle.uid)?.parts || []}
highlight={sidesToHighlight[rountangle.uid] || []}
errors={errors.filter(([uid,msg])=>uid===rountangle.uid).map(err=>err[1])}
/>)}
{state.arrows.map(arrow => <ArrowSVG
key={arrow.uid}
arrow={arrow}
selected={selection.find(a => a.uid === arrow.uid)?.parts || []}
errors={errors.filter(([uid,msg])=>uid===arrow.uid).map(err=>err[1])}
/>
)}
@ -542,7 +486,7 @@ export function VisualEditor() {
{selectingState && <Selecting {...selectingState} />}
{showHelp && <>
{showHelp ? <>
<text x={5} y={20}>
Left mouse button: Select/Drag.
</text>
@ -563,14 +507,11 @@ export function VisualEditor() {
<text x={5} y={140}>
[H] Show/hide this help.
</text>
</>}
</> : <text x={5} y={20}>[H] To show help.</text>}
</svg>;
}
const cornerOffset = 4;
const cornerRadius = 16;
function rountangleMinSize(size: Vec2D): Vec2D {
if (size.x >= 40 && size.y >= 40) {
return size;
@ -581,7 +522,7 @@ function rountangleMinSize(size: Vec2D): Vec2D {
};
}
export function RountangleSVG(props: {rountangle: Rountangle, selected: string[], highlight: RountanglePart[]}) {
export function RountangleSVG(props: {rountangle: Rountangle, selected: string[], highlight: RountanglePart[], errors: string[]}) {
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
@ -590,8 +531,10 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
<rect
className={"rountangle"
+(props.selected.length===4?" selected":"")
+((props.rountangle.kind==="or")?" or":"")}
rx={20} ry={20}
+((props.rountangle.kind==="or")?" or":"")
+(props.errors.length>0?" error":"")
}
rx={ROUNTANGLE_RADIUS} ry={ROUNTANGLE_RADIUS}
x={0}
y={0}
width={minSize.x}
@ -599,6 +542,10 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
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":"")
@ -650,51 +597,45 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
<circle
className="circleHelper corner"
cx={cornerOffset}
cy={cornerOffset}
r={cornerRadius}
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-cornerOffset}
cy={cornerOffset}
r={cornerRadius}
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-cornerOffset}
cy={minSize.y-cornerOffset}
r={cornerRadius}
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={cornerOffset}
cy={minSize.y-cornerOffset}
r={cornerRadius}
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}>{uid}</text>
</g>;
}
export function ArrowSVG(props: {arrow: Arrow, selected: string[]}) {
export function ArrowSVG(props: {arrow: Arrow, selected: string[], errors: string[]}) {
const {start, end, uid} = props.arrow;
return <g>
<line
className={"arrow"}
className={"arrow"+(props.errors.length>0?" error":"")}
markerEnd='url(#arrowEnd)'
x1={start.x}
y1={start.y}
@ -703,6 +644,9 @@ export function ArrowSVG(props: {arrow: Arrow, selected: string[]}) {
data-uid={uid}
data-parts="start end"
/>
{props.errors.length>0 && <text className="error" x={(start.x+end.x)/2} y={(start.y+end.y)/2} data-uid={uid} data-parts="start end">{props.errors.join(' ')}</text>}
<line
className="lineHelper"
x1={start.x}
@ -718,7 +662,7 @@ export function ArrowSVG(props: {arrow: Arrow, selected: string[]}) {
+(props.selected.includes("start")?" selected":"")}
cx={start.x}
cy={start.y}
r={cornerRadius}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="start"
/>
@ -727,7 +671,7 @@ export function ArrowSVG(props: {arrow: Arrow, selected: string[]}) {
+(props.selected.includes("end")?" selected":"")}
cx={end.x}
cy={end.y}
r={cornerRadius}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="end"
/>

57
src/VisualEditor/ast.ts Normal file
View file

@ -0,0 +1,57 @@
export type AbstractState = {
uid: string;
children: ConcreteState[];
}
export type AndState = {
kind: "and";
} & AbstractState;
export type OrState = {
kind: "or";
// array of tuples: (uid of Arrow indicating initial state, initial state)
// in a valid AST, there must be one initial state, but we allow the user to draw crazy shit
initial: [string, ConcreteState][];
} & AbstractState;
export type ConcreteState = AndState | OrState;
export type Transition = {
uid: string;
src: ConcreteState;
tgt: ConcreteState;
trigger: Trigger;
guard: Expression;
actions: Action[];
}
export type EventTrigger = {
kind: "event";
event: string;
}
export type AfterTrigger = {
kind: "after";
delay_ms: number;
}
export type Trigger = EventTrigger | AfterTrigger;
export type RaiseEvent = {
kind: "raise";
event: string;
}
export type Assign = {
lhs: string;
rhs: Expression;
}
export type Expression = {};
export type Action = RaiseEvent | Assign;
export type Statechart = {
root: ConcreteState;
transitions: Map<string, Transition[]>; // key: source state uid
}

View file

@ -0,0 +1,65 @@
import { Rect2D, Vec2D, Line2D, euclideanDistance, intersectLines, isWithin, lineBBox } from "./geometry";
import { ARROW_SNAP_THRESHOLD } from "./parameters";
import { sides } from "./VisualEditor";
export type Rountangle = {
uid: string;
kind: "and" | "or";
} & Rect2D;
type Text = {
uid: string;
topLeft: Vec2D;
text: string;
};
export type Arrow = {
uid: string;
} & Line2D;
export type VisualEditorState = {
rountangles: Rountangle[];
texts: Text[];
arrows: Arrow[];
nextID: number;
};
// independently moveable parts of our shapes:
export type RountanglePart = "left" | "top" | "right" | "bottom";
export type ArrowPart = "start" | "end";
export const emptyState = {
rountangles: [], texts: [], arrows: [], nextID: 0,
};
export const onOffStateMachine = {
rountangles: [
{ uid: "0", topLeft: { x: 100, y: 100 }, size: { x: 100, y: 100 }, kind: "and" },
{ uid: "1", topLeft: { x: 100, y: 300 }, size: { x: 100, y: 100 }, kind: "and" },
],
texts: [],
arrows: [
{ uid: "2", start: { x: 150, y: 200 }, end: { x: 160, y: 300 } },
],
nextID: 3,
};
export function findNearestRountangleSide(arrow: Line2D, arrowPart: "start" | "end", candidates: Rountangle[]): {uid: string, part: RountanglePart} | undefined {
let best = Infinity;
let bestSide: undefined | {uid: string, part: RountanglePart};
for (const rountangle of candidates) {
for (const [side, getSide] of sides) {
const asLine = getSide(rountangle);
const intersection = intersectLines(arrow, asLine);
if (intersection !== null) {
const bbox = lineBBox(asLine, ARROW_SNAP_THRESHOLD);
const dist = euclideanDistance(arrow[arrowPart], intersection);
if (isWithin(arrow[arrowPart], bbox) && dist < best) {
best = dist;
bestSide = { uid: rountangle.uid, part: side };
}
}
}
}
return bestSide;
}

View file

@ -0,0 +1,9 @@
export const ARROW_SNAP_THRESHOLD = 20;
export const ROUNTANGLE_RADIUS = 20;
export const MIN_ROUNTANGLE_SIZE = { x: ROUNTANGLE_RADIUS*2, y: ROUNTANGLE_RADIUS*2 };
// those hoverable green transparent circles in the corners of rountangles:
export const CORNER_HELPER_OFFSET = 4;
export const CORNER_HELPER_RADIUS = 16;

100
src/VisualEditor/parser.ts Normal file
View file

@ -0,0 +1,100 @@
import { ConcreteState, OrState, Statechart, Transition } from "./ast";
import { findNearestRountangleSide, Rountangle, VisualEditorState } from "./editor_types";
import { isEntirelyWithin } from "./geometry";
export function parseStatechart(state: VisualEditorState): [Statechart, [string,string][]] {
// implicitly, the root is always an Or-state
const root: OrState = {
kind: "or",
uid: "root",
children: [],
initial: [],
}
const uid2State = new Map<string, ConcreteState>([["root", root]]);
// we will always look for the smallest parent rountangle
const parentCandidates: Rountangle[] = [{
kind: "or",
uid: root.uid,
topLeft: {x: -Infinity, y: -Infinity},
size: {x: Infinity, y: Infinity},
}];
const parentLinks = new Map<string, string>();
// we assume that the rountangles are sorted from big to small:
for (const rt of state.rountangles) {
const state: ConcreteState = {
kind: rt.kind,
uid: rt.uid,
children: [],
}
if (state.kind === "or") {
state.initial = [];
}
uid2State.set(rt.uid, state);
// iterate in reverse:
for (let i=parentCandidates.length-1; i>=0; i--) {
const candidate = parentCandidates[i];
console.log('candidate:', candidate, 'rt:', rt);
if (candidate.uid === "root" || isEntirelyWithin(rt, candidate)) {
// found our parent :)
const parentState = uid2State.get(candidate.uid);
parentState!.children.push(state);
parentCandidates.push(rt);
parentLinks.set(rt.uid, candidate.uid);
break;
}
}
}
const transitions = new Map<string, Transition[]>();
const errorShapes: [string, string][] = [];
for (const arr of state.arrows) {
const srcUID = findNearestRountangleSide(arr, "start", state.rountangles)?.uid;
const tgtUID = findNearestRountangleSide(arr, "end", state.rountangles)?.uid;
if (!srcUID) {
if (!tgtUID) {
// dangling edge - todo: display error...
errorShapes.push([arr.uid, "Dangling edge"]);
}
else {
// target but no source, so we treat is as an 'initial' marking
const initialState = uid2State.get(tgtUID)!;
const ofState = uid2State.get(parentLinks.get(tgtUID)!)!;
if (ofState.kind === "or") {
ofState.initial.push([arr.uid, initialState]);
}
else {
// and states do not have an 'initial' state - todo: display error...
errorShapes.push([arr.uid, "AND-state cannot have an initial state"]);
}
}
}
else {
if (!tgtUID) {
errorShapes.push([arr.uid, "Needs target"]);
}
}
}
for (const state of uid2State.values()) {
if (state.kind === "or") {
if (state.initial.length > 1) {
errorShapes.push(...state.initial.map(([uid,childState])=>[uid,"OR-state can only have 1 initial state"] as [string, string]));
}
else if (state.initial.length === 0) {
errorShapes.push([state.uid, "Needs initial state"]);
}
}
}
return [{
root,
transitions,
}, errorShapes];
}