@webwriter/automaton
Version:
Build, visualize, and interact with different kinds of automata (DFA, NFA, PDA).
642 lines (567 loc) • 21.5 kB
text/typescript
import {
html,
LitElement,
PropertyDeclaration,
PropertyValueMap,
TemplateResult,
} from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { LitElementWw } from "@webwriter/lit";
import { styles } from "./styles/styles";
import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js";
import SlDetails from "@shoelace-style/shoelace/dist/components/details/details.component.js";
import SlInput from "@shoelace-style/shoelace/dist/components/input/input.component.js";
import SlCheckbox from "@shoelace-style/shoelace/dist/components/checkbox/checkbox.component.js";
import SlTooltip from "@shoelace-style/shoelace/dist/components/tooltip/tooltip.component.js";
import SlButtonGroup from "@shoelace-style/shoelace/dist/components/button-group/button-group.component.js";
import SlAlert from "@shoelace-style/shoelace/dist/components/alert/alert.component.js";
import SlSelect from "@shoelace-style/shoelace/dist/components/select/select.component.js";
import SlOption from "@shoelace-style/shoelace/dist/components/option/option.component.js";
import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.component.js";
import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.component.js";
import SlPopup from "@shoelace-style/shoelace/dist/components/popup/popup.component.js";
import SlTag from "@shoelace-style/shoelace/dist/components/tag/tag.component.js";
import SlDialog from "@shoelace-style/shoelace/dist/components/dialog/dialog.component.js";
import SlRange from "@shoelace-style/shoelace/dist/components/range/range.component.js";
import SlSwitch from "@shoelace-style/shoelace/dist/components/switch/switch.component.js";
import SlDivider from "@shoelace-style/shoelace/dist/components/divider/divider.component.js";
import "@shoelace-style/shoelace/dist/themes/light.css";
import { DFA } from "./automata/dfa";
import { Automaton, Node, Transition } from "./automata";
import { Graph } from "./graph";
import { Logger } from "./utils/logger";
import { TopMenu } from "./components/TopMenu";
import { ToolMenu } from "./components/ToolMenu";
import { InfoMenu } from "./components/InfoMenu";
import { SimulatorMenu } from "./components/SimulatorMenu";
import { Settings } from "./components/Settings";
import { biBoxes, biExclamationTriangle, biPencil } from "./styles/icons";
import { styleMap } from "lit/directives/style-map.js";
import { guard } from "lit/directives/guard.js";
import { SlChangeEvent } from "@shoelace-style/shoelace";
import {
checkIfNodesUpdated,
checkIfTransitionsUpdated,
stripNode,
stripTransition,
} from "./utils/updates";
import { NFA } from "./automata/nfa";
import { PDA, StackExtension } from "./automata/pda";
import { debounce } from "lodash";
// @ts-ignore
import LOCALIZE from "../localization/generated";
import { localized, msg } from "@lit/localize";
import { ATTRIBUTE_CONVERTERS } from "@u/attributeConverters";
export type AutomatonType = "dfa" | "nfa" | "pda";
/**
* Represents an Automaton Component.
* This component is responsible for rendering and managing the automaton editor and simulator.
*/
export class AutomatonComponent extends LitElementWw {
private accessor graphCanvas!: HTMLElement;
private accessor toolMenu!: ToolMenu;
private accessor simulatorMenu!: SimulatorMenu;
private accessor infoMenu!: InfoMenu;
private accessor topMenu!: TopMenu;
protected localize = LOCALIZE;
/**
* Each automaton state is encoded as a string using the following format:<br>`[#][%]<id>[<label>](<x>|<y>)`<br><br>Regex:<br>`^(#)?(%)?(\d+)(?:\[([^\]]*(?:\\.[^\]]*)*)\])?\((-?\d+)\|(-?\d+)\)$`<br><br>Explanation:<br>`#`: Optional - marks the initial state.<br>`%`: Optional - marks the final (accepting) state.<br>`<id>`: Required non-negative integer - unique state ID.<br>`[<label>]`: Optional - URI-encoded label. Defaults to `q<id>` if omitted.<br>`(<x>|<y>)`: Required coordinates - signed integers representing the node's position.<br><br>Multiple nodes are separated by semicolons (`;`). Example:<br>`#0(-150|0);%1[accept](0|0);2[sink](150|0)`<br><br>Requirements:<br>Coordinates are mandatory, and there must be at least 150 units of distance between any two nodes.<br>In the case of DFAs, the automaton MUST be complete, i.e. every node MUST have EXACTLY one transition for every input symbol. Correspondingly, a sink should be non-final and have self-loops on all inputs. Implicit sinks are NOT allowed.
*/
public accessor nodes: Node[] = [];
/**
* Each automaton transition is encoded as:<br>`<from>-<to>[<symbolsOrStackOps>][~<roundness>][@<angle>]`<br>Multiple transitions are separated by semicolons (`;`).<br><br>Regex:<br>`^(?:;\d+-\d+(?:\[(?:[^,\{\]\|]+(?:,[^,\{\]\|]+)*|[^,\{\]\|]*\{(?:p|o|e|n)\|[^,\{\]\|]*\|[^,\{\]\|]*\}(?:,[^,\{\]\|]*\{(?:p|o|e|n)\|[^,\{\]\|]*\|[^,\{\]\|]*\})*)\])?(?:~(-?)(\d+(?:\.\d+)?))?(?:@(-?\d+))?)*$`<br><br>Explanation:<br>`<from>-<to>`: Required - origin and destination state IDs (non-negative integers).<br>`[<symbolsOrStackOps>]`: Required - DFA or NFA -> Symbols only: one or more URI-encoded symbols, separated by commas. PDA -> Stack operations: <symbol>{<op>|<stackSymbol>|<condition>}, with <op> one of: p (push), o (pop), e (empty check), or n (no-op); <stackSymbol> being the optional symbol to push or pop, and <condition> being an optional symbol the stack must have on top.<br>`~<roundness>`: Optional - curvature of the arc, negative sign means counter-clockwise. 0.2 is recommended if there is a circle between two states.<br>`@<angle>`: Optional - integer degrees for self-loop placement. Defaults to 45°.<br><br>Multiple transitions must be separated by semicolons, no trailing `;`.<br><br>Examples:<br>`0-1[a]`<br>`1-2[a,b]~0.5`<br>`2-2[a{p|X|},b{o||}]@45;0-1[c]~1.2`
*/
public accessor transitions: Transition[] = [];
/** The type of the automaton. Can be `'dfa'`, `'nfa'`, or `'pda'`. */
public accessor type: AutomatonType = "dfa";
/** The current mode. Can be `'edit'`, or `'simulate'`. */
public accessor mode: "edit" | "simulate" = "edit";
/** A regular expression to check the language of the automaton against. */
public accessor testLanguage: string = "";
/** The alphabet that the automaton is forced to use as characters separated by spaces. */
public accessor forcedAlphabet: string[] = [];
/** Words used for automatically testing the automaton as a string separated by spaces. */
public accessor testWords: string[] = [];
private _verbose: boolean = false;
/** Enables logging of numerous events to the console. */
public set verbose(v: boolean) {
this._verbose = v;
Logger.verbose = v;
}
public get verbose(): boolean {
return this._verbose;
}
/** The encoded permissions for the editor. */
public accessor permissions: string = "777";
/** If true, the widget displays automaton error messages. */
public accessor showHelp: string = "true";
/** If true, the widget allows viewing the automaton's formal definition. */
public accessor showFormalDefinition: string = "true";
/** If true, the widget allows viewing the automaton's transition table. */
public accessor showTransitionsTable: string = "true";
/** The types of automata that are allowed in the editor. */
public accessor allowedTypes: string[] = ["dfa", "nfa", "pda"];
/** The modes that are allowed in the editor. */
public accessor allowedModes: string[] = ["edit", "simulate"];
/** The transformations that are allowed in the editor. */
public accessor allowedTransformations: string[] = ["sink"];
private accessor _graph!: Graph;
protected set graph(g: Graph) {
this._graph = g;
}
protected get graph() {
return this._graph;
}
/** @internal */
public settings = new Settings(this);
/** @internal */
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
private accessor _automaton: Automaton = new DFA([], []);
private accessor _helpOverlay: boolean = false;
private set helpOverlay(h: boolean) {
this._helpOverlay = h;
this.toolMenu.visible = h ? true : this.toolMenu.visible;
}
/** @internal */
public set automaton(a: Automaton) {
this._automaton = a;
this.type = a.type;
this.setUpListeners(this._automaton);
this._graph?.setAutomaton(this._automaton);
if (this.simulatorMenu) this.simulatorMenu.automaton = this._automaton;
Logger.log("Automaton set", this._automaton.transitions.get());
}
/** @internal */
public get automaton() {
return this._automaton;
}
public static get styles() {
return styles;
}
protected static get scopedElements() {
return {
"sl-button": SlButton,
"sl-button-group": SlButtonGroup,
"sl-details": SlDetails,
"sl-input": SlInput,
"sl-checkbox": SlCheckbox,
"sl-tooltip": SlTooltip,
"sl-alert": SlAlert,
"sl-select": SlSelect,
"sl-option": SlOption,
"sl-dialog": SlDialog,
"webwriter-automaton-toolmenu": ToolMenu,
"webwriter-automaton-simulatormenu": SimulatorMenu,
"webwriter-automaton-infomenu": InfoMenu,
"webwriter-automaton-topmenu": TopMenu,
"stack-extension": StackExtension,
"sl-icon": SlIcon,
"sl-icon-button": SlIconButton,
"sl-popup": SlPopup,
"sl-tag": SlTag,
"sl-range": SlRange,
"sl-switch": SlSwitch,
"sl-divider": SlDivider,
};
}
constructor() {
super();
Logger.verbose = this.verbose;
Logger.log("constructor");
}
/**
* Lifecycle callback called after the element's first update.
* @param _changedProperties - Map of properties that have changed with their previous values.
*/
protected firstUpdated(
_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
): void {
super.firstUpdated(_changedProperties);
this._graph = new Graph(
this.graphCanvas,
this.automaton,
this.toolMenu,
this
);
this._graph.requestUpdate = () => this.requestUpdate();
this._graph.toggleMode = () => this.toggleMode();
this.toolMenu.graph = this._graph;
this.topMenu.component = this;
this.topMenu.graph = this._graph;
this.topMenu.setHelpOverlay = (h: boolean) => {
this.helpOverlay = h;
};
this.simulatorMenu.automaton = this.automaton;
this.simulatorMenu.graph = this._graph;
this.settings = new Settings(this);
this.settings.numberStringToPermissions(this.permissions);
if (this.automaton.extension) {
this.automaton.extension.contentEditable = "true";
(this.automaton.extension as StackExtension).add =
this.settings.permissions.stack.add;
(this.automaton.extension as StackExtension).delete =
this.settings.permissions.stack.delete;
(this.automaton.extension as StackExtension).change =
this.settings.permissions.stack.change;
}
Logger.log("first updated");
}
/**
* This method is called before the component updates. It handles the logic for updating the automaton based on the changed properties.
*
* @param _changedProperties - A map of changed properties.
*/
protected willUpdate(
_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
): void {
super.willUpdate(_changedProperties);
if (_changedProperties.has("type")) {
if (this.type === "dfa")
this.automaton = new DFA(this.nodes, this.transitions);
if (this.type === "nfa")
this.automaton = new NFA(this.nodes, this.transitions);
if (this.type === "pda")
this.automaton = new PDA(this.nodes, this.transitions);
}
if (_changedProperties.has("allowedModes")) {
if (
!this.allowedModes.includes(this.mode) &&
this.allowedModes.length > 0
) {
this.mode = this.allowedModes[0] as "edit" | "simulate";
}
}
if (_changedProperties.has("mode")) {
if (this.allowedModes.includes(this.mode)) {
this._graph?.network.unselectAll();
this.automaton?.clearHighlights();
this._graph?.contextMenu.hide();
if (this.mode === "edit") {
this.simulatorMenu?.reset();
this._graph?.requestUpdate();
this._graph?.setInteractive(true);
if (this.automaton?.extension)
this.automaton.extension.contentEditable = "true";
} else if (this.mode === "simulate") {
this.automaton?.redrawNodes();
this.simulatorMenu?.init();
this._graph?.setInteractive(false);
if (this.automaton?.extension)
this.automaton.extension.contentEditable = "false";
}
} else {
Logger.warn(
`Mode ${
this.mode
} is not allowed. Allowed modes are: ${this.allowedModes.join(
", "
)}`
);
if (this.allowedModes.length > 0) {
Logger.warn(
`Switching to first allowed mode: ${this.allowedModes[0]}`
);
this.mode = this.allowedModes[0] as "edit" | "simulate";
}
}
}
if (
_changedProperties.has("nodes") ||
_changedProperties.has("transitions")
) {
this.automaton.updateAutomaton(this.nodes, this.transitions);
if (this._graph) this._graph.updateGhostNodePosition();
}
if (this.automaton && this.automaton.extension) {
(this.automaton.extension as StackExtension).add =
this.settings.permissions.stack.add;
(this.automaton.extension as StackExtension).delete =
this.settings.permissions.stack.delete;
(this.automaton.extension as StackExtension).change =
this.settings.permissions.stack.change;
}
if (_changedProperties.has("showHelp")) {
this.automaton.showErrors = this.showHelp === "true";
}
Logger.log("will update");
}
requestUpdate(
name?: PropertyKey | undefined,
oldValue?: unknown,
options?: PropertyDeclaration<unknown, unknown> | undefined
): void {
super.requestUpdate(name, oldValue, options);
this.toolMenu?.requestUpdate();
this.simulatorMenu?.requestUpdate();
this.infoMenu?.requestUpdate();
this.topMenu?.requestUpdate();
}
/**
* Renders the component and returns a TemplateResult.
* @returns {TemplateResult} The rendered TemplateResult.
*/
public render(): TemplateResult {
return html`
${this.renderEditor()}
${this.isContentEditable
? guard([this.settings], () => this.renderSettings())
: ""}
`;
}
private renderEditor(): TemplateResult {
return html`
<div class="editor">
${this._helpOverlay ? this.renderHelpOverlay() : ""}
<div id="graphCanvas"></div>
${this._graph?.contextMenu.render()} ${this.renderModeSwitch()}
${this._graph?.renderErrorDisplay()}
<!-- <webwriter-automaton-infomenu id="infoMenu"></webwriter-automaton-infomenu> -->
<webwriter-automaton-topmenu
id="topMenu"
></webwriter-automaton-topmenu>
<webwriter-automaton-simulatormenu
id="simulatorMenu"
style=${styleMap({
display: this.mode === "simulate" ? "flex" : "none",
})}
></webwriter-automaton-simulatormenu>
<webwriter-automaton-toolmenu
id="toolMenu"
style=${styleMap({
display: this.mode === "edit" ? "flex" : "none",
})}
></webwriter-automaton-toolmenu>
${guard(
[this.permissions, this.automaton],
() => this.automaton.extension
)}
</div>
`;
}
/**
* Renders the settings section of the component.
* @returns {TemplateResult} The rendered settings section.
*/
private renderSettings(): TemplateResult {
return html`
<aside class="settings" part="options">
${this.settings.render()}
</aside>
`;
}
/**
* Renders the help overlay.
* @returns {TemplateResult} The rendered help overlay.
*/
private renderHelpOverlay(): TemplateResult {
return html`
<div class="help-backdrop"></div>
<div class="help-overlay">
<sl-tag size="small" style="top:50px;left:10px"
>${msg("Mode Switch")}</sl-tag
>
<sl-tag size="small" style="top: 228px;right:10px"
>${msg("Fullscreen")}</sl-tag
>
<div
class="line"
style="top: 55px;right: 30px;height: 164px;"
></div>
<sl-tag size="small" style="top:18px;right:325px"
>${msg("Type")}</sl-tag
>
<div class="line" style="top:28px;right:310px;width:10px"></div>
<sl-tag size="small" style="top:60px;right:325px"
>${msg("Transformations")}</sl-tag
>
<div
class="line"
style="top:55px;right:230px;width:90px;height:15px"
></div>
<sl-tag size="small" style="top: 186px;right: 325px;"
>${msg("Help")}</sl-tag
>
<div
class="line"
style="top: 55px;right: 80px;width: 240px;height: 141px;"
></div>
<sl-tag size="small" style="top: 144px;right: 325px;"
>${msg("Test Cases")}</sl-tag
>
<div
class="line"
style="top: 55px;right: 130px;width: 190px;height: 99px;"
></div>
<sl-tag size="small" style="top: 102px;right: 325px;"
>${msg("Definition")}</sl-tag
>
<div
class="line"
style="top: 55px;right: 180px;width: 140px;height: 57px;"
></div>
</div>
<div
class="help-overlay"
style=${styleMap({
display: this.mode === "edit" ? "block" : "none",
})}
>
<sl-tag size="small" style="bottom: 77px;left: 80px;"
>${msg("Add Node by click")}</sl-tag
>
<sl-tag size="small" style="bottom: 128px;left: 80px;"
>${msg("Add Transition by drag and drop")}</sl-tag
>
<sl-tag size="small" style="bottom: 110px;right: 30px;"
>${msg("Move the elements by drag and drop")}</sl-tag
>
<sl-tag size="small" style="bottom: 70px;right: 30px;"
>${msg("To edit a node right click the node")}</sl-tag
>
<sl-tag size="small" style="bottom: 30px;right: 30px;"
>${msg(
"To edit a transition right click the transition"
)}</sl-tag
>
</div>
<div
class="help-overlay"
style=${styleMap({
display: this.mode === "simulate" ? "block" : "none",
})}
>
<sl-tag size="small" style="bottom: 60px;left: 10px;"
>${msg("Input word")}</sl-tag
>
<sl-tag size="small" style="bottom: 60px;left: 10px;"
>${msg("Simulation Controls")}</sl-tag
>
</div>
`;
}
/**
* Renders the mode switch component.
* @returns {TemplateResult} The rendered mode switch component.
*/
private renderModeSwitch(): TemplateResult {
return html`<div class="mode_switch">
<sl-tooltip
content=${msg("Switch mode (Ctrl+M)")}
placement="right"
>
<sl-select
size="small"
class="mode_switch__select"
value=${this.mode}
.value=${this.mode}
defaultValue="${this.mode}"
?disabled=${this.allowedModes.length <= 1}
-change=${(e: SlChangeEvent) => {
this.mode = (e.target as SlSelect).value as
| "edit"
| "simulate";
}}
name="mode"
>
<span slot="prefix"
>${this.mode === "edit" ? biPencil : biBoxes}</span
>
<sl-option value=${"edit"} selected>
<span slot="prefix">${biPencil}</span> ${msg("Edit")}
</sl-option>
<sl-option value=${"simulate"}>
<span slot="prefix">${biBoxes}</span> ${msg("Simulate")}
</sl-option>
</sl-select>
</sl-tooltip>
<div
class="mode_switch__error_indicator"
style=${this._graph?.errors.length > 0
? "display: block"
: "display: none"}
>
${biExclamationTriangle}
</div>
</div>`;
}
/**
* Toggles the mode between 'edit' and 'simulate'.
*/
private toggleMode() {
this.mode = this.mode === "edit" ? "simulate" : "edit";
}
/**
* Sets up the listeners for the automaton.
* @param a The automaton object.
*/
private setUpListeners(a: Automaton) {
/**
* Updates the attributes of the graph by filtering and mapping the nodes and transitions
* based on certain conditions.
*/
const updateAttributes = () => {
this.nodes = this.automaton.nodes
.get()
.filter((n) => n.id !== Graph.initialGhostNode.id)
.map(stripNode);
this.nodes = [...this.nodes];
this.transitions = this.automaton.transitions
.get()
.filter((t) => t.from !== Graph.initialGhostNode.id)
.map(stripTransition);
this.transitions = [...this.transitions];
};
a.nodes.on("*", debounce(updateAttributes, 200));
a.transitions.on("*", debounce(updateAttributes, 200));
}
}