highlight fired transitions

This commit is contained in:
Joeri Exelmans 2025-10-19 11:27:32 +02:00
parent b8bc977a8e
commit a10bf9acc8
6 changed files with 80 additions and 34 deletions

View file

@ -19,13 +19,10 @@ import { BottomPanel } from "./BottomPanel";
export function App() { export function App() {
const [mode, setMode] = useState<InsertMode>("and"); const [mode, setMode] = useState<InsertMode>("and");
const [ast, setAST] = useState<Statechart>(emptyStatechart); const [ast, setAST] = useState<Statechart>(emptyStatechart);
const [errors, setErrors] = useState<TraceableError[]>([]); const [errors, setErrors] = useState<TraceableError[]>([]);
const [rt, setRT] = useState<BigStep[]>([]); const [rt, setRT] = useState<BigStep[]>([]);
const [rtIdx, setRTIdx] = useState<number|undefined>(); const [rtIdx, setRTIdx] = useState<number|undefined>();
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0}); const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
const refRightSideBar = useRef<HTMLDivElement>(null); const refRightSideBar = useRef<HTMLDivElement>(null);
@ -54,17 +51,22 @@ export function App() {
function appendNewConfig(inputEvent: string, simtime: number, config: BigStepOutput) { function appendNewConfig(inputEvent: string, simtime: number, config: BigStepOutput) {
setRT([...rt.slice(0, rtIdx!+1), {inputEvent, simtime, ...config}]); setRT([...rt.slice(0, rtIdx!+1), {inputEvent, simtime, ...config}]);
setRTIdx(rtIdx!+1); setRTIdx(rtIdx!+1);
// console.log('new config:', config); console.log('new config:', config);
if (refRightSideBar.current) { if (refRightSideBar.current) {
const el = refRightSideBar.current; const el = refRightSideBar.current;
console.log('scrolling to:', el);
setTimeout(() => { setTimeout(() => {
el.scrollIntoView({block: "end", behavior: "smooth"}); el.scrollIntoView({block: "end", behavior: "smooth"});
}, 100); }, 100);
} }
} }
useEffect(() => {
console.log("Welcome to StateBuddy!");
() => {
console.log("Goodbye!");
}
}, []);
useEffect(() => { useEffect(() => {
let timeout: NodeJS.Timeout | undefined; let timeout: NodeJS.Timeout | undefined;
if (rtIdx !== undefined) { if (rtIdx !== undefined) {
@ -108,6 +110,8 @@ export function App() {
return state && state.parent?.kind !== "and"; return state && state.parent?.kind !== "and";
})) || new Set(); })) || new Set();
const highlightTransitions = (rtIdx === undefined) ? [] : rt[rtIdx].firedTransitions;
return <Stack sx={{height:'100vh'}}> return <Stack sx={{height:'100vh'}}>
{/* Top bar */} {/* Top bar */}
<Box <Box
@ -125,7 +129,7 @@ export function App() {
<Stack direction="row" sx={{height:'calc(100vh - 64px)'}}> <Stack direction="row" sx={{height:'calc(100vh - 64px)'}}>
{/* main */} {/* main */}
<Box sx={{flexGrow:1, overflow:'auto'}}> <Box sx={{flexGrow:1, overflow:'auto'}}>
<VisualEditor {...{ast, setAST, rt: rt.at(rtIdx!), setRT, errors, setErrors, mode, highlightActive}}/> <VisualEditor {...{ast, setAST, rt: rt.at(rtIdx!), setRT, errors, setErrors, mode, highlightActive, highlightTransitions}}/>
</Box> </Box>
{/* right sidebar */} {/* right sidebar */}
<Box <Box

View file

@ -3,18 +3,25 @@ import { ArcDirection, euclideanDistance } from "./geometry";
import { CORNER_HELPER_RADIUS } from "./parameters"; import { CORNER_HELPER_RADIUS } from "./parameters";
export function ArrowSVG(props: { arrow: Arrow; selected: string[]; errors: string[]; highlight: boolean; arc: ArcDirection; }) { export function ArrowSVG(props: { arrow: Arrow; selected: string[]; errors: string[]; highlight: boolean; fired: boolean; arc: ArcDirection; initialMarker: boolean }) {
const { start, end, uid } = props.arrow; const { start, end, uid } = props.arrow;
const radius = euclideanDistance(start, end) / 1.6; const radius = euclideanDistance(start, end) / 1.6;
const largeArc = "1"; let largeArc = "1";
const arcOrLine = props.arc === "no" ? "L" : let arcOrLine = props.arc === "no" ? "L" :
`A ${radius} ${radius} 0 ${largeArc} ${props.arc === "ccw" ? "0" : "1"}`; `A ${radius} ${radius} 0 ${largeArc} ${props.arc === "ccw" ? "0" : "1"}`;
if (props.initialMarker) {
// largeArc = "0";
arcOrLine = `A ${radius*2} ${radius*2} 0 0 1`
}
return <g> return <g>
<path <path
className={"arrow" className={"arrow"
+ (props.selected.length === 2 ? " selected" : "") + (props.selected.length === 2 ? " selected" : "")
+ (props.errors.length > 0 ? " error" : "") + (props.errors.length > 0 ? " error" : "")
+ (props.highlight ? " highlight" : "")} + (props.highlight ? " highlight" : "")
+ (props.fired ? " fired" : "")
}
markerStart={props.initialMarker ? 'url(#initialMarker)' : undefined}
markerEnd='url(#arrowEnd)' markerEnd='url(#arrowEnd)'
d={`M ${start.x} ${start.y} d={`M ${start.x} ${start.y}
${arcOrLine} ${arcOrLine}

View file

@ -46,7 +46,11 @@
/* fill-opacity: 0.2; */ /* fill-opacity: 0.2; */
/* stroke: rgb(100, 149, 237); */ /* stroke: rgb(100, 149, 237); */
/* stroke: */ /* stroke: */
filter: drop-shadow( 0px 0px 6px rgba(128, 72, 0, 0.856)); stroke: rgb(192, 125, 0);
fill:rgb(255, 251, 244);
/* fill: lightgrey; */
/* color: white; */
filter: drop-shadow( 0px 0px 3px rgba(192, 125, 0, 0.856));
/* stroke-width: 3px; */ /* stroke-width: 3px; */
} }
@ -99,9 +103,16 @@ circle.helper:hover:not(:active) {
stroke-width: 3px; stroke-width: 3px;
} }
.arrow::marker {
fill: content-stroke;
}
#arrowEnd { #arrowEnd {
fill: context-stroke; fill: context-stroke;
} }
#initialMarker {
fill: context-stroke;
}
.arrow:hover { .arrow:hover {
cursor: grab; cursor: grab;
@ -157,6 +168,11 @@ text.helper:hover {
.arrow.error { .arrow.error {
stroke: rgb(230,0,0); stroke: rgb(230,0,0);
} }
.arrow.fired {
stroke: rgb(192, 125, 0);
stroke-width: 3px;
}
text.error, tspan.error { text.error, tspan.error {
fill: rgb(230,0,0); fill: rgb(230,0,0);
font-weight: 600; font-weight: 600;

View file

@ -68,9 +68,10 @@ type VisualEditorProps = {
setErrors: Dispatch<SetStateAction<TraceableError[]>>, setErrors: Dispatch<SetStateAction<TraceableError[]>>,
mode: InsertMode, mode: InsertMode,
highlightActive: Set<string>, highlightActive: Set<string>,
highlightTransitions: string[],
}; };
export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highlightActive}: VisualEditorProps) { export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highlightActive, highlightTransitions}: VisualEditorProps) {
const [historyState, setHistoryState] = useState<HistoryState>({current: emptyState, history: [], future: []}); const [historyState, setHistoryState] = useState<HistoryState>({current: emptyState, history: [], future: []});
const state = historyState.current; const state = historyState.current;
@ -679,11 +680,8 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh
} }
} }
const active = rt?.mode || new Set(); const active = rt?.mode || new Set();
console.log(highlightActive);
const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message); const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message);
return <svg width="4000px" height="4000px" return <svg width="4000px" height="4000px"
@ -700,6 +698,16 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh
onCut={onCut} onCut={onCut}
> >
<defs> <defs>
<marker
id="initialMarker"
viewBox="0 0 9 9"
refX="4.5"
refY="4.5"
markerWidth="9"
markerHeight="9"
markerUnits="userSpaceOnUse">
<circle cx={4.5} cy={4.5} r={4.5}/>
</marker>
<marker <marker
id="arrowEnd" id="arrowEnd"
viewBox="0 0 10 10" viewBox="0 0 10 10"
@ -709,7 +717,7 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh
markerHeight="12" markerHeight="12"
orient="auto-start-reverse" orient="auto-start-reverse"
markerUnits="userSpaceOnUse"> markerUnits="userSpaceOnUse">
<path d="M 0 0 L 10 5 L 0 10 z" /> <path d="M 0 0 L 10 5 L 0 10 z"/>
</marker> </marker>
</defs> </defs>
@ -752,6 +760,7 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh
if (sides && sides[0]?.uid === sides[1]?.uid && sides[0]!.uid !== undefined) { if (sides && sides[0]?.uid === sides[1]?.uid && sides[0]!.uid !== undefined) {
arc = arcDirection(sides[0]!.part, sides[1]!.part); arc = arcDirection(sides[0]!.part, sides[1]!.part);
} }
const initialMarker = sides && sides[0] === undefined && sides[1] !== undefined;
return <ArrowSVG return <ArrowSVG
key={arrow.uid} key={arrow.uid}
arrow={arrow} arrow={arrow}
@ -760,7 +769,9 @@ export function VisualEditor({ast, setAST, rt, errors, setErrors, mode, highligh
.filter(({shapeUid}) => shapeUid === arrow.uid) .filter(({shapeUid}) => shapeUid === arrow.uid)
.map(({message}) => message)} .map(({message}) => message)}
highlight={arrowsToHighlight.hasOwnProperty(arrow.uid)} highlight={arrowsToHighlight.hasOwnProperty(arrow.uid)}
fired={highlightTransitions.includes(arrow.uid)}
arc={arc} arc={arc}
initialMarker={initialMarker}
/>; />;
} }
)} )}

