grealy improved memoization

This commit is contained in:
Joeri Exelmans 2025-11-14 20:45:43 +01:00
parent 7994cd6eb0
commit 64aab1a6df
8 changed files with 217 additions and 119 deletions

View file

@ -3,7 +3,7 @@ import "./App.css";
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { detectConnections } from "@/statecharts/detect_connections"; import { connectionsEqual, detectConnections, reducedConcreteSyntaxEqual } from "@/statecharts/detect_connections";
import { parseStatechart } from "../statecharts/parser"; import { parseStatechart } from "../statecharts/parser";
import { BottomPanel } from "./BottomPanel/BottomPanel"; import { BottomPanel } from "./BottomPanel/BottomPanel";
import { defaultSideBarState, SideBar, SideBarState } from "./SideBar/SideBar"; import { defaultSideBarState, SideBar, SideBarState } from "./SideBar/SideBar";
@ -18,6 +18,7 @@ import { plants } from "./plants";
import { emptyState } from "@/statecharts/concrete_syntax"; import { emptyState } from "@/statecharts/concrete_syntax";
import { ModalOverlay } from "./Overlays/ModalOverlay"; import { ModalOverlay } from "./Overlays/ModalOverlay";
import { FindReplace } from "./BottomPanel/FindReplace"; import { FindReplace } from "./BottomPanel/FindReplace";
import { useCustomMemo } from "@/hooks/useCustomMemo";
export type EditHistory = { export type EditHistory = {
current: VisualEditorState, current: VisualEditorState,
@ -59,7 +60,17 @@ export function App() {
// parse concrete syntax always: // parse concrete syntax always:
const conns = useMemo(() => editorState && detectConnections(editorState), [editorState]); const conns = useMemo(() => editorState && detectConnections(editorState), [editorState]);
const parsed = useMemo(() => editorState && conns && parseStatechart(editorState, conns), [editorState, conns]); const parsed = useCustomMemo(() => editorState && conns && parseStatechart(editorState, conns),
[editorState, conns] as const,
// only parse again if anything changed to the connectedness / insideness...
// parsing is fast, BUT re-rendering everything that depends on the AST is slow, and it's difficult to check if the AST changed because AST objects have recursive structure.
([prevState, prevConns], [nextState, nextConns]) => {
if ((prevState === null) !== (nextState === null)) return false;
if ((prevConns === null) !== (nextConns === null)) return false;
// the following check is much cheaper than re-rendering everything that depends on
return connectionsEqual(prevConns!, nextConns!)
&& reducedConcreteSyntaxEqual(prevState!, nextState!);
});
const ast = parsed && parsed[0]; const ast = parsed && parsed[0];
const [appState, setAppState] = useState<AppState>(defaultAppState); const [appState, setAppState] = useState<AppState>(defaultAppState);
@ -118,14 +129,6 @@ export function App() {
const simulator = useSimulator(ast, plant, plantConns, scrollDownSidebar); const simulator = useSimulator(ast, plant, plantConns, scrollDownSidebar);
// console.log('render app', {ast, plant, appState});
// useDetectChange(ast, 'ast');
// useDetectChange(plant, 'plant');
// useDetectChange(scrollDownSidebar, 'scrollDownSidebar');
// useDetectChange(appState, 'appState');
// useDetectChange(simulator.time, 'simulator.time');
// useDetectChange(simulator.trace, 'simulator.trace');
const setters = makeAllSetters(setAppState, Object.keys(appState) as (keyof AppState)[]); const setters = makeAllSetters(setAppState, Object.keys(appState) as (keyof AppState)[]);
const syntaxErrors = parsed && parsed[1] || []; const syntaxErrors = parsed && parsed[1] || [];
@ -141,7 +144,9 @@ export function App() {
const highlightActive = (currentBigStep && currentBigStep.state.sc.mode) || new Set(); const highlightActive = (currentBigStep && currentBigStep.state.sc.mode) || new Set();
const highlightTransitions = currentBigStep && currentBigStep.state.sc.firedTransitions || []; const highlightTransitions = currentBigStep && currentBigStep.state.sc.firedTransitions || [];
const plantState = currentBigStep && currentBigStep.state.plant || plant.execution.initial()[1]; const plantState = useMemo(() =>
currentBigStep && currentBigStep.state.plant || plant.execution.initial()[1],
[currentBigStep, plant]);
return <div style={{ return <div style={{
height:'100%', height:'100%',

View file

@ -12,7 +12,7 @@ import digitalFont from "./digital-font.ttf";
import "./DigitalWatch.css"; import "./DigitalWatch.css";
import imgNote from "./noteSmall.png"; import imgNote from "./noteSmall.png";
import imgWatch from "./watch.png"; import imgWatch from "./watch.png";
import { objectsEqual } from "@/util/util"; import { jsonDeepEqual } from "@/util/util";
export const [dwatchAbstractSyntax, dwatchErrors] = parseStatechart(dwatchConcreteSyntax as ConcreteSyntax, detectConnections(dwatchConcreteSyntax as ConcreteSyntax)); export const [dwatchAbstractSyntax, dwatchErrors] = parseStatechart(dwatchConcreteSyntax as ConcreteSyntax, detectConnections(dwatchConcreteSyntax as ConcreteSyntax));
@ -140,7 +140,7 @@ export const DigitalWatch = memo(function DigitalWatch({state: {displayingTime,
} }
</svg> </svg>
</>; </>;
}, objectsEqual); }, jsonDeepEqual);
export const digitalWatchPlant = makeStatechartPlant({ export const digitalWatchPlant = makeStatechartPlant({
ast: dwatchAbstractSyntax, ast: dwatchAbstractSyntax,

View file

@ -5,6 +5,7 @@ import { ConcreteState, stateDescription, Transition, UnstableState } from "../.
import { Action, EventTrigger, Expression } from "../../statecharts/label_ast"; import { Action, EventTrigger, Expression } from "../../statecharts/label_ast";
import { KeyInfoHidden, KeyInfoVisible } from "../TopPanel/KeyInfo"; import { KeyInfoHidden, KeyInfoVisible } from "../TopPanel/KeyInfo";
import { useShortcuts } from '@/hooks/useShortcuts'; import { useShortcuts } from '@/hooks/useShortcuts';
import { arraysEqual, jsonDeepEqual } from '@/util/util';
export function ShowTransition(props: {transition: Transition}) { export function ShowTransition(props: {transition: Transition}) {
return <> {stateDescription(props.transition.tgt)}</>; return <> {stateDescription(props.transition.tgt)}</>;
@ -50,7 +51,7 @@ export const ShowAST = memo(function ShowASTx(props: {root: ConcreteState | Unst
}); });
export function ShowInputEvents({inputEvents, onRaise, disabled}: {inputEvents: EventTrigger[], onRaise: (e: string, p: any) => void, disabled: boolean}) { export const ShowInputEvents = memo(function ShowInputEvents({inputEvents, onRaise, disabled}: {inputEvents: EventTrigger[], onRaise: (e: string, p: any) => void, disabled: boolean}) {
const raiseHandlers = inputEvents.map(({event}) => { const raiseHandlers = inputEvents.map(({event}) => {
return () => { return () => {
// @ts-ignore // @ts-ignore
@ -105,17 +106,22 @@ export function ShowInputEvents({inputEvents, onRaise, disabled}: {inputEvents:
&nbsp; &nbsp;
</div>; </div>;
}) })
} }, (prevProps, nextProps) => {
console.log('onRaise changed:', prevProps.onRaise === nextProps.onRaise, prevProps.onRaise, nextProps.onRaise);
return prevProps.onRaise === nextProps.onRaise
&& prevProps.disabled === nextProps.disabled
&& jsonDeepEqual(prevProps.inputEvents, nextProps.inputEvents);
});
export function ShowInternalEvents(props: {internalEvents: EventTrigger[]}) { export function ShowInternalEvents(props: {internalEvents: EventTrigger[]}) {
return [...props.internalEvents].map(({event, paramName}) => { return [...props.internalEvents].map(({event, paramName}) => {
return <><div className="internalEvent">{event}{paramName===undefined?<></>:<>({paramName})</>}</div> </>; return <div className="internalEvent" key={event}>{event}{paramName===undefined?<></>:<>({paramName})</>}</div>;
}); });
} }
export function ShowOutputEvents(props: {outputEvents: Set<string>}) { export function ShowOutputEvents(props: {outputEvents: Set<string>}) {
return [...props.outputEvents].map(eventName => { return [...props.outputEvents].map(eventName => {
return <><div className="outputEvent">{eventName}</div> </>; return <div className="outputEvent" key={eventName}>{eventName}</div>;
}); });
} }

View file

@ -5,7 +5,7 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined'; import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
import { Conns } from '@/statecharts/timed_reactive'; import { Conns } from '@/statecharts/timed_reactive';
import { Dispatch, Ref, SetStateAction, useEffect, useRef, useState } from 'react'; import { Dispatch, memo, Ref, SetStateAction, useCallback, useEffect, useRef, useState } from 'react';
import { Statechart } from '@/statecharts/abstract_syntax'; import { Statechart } from '@/statecharts/abstract_syntax';
import { ShowAST, ShowInputEvents, ShowInternalEvents, ShowOutputEvents } from './ShowAST'; import { ShowAST, ShowInputEvents, ShowInternalEvents, ShowOutputEvents } from './ShowAST';
import { Plant } from '../Plant/Plant'; import { Plant } from '../Plant/Plant';
@ -17,6 +17,7 @@ import { plants, UniversalPlantState } from '../plants';
import { TimeMode } from '@/statecharts/time'; import { TimeMode } from '@/statecharts/time';
import { PersistentDetails } from '../Components/PersistentDetails'; import { PersistentDetails } from '../Components/PersistentDetails';
import "./SideBar.css"; import "./SideBar.css";
import { objectsEqual } from '@/util/util';
type SavedTraces = [string, BigStepCause[]][]; type SavedTraces = [string, BigStepCause[]][];
@ -76,20 +77,20 @@ type SideBarProps = SideBarState & {
time: TimeMode, time: TimeMode,
} & Setters<SideBarState>; } & Setters<SideBarState>;
export function SideBar({showExecutionTrace, showConnections, plantName, showPlantTrace, showProperties, activeProperty, autoConnect, autoScroll, plantConns, properties, savedTraces, refRightSideBar, ast, plant, setSavedTraces, trace, setTrace, setProperties, setShowPlantTrace, setActiveProperty, setPlantConns, setPlantName, setAutoConnect, setShowProperties, setAutoScroll, time, plantState, onReplayTrace, onRaise, setTime, setShowConnections, setShowExecutionTrace, showPlant, setShowPlant, showOutputEvents, setShowOutputEvents, setShowInternalEvents, showInternalEvents, setShowInputEvents, setShowStateTree, showInputEvents, showStateTree}: SideBarProps) { export const SideBar = memo(function SideBar({showExecutionTrace, showConnections, plantName, showPlantTrace, showProperties, activeProperty, autoConnect, autoScroll, plantConns, properties, savedTraces, refRightSideBar, ast, plant, setSavedTraces, trace, setTrace, setProperties, setShowPlantTrace, setActiveProperty, setPlantConns, setPlantName, setAutoConnect, setShowProperties, setAutoScroll, time, plantState, onReplayTrace, onRaise, setTime, setShowConnections, setShowExecutionTrace, showPlant, setShowPlant, showOutputEvents, setShowOutputEvents, setShowInternalEvents, showInternalEvents, setShowInputEvents, setShowStateTree, showInputEvents, showStateTree}: SideBarProps) {
const [propertyResults, setPropertyResults] = useState<PropertyCheckResult[] | null>(null); const [propertyResults, setPropertyResults] = useState<PropertyCheckResult[] | null>(null);
const speed = time.kind === "paused" ? 0 : time.scale; const speed = time.kind === "paused" ? 0 : time.scale;
const onSaveTrace = () => { const onSaveTrace = useCallback(() => {
if (trace) { if (trace) {
setSavedTraces(savedTraces => [ setSavedTraces(savedTraces => [
...savedTraces, ...savedTraces,
["untitled", trace.trace.map((item) => item.cause)] as [string, BigStepCause[]], ["untitled", trace.trace.map((item) => item.cause)] as [string, BigStepCause[]],
]); ]);
} }
} }, [trace, setSavedTraces]);
// if some properties change, re-evaluate them: // if some properties change, re-evaluate them:
useEffect(() => { useEffect(() => {
@ -115,6 +116,9 @@ export function SideBar({showExecutionTrace, showConnections, plantName, showPla
} }
}, [ast, plant, autoConnect]); }, [ast, plant, autoConnect]);
const raiseDebugEvent = useCallback((e,p) => onRaise("debug."+e,p), [onRaise]);
const raiseUIEvent = useCallback(e => onRaise("plant.ui."+e.name, e.param), [onRaise]);
return <> return <>
<div <div
className={showExecutionTrace ? "shadowBelow" : ""} className={showExecutionTrace ? "shadowBelow" : ""}
@ -132,7 +136,7 @@ export function SideBar({showExecutionTrace, showConnections, plantName, showPla
<summary>input events</summary> <summary>input events</summary>
{ast && <ShowInputEvents {ast && <ShowInputEvents
inputEvents={ast.inputEvents} inputEvents={ast.inputEvents}
onRaise={(e,p) => onRaise("debug."+e,p)} onRaise={raiseDebugEvent}
disabled={trace===null || trace.trace[trace.idx].kind === "error"} disabled={trace===null || trace.trace[trace.idx].kind === "error"}
/>} />}
</PersistentDetails> </PersistentDetails>
@ -153,14 +157,14 @@ export function SideBar({showExecutionTrace, showConnections, plantName, showPla
disabled={trace!==null} disabled={trace!==null}
value={plantName} value={plantName}
onChange={e => setPlantName(() => e.target.value)}> onChange={e => setPlantName(() => e.target.value)}>
{plants.map(([plantName, p]) => {plants.map(([plantName]) =>
<option>{plantName}</option> <option key={plantName}>{plantName}</option>
)} )}
</select> </select>
<br/> <br/>
{/* Render plant */} {/* Render plant */}
{<plant.render state={plant.cleanupState(plantState)} speed={speed} {<plant.render state={plant.cleanupState(plantState)} speed={speed}
raiseUIEvent={e => onRaise("plant.ui."+e.name, e.param)} raiseUIEvent={raiseUIEvent}
/>} />}
</PersistentDetails> </PersistentDetails>
{/* Connections */} {/* Connections */}
@ -251,7 +255,9 @@ export function SideBar({showExecutionTrace, showConnections, plantName, showPla
</div> </div>
</div>} </div>}
</>; </>;
} }, (prevProps, nextProps) => {
return objectsEqual(prevProps, nextProps);
});
function autoDetectConns(ast: Statechart, plant: Plant<any, any>, setPlantConns: Dispatch<SetStateAction<Conns>>) { function autoDetectConns(ast: Statechart, plant: Plant<any, any>, setPlantConns: Dispatch<SetStateAction<Conns>>) {
for (const {event: a} of plant.uiEvents) { for (const {event: a} of plant.uiEvents) {
@ -289,7 +295,7 @@ function ConnEditor(ast: Statechart, plant: Plant<any, any>, plantConns: Conns,
return <> return <>
{/* SC output events can go to Plant */} {/* SC output events can go to Plant */}
{[...ast.outputEvents].map(e => <div style={{width:'100%', textAlign:'right'}}> {[...ast.outputEvents].map(e => <div key={e} style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-sc-${e}`} style={{width:'50%'}}>sc.{e}&nbsp;&nbsp;</label> <label htmlFor={`select-dst-sc-${e}`} style={{width:'50%'}}>sc.{e}&nbsp;&nbsp;</label>
<select id={`select-dst-sc-${e}`} <select id={`select-dst-sc-${e}`}
style={{width:'50%'}} style={{width:'50%'}}
@ -302,7 +308,7 @@ function ConnEditor(ast: Statechart, plant: Plant<any, any>, plantConns: Conns,
</div>)} </div>)}
{/* Plant output events can go to Statechart */} {/* Plant output events can go to Statechart */}
{[...plant.outputEvents.map(e => <div style={{width:'100%', textAlign:'right'}}> {[...plant.outputEvents.map(e => <div key={e.event} style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-plant-${e.event}`} style={{width:'50%'}}>plant.{e.event}&nbsp;&nbsp;</label> <label htmlFor={`select-dst-plant-${e.event}`} style={{width:'50%'}}>plant.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-${e.event}`} <select id={`select-dst-plant-${e.event}`}
style={{width:'50%'}} style={{width:'50%'}}
@ -315,7 +321,7 @@ function ConnEditor(ast: Statechart, plant: Plant<any, any>, plantConns: Conns,
</div>)]} </div>)]}
{/* Plant UI events typically go to the Plant */} {/* Plant UI events typically go to the Plant */}
{plant.uiEvents.map(e => <div style={{width:'100%', textAlign:'right'}}> {plant.uiEvents.map(e => <div key={e.event} style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-plant-ui-${e.event}`} style={{width:'50%', color: 'grey'}}>ui.{e.event}&nbsp;&nbsp;</label> <label htmlFor={`select-dst-plant-ui-${e.event}`} style={{width:'50%', color: 'grey'}}>ui.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-ui-${e.event}`} <select id={`select-dst-plant-ui-${e.event}`}
style={{width:'50%'}} style={{width:'50%'}}

View file

@ -40,6 +40,8 @@ export type TraceState = {
idx: number, idx: number,
}; };
const ignoreRaise = (inputEvent: string, param: any) => {};
export function useSimulator(ast: Statechart|null, plant: Plant<any, UniversalPlantState>, plantConns: Conns, onStep: () => void) { export function useSimulator(ast: Statechart|null, plant: Plant<any, UniversalPlantState>, plantConns: Conns, onStep: () => void) {
const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0}); const [time, setTime] = useState<TimeMode>({kind: "paused", simtime: 0});
@ -53,7 +55,7 @@ export function useSimulator(ast: Statechart|null, plant: Plant<any, UniversalPl
}, { }, {
...plantConns, ...plantConns,
...Object.fromEntries(ast.inputEvents.map(({event}) => ["debug."+event, ['sc',event] as [string,string]])), ...Object.fromEntries(ast.inputEvents.map(({event}) => ["debug."+event, ['sc',event] as [string,string]])),
}), [ast]); }), [ast, plant, plantConns]);
const onInit = useCallback(() => { const onInit = useCallback(() => {
if (cE === null) return; if (cE === null) return;
@ -92,18 +94,51 @@ export function useSimulator(ast: Statechart|null, plant: Plant<any, UniversalPl
setTime({kind: "paused", simtime: 0}); setTime({kind: "paused", simtime: 0});
}, [setTrace, setTime]); }, [setTrace, setTime]);
const appendNewConfig = useCallback((simtime: number, cause: BigStepCause, computeNewState: () => [RaisedEvent[], CoupledState]) => {
let newItem: TraceItem;
const metadata = {simtime, cause}
try {
const [outputEvents, state] = computeNewState(); // may throw RuntimeError
newItem = {kind: "bigstep", ...metadata, state, outputEvents};
}
catch (error) {
if (error instanceof RuntimeError) {
newItem = {kind: "error", ...metadata, error};
// also pause the simulation, for dramatic effect:
setTime({kind: "paused", simtime});
}
else {
throw error;
}
}
// @ts-ignore
setTrace(trace => ({
trace: [
...trace!.trace.slice(0, trace!.idx+1), // remove everything after current item
newItem,
],
// idx: 0,
idx: trace!.idx+1,
}));
onStep();
}, [onStep, setTrace, setTime]);
// raise input event, producing a new runtime configuration (or a runtime error) // raise input event, producing a new runtime configuration (or a runtime error)
const onRaise = (inputEvent: string, param: any) => { const onRaise = useMemo(() => {
if (cE === null) return; if (cE === null || currentTraceItem === null) {
if (currentTraceItem !== null /*&& ast.inputEvents.some(e => e.event === inputEvent)*/) { return ignoreRaise; // this speeds up rendering of components that depend on onRaise if the model is being edited while there is no ongoing trace
}
else return (inputEvent: string, param: any) => {
if (currentTraceItem.kind === "bigstep") { if (currentTraceItem.kind === "bigstep") {
const simtime = getSimTime(time, Math.round(performance.now())); const simtime = getSimTime(time, Math.round(performance.now()));
appendNewConfig(simtime, {kind: "input", simtime, eventName: inputEvent, param}, () => { appendNewConfig(simtime, {kind: "input", simtime, eventName: inputEvent, param}, () => {
return cE.extTransition(simtime, currentTraceItem.state, {kind: "input", name: inputEvent, param}); return cE.extTransition(simtime, currentTraceItem.state, {kind: "input", name: inputEvent, param});
}); });
} }
} };
}; }, [cE, currentTraceItem, time, appendNewConfig]);
console.log({onRaise});
// timer elapse events are triggered by a change of the simulated time (possibly as a scheduled JS event loop timeout) // timer elapse events are triggered by a change of the simulated time (possibly as a scheduled JS event loop timeout)
useEffect(() => { useEffect(() => {
@ -137,35 +172,6 @@ export function useSimulator(ast: Statechart|null, plant: Plant<any, UniversalPl
} }
}, [time, currentTraceItem]); // <-- todo: is this really efficient? }, [time, currentTraceItem]); // <-- todo: is this really efficient?
function appendNewConfig(simtime: number, cause: BigStepCause, computeNewState: () => [RaisedEvent[], CoupledState]) {
let newItem: TraceItem;
const metadata = {simtime, cause}
try {
const [outputEvents, state] = computeNewState(); // may throw RuntimeError
newItem = {kind: "bigstep", ...metadata, state, outputEvents};
}
catch (error) {
if (error instanceof RuntimeError) {
newItem = {kind: "error", ...metadata, error};
// also pause the simulation, for dramatic effect:
setTime({kind: "paused", simtime});
}
else {
throw error;
}
}
// @ts-ignore
setTrace(trace => ({
trace: [
...trace!.trace.slice(0, trace!.idx+1), // remove everything after current item
newItem,
],
// idx: 0,
idx: trace!.idx+1,
}));
onStep();
}
const onBack = useCallback(() => { const onBack = useCallback(() => {
if (trace !== null) { if (trace !== null) {
setTime(() => { setTime(() => {
@ -182,9 +188,9 @@ export function useSimulator(ast: Statechart|null, plant: Plant<any, UniversalPl
idx: trace.idx-1, idx: trace.idx-1,
}); });
} }
}, [trace]); }, [trace, setTime, setTrace]);
const onReplayTrace = (causes: BigStepCause[]) => { const onReplayTrace = useCallback((causes: BigStepCause[]) => {
if (cE) { if (cE) {
function run_until(simtime: number) { function run_until(simtime: number) {
while (true) { while (true) {
@ -218,7 +224,7 @@ export function useSimulator(ast: Statechart|null, plant: Plant<any, UniversalPl
setTrace({trace: newTrace, idx: newTrace.length-1}); setTrace({trace: newTrace, idx: newTrace.length-1});
setTime({kind: "paused", simtime: lastSimtime}); setTime({kind: "paused", simtime: lastSimtime});
} }
} }, [setTrace, setTime, cE]);
return {trace, setTrace, plant, onInit, onClear, onBack, onRaise, onReplayTrace, time, setTime}; return {trace, setTrace, plant, onInit, onClear, onBack, onRaise, onReplayTrace, time, setTime};
} }

View file

@ -0,0 +1,19 @@
import { useRef } from "react";
// author: ChatGPT
export function useCustomMemo<T, DepsType extends unknown[]>(
compute: () => T,
deps: DepsType,
isEqual: (a: DepsType, b: DepsType) => boolean
) {
const prev = useRef<{ deps: DepsType; value: T }>(null);
if (!prev.current || !isEqual(deps, prev.current.deps)) {
prev.current = {
deps,
value: compute(),
};
}
return prev.current.value;
}

View file

@ -1,60 +1,77 @@
import { VisualEditorState } from "@/App/VisualEditor/VisualEditor"; import { isEntirelyWithin, Rect2D } from "@/util/geometry";
import { ConcreteSyntax } from "./concrete_syntax"; import { ConcreteSyntax, Rountangle } from "./concrete_syntax";
import { findNearestArrow, findNearestHistory, findNearestSide, findRountangle, RectSide } from "./concrete_syntax"; import { findNearestArrow, findNearestHistory, findNearestSide, findRountangle, RectSide } from "./concrete_syntax";
import { arraysEqual, jsonDeepEqual, mapsEqual, setsEqual } from "@/util/util";
import { HISTORY_RADIUS } from "@/App/parameters";
export type Connections = { export type Connections = {
arrow2SideMap: Map<string,[{ uid: string; part: RectSide; } | undefined, { uid: string; part: RectSide; } | undefined]>, arrow2SideMap: Map<string,[{ uid: string; part: RectSide; } | undefined, { uid: string; part: RectSide; } | undefined]>,
side2ArrowMap: Map<string, Set<["start"|"end", string]>>, side2ArrowMap: Map<string, ["start"|"end", string][]>,
text2ArrowMap: Map<string,string>, text2ArrowMap: Map<string,string>,
arrow2TextMap: Map<string,string[]>, arrow2TextMap: Map<string,string[]>,
arrow2HistoryMap: Map<string,string>, arrow2HistoryMap: Map<string,string>,
text2RountangleMap: Map<string, string>, text2RountangleMap: Map<string, string>,
rountangle2TextMap: Map<string, string[]>, rountangle2TextMap: Map<string, string[]>,
history2ArrowMap: Map<string, string[]>, history2ArrowMap: Map<string, string[]>,
insidenessMap: Map<string, string>;
} }
export function detectConnections(state: ConcreteSyntax): Connections { export function connectionsEqual(a: Connections, b: Connections) {
return mapsEqual(a.arrow2SideMap, b.arrow2SideMap, jsonDeepEqual)
&& mapsEqual(a.side2ArrowMap, b.side2ArrowMap, (a,b)=>arraysEqual(a,b,jsonDeepEqual))
&& mapsEqual(a.text2ArrowMap, b.text2ArrowMap)
&& mapsEqual(a.arrow2HistoryMap, b.arrow2HistoryMap)
&& mapsEqual(a.text2RountangleMap, b.text2RountangleMap)
&& mapsEqual(a.rountangle2TextMap, b.rountangle2TextMap, arraysEqual)
&& mapsEqual(a.history2ArrowMap, b.history2ArrowMap, arraysEqual)
&& mapsEqual(a.insidenessMap, b.insidenessMap)
}
// This function does the heavy lifting of parsing the concrete syntax:
// It detects insideness and connectedness relations based on the geometries of the shapes.
export function detectConnections(concreteSyntax: ConcreteSyntax): Connections {
const startTime = performance.now(); const startTime = performance.now();
// detect what is 'connected' // detect what is 'connected'
const arrow2SideMap = new Map<string,[{ uid: string; part: RectSide; } | undefined, { uid: string; part: RectSide; } | undefined]>(); const arrow2SideMap = new Map<string,[{ uid: string; part: RectSide; } | undefined, { uid: string; part: RectSide; } | undefined]>();
const side2ArrowMap = new Map<string, Set<["start"|"end", string]>>(); const side2ArrowMap = new Map<string, ["start"|"end", string][]>();
const text2ArrowMap = new Map<string,string>(); const text2ArrowMap = new Map<string,string>();
const arrow2TextMap = new Map<string,string[]>(); const arrow2TextMap = new Map<string,string[]>();
const arrow2HistoryMap = new Map<string,string>(); const arrow2HistoryMap = new Map<string,string>();
const text2RountangleMap = new Map<string, string>(); const text2RountangleMap = new Map<string, string>();
const rountangle2TextMap = new Map<string, string[]>(); const rountangle2TextMap = new Map<string, string[]>();
const history2ArrowMap = new Map<string, string[]>(); const history2ArrowMap = new Map<string, string[]>();
const insidenessMap = new Map<string, string>();
// arrow <-> (rountangle | diamond) // arrow <-> (rountangle | diamond)
for (const arrow of state.arrows) { for (const arrow of concreteSyntax.arrows) {
// snap to history: // snap to history:
const historyTarget = findNearestHistory(arrow.end, state.history); const historyTarget = findNearestHistory(arrow.end, concreteSyntax.history);
if (historyTarget) { if (historyTarget) {
arrow2HistoryMap.set(arrow.uid, historyTarget.uid); arrow2HistoryMap.set(arrow.uid, historyTarget.uid);
history2ArrowMap.set(historyTarget.uid, [...(history2ArrowMap.get(historyTarget.uid) || []), arrow.uid]); history2ArrowMap.set(historyTarget.uid, [...(history2ArrowMap.get(historyTarget.uid) || []), arrow.uid]);
} }
// snap to rountangle/diamon side: // snap to rountangle/diamon side:
const sides = [...state.rountangles, ...state.diamonds]; const sides = [...concreteSyntax.rountangles, ...concreteSyntax.diamonds];
const startSide = findNearestSide(arrow, "start", sides); const startSide = findNearestSide(arrow, "start", sides);
const endSide = historyTarget ? undefined : findNearestSide(arrow, "end", sides); const endSide = historyTarget ? undefined : findNearestSide(arrow, "end", sides);
if (startSide || endSide) { if (startSide || endSide) {
arrow2SideMap.set(arrow.uid, [startSide, endSide]); arrow2SideMap.set(arrow.uid, [startSide, endSide]);
} }
if (startSide) { if (startSide) {
const arrowConns = side2ArrowMap.get(startSide.uid + '/' + startSide.part) || new Set(); const arrowConns = side2ArrowMap.get(startSide.uid + '/' + startSide.part) || [];
arrowConns.add(["start", arrow.uid]); arrowConns.push(["start", arrow.uid]);
side2ArrowMap.set(startSide.uid + '/' + startSide.part, arrowConns); side2ArrowMap.set(startSide.uid + '/' + startSide.part, arrowConns);
} }
if (endSide) { if (endSide) {
const arrowConns = side2ArrowMap.get(endSide.uid + '/' + endSide.part) || new Set(); const arrowConns = side2ArrowMap.get(endSide.uid + '/' + endSide.part) || [];
arrowConns.add(["end", arrow.uid]); arrowConns.push(["end", arrow.uid]);
side2ArrowMap.set(endSide.uid + '/' + endSide.part, arrowConns); side2ArrowMap.set(endSide.uid + '/' + endSide.part, arrowConns);
} }
} }
// text <-> arrow // text <-> arrow
for (const text of state.texts) { for (const text of concreteSyntax.texts) {
const nearestArrow = findNearestArrow(text.topLeft, state.arrows); const nearestArrow = findNearestArrow(text.topLeft, concreteSyntax.arrows);
if (nearestArrow) { if (nearestArrow) {
// prioritize text belonging to arrows: // prioritize text belonging to arrows:
text2ArrowMap.set(text.uid, nearestArrow.uid); text2ArrowMap.set(text.uid, nearestArrow.uid);
@ -64,7 +81,7 @@ export function detectConnections(state: ConcreteSyntax): Connections {
} }
else { else {
// text <-> rountangle // text <-> rountangle
const rountangle = findRountangle(text.topLeft, state.rountangles); const rountangle = findRountangle(text.topLeft, concreteSyntax.rountangles);
if (rountangle) { if (rountangle) {
text2RountangleMap.set(text.uid, rountangle.uid); text2RountangleMap.set(text.uid, rountangle.uid);
const texts = rountangle2TextMap.get(rountangle.uid) || []; const texts = rountangle2TextMap.get(rountangle.uid) || [];
@ -74,6 +91,42 @@ export function detectConnections(state: ConcreteSyntax): Connections {
} }
} }
// figure out insideness...
const parentCandidates: Rountangle[] = [{
kind: "or",
uid: "root",
topLeft: {x: -Infinity, y: -Infinity},
size: {x: Infinity, y: Infinity},
}];
function findParent(geom: Rect2D): string {
// iterate in reverse:
for (let i = parentCandidates.length-1; i >= 0; i--) {
const candidate = parentCandidates[i];
if (candidate.uid === "root" || isEntirelyWithin(geom, candidate)) {
// found our parent
return candidate.uid;
}
}
throw new Error("impossible: should always find a parent state");
}
// IMPORTANT ASSUMPTION: state.rountangles is sorted from big to small surface area:
for (const rt of concreteSyntax.rountangles) {
const parent = findParent(rt);
insidenessMap.set(rt.uid, parent);
parentCandidates.push(rt);
}
for (const d of concreteSyntax.diamonds) {
const parent = findParent(d);
insidenessMap.set(d.uid, parent);
}
for (const h of concreteSyntax.history) {
const parent = findParent({topLeft: h.topLeft, size: {x: HISTORY_RADIUS*2, y: HISTORY_RADIUS*2}});
insidenessMap.set(h.uid, parent);
}
const endTime = performance.now(); const endTime = performance.now();
// rather slow, about 10ms for a large model: // rather slow, about 10ms for a large model:
@ -88,5 +141,35 @@ export function detectConnections(state: ConcreteSyntax): Connections {
text2RountangleMap, text2RountangleMap,
rountangle2TextMap, rountangle2TextMap,
history2ArrowMap, history2ArrowMap,
insidenessMap,
}; };
} }
export type ReducedConcreteSyntax = {
rountangles: {
kind: "and" | "or",
uid: string,
}[];
texts: {
text: string,
uid: string,
}[];
arrows: {
uid: string,
}[];
diamonds: {
uid: string,
}[];
history: {
kind: "deep" | "shallow",
uid: string,
}[];
};
export function reducedConcreteSyntaxEqual(a: ReducedConcreteSyntax, b: ReducedConcreteSyntax) {
return arraysEqual(a.rountangles, b.rountangles, (a,b)=>a.kind===b.kind&&a.uid===b.uid)
&& arraysEqual(a.texts, b.texts, (a,b)=>a.text===b.text&&a.uid===b.uid)
&& arraysEqual(a.arrows, b.arrows, (a,b)=>a.uid===b.uid)
&& arraysEqual(a.diamonds, b.diamonds, (a,b)=>a.uid===b.uid)
&& arraysEqual(a.history, b.history, (a,b)=>a.kind===b.kind&&a.uid===b.uid);
}

View file

@ -1,11 +1,7 @@
import { ConcreteState, HistoryState, OrState, UnstableState, Statechart, stateDescription, Transition } from "./abstract_syntax"; import { ConcreteState, HistoryState, OrState, UnstableState, Statechart, stateDescription, Transition } from "./abstract_syntax";
import { Rountangle } from "./concrete_syntax";
import { isEntirelyWithin, Rect2D } from "../util/geometry";
import { Action, EventTrigger, Expression, ParsedText } from "./label_ast"; import { Action, EventTrigger, Expression, ParsedText } from "./label_ast";
import { parse as parseLabel, SyntaxError } from "./label_parser"; import { parse as parseLabel, SyntaxError } from "./label_parser";
import { Connections } from "./detect_connections"; import { Connections, ReducedConcreteSyntax } from "./detect_connections";
import { HISTORY_RADIUS } from "../App/parameters";
import { ConcreteSyntax } from "./concrete_syntax";
import { memoize } from "@/util/util"; import { memoize } from "@/util/util";
export type TraceableError = { export type TraceableError = {
@ -34,7 +30,7 @@ function addEvent(events: EventTrigger[], e: EventTrigger, textUid: string) {
} }
} }
export function parseStatechart(state: ConcreteSyntax, conns: Connections): [Statechart, TraceableError[]] { export function parseStatechart(concreteSyntax: ReducedConcreteSyntax, conns: Connections): [Statechart, TraceableError[]] {
const errors: TraceableError[] = []; const errors: TraceableError[] = [];
// implicitly, the root is always an Or-state // implicitly, the root is always an Or-state
@ -54,36 +50,14 @@ export function parseStatechart(state: ConcreteSyntax, conns: Connections): [Sta
const uid2State = new Map<string, ConcreteState|UnstableState>([["root", root]]); const uid2State = new Map<string, ConcreteState|UnstableState>([["root", root]]);
const label2State = new Map<string, ConcreteState>(); const label2State = new Map<string, ConcreteState>();
const historyStates: HistoryState[] = []; const historyStates: HistoryState[] = [];
// 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>(); const parentLinks = new Map<string, string>();
function findParent(geom: Rect2D): ConcreteState {
// iterate in reverse:
for (let i=parentCandidates.length-1; i>=0; i--) {
const candidate = parentCandidates[i];
if (candidate.uid === "root" || isEntirelyWithin(geom, candidate)) {
// found our parent
return uid2State.get(candidate.uid)! as ConcreteState;
}
}
throw new Error("impossible: should always find a parent state");
}
// step 1: figure out state hierarchy // step 1: figure out state hierarchy
const startTime = performance.now(); const startTime = performance.now();
// IMPORTANT ASSUMPTION: state.rountangles is sorted from big to small surface area: for (const rt of concreteSyntax.rountangles) {
for (const rt of state.rountangles) { const parent = uid2State.get(conns.insidenessMap.get(rt.uid)!)! as ConcreteState;
const parent = findParent(rt);
const common = { const common = {
kind: rt.kind, kind: rt.kind,
uid: rt.uid, uid: rt.uid,
@ -112,12 +86,11 @@ export function parseStatechart(state: ConcreteSyntax, conns: Connections): [Sta
}; };
} }
parent.children.push(state as ConcreteState); parent.children.push(state as ConcreteState);
parentCandidates.push(rt);
parentLinks.set(rt.uid, parent.uid); parentLinks.set(rt.uid, parent.uid);
uid2State.set(rt.uid, state as ConcreteState); uid2State.set(rt.uid, state as ConcreteState);
} }
for (const d of state.diamonds) { for (const d of concreteSyntax.diamonds) {
const parent = findParent(d); const parent = uid2State.get(conns.insidenessMap.get(d.uid)!)! as ConcreteState;
const pseudoState = { const pseudoState = {
kind: "pseudo", kind: "pseudo",
uid: d.uid, uid: d.uid,
@ -130,8 +103,8 @@ export function parseStatechart(state: ConcreteSyntax, conns: Connections): [Sta
uid2State.set(d.uid, pseudoState); uid2State.set(d.uid, pseudoState);
parent.children.push(pseudoState); parent.children.push(pseudoState);
} }
for (const h of state.history) { for (const h of concreteSyntax.history) {
const parent = findParent({topLeft: h.topLeft, size: {x: HISTORY_RADIUS*2, y: HISTORY_RADIUS*2}}); const parent = uid2State.get(conns.insidenessMap.get(h.uid)!)! as ConcreteState;
const historyState = { const historyState = {
kind: h.kind, kind: h.kind,
uid: h.uid, uid: h.uid,
@ -152,7 +125,7 @@ export function parseStatechart(state: ConcreteSyntax, conns: Connections): [Sta
const transitions = new Map<string, Transition[]>(); const transitions = new Map<string, Transition[]>();
const uid2Transition = new Map<string, Transition>(); const uid2Transition = new Map<string, Transition>();
for (const arr of state.arrows) { for (const arr of concreteSyntax.arrows) {
const srcUID = conns.arrow2SideMap.get(arr.uid)?.[0]?.uid; const srcUID = conns.arrow2SideMap.get(arr.uid)?.[0]?.uid;
const tgtUID = conns.arrow2SideMap.get(arr.uid)?.[1]?.uid; const tgtUID = conns.arrow2SideMap.get(arr.uid)?.[1]?.uid;
const historyTgtUID = conns.arrow2HistoryMap.get(arr.uid); const historyTgtUID = conns.arrow2HistoryMap.get(arr.uid);
@ -243,7 +216,7 @@ export function parseStatechart(state: ConcreteSyntax, conns: Connections): [Sta
// step 3: figure out labels // step 3: figure out labels
const textsSorted = state.texts.toSorted((a,b) => a.topLeft.y - b.topLeft.y); const textsSorted = concreteSyntax.texts.toSorted((a,b) => a.topLeft.y - b.topLeft.y);
for (const text of textsSorted) { for (const text of textsSorted) {
let parsed: ParsedText; let parsed: ParsedText;
try { try {