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 {
fill: blue;
/* font-weight: bold; */
font-weight: 600;
}
text:hover {
fill: darkcyan;
@ -125,12 +125,13 @@ text:hover {
.highlight {
stroke: green;
stroke-width: 4px;
stroke-width: 3px;
}
.arrow.error {
stroke: rgb(230,0,0);
}
text.error {
text.error, tspan.error {
fill: rgb(230,0,0);
}
font-weight: 600;
}

View file

@ -470,39 +470,62 @@ export function VisualEditor() {
/>
)}
{state.texts.map(txt => <text
key={txt.uid}
className={
(selection.find(s => s.uid === txt.uid)?.parts?.length ? "selected":"")
+(textsToHighlight.hasOwnProperty(txt.uid)?" highlight":"")
{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)}
</>;
}
x={txt.topLeft.x}
y={txt.topLeft.y}
textAnchor="middle"
data-uid={txt.uid}
data-parts="text"
onDoubleClick={() => {
const newText = prompt("", txt.text);
if (newText) {
setState(state => ({
...state,
texts: state.texts.map(t => {
if (t.uid === txt.uid) {
return {
...txt,
text: newText,
}
}
else {
return t;
}
}),
}));
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}
className={
(selection.find(s => s.uid === txt.uid)?.parts?.length ? "selected":"")
+(textsToHighlight.hasOwnProperty(txt.uid)?" highlight":"")
// +(errors.some(([uid]) => uid === txt.uid)?" error":"")
}
}}
>
{txt.text}
</text>)}
x={txt.topLeft.x}
y={txt.topLeft.y}
textAnchor="middle"
data-uid={txt.uid}
data-parts="text"
onDoubleClick={() => {
const newText = prompt("", txt.text);
if (newText) {
setState(state => ({
...state,
texts: state.texts.map(t => {
if (t.uid === txt.uid) {
return {
...txt,
text: newText,
}
}
else {
return t;
}
}),
}));
}
}}
>
{markedText}
</text>;})}
{selectingState && <Selecting {...selectingState} />}
@ -648,7 +671,9 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
data-uid={uid}
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>;
}

View file

@ -1,3 +1,5 @@
import { TransitionLabel } from "./label_ast";
export type AbstractState = {
uid: string;
children: ConcreteState[];
@ -20,37 +22,9 @@ export type Transition = {
uid: string;
src: ConcreteState;
tgt: ConcreteState;
trigger: Trigger;
guard: Expression;
actions: Action[];
label: TransitionLabel[];
}
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,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 { findNearestRountangleSide, Rountangle, VisualEditorState } from "./editor_types";
import { findNearestArrow, findNearestRountangleSide, Rountangle, VisualEditorState } from "./editor_types";
import { isEntirelyWithin } from "./geometry";
import { TransitionLabel } from "./label_ast";
import { parse as parseLabel } from "./label_parser";
export function parseStatechart(state: VisualEditorState): [Statechart, [string,string][]] {
const errorShapes: [string, string][] = [];
// implicitly, the root is always an Or-state
const root: OrState = {
kind: "or",
@ -23,6 +28,8 @@ export function parseStatechart(state: VisualEditorState): [Statechart, [string,
const parentLinks = new Map<string, string>();
// step 1: figure out state hierarchy
// we assume that the rountangles are sorted from big to small:
for (const rt of state.rountangles) {
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) {
const srcUID = findNearestRountangleSide(arr, "start", state.rountangles)?.uid;
@ -84,15 +92,12 @@ export function parseStatechart(state: VisualEditorState): [Statechart, [string,
uid: arr.uid,
src: uid2State.get(srcUID)!,
tgt: uid2State.get(tgtUID)!,
trigger: {
kind: "?",
},
guard: {},
actions: [],
label: [],
};
const existingTransitions = transitions.get(srcUID) || [];
existingTransitions.push(transition);
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 [{
root,
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,8 +1,9 @@
html, body {
margin: 0;
height: 100%;
font-family: Roboto, sans-serif;
}
div#root {
height: 100%;
}
}