View file

@ -94,10 +94,10 @@ export function exitActions(simtime: number, state: ConcreteState, {enteredState
// recursively enter the given state's default state // recursively enter the given state's default state
export function enterDefault(simtime: number, state: ConcreteState, rt: ActionScope): EnteredScope { export function enterDefault(simtime: number, state: ConcreteState, rt: ActionScope): EnteredScope {
let actionScope = rt; let {firedTransitions, ...actionScope} = rt;
// execute entry actions // execute entry actions
actionScope = entryActions(simtime, state, actionScope); ({firedTransitions, ...actionScope} = entryActions(simtime, state, {firedTransitions, ...actionScope}));
// enter children... // enter children...
let enteredStates = new Set([state.uid]); let enteredStates = new Set([state.uid]);
@ -105,7 +105,7 @@ export function enterDefault(simtime: number, state: ConcreteState, rt: ActionSc
// enter every child // enter every child
for (const child of state.children) { for (const child of state.children) {
let enteredChildren; let enteredChildren;
({enteredStates: enteredChildren, ...actionScope} = enterDefault(simtime, child, actionScope)); ({enteredStates: enteredChildren, firedTransitions, ...actionScope} = enterDefault(simtime, child, {firedTransitions, ...actionScope}));
enteredStates = enteredStates.union(enteredChildren); enteredStates = enteredStates.union(enteredChildren);
} }
} }
@ -115,14 +115,16 @@ export function enterDefault(simtime: number, state: ConcreteState, rt: ActionSc
if (state.initial.length > 1) { if (state.initial.length > 1) {
console.warn(state.uid + ': multiple initial states, only entering one of them'); console.warn(state.uid + ': multiple initial states, only entering one of them');
} }
const [arrowUid, toEnter] = state.initial[0];
firedTransitions = [...firedTransitions, arrowUid];
let enteredChildren; let enteredChildren;
({enteredStates: enteredChildren, ...actionScope} = enterDefault(simtime, state.initial[0][1], actionScope)); ({enteredStates: enteredChildren, firedTransitions, ...actionScope} = enterDefault(simtime, toEnter, {firedTransitions, ...actionScope}));
enteredStates = enteredStates.union(enteredChildren); enteredStates = enteredStates.union(enteredChildren);
} }
// console.warn(state.uid + ': no initial state'); // console.warn(state.uid + ': no initial state');
} }
return {enteredStates, ...actionScope}; return {enteredStates, firedTransitions, ...actionScope};
} }
// recursively enter the given state and, if children need to be entered, preferrably those occurring in 'toEnter' will be entered. If no child occurs in 'toEnter', the default child will be entered. // recursively enter the given state and, if children need to be entered, preferrably those occurring in 'toEnter' will be entered. If no child occurs in 'toEnter', the default child will be entered.
@ -281,14 +283,14 @@ export function handleInputEvent(simtime: number, event: RT_Event, statechart: S
return handleInternalEvents(simtime, statechart, {mode, environment, history, ...raised}); return handleInternalEvents(simtime, statechart, {mode, environment, history, ...raised});
} }
export function handleInternalEvents(simtime: number, statechart: Statechart, {mode, environment, history, ...raised}: RT_Statechart & RaisedEvents): BigStepOutput { export function handleInternalEvents(simtime: number, statechart: Statechart, {internalEvents, ...rest}: RT_Statechart & RaisedEvents): BigStepOutput {
while (raised.internalEvents.length > 0) { while (internalEvents.length > 0) {
const [internalEvent, ...rest] = raised.internalEvents; const [nextEvent, ...remainingEvents] = internalEvents;
({mode, environment, ...raised} = handleEvent(simtime, ({internalEvents, ...rest} = handleEvent(simtime,
{kind: "input", ...internalEvent}, // internal event becomes input event {kind: "input", ...nextEvent}, // internal event becomes input event
statechart, statechart.root, {mode, environment, history, internalEvents: rest, outputEvents: raised.outputEvents})); statechart, statechart.root, {internalEvents: remainingEvents, ...rest}));
} }
return {mode, environment, history, outputEvents: raised.outputEvents}; return rest;
} }
export function fireTransition(simtime: number, t: Transition, ts: Map<string, Transition[]>, label: TransitionLabel, arena: OrState, {mode, environment, history, ...raised}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents { export function fireTransition(simtime: number, t: Transition, ts: Map<string, Transition[]>, label: TransitionLabel, arena: OrState, {mode, environment, history, ...raised}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents {
@ -309,12 +311,14 @@ export function fireTransition(simtime: number, t: Transition, ts: Map<string, T
// assuming we've already exited the source state of the transition, now enter the target state // assuming we've already exited the source state of the transition, now enter the target state
// IF however, the target is a pseudo-state, DON'T enter it (pseudo-states are NOT states), instead fire the first pseudo-outgoing transition. // IF however, the target is a pseudo-state, DON'T enter it (pseudo-states are NOT states), instead fire the first pseudo-outgoing transition.
export function fireSecondHalfOfTransition(simtime: number, t: Transition, ts: Map<string, Transition[]>, label: TransitionLabel, arena: OrState, {mode, environment, history, ...raised}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents { export function fireSecondHalfOfTransition(simtime: number, t: Transition, ts: Map<string, Transition[]>, label: TransitionLabel, arena: OrState, {mode, environment, history, firedTransitions, ...raised}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents {
// exec transition actions // exec transition actions
for (const action of label.actions) { for (const action of label.actions) {
({environment, history, ...raised} = execAction(action, {environment, history, ...raised})); ({environment, history, firedTransitions, ...raised} = execAction(action, {environment, history, firedTransitions, ...raised}));
} }
firedTransitions = [...firedTransitions, t.uid];
if (t.tgt.kind === "pseudo") { if (t.tgt.kind === "pseudo") {
const outgoing = ts.get(t.tgt.uid) || []; const outgoing = ts.get(t.tgt.uid) || [];
for (const nextT of outgoing) { for (const nextT of outgoing) {
@ -323,7 +327,7 @@ export function fireSecondHalfOfTransition(simtime: number, t: Transition, ts: M
if (evalExpr(nextLabel.guard, environment)) { if (evalExpr(nextLabel.guard, environment)) {
console.log('fire', transitionDescription(nextT)); console.log('fire', transitionDescription(nextT));
// found ourselves an enabled transition // found ourselves an enabled transition
return fireSecondHalfOfTransition(simtime, nextT, ts, nextLabel, arena, {mode, environment, history, ...raised}); return fireSecondHalfOfTransition(simtime, nextT, ts, nextLabel, arena, {mode, environment, history, firedTransitions, ...raised});
} }
} }
} }
@ -346,11 +350,11 @@ export function fireSecondHalfOfTransition(simtime: number, t: Transition, ts: M
// enter tgt // enter tgt
let enteredStates; let enteredStates;
({enteredStates, environment, history, ...raised} = enterStates(simtime, state, toEnter, {environment, history, ...raised})); ({enteredStates, environment, history, ...raised} = enterStates(simtime, state, toEnter, {environment, history, firedTransitions, ...raised}));
const enteredMode = mode.union(enteredStates); const enteredMode = mode.union(enteredStates);
// console.log({enteredMode}); // console.log({enteredMode});
return {mode: enteredMode, environment, history, ...raised}; return {mode: enteredMode, environment, history, firedTransitions, ...raised};
} }
} }

View file

@ -101,6 +101,7 @@ export type RT_Statechart = {
export type BigStepOutput = RT_Statechart & { export type BigStepOutput = RT_Statechart & {
outputEvents: RaisedEvent[], outputEvents: RaisedEvent[],
firedTransitions: string[],
}; };
export type BigStep = { export type BigStep = {
@ -117,6 +118,7 @@ export type RaisedEvent = {
export type RaisedEvents = { export type RaisedEvents = {
internalEvents: RaisedEvent[]; internalEvents: RaisedEvent[];
outputEvents: RaisedEvent[]; outputEvents: RaisedEvent[];
firedTransitions: string[]; // list of UIDs
}; };
// export type Timers = Map<string, number>; // transition uid -> timestamp // export type Timers = Map<string, number>; // transition uid -> timestamp
@ -124,6 +126,8 @@ export type RaisedEvents = {
export const initialRaised: RaisedEvents = { export const initialRaised: RaisedEvents = {
internalEvents: [], internalEvents: [],
outputEvents: [], outputEvents: [],
firedTransitions: [],
}; };
export type Timers = [number, TimerElapseEvent][]; export type Timers = [number, TimerElapseEvent][];