statechart parsing and error reporting working
This commit is contained in:
parent
2adf902a7f
commit
f40e7f60b5
6 changed files with 300 additions and 115 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg > text {
|
text {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,6 +37,9 @@ svg > text {
|
||||||
/* stroke: blue;
|
/* stroke: blue;
|
||||||
stroke-width: 4px; */
|
stroke-width: 4px; */
|
||||||
}
|
}
|
||||||
|
.rountangle.error {
|
||||||
|
stroke: rgb(230,0,0);
|
||||||
|
}
|
||||||
|
|
||||||
.selected:hover {
|
.selected:hover {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
|
@ -104,4 +107,11 @@ text:hover {
|
||||||
.highlight {
|
.highlight {
|
||||||
stroke: green;
|
stroke: green;
|
||||||
stroke-width: 4px;
|
stroke-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow.error {
|
||||||
|
stroke: rgb(230,0,0);
|
||||||
|
}
|
||||||
|
text.error {
|
||||||
|
fill: rgb(230,0,0);
|
||||||
}
|
}
|
||||||
|
|
@ -1,60 +1,21 @@
|
||||||
import { Dispatch, MouseEventHandler, SetStateAction, useEffect, useRef, useState } from "react";
|
import { 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 { Line2D, Rect2D, Vec2D, addV2D, area, getBottomSide, getLeftSide, getRightSide, getTopSide, isEntirelyWithin, normalizeRect, subtractV2D, transformLine, transformRect } from "./geometry";
|
||||||
|
|
||||||
import "./VisualEditor.css";
|
import "./VisualEditor.css";
|
||||||
|
|
||||||
import { getBBoxInSvgCoords } from "./svg_helper";
|
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 = {
|
type DraggingState = {
|
||||||
lastMousePos: Vec2D;
|
lastMousePos: Vec2D;
|
||||||
} | null; // null means: not dragging
|
} | null; // null means: not dragging
|
||||||
|
|
||||||
type SelectingState = Rect2D | null;
|
type SelectingState = Rect2D | null;
|
||||||
|
|
||||||
|
export type RountangleSelectable = {
|
||||||
// independently moveable parts of our shapes:
|
|
||||||
type RountanglePart = "left" | "top" | "right" | "bottom";
|
|
||||||
type ArrowPart = "start" | "end";
|
|
||||||
|
|
||||||
type RountangleSelectable = {
|
|
||||||
// kind: "rountangle";
|
// kind: "rountangle";
|
||||||
parts: RountanglePart[];
|
parts: RountanglePart[];
|
||||||
uid: string;
|
uid: string;
|
||||||
|
|
@ -71,44 +32,19 @@ type TextSelectable = {
|
||||||
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable;
|
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable;
|
||||||
type Selection = Selectable[];
|
type Selection = Selectable[];
|
||||||
|
|
||||||
const minStateSize = {x: 40, y: 40};
|
|
||||||
|
|
||||||
type HistoryState = {
|
type HistoryState = {
|
||||||
current: VisualEditorState,
|
current: VisualEditorState,
|
||||||
history: VisualEditorState[],
|
history: VisualEditorState[],
|
||||||
future: VisualEditorState[],
|
future: VisualEditorState[],
|
||||||
}
|
}
|
||||||
|
|
||||||
const threshold = 20;
|
export const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [
|
||||||
|
|
||||||
const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [
|
|
||||||
["left", getLeftSide],
|
["left", getLeftSide],
|
||||||
["top", getTopSide],
|
["top", getTopSide],
|
||||||
["right", getRightSide],
|
["right", getRightSide],
|
||||||
["bottom", getBottomSide],
|
["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() {
|
export function VisualEditor() {
|
||||||
const [historyState, setHistoryState] = useState<HistoryState>({current: emptyState, history: [], future: []});
|
const [historyState, setHistoryState] = useState<HistoryState>({current: emptyState, history: [], future: []});
|
||||||
|
|
||||||
|
|
@ -162,10 +98,8 @@ export function VisualEditor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const [dragging, setDragging] = useState<DraggingState>(null);
|
const [dragging, setDragging] = useState<DraggingState>(null);
|
||||||
|
|
||||||
const [mode, setMode] = useState<"state"|"transition"|"text">("state");
|
const [mode, setMode] = useState<"state"|"transition"|"text">("state");
|
||||||
|
const [showHelp, setShowHelp] = useState<boolean>(false);
|
||||||
const [showHelp, setShowHelp] = useState<boolean>(true);
|
|
||||||
|
|
||||||
// uid's of selected rountangles
|
// uid's of selected rountangles
|
||||||
const [selection, setSelection] = useState<Selection>([]);
|
const [selection, setSelection] = useState<Selection>([]);
|
||||||
|
|
@ -173,17 +107,17 @@ export function VisualEditor() {
|
||||||
// not null while the user is making a selection
|
// not null while the user is making a selection
|
||||||
const [selectingState, setSelectingState] = useState<SelectingState>(null);
|
const [selectingState, setSelectingState] = useState<SelectingState>(null);
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<[string,string][]>([]);
|
||||||
|
|
||||||
const refSVG = useRef<SVGSVGElement>(null);
|
const refSVG = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const recoveredState = JSON.parse(window.localStorage.getItem("state") || "null") || emptyState;
|
const recoveredState = JSON.parse(window.localStorage.getItem("state") || "null");
|
||||||
setState(recoveredState);
|
if (recoveredState) {
|
||||||
|
setState(recoveredState);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// console.log(`history: ${history.length}, future: ${future.length}`);
|
|
||||||
// }, [editorState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// delay is necessary for 2 reasons:
|
// 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)
|
// 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(() => {
|
const timeout = setTimeout(() => {
|
||||||
window.localStorage.setItem("state", JSON.stringify(state));
|
window.localStorage.setItem("state", JSON.stringify(state));
|
||||||
console.log('saved to localStorage');
|
console.log('saved to localStorage');
|
||||||
|
|
||||||
|
const [statechart, errors] = parseStatechart(state);
|
||||||
|
console.log('statechart: ', statechart, 'errors:', errors);
|
||||||
|
setErrors(errors);
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
@ -212,7 +150,7 @@ export function VisualEditor() {
|
||||||
rountangles: [...state.rountangles, {
|
rountangles: [...state.rountangles, {
|
||||||
uid: newID,
|
uid: newID,
|
||||||
topLeft: currentPointer,
|
topLeft: currentPointer,
|
||||||
size: minStateSize,
|
size: MIN_ROUNTANGLE_SIZE,
|
||||||
kind: "and",
|
kind: "and",
|
||||||
}],
|
}],
|
||||||
nextID: state.nextID+1,
|
nextID: state.nextID+1,
|
||||||
|
|
@ -464,17 +402,19 @@ export function VisualEditor() {
|
||||||
if (arrow.uid === selected.uid) {
|
if (arrow.uid === selected.uid) {
|
||||||
const rSideStart = findNearestRountangleSide(arrow, "start", state.rountangles);
|
const rSideStart = findNearestRountangleSide(arrow, "start", state.rountangles);
|
||||||
if (rSideStart) {
|
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);
|
const rSideEnd = findNearestRountangleSide(arrow, "end", state.rountangles);
|
||||||
if (rSideEnd) {
|
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"
|
return <svg width="4000px" height="4000px"
|
||||||
className="svgCanvas"
|
className="svgCanvas"
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
|
|
@ -494,17 +434,21 @@ export function VisualEditor() {
|
||||||
</marker>
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
|
{(rootErrors.length>0) && <text className="error" x={5} y={50}>{rootErrors.join(' ')}</text>}
|
||||||
|
|
||||||
{state.rountangles.map(rountangle => <RountangleSVG
|
{state.rountangles.map(rountangle => <RountangleSVG
|
||||||
key={rountangle.uid}
|
key={rountangle.uid}
|
||||||
rountangle={rountangle}
|
rountangle={rountangle}
|
||||||
selected={selection.find(r => r.uid === rountangle.uid)?.parts || []}
|
selected={selection.find(r => r.uid === rountangle.uid)?.parts || []}
|
||||||
highlight={sidesToHighlight[rountangle.uid] || []}
|
highlight={sidesToHighlight[rountangle.uid] || []}
|
||||||
|
errors={errors.filter(([uid,msg])=>uid===rountangle.uid).map(err=>err[1])}
|
||||||
/>)}
|
/>)}
|
||||||
|
|
||||||
{state.arrows.map(arrow => <ArrowSVG
|
{state.arrows.map(arrow => <ArrowSVG
|
||||||
key={arrow.uid}
|
key={arrow.uid}
|
||||||
arrow={arrow}
|
arrow={arrow}
|
||||||
selected={selection.find(a => a.uid === arrow.uid)?.parts || []}
|
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} />}
|
{selectingState && <Selecting {...selectingState} />}
|
||||||
|
|
||||||
{showHelp && <>
|
{showHelp ? <>
|
||||||
<text x={5} y={20}>
|
<text x={5} y={20}>
|
||||||
Left mouse button: Select/Drag.
|
Left mouse button: Select/Drag.
|
||||||
</text>
|
</text>
|
||||||
|
|
@ -563,14 +507,11 @@ export function VisualEditor() {
|
||||||
<text x={5} y={140}>
|
<text x={5} y={140}>
|
||||||
[H] Show/hide this help.
|
[H] Show/hide this help.
|
||||||
</text>
|
</text>
|
||||||
</>}
|
</> : <text x={5} y={20}>[H] To show help.</text>}
|
||||||
|
|
||||||
</svg>;
|
</svg>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cornerOffset = 4;
|
|
||||||
const cornerRadius = 16;
|
|
||||||
|
|
||||||
function rountangleMinSize(size: Vec2D): Vec2D {
|
function rountangleMinSize(size: Vec2D): Vec2D {
|
||||||
if (size.x >= 40 && size.y >= 40) {
|
if (size.x >= 40 && size.y >= 40) {
|
||||||
return size;
|
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;
|
const {topLeft, size, uid} = props.rountangle;
|
||||||
// always draw a rountangle with a minimum size
|
// 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
|
// 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
|
<rect
|
||||||
className={"rountangle"
|
className={"rountangle"
|
||||||
+(props.selected.length===4?" selected":"")
|
+(props.selected.length===4?" selected":"")
|
||||||
+((props.rountangle.kind==="or")?" or":"")}
|
+((props.rountangle.kind==="or")?" or":"")
|
||||||
rx={20} ry={20}
|
+(props.errors.length>0?" error":"")
|
||||||
|
}
|
||||||
|
rx={ROUNTANGLE_RADIUS} ry={ROUNTANGLE_RADIUS}
|
||||||
x={0}
|
x={0}
|
||||||
y={0}
|
y={0}
|
||||||
width={minSize.x}
|
width={minSize.x}
|
||||||
|
|
@ -599,6 +542,10 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
|
||||||
data-uid={uid}
|
data-uid={uid}
|
||||||
data-parts="left top right bottom"
|
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
|
<line
|
||||||
className={"lineHelper"
|
className={"lineHelper"
|
||||||
+(props.selected.includes("top")?" selected":"")
|
+(props.selected.includes("top")?" selected":"")
|
||||||
|
|
@ -650,51 +597,45 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
|
||||||
|
|
||||||
<circle
|
<circle
|
||||||
className="circleHelper corner"
|
className="circleHelper corner"
|
||||||
cx={cornerOffset}
|
cx={CORNER_HELPER_OFFSET}
|
||||||
cy={cornerOffset}
|
cy={CORNER_HELPER_OFFSET}
|
||||||
r={cornerRadius}
|
r={CORNER_HELPER_RADIUS}
|
||||||
data-uid={uid}
|
data-uid={uid}
|
||||||
data-parts="top left"
|
data-parts="top left"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<circle
|
<circle
|
||||||
className="circleHelper corner"
|
className="circleHelper corner"
|
||||||
cx={minSize.x-cornerOffset}
|
cx={minSize.x-CORNER_HELPER_OFFSET}
|
||||||
cy={cornerOffset}
|
cy={CORNER_HELPER_OFFSET}
|
||||||
r={cornerRadius}
|
r={CORNER_HELPER_RADIUS}
|
||||||
data-uid={uid}
|
data-uid={uid}
|
||||||
data-parts="top right"
|
data-parts="top right"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<circle
|
<circle
|
||||||
className="circleHelper corner"
|
className="circleHelper corner"
|
||||||
cx={minSize.x-cornerOffset}
|
cx={minSize.x-CORNER_HELPER_OFFSET}
|
||||||
cy={minSize.y-cornerOffset}
|
cy={minSize.y-CORNER_HELPER_OFFSET}
|
||||||
r={cornerRadius}
|
r={CORNER_HELPER_RADIUS}
|
||||||
data-uid={uid}
|
data-uid={uid}
|
||||||
data-parts="bottom right"
|
data-parts="bottom right"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<circle
|
<circle
|
||||||
className="circleHelper corner"
|
className="circleHelper corner"
|
||||||
cx={cornerOffset}
|
cx={CORNER_HELPER_OFFSET}
|
||||||
cy={minSize.y-cornerOffset}
|
cy={minSize.y-CORNER_HELPER_OFFSET}
|
||||||
r={cornerRadius}
|
r={CORNER_HELPER_RADIUS}
|
||||||
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}>{uid}</text>
|
||||||
</g>;
|
</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;
|
const {start, end, uid} = props.arrow;
|
||||||
return <g>
|
return <g>
|
||||||
<line
|
<line
|
||||||
className={"arrow"}
|
className={"arrow"+(props.errors.length>0?" error":"")}
|
||||||
markerEnd='url(#arrowEnd)'
|
markerEnd='url(#arrowEnd)'
|
||||||
x1={start.x}
|
x1={start.x}
|
||||||
y1={start.y}
|
y1={start.y}
|
||||||
|
|
@ -703,6 +644,9 @@ export function ArrowSVG(props: {arrow: Arrow, selected: string[]}) {
|
||||||
data-uid={uid}
|
data-uid={uid}
|
||||||
data-parts="start end"
|
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
|
<line
|
||||||
className="lineHelper"
|
className="lineHelper"
|
||||||
x1={start.x}
|
x1={start.x}
|
||||||
|
|
@ -718,7 +662,7 @@ export function ArrowSVG(props: {arrow: Arrow, selected: string[]}) {
|
||||||
+(props.selected.includes("start")?" selected":"")}
|
+(props.selected.includes("start")?" selected":"")}
|
||||||
cx={start.x}
|
cx={start.x}
|
||||||
cy={start.y}
|
cy={start.y}
|
||||||
r={cornerRadius}
|
r={CORNER_HELPER_RADIUS}
|
||||||
data-uid={uid}
|
data-uid={uid}
|
||||||
data-parts="start"
|
data-parts="start"
|
||||||
/>
|
/>
|
||||||
|
|
@ -727,7 +671,7 @@ export function ArrowSVG(props: {arrow: Arrow, selected: string[]}) {
|
||||||
+(props.selected.includes("end")?" selected":"")}
|
+(props.selected.includes("end")?" selected":"")}
|
||||||
cx={end.x}
|
cx={end.x}
|
||||||
cy={end.y}
|
cy={end.y}
|
||||||
r={cornerRadius}
|
r={CORNER_HELPER_RADIUS}
|
||||||
data-uid={uid}
|
data-uid={uid}
|
||||||
data-parts="end"
|
data-parts="end"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
57
src/VisualEditor/ast.ts
Normal file
57
src/VisualEditor/ast.ts
Normal 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
|
||||||
|
}
|
||||||
65
src/VisualEditor/editor_types.ts
Normal file
65
src/VisualEditor/editor_types.ts
Normal 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;
|
||||||
|
}
|
||||||
9
src/VisualEditor/parameters.ts
Normal file
9
src/VisualEditor/parameters.ts
Normal 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
100
src/VisualEditor/parser.ts
Normal 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];
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue