From 877b162eaa4319587e452e388fe1b7ba7e6739ad Mon Sep 17 00:00:00 2001 From: clfreville2 Date: Tue, 6 Feb 2024 21:20:02 +0100 Subject: [PATCH] Introduce the FSM editor --- src/editor/GraphEditor.ts | 11 +++++++ src/editor/mapper.ts | 50 ++++++++++++++++++++++++++++ src/examples.js | 1 + src/fsm.js | 69 ++++++++++++++++++++------------------- src/main.js | 24 +++++++++++--- src/style.css | 43 ++++++++++++++---------- 6 files changed, 142 insertions(+), 56 deletions(-) create mode 100644 src/editor/mapper.ts diff --git a/src/editor/GraphEditor.ts b/src/editor/GraphEditor.ts index d10fdfd..420b53d 100644 --- a/src/editor/GraphEditor.ts +++ b/src/editor/GraphEditor.ts @@ -89,6 +89,7 @@ export class GraphEditor extends HTMLElement { } const [x, y] = d3.pointer(event, this.canvas!.node()); this.createNode(x, y); + this.fireOnChange(); }, ); initMarkers(this.canvas, this.config); @@ -171,6 +172,7 @@ export class GraphEditor extends HTMLElement { } event.preventDefault(); if (this.graph.createLink(source.id, target.id) !== null) { + this.fireOnChange(); this.restart(); } } @@ -193,12 +195,14 @@ export class GraphEditor extends HTMLElement { const transition = prompt('Transition'); if (transition !== null) { d.transition = transition; + this.fireOnChange(); this.restart(); } }) .on('contextmenu', (event: MouseEvent, d: Link) => { event.preventDefault(); this.graph.removeLink(d); + this.fireOnChange(); this.restart(); }); } @@ -230,14 +234,17 @@ export class GraphEditor extends HTMLElement { this.moveMenu(event, [ new MenuAction('Start', () => d.start, () => { d.start = !d.start; + this.fireOnChange(); this.restart(); }), new MenuAction('Accepting', () => d.accepting, () => { d.accepting = !d.accepting; + this.fireOnChange(); this.restart(); }), new MenuAction('Delete', () => false, () => { this.graph.removeNode(d); + this.fireOnChange(); this.resetDraggableLink(); this.restart(); }), @@ -439,6 +446,10 @@ export class GraphEditor extends HTMLElement { e.preventDefault(); this.menu.setAttribute('hidden', 'hidden'); } + + private fireOnChange() { + this.dispatchEvent(new Event('change')); + } } type NodeSelection = d3.Selection< diff --git a/src/editor/mapper.ts b/src/editor/mapper.ts new file mode 100644 index 0000000..0520fde --- /dev/null +++ b/src/editor/mapper.ts @@ -0,0 +1,50 @@ +import { Graph } from './d3/graph.ts'; + +type State = { + transitions: Record; + start?: boolean; + accepting?: boolean; +}; + +export function createGraph(states: State[]): Graph { + const graph = new Graph(); + for (let i = 0; i < states.length; i++) { + const node = graph.createNode(i); + if (states[i].accepting) { + node.accepting = true; + } + if (i === 0) { + node.start = true; + } + } + for (let i = 0; i < states.length; i++) { + const state = states[i]; + for (const [letter, target] of Object.entries(state.transitions)) { + graph.createLink(i, target, letter); + } + } + return graph; +} + +export function createStateList(graph: Graph): State[] { + const states: State[] = []; + // Map node ids to state ids, as graph nodes may contain gaps. + const reduced: { [id: number]: number } = {}; + for (const node of graph.nodes) { + const state: State = { + transitions: {}, + start: node.start, + accepting: node.accepting, + }; + reduced[node.id] = states.length; + states.push(state); + } + for (const link of graph.links) { + const source = reduced[link.source.id]; + const target = reduced[link.target.id]; + for (const label of link.transition.split('')) { + states[source].transitions[label] = target; + } + } + return states; +} diff --git a/src/examples.js b/src/examples.js index 096ad86..1e9c471 100644 --- a/src/examples.js +++ b/src/examples.js @@ -1,6 +1,7 @@ /** * @typedef State * @property {Object.} transitions + * @property {boolean} [start] * @property {boolean} [accepting] */ diff --git a/src/fsm.js b/src/fsm.js index 8c5c15c..bf17b7d 100644 --- a/src/fsm.js +++ b/src/fsm.js @@ -1,5 +1,5 @@ -import { Graph } from './editor/d3/graph.ts'; import { GraphEditor } from './editor/GraphEditor.ts'; +import { createGraph, createStateList } from './editor/mapper.ts'; const IS_VALID = 'is-valid'; const IS_INVALID = 'is-invalid'; @@ -11,30 +11,23 @@ const light = /** @type {HTMLDivElement} */ (document.getElementById('light')); /** * @param {import('./examples.js').State[]} states + * @param {boolean} [editable] */ -export async function selectAutomaton(states) { - let state = 0; +export function openAutomaton(states, editable = false) { + let state = findStart(states); let builder = ''; const viewer = new GraphEditor(); - viewer.readonly = true; - const graph = new Graph(); - for (let i = 0; i < states.length; i++) { - const node = graph.createNode(i); - if (states[i].accepting) { - node.accepting = true; + viewer.readonly = !editable; + const graph = createGraph(states); + viewer.addEventListener('change', () => { + try { + states = createStateList(graph); + type(); + } catch (e) { + console.error(e); } - if (i === 0) { - node.start = true; - } - } - for (let i = 0; i < states.length; i++) { - const state = states[i]; - for (const [letter, target] of Object.entries(state.transitions)) { - graph.createLink(i, target, letter); - } - } - + }); const container = /** @type {HTMLDivElement} */ (document.getElementById('state-graph')); container.appendChild(viewer); viewer.graph = graph; @@ -44,7 +37,7 @@ export async function selectAutomaton(states) { */ function updateUIState() { wordInput.value = builder; - if (state === -1 || !states[state].accepting) { + if (state === -1 || states.length === 0 || !states[state].accepting) { light.classList.remove(IS_VALID); light.classList.add(IS_INVALID); } else { @@ -58,6 +51,18 @@ export async function selectAutomaton(states) { viewer.restart(); } + function type() { + const value = wordInput.value; + builder = ''; + state = findStart(states); + for (const letter of value) { + step(letter); + } + if (!value.length) { + updateUIState(); + } + } + /** * Steps the FSM with the given letter. * @@ -88,24 +93,22 @@ export async function selectAutomaton(states) { } // Reacts to input in the text box - wordInput.addEventListener('input', () => { - const value = wordInput.value; - builder = ''; - state = 0; - for (const letter of value) { - step(letter); - } - if (!value.length) { - updateUIState(); - } - }); + wordInput.addEventListener('input', () => type()); clearButton.addEventListener('click', () => { wordInput.value = ''; builder = ''; - state = 0; + state = findStart(states); updateUIState(); }); updateUIState(); } + +/** + * @param {import('./examples.js').State[]} states + */ +function findStart(states) { + let state = states.findIndex(state => state.start); + return Math.max(state, 0); +} diff --git a/src/main.js b/src/main.js index e251a52..0d05b03 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,5 @@ import { AUTOMATONS } from './examples.js'; -import { selectAutomaton } from './fsm.js'; +import { openAutomaton } from './fsm.js'; const automatonSelector = /** @type {HTMLDivElement} */ (document.getElementById('automaton-selector')); const automatonCollection = /** @type {HTMLDivElement} */ (document.getElementById('automaton-collection')); @@ -16,16 +16,30 @@ for (const [displayName, automaton] of Object.entries(AUTOMATONS)) {

${automaton.length} states

`; - const handleEvent = async () => { + const handleEvent = () => { automatonSelector.setAttribute('hidden', 'hidden'); app.removeAttribute('hidden'); - await selectAutomaton(automaton); + openAutomaton(automaton); }; card.addEventListener('click', handleEvent); - card.addEventListener('keydown', async (event) => { + card.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { - await handleEvent(); + handleEvent(); } }); automatonCollection.appendChild(card); } +const create = document.createElement('div'); +create.classList.add('card'); +create.innerHTML = ` +
+
Create New Automaton
+

Create a new automaton from scratch.

+
+`; +automatonCollection.appendChild(create); +create.addEventListener('click', () => { + automatonSelector.setAttribute('hidden', 'hidden'); + app.removeAttribute('hidden'); + openAutomaton([], true); +}); diff --git a/src/style.css b/src/style.css index 67fea09..2993b82 100644 --- a/src/style.css +++ b/src/style.css @@ -103,19 +103,6 @@ button:focus-visible { background-color: #2a2a2a; } -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - button { - background-color: #f9f9f9; - } - .accepting-node { - outline-color: #9370DB; - } -} - graph-editor { display: block; height: 600px; @@ -169,8 +156,8 @@ graph-editor { .context-menu { position: absolute; - background: white; - border: 1px solid #ccc; + background: #313131; + border: 1px solid #222; box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); min-width: 150px; } @@ -187,13 +174,33 @@ graph-editor { } .context-menu li:hover, .context-menu li:focus { - background-color: #f0f0f0; + background-color: #2a2a2a; } - .context-menu li:active { - background-color: #e0e0e0; + background-color: #1a1a1a; } .context-menu .checked:after { content: "✓"; float: right; } + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + button { + background-color: #f9f9f9; + } + .context-menu { + background: white; + border: 1px solid #ccc; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); + } + .context-menu li:hover, .context-menu li:focus { + background-color: #f0f0f0; + } + .context-menu li:active { + background-color: #e0e0e0; + } +}