From f40e7f60b5aa25ecdb4711ae072442db47fc74b7 Mon Sep 17 00:00:00 2001 From: Joeri Exelmans Date: Sun, 5 Oct 2025 15:28:31 +0200 Subject: [PATCH] statechart parsing and error reporting working --- src/VisualEditor/VisualEditor.css | 12 ++- src/VisualEditor/VisualEditor.tsx | 172 ++++++++++-------------------- src/VisualEditor/ast.ts | 57 ++++++++++ src/VisualEditor/editor_types.ts | 65 +++++++++++ src/VisualEditor/parameters.ts | 9 ++ src/VisualEditor/parser.ts | 100 +++++++++++++++++ 6 files changed, 300 insertions(+), 115 deletions(-) create mode 100644 src/VisualEditor/ast.ts create mode 100644 src/VisualEditor/editor_types.ts create mode 100644 src/VisualEditor/parameters.ts create mode 100644 src/VisualEditor/parser.ts diff --git a/src/VisualEditor/VisualEditor.css b/src/VisualEditor/VisualEditor.css index 6dddec4..11c6c77 100644 --- a/src/VisualEditor/VisualEditor.css +++ b/src/VisualEditor/VisualEditor.css @@ -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; @@ -104,4 +107,11 @@ text:hover { .highlight { stroke: green; stroke-width: 4px; +} + +.arrow.error { + stroke: rgb(230,0,0); +} +text.error { + fill: rgb(230,0,0); } \ No newline at end of file diff --git a/src/VisualEditor/VisualEditor.tsx b/src/VisualEditor/VisualEditor.tsx index f5b0de9..af7e7a9 100644 --- a/src/VisualEditor/VisualEditor.tsx +++ b/src/VisualEditor/VisualEditor.tsx @@ -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({current: emptyState, history: [], future: []}); @@ -162,10 +98,8 @@ export function VisualEditor() { } const [dragging, setDragging] = useState(null); - const [mode, setMode] = useState<"state"|"transition"|"text">("state"); - - const [showHelp, setShowHelp] = useState(true); + const [showHelp, setShowHelp] = useState(false); // uid's of selected rountangles const [selection, setSelection] = useState([]); @@ -173,17 +107,17 @@ export function VisualEditor() { // not null while the user is making a selection const [selectingState, setSelectingState] = useState(null); + const [errors, setErrors] = useState<[string,string][]>([]); + const refSVG = useRef(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 + {(rootErrors.length>0) && {rootErrors.join(' ')}} + {state.rountangles.map(rountangle => 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 => 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 && } - {showHelp && <> + {showHelp ? <> Left mouse button: Select/Drag. @@ -563,14 +507,11 @@ export function VisualEditor() { [H] Show/hide this help. - } + : [H] To show help.} ; } -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[] 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) && {props.errors.join(' ')}} + + - - - - - - {uid} ; } -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 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 && {props.errors.join(' ')}} + @@ -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" /> diff --git a/src/VisualEditor/ast.ts b/src/VisualEditor/ast.ts new file mode 100644 index 0000000..caf1f87 --- /dev/null +++ b/src/VisualEditor/ast.ts @@ -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; // key: source state uid +} diff --git a/src/VisualEditor/editor_types.ts b/src/VisualEditor/editor_types.ts new file mode 100644 index 0000000..2c73b62 --- /dev/null +++ b/src/VisualEditor/editor_types.ts @@ -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; +} diff --git a/src/VisualEditor/parameters.ts b/src/VisualEditor/parameters.ts new file mode 100644 index 0000000..0ecdcec --- /dev/null +++ b/src/VisualEditor/parameters.ts @@ -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; diff --git a/src/VisualEditor/parser.ts b/src/VisualEditor/parser.ts new file mode 100644 index 0000000..43c4434 --- /dev/null +++ b/src/VisualEditor/parser.ts @@ -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([["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(); + + // 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(); + + 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]; +} \ No newline at end of file