add transition label parser

This commit is contained in:
Joeri Exelmans 2025-10-06 15:10:55 +02:00
parent 58a75ddd8b
commit e009f718d2
8 changed files with 1399 additions and 74 deletions

View file

@ -116,7 +116,7 @@ line.selected, circle.selected {
text.selected, text.selected:hover { text.selected, text.selected:hover {
fill: blue; fill: blue;
/* font-weight: bold; */ font-weight: 600;
} }
text:hover { text:hover {
fill: darkcyan; fill: darkcyan;
@ -125,12 +125,13 @@ text:hover {
.highlight { .highlight {
stroke: green; stroke: green;
stroke-width: 4px; stroke-width: 3px;
} }
.arrow.error { .arrow.error {
stroke: rgb(230,0,0); stroke: rgb(230,0,0);
} }
text.error { text.error, tspan.error {
fill: rgb(230,0,0); fill: rgb(230,0,0);
font-weight: 600;
} }

View file

@ -470,11 +470,34 @@ export function VisualEditor() {
/> />
)} )}
{state.texts.map(txt => <text {state.texts.map(txt => {
const err = errors.find(([uid]) => txt.uid === uid)?.[1];
let markedText;
if (err) {
const {start,end} = err.location;
markedText = <>
{txt.text.slice(0, start.offset)}
<tspan className="error" data-uid={txt.uid} data-parts="text">
{txt.text.slice(start.offset, end.offset)}
</tspan>
{txt.text.slice(end.offset)}
</>;
}
else {
markedText = <>{txt.text}</>;
}
// const annotatedText = err ? [...txt.text].map((char,i) => {
// if (i >= err.location.start.offset && i < err.location.end.offset) {
// return char+'\u0332';
// }
// return char;
// }).join('') : txt.text;
return <text
key={txt.uid} key={txt.uid}
className={ className={
(selection.find(s => s.uid === txt.uid)?.parts?.length ? "selected":"") (selection.find(s => s.uid === txt.uid)?.parts?.length ? "selected":"")
+(textsToHighlight.hasOwnProperty(txt.uid)?" highlight":"") +(textsToHighlight.hasOwnProperty(txt.uid)?" highlight":"")
// +(errors.some(([uid]) => uid === txt.uid)?" error":"")
} }
x={txt.topLeft.x} x={txt.topLeft.x}
y={txt.topLeft.y} y={txt.topLeft.y}
@ -501,8 +524,8 @@ export function VisualEditor() {
} }
}} }}
> >
{txt.text} {markedText}
</text>)} </text>;})}
{selectingState && <Selecting {...selectingState} />} {selectingState && <Selecting {...selectingState} />}
@ -648,7 +671,9 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
data-uid={uid} data-uid={uid}
data-parts="bottom left" data-parts="bottom left"
/> />
<text x={10} y={20}>{uid}</text> <text x={10} y={20}
data-uid={uid}
data-parts="left top right bottom">{uid}</text>
</g>; </g>;
} }

View file

@ -1,3 +1,5 @@
import { TransitionLabel } from "./label_ast";
export type AbstractState = { export type AbstractState = {
uid: string; uid: string;
children: ConcreteState[]; children: ConcreteState[];
@ -20,37 +22,9 @@ export type Transition = {
uid: string; uid: string;
src: ConcreteState; src: ConcreteState;
tgt: ConcreteState; tgt: ConcreteState;
trigger: Trigger; label: TransitionLabel[];
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 = { export type Statechart = {
root: ConcreteState; root: ConcreteState;
transitions: Map<string, Transition[]>; // key: source state uid transitions: Map<string, Transition[]>; // key: source state uid

View file

@ -0,0 +1,53 @@
export type TransitionLabel = {
trigger: Trigger;
guard: Expression;
actions: Action[];
}
export type Trigger = EventTrigger | AfterTrigger;
export type EventTrigger = {
kind: "event";
event: string;
}
export type AfterTrigger = {
kind: "after";
durationMs: number;
}
export type Action = Assignment | RaiseEvent;
export type Assignment = {
kind: "assignment";
lhs: string;
rhs: Expression;
}
export type RaiseEvent = {
kind: "raise";
event: string;
}
export type Expression = BinaryExpression | UnaryExpression | VarRef;
export type BinaryExpression = {
kind: "binaryExpr";
operator: "+" | "-" | "*" | "/" | "&&" | "||";
lhs: Expression;
rhs: Expression;
}
export type UnaryExpression = {
kind: "unaryExpr";
operator: "!" | "-";
expr: Expression;
}
export type VarRef = {
kind: "ref";
variable: string;
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,13 @@
import { ConcreteState, OrState, Statechart, Transition } from "./ast"; import { ConcreteState, OrState, Statechart, Transition } from "./ast";
import { findNearestRountangleSide, Rountangle, VisualEditorState } from "./editor_types"; import { findNearestArrow, findNearestRountangleSide, Rountangle, VisualEditorState } from "./editor_types";
import { isEntirelyWithin } from "./geometry"; import { isEntirelyWithin } from "./geometry";
import { TransitionLabel } from "./label_ast";
import { parse as parseLabel } from "./label_parser";
export function parseStatechart(state: VisualEditorState): [Statechart, [string,string][]] { export function parseStatechart(state: VisualEditorState): [Statechart, [string,string][]] {
const errorShapes: [string, string][] = [];
// implicitly, the root is always an Or-state // implicitly, the root is always an Or-state
const root: OrState = { const root: OrState = {
kind: "or", kind: "or",
@ -23,6 +28,8 @@ export function parseStatechart(state: VisualEditorState): [Statechart, [string,
const parentLinks = new Map<string, string>(); const parentLinks = new Map<string, string>();
// step 1: figure out state hierarchy
// we assume that the rountangles are sorted from big to small: // we assume that the rountangles are sorted from big to small:
for (const rt of state.rountangles) { for (const rt of state.rountangles) {
const state: ConcreteState = { const state: ConcreteState = {
@ -49,9 +56,10 @@ export function parseStatechart(state: VisualEditorState): [Statechart, [string,
} }
} }
const transitions = new Map<string, Transition[]>(); // step 2: figure out transitions
const errorShapes: [string, string][] = []; const transitions = new Map<string, Transition[]>();
const uid2Transition = new Map<string, Transition>();
for (const arr of state.arrows) { for (const arr of state.arrows) {
const srcUID = findNearestRountangleSide(arr, "start", state.rountangles)?.uid; const srcUID = findNearestRountangleSide(arr, "start", state.rountangles)?.uid;
@ -84,15 +92,12 @@ export function parseStatechart(state: VisualEditorState): [Statechart, [string,
uid: arr.uid, uid: arr.uid,
src: uid2State.get(srcUID)!, src: uid2State.get(srcUID)!,
tgt: uid2State.get(tgtUID)!, tgt: uid2State.get(tgtUID)!,
trigger: { label: [],
kind: "?",
},
guard: {},
actions: [],
}; };
const existingTransitions = transitions.get(srcUID) || []; const existingTransitions = transitions.get(srcUID) || [];
existingTransitions.push(transition); existingTransitions.push(transition);
transitions.set(srcUID, existingTransitions); transitions.set(srcUID, existingTransitions);
uid2Transition.set(arr.uid, transition);
} }
} }
} }
@ -108,6 +113,36 @@ export function parseStatechart(state: VisualEditorState): [Statechart, [string,
} }
} }
// step 3: figure out labels
for (const text of state.texts) {
const belongsToArrow = findNearestArrow(text.topLeft, state.arrows);
if (belongsToArrow) {
const belongsToTransition = uid2Transition.get(belongsToArrow.uid);
if (belongsToTransition) {
// parse as transition label
let transitionLabel: TransitionLabel;
try {
transitionLabel = parseLabel(text.text); // may throw
belongsToTransition.label.push(transitionLabel);
}
catch (e) {
console.log({e});
errorShapes.push([text.uid, e]);
}
}
}
}
for (const transition of uid2Transition.values()) {
if (transition.label.length === 0) {
errorShapes.push([transition.uid, "no label"]);
}
else if (transition.label.length > 1) {
errorShapes.push([transition.uid, "multiple labels"]);
}
}
return [{ return [{
root, root,
transitions, transitions,

View file

@ -0,0 +1,95 @@
start = _ trigger:trigger _ guard:("[" _ guard _ "]")? _ actions:("/" _ actions )? _ {
return {
trigger,
guard: guard ? guard[2] : {kind: "literal", value: true},
actions: actions ? actions[2] : [],
};
}
trigger = afterTrigger / eventTrigger
eventTrigger = event:identifier {
return {kind: "event", event};
}
afterTrigger = "after" _ dur:durationMs {
return {kind: "after", durationMs: dur};
}
durationMs = num:number _ u:timeUnit {
return num * (u === "s" ? 1000 : 1);
}
timeUnit = "ms" / "s" {
return text();
}
guard = expr
actions = head:action tail:(_ ";" _ action)* _ ";"? {
return [head, ...tail.map(t => t[3])];
}
action = assignment / raise
assignment = lhs:identifier _ "=" _ rhs:expr {
return {kind: "assignment", lhs, rhs};
}
identifier = [a-zA-Z0-9]+ {
return text();
}
number = [0-9]+ {
return parseInt(text());
}
_ "whitespace"
= [ \t\n\r]*
expr = sum
sum = prod:product rest:((_ ("+" / "-") _) sum)? {
if (rest === null) {
return prod;
}
return {
kind:"binaryExpr",
operator: rest[0][1],
lhs: prod,
rhs: rest[1],
};
}
product = atom:atom rest:((_ ("*" / "/") _) product)? {
if (rest === null) {
return atom;
}
return {
kind:"binaryExpr",
operator: rest[0][1],
lhs: atom,
rhs: rest[1],
};
}
atom = nested / literal / ref
nested = "(" _ expr:expr _ ")" {
return expr;
}
literal = value:(number / boolean) {
return {kind: "literal", value}
}
ref = variable:identifier {
return {kind: "ref", variable}
}
boolean = ("true" / "false") {
return text() === "true";
}
raise = "^" _ event:identifier {
return {kind: "raise", event};
}

View file

@ -1,6 +1,7 @@
html, body { html, body {
margin: 0; margin: 0;
height: 100%; height: 100%;
font-family: Roboto, sans-serif;
} }
div#root { div#root {