moved frontend to event driven bus, added more simulation stuff

This commit is contained in:
Parker TenBroeck 2026-01-11 00:30:06 -05:00
parent c7309a75d9
commit 8c8bb103b2
17 changed files with 767 additions and 493 deletions

View file

@ -30,7 +30,7 @@
<section class="flexCol gap marginTop"> <section class="flexCol gap marginTop">
<div class="flexCenter sidePadding gap"> <div class="flexCenter sidePadding gap">
<button id="themeToggle" class="btn btn-grey" title="Toggle light/dark"> <button id="themeToggle" class="btn btn-blue" title="Toggle light/dark">
🌙 Dark 🌙 Dark
</button> </button>
<button class="btn btm-grey" style="position: relative" id="shareBtn" type="button"> <button class="btn btm-grey" style="position: relative" id="shareBtn" type="button">
@ -46,6 +46,14 @@
</select> </select>
</div> </div>
<div class="hSplit styleOnly" title="Drag to resize canvas height"></div> <div class="hSplit styleOnly" title="Drag to resize canvas height"></div>
<div class="flexCenter sidePadding" style="font-size: calc(16px);font-weight: bold;color: var(--fg-1)">
<span style="margin-right: 0.5em;">Status: </span><span style="color: var(--fg-2)" id="simulationStatus">N/A</span>
</div>
<div class="flexCenter sidePadding">
<input id="machineInput" type="text" class="test-input" placeholder="Enter machine input…" />
</div>
</section> </section>
</section> </section>
@ -53,7 +61,7 @@
<div class="flexCol"> <div class="flexCol">
<div class="controls" style="background: var(--bg-0)"> <div class="controls" style="background: var(--bg-0)">
<button id="togglePhysics" class="btn btn-toggle active" title="Toggle physics layout"> <button id="togglePhysics" class="btn btn-red btn-toggle active" title="Toggle physics layout">
Physics: ON Physics: ON
</button> </button>
@ -63,21 +71,25 @@
<span class="spacer"></span> <span class="spacer"></span>
<button id="resetSim" class="btn btn-blue" title="Stop and reset simulation"> <button id="reloadSim" class="btn btn-blue" title="Stop and Reload simulation">
⟲ Reset ⟲ Reload
</button> </button>
<button id="playPause" class="btn btn-green" title="Run / pause simulation"> <button id="clearSim" class="btn btn-red" title="Stop and Clear simulation">
▶ Play ✖ Clear
</button> </button>
<button id="step" class="btn btn-grey" title="Advance one step">
Step <button id="stepSim" class="btn btn-yellow" title="Advance one step">
⏭ Step
</button>
<button id="playPauseSim" class="btn btn-green" title="Run / pause simulation">
▶ Play
</button> </button>
<label class="speed"> <label class="speed">
Speed Speed
<input id="speed" type="range" min="1" max="60" value="10" /> <input id="speedSim" type="range" min="1" max="60" value="1" />
<span id="speedLabel">10x</span> <span id="speedSimLabel">10x</span>
</label> </label>
</div> </div>
<div class="hSplit styleOnly" title="Drag to resize canvas height"></div> <div class="hSplit styleOnly" title="Drag to resize canvas height"></div>

View file

@ -1,8 +1,6 @@
import { network, updateVisualization } from "./visualizer.ts";
export type Machine = Fa | Pda | Tm; export type Machine = Fa | Pda | Tm;
export function machine_from_json(json: string): Machine { export function parse_machine_from_json(json: string): Machine {
const machine: Machine = JSON.parse(json); const machine: Machine = JSON.parse(json);
machine.states = new Map(Object.entries(machine.states)); machine.states = new Map(Object.entries(machine.states));
@ -202,219 +200,3 @@ export type Tm = {
edges: Map<string, Edge[]>; edges: Map<string, Edge[]>;
}; };
export type SimStepResult = "pending" | "accept" | "reject";
export class FaState {
readonly state: State;
readonly position: number;
readonly input: string;
readonly accepted: boolean = false;
private repr!: string;
constructor(state: State, position: number, input: string){
this.state=state;
this.position=position;
this.input = input;
}
toString(): string{
if(!this.repr) this.repr = this.state + " " + this.position;
return this.repr
}
};
export class FaSim {
current_states: Map<string, FaState[]> = new Map();
accepted: FaState[] = []
step(): SimStepResult {
return "pending";
}
}
export class PdaState {
readonly state: State;
readonly stack: Symbol[];
readonly position: number;
readonly input: string;
readonly accepted: boolean = false
private repr!: string;
constructor(state: State, stack: Symbol[], position: number, input: string){
this.state=state;
this.stack=stack;
this.position=position;
this.input = input;
}
toString(): string{
if(!this.repr) this.repr = this.state + " [" + this.stack + "]" + " " + this.position;
return this.repr
}
};
export class PdaSim {
machine: Pda;
paths: PdaState[];
input: string;
current_states: Map<string, PdaState[]> = new Map();
accepted: PdaState[] = []
constructor(machine: Pda, input: string) {
this.machine = machine;
this.paths = [new PdaState(machine.initial_state, [machine.initial_stack], 0, input)];
this.current_states.set(machine.initial_state, [this.paths[0]])
this.input = input;
}
step(): SimStepResult {
if (this.paths.length == 0) return "reject";
if (this.accepted.length != 0) return "accept";
const paths: PdaState[] = [];
this.current_states.clear();
const push = (state: PdaState) => {
paths.push(state);
if (!this.current_states.has(state.state)) this.current_states.set(state.state, []);
this.current_states.get(state.state)?.push(state);
if (
state.position == this.input.length && this.machine.final_states &&
this.machine.final_states.has(state.state)
||
state.position == this.input.length && !this.machine.final_states &&
state.stack.length == 1 && state.stack[0] == this.machine.initial_stack
) {
// @ts-expect-error sillllyyyy
state.accepted = true
this.accepted.push(state);
}
};
for (const path of this.paths) {
const stack = path.stack.pop()!;
const letter_map = this.machine.transitions_components.get(path.state)
?.get(stack);
if (!letter_map) continue;
for (const to of letter_map.get(null) ?? []) {
push(new PdaState(to.state, path.stack.concat(to.stack), path.position, this.input));
}
if (path.position >= this.input.length) continue;
const char = this.input.charAt(path.position);
for (const to of letter_map.get(char) ?? []) {
push(new PdaState(to.state, path.stack.concat(to.stack), path.position+1, this.input));
}
}
this.paths = paths;
if (this.paths.length == 0) return "reject";
if (this.accepted.length != 0) return "accept";
return "pending"
}
}
export class TmState{
readonly state: State;
readonly tape: Symbol[];
readonly position: number;
readonly input: string;
readonly accepted: boolean = false
private repr!: string;
constructor(state: State, tape: Symbol[], position: number, input: string){
this.state=state;
this.tape = tape;
this.position=position;
this.input = input;
}
toString(): string{
if(!this.repr) this.repr = this.state + " " + this.position;
return this.repr
}
}
export class TmSim {
current_states: Map<string, TmState[]> = new Map();
accepted: TmState[] = []
step(): SimStepResult {
return "pending"
}
}
export type Sim = FaSim | PdaSim | TmSim | null
export let sim: Sim = null;
export let automaton: Machine = {
type: "fa",
alphabet: new Map(),
final_states: new Map(),
initial_state: "",
states: new Map(),
transitions: new Map(),
transitions_components: new Map(),
edges: new Map(),
};
export function clearSimulation(){
setSimulation(null);
}
export function setSimulation(sim_: Sim){
sim = sim_;
network.redraw()
}
export function setAutomaton(auto: Machine) {
automaton = auto;
sim = null;
updateVisualization()
}
export function clearAutomaton() {
setAutomaton({
type: "fa",
alphabet: new Map(),
final_states: new Map(),
initial_state: "",
states: new Map(),
transitions: new Map(),
transitions_components: new Map(),
edges: new Map(),
});
}
export function stepSimulation(): void {
if (sim) {
console.log(sim.step());
}
network.redraw()
}
export function resetSimulation(): void {
switch (automaton.type) {
case "fa":
break;
case "pda":
setSimulation(new PdaSim(automaton as Pda, "aabbaabbaa"));
break;
case "tm":
break;
}
}

87
web/root/src/bus.ts Normal file
View file

@ -0,0 +1,87 @@
// deno-lint-ignore-file
import type { Machine } from "./automata.ts";
import type { Sim, SimStepResult } from "./simulation.ts";
import type wasm from "./wasm.ts";
import type { Text } from "npm:@codemirror/state";
type Unsubscribe = () => void;
export class EventBus<Events extends Record<string, any>> {
private listeners = new Map<keyof Events, Set<(payload: any) => void>>();
on<K extends keyof Events>(
event: K,
handler: (payload: Events[K]) => void,
): Unsubscribe {
let set = this.listeners.get(event);
if (!set) {
set = new Set();
this.listeners.set(event, set);
}
set.add(handler as any);
return () => this.off(event, handler);
}
once<K extends keyof Events>(
event: K,
handler: (payload: Events[K]) => void,
): Unsubscribe {
const off = this.on(event, (payload) => {
off();
handler(payload);
});
return off;
}
off<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void) {
const set = this.listeners.get(event);
if (!set) return;
set.delete(handler as any);
if (set.size === 0) this.listeners.delete(event);
}
emit<K extends keyof Events>(event: K, payload: Events[K]) {
const set = this.listeners.get(event);
if (!set) return;
// Copy to avoid issues if handlers subscribe/unsubscribe during emit
for (const handler of Array.from(set)) {
try {
(handler as (p: Events[K]) => void)(payload);
} catch (e) {
console.log(e);
}
}
}
clear(event?: keyof Events) {
if (event) this.listeners.delete(event);
else this.listeners.clear();
}
}
type AppEvents = {
"begin": void;
"editor/change": {text: string, doc: Text};
"compiled": {log: wasm.CompileLog[], ansi_log: string, machine: string|undefined};
"automata/sim/update": { simulation: Sim|null };
"automata/sim/before_step": { simulation: Sim };
"automata/sim/after_step": { simulation: Sim, result: SimStepResult };
"automata/update": { automaton: Machine };
"controls/physics": {enabled: boolean},
"controls/reset_network": void,
"controls/step_simulation": void,
"controls/reload_simulation": void,
"controls/clear_simulation": void,
"theme/update": void;
};
export const bus = new EventBus<AppEvents>();

View file

@ -1,49 +1,70 @@
import { resetSimulation, stepSimulation } from "./automata.ts"; import { bus } from "./bus.ts";
import {nodes, edges, network} from "./visualizer.ts"
const togglePhysicsBtn = document.getElementById("togglePhysics") as HTMLButtonElement; const togglePhysicsBtn = document.getElementById(
const resetLayoutBtn = document.getElementById("resetLayout") as HTMLButtonElement; "togglePhysics",
const playPauseBtn = document.getElementById("playPause") as HTMLButtonElement; ) as HTMLButtonElement;
const stepBtn = document.getElementById("step") as HTMLButtonElement; const resetLayoutBtn = document.getElementById(
const speedSlider = document.getElementById("speed") as HTMLInputElement; "resetLayout",
const speedLabel = document.getElementById("speedLabel") as HTMLSpanElement; ) as HTMLButtonElement;
const resetSimBtn = document.getElementById("resetSim") as HTMLButtonElement; const playPauseBtn = document.getElementById(
"playPauseSim",
) as HTMLButtonElement;
const stepBtn = document.getElementById("stepSim") as HTMLButtonElement;
const speedSlider = document.getElementById("speedSim") as HTMLInputElement;
const speedLabel = document.getElementById("speedSimLabel") as HTMLSpanElement;
const reloadSimBtn = document.getElementById("reloadSim") as HTMLButtonElement;
const clearSimBtn = document.getElementById("clearSim") as HTMLButtonElement;
bus.on("controls/physics", ({ enabled }) => {
// ---- Physics toggle (styled label) ----
function setPhysicsButtonUI(enabled: boolean) {
togglePhysicsBtn.classList.toggle("active", enabled); togglePhysicsBtn.classList.toggle("active", enabled);
togglePhysicsBtn.textContent = enabled ? "Physics: ON" : "Physics: OFF"; togglePhysicsBtn.textContent = enabled ? "Physics: ON" : "Physics: OFF";
} });
togglePhysicsBtn.onclick = () => { togglePhysicsBtn.onclick = () => {
const enabled = !togglePhysicsBtn.classList.contains("active"); const enabled = !togglePhysicsBtn.classList.contains("active");
setPhysicsButtonUI(enabled); bus.emit("controls/physics", { enabled });
network.setOptions({ physics: { enabled } });
network.setOptions({edges: {smooth: enabled}});
}; };
setPhysicsButtonUI(togglePhysicsBtn.classList.contains("active")); bus.emit("controls/physics", {
enabled: togglePhysicsBtn.classList.contains("active"),
});
resetLayoutBtn.onclick = () => { resetLayoutBtn.onclick = () => bus.emit("controls/reset_network", undefined);
try {
nodes.forEach((n) => { clearSimBtn.onclick = () => bus.emit("controls/clear_simulation", undefined);
n.physics = true;
n.x = undefined; stepBtn.onclick = () => {
n.y = undefined; bus.emit("controls/step_simulation", undefined);
}); };
network.setData({ nodes, edges });
} catch { reloadSimBtn.onclick = () => bus.emit("controls/reload_simulation", undefined);
// Last resort
network.setData({ nodes, edges }); function updateButtons() {
stepBtn.disabled = !simulation_active || running;
playPauseBtn.disabled = !simulation_active;
clearSimBtn.disabled = !simulation_active;
}
bus.on("controls/reload_simulation", (_) => {
if (running) setRunning(false);
updateButtons();
});
bus.on("automata/sim/update", ({ simulation }) => {
simulation_active = !!simulation;
if (!simulation) {
if (running) setRunning(false);
} }
updateButtons();
});
// If physics button is OFF, keep it OFF (dont surprise the user) bus.on("automata/sim/after_step", ({ result }) => {
const physicsEnabled = togglePhysicsBtn.classList.contains("active"); if (result !== "pending") {
network.setOptions({ physics: { enabled: physicsEnabled } }); if (running) setRunning(false);
}; }
});
// ---- Play/Pause + Speed ---- let simulation_active = false;
let running = false; let running = false;
let timer: number | null = null; let timer: number | null = null;
@ -74,8 +95,7 @@ function restartTimer() {
const intervalMs = Math.round(1000 / sps); const intervalMs = Math.round(1000 / sps);
timer = globalThis.window.setInterval(() => { timer = globalThis.window.setInterval(() => {
// If your step can throw, keep the interval alive: bus.emit("controls/step_simulation", undefined);
try { stepSimulation(); } catch (e) { console.error(e); }
}, intervalMs); }, intervalMs);
} }
@ -93,13 +113,3 @@ function setRunning(on: boolean) {
} }
playPauseBtn.onclick = () => setRunning(!running); playPauseBtn.onclick = () => setRunning(!running);
stepBtn.onclick = () => {
stepSimulation();
};
resetSimBtn.onclick = () => {
if (running) setRunning(false);
resetSimulation();
stepBtn.disabled = false;
};

View file

@ -5,7 +5,6 @@ import {
keymap, keymap,
hoverTooltip, hoverTooltip,
Decoration, Decoration,
ViewPlugin,
lineNumbers, lineNumbers,
highlightActiveLineGutter, highlightActiveLineGutter,
highlightActiveLine highlightActiveLine
@ -18,11 +17,10 @@ import { closeBrackets } from "npm:@codemirror/autocomplete";
import wasm from "./wasm.ts" import wasm from "./wasm.ts"
import { terminalPlugin } from "./terminal.ts";
import { machine_from_json, setAutomaton } from "./automata.ts";
import { sharedText } from "./share.ts"; import { sharedText } from "./share.ts";
import { examples } from "./examples.ts"; import { examples } from "./examples.ts";
import { bus } from "./bus.ts";
function tokenize(text: string) { function tokenize(text: string) {
@ -44,31 +42,26 @@ function compile(text: string): wasm.CompileResult {
} }
} }
export const analysisField = StateField.define({ const eventBusConnection = StateField.define({
create(state) { create(state) {
const text = state.doc.toString(); const text = state.doc.toString();
bus.emit("editor/change", {text, doc: state.doc});
return buildAnalysis(text, state.doc); return buildAnalysis(text, state.doc);
}, },
update(value, tr) { update(value, tr) {
if (!tr.docChanged) return value; if (!tr.docChanged) return value;
const text = tr.state.doc.toString(); const text = tr.state.doc.toString();
bus.emit("editor/change", {text, doc: state.doc});
return buildAnalysis(text, tr.state.doc); return buildAnalysis(text, tr.state.doc);
}, },
provide: (f) => EditorView.decorations.from(f, (v) => v.deco), provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
}); });
function buildAnalysis(text: string, doc: Text) { function buildAnalysis(text: string, doc: Text) {
save(text);
const tokens = tokenize(text); const tokens = tokenize(text);
const { log, log_formatted, graph } = compile(text); const { log, ansi_log, machine } = compile(text);
if (graph){ bus.emit("compiled", {log, ansi_log, machine})
try{
setAutomaton(machine_from_json(graph))
}catch(e){
console.log(e);
}
}
const marks = []; const marks = [];
const docLen = doc.length; const docLen = doc.length;
@ -100,7 +93,7 @@ function buildAnalysis(text: string, doc: Text) {
} }
const deco = Decoration.set(marks, true); const deco = Decoration.set(marks, true);
return { tokens, log, log_formatted, deco }; return { tokens, log, ansi_log, deco };
} }
const tokenClass = (t: string) => const tokenClass = (t: string) =>
@ -135,7 +128,7 @@ function sevRank(sev: string) {
// ===================== Hover tooltip (uses cached diags) ===================== // ===================== Hover tooltip (uses cached diags) =====================
const diagHover = hoverTooltip((view, pos) => { const diagHover = hoverTooltip((view, pos) => {
const { log } = view.state.field(analysisField); const { log } = view.state.field(eventBusConnection);
const hits = log.filter((d) => d.start !== undefined && d.end !== undefined && pos >= d.start && pos <= d.end); const hits = log.filter((d) => d.start !== undefined && d.end !== undefined && pos >= d.start && pos <= d.end);
if (hits.length === 0) return null; if (hits.length === 0) return null;
@ -186,7 +179,7 @@ export function getText(): string{
} }
const state = EditorState.create({ const state = EditorState.create({
doc: sharedText() ?? getSaved() ?? examples[0].machine, doc: "",
extensions: [ extensions: [
lineNumbers(), lineNumbers(),
highlightActiveLineGutter(), highlightActiveLineGutter(),
@ -197,9 +190,8 @@ const state = EditorState.create({
closeBrackets(), closeBrackets(),
keymap.of([...defaultKeymap, ...historyKeymap]), keymap.of([...defaultKeymap, ...historyKeymap]),
analysisField, eventBusConnection,
diagHover, diagHover,
terminalPlugin,
EditorView.lineWrapping, EditorView.lineWrapping,
], ],
@ -208,4 +200,6 @@ const state = EditorState.create({
const editor = new EditorView({ const editor = new EditorView({
state, state,
parent: document.getElementById("editor")!, parent: document.getElementById("editor")!,
}); });
bus.on("begin", _ => setText(sharedText() ?? getSaved() ?? examples[0].machine))

View file

@ -10,9 +10,9 @@ export type Category =
| "NTM"; | "NTM";
export class Example { export class Example {
category: Category; readonly category: Category;
title: string; readonly title: string;
machine: string; readonly machine: string;
constructor(category: Category, title: string, machine: string) { constructor(category: Category, title: string, machine: string) {
this.category = category; this.category = category;
@ -21,7 +21,7 @@ export class Example {
} }
} }
export const examples: Example[] = [ export const examples: readonly Example[] = [
new Example( new Example(
"Tutorial", "Tutorial",
"DFA", "DFA",
@ -174,7 +174,7 @@ const CATEGORY_ORDER: Category[] = [
function buildExamplesDropdown( function buildExamplesDropdown(
selectEl: HTMLSelectElement, selectEl: HTMLSelectElement,
examples: Example[], examples: readonly Example[],
onPick?: (ex: Example) => void, onPick?: (ex: Example) => void,
) { ) {
// Clear everything except the first placeholder option (if present) // Clear everything except the first placeholder option (if present)

View file

@ -1,7 +1,11 @@
import "./editor.ts" import { bus } from "./bus.ts";
import "./visualizer.ts"
import "./splitters.ts" import "./splitters.ts"
import "./controls.ts" import "./controls.ts"
import "./theme.ts" import "./theme.ts"
import "./share.ts" import "./share.ts"
import "./examples.ts" import "./examples.ts"
import "./visualizer.ts"
import "./editor.ts"
import "./simulation.ts"
bus.emit("begin", undefined);

325
web/root/src/simulation.ts Normal file
View file

@ -0,0 +1,325 @@
import { bus } from "./bus.ts";
import {
Fa,
Machine,
parse_machine_from_json,
Pda,
State,
Symbol,
Tm,
} from "./automata.ts";
export type SimStepResult = "pending" | "accept" | "reject";
export type Sim = FaSim | PdaSim | TmSim;
let simulation: Sim | null = null;
let automaton: Machine = {
type: "fa",
alphabet: new Map(),
final_states: new Map(),
initial_state: "",
states: new Map(),
transitions: new Map(),
transitions_components: new Map(),
edges: new Map(),
};
bus.on("compiled", ({ machine }) => {
if (machine) {
try {
bus.emit("controls/clear_simulation", undefined);
automaton = parse_machine_from_json(machine);
bus.emit("automata/update", { automaton });
} catch (e) {
console.log(e);
}
}
});
bus.on("controls/clear_simulation", (_) => {
simulation = null;
bus.emit("automata/sim/update", { simulation: null });
});
bus.on("controls/step_simulation", (_) => {
if (simulation) {
bus.emit("automata/sim/before_step", { simulation });
bus.emit("automata/sim/after_step", {
result: simulation.step(),
simulation: simulation,
});
}
});
const machineInput = document.getElementById("machineInput") as HTMLInputElement;
machineInput.addEventListener("input", () => bus.emit("automata/sim/update", {simulation: null}));
machineInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
bus.emit("controls/reload_simulation", undefined)
}
});
bus.on("controls/reload_simulation", (_) => {
const input = machineInput.value;
switch (automaton.type) {
case "fa":
simulation = new FaSim(automaton as Fa, input);
break;
case "pda":
simulation = new PdaSim(automaton as Pda, input);
break;
case "tm":
simulation = new TmSim(automaton as Tm, input);
break;
}
bus.emit("automata/sim/update", { simulation });
});
const simulationStatus = document.getElementById("simulationStatus") as HTMLInputElement;
bus.on("automata/sim/update", ({simulation}) => {
if (!simulation){
simulationStatus.innerText = "N/A"
simulationStatus.style.color = "var(--fg-2)";
}else{
simulationStatus.innerText = "Pending"
simulationStatus.style.color = "var(--warning)";
}
});
bus.on("automata/sim/after_step", ({result}) => {
if (result === "pending"){
simulationStatus.innerText = "Pending"
simulationStatus.style.color = "var(--warning)";
}else if (result==="accept"){
simulationStatus.innerText = "Accepted"
simulationStatus.style.color = "var(--success)";
}else if (result==="reject"){
simulationStatus.innerText = "Rejected"
simulationStatus.style.color = "var(--error)";
}
});
export class FaState {
readonly state: State;
readonly position: number;
readonly input: string;
readonly accepted: boolean = false;
private repr!: string;
constructor(state: State, position: number, input: string) {
this.state = state;
this.position = position;
this.input = input;
}
toString(): string {
if (!this.repr) {
this.repr = this.state + +" >" + this.input.substring(this.position);
}
return this.repr;
}
}
export class FaSim {
machine: Fa;
paths: FaState[];
input: string;
current_states: Map<string, FaState[]> = new Map();
accepted: FaState[] = [];
constructor(machine: Fa, input: string) {
this.machine = machine;
this.paths = [new FaState(machine.initial_state, 0, input)];
this.current_states.set(machine.initial_state, [this.paths[0]]);
this.input = input;
}
step(): SimStepResult {
if (this.paths.length == 0) return "reject";
if (this.accepted.length != 0) return "accept";
const paths: FaState[] = [];
this.current_states.clear();
const push = (state: FaState) => {
paths.push(state);
if (!this.current_states.has(state.state)) {
this.current_states.set(state.state, []);
}
this.current_states.get(state.state)?.push(state);
if (
state.position == this.input.length &&
this.machine.final_states.has(state.state)
) {
// @ts-expect-error sillllyyyy
state.accepted = true;
this.accepted.push(state);
}
};
for (const path of this.paths) {
const letter_map = this.machine.transitions_components.get(path.state)!;
if (!letter_map) continue;
for (const to of letter_map.get(null) ?? []) {
push(new FaState(to.state, path.position, this.input));
}
if (path.position >= this.input.length) continue;
const char = this.input.charAt(path.position);
for (const to of letter_map.get(char) ?? []) {
push(new FaState(to.state, path.position + 1, this.input));
}
}
this.paths = paths;
if (this.paths.length == 0) return "reject";
if (this.accepted.length != 0) return "accept";
return "pending";
}
}
export class PdaState {
readonly state: State;
readonly stack: Symbol[];
readonly position: number;
readonly input: string;
readonly accepted: boolean = false;
private repr!: string;
constructor(state: State, stack: Symbol[], position: number, input: string) {
this.state = state;
this.stack = stack;
this.position = position;
this.input = input;
}
toString(): string {
if (!this.repr) {
this.repr = this.state + " [" + this.stack + "]" + " >" +
this.input.substring(this.position);
}
return this.repr;
}
}
export class PdaSim {
machine: Pda;
paths: PdaState[];
input: string;
current_states: Map<string, PdaState[]> = new Map();
accepted: PdaState[] = [];
constructor(machine: Pda, input: string) {
this.machine = machine;
this.paths = [
new PdaState(machine.initial_state, [machine.initial_stack], 0, input),
];
this.current_states.set(machine.initial_state, [this.paths[0]]);
this.input = input;
}
step(): SimStepResult {
if (this.paths.length == 0) return "reject";
if (this.accepted.length != 0) return "accept";
const paths: PdaState[] = [];
this.current_states.clear();
const push = (state: PdaState) => {
paths.push(state);
if (!this.current_states.has(state.state)) {
this.current_states.set(state.state, []);
}
this.current_states.get(state.state)?.push(state);
if (
state.position == this.input.length && this.machine.final_states &&
this.machine.final_states.has(state.state) ||
state.position == this.input.length && !this.machine.final_states &&
state.stack.length == 1 &&
state.stack[0] == this.machine.initial_stack
) {
// @ts-expect-error sillllyyyy
state.accepted = true;
this.accepted.push(state);
}
};
for (const path of this.paths) {
const stack = path.stack.pop()!;
const letter_map = this.machine.transitions_components.get(path.state)
?.get(stack);
if (!letter_map) continue;
for (const to of letter_map.get(null) ?? []) {
push(
new PdaState(
to.state,
path.stack.concat(to.stack),
path.position,
this.input,
),
);
}
if (path.position >= this.input.length) continue;
const char = this.input.charAt(path.position);
for (const to of letter_map.get(char) ?? []) {
push(
new PdaState(
to.state,
path.stack.concat(to.stack),
path.position + 1,
this.input,
),
);
}
}
this.paths = paths;
if (this.paths.length == 0) return "reject";
if (this.accepted.length != 0) return "accept";
return "pending";
}
}
export class TmState {
readonly state: State;
readonly tape: Symbol[];
readonly position: number;
readonly input: string;
readonly accepted: boolean = false;
private repr!: string;
constructor(state: State, tape: Symbol[], position: number, input: string) {
this.state = state;
this.tape = tape;
this.position = position;
this.input = input;
}
toString(): string {
if (!this.repr) this.repr = this.state + " " + this.position;
return this.repr;
}
}
export class TmSim {
machine: Tm;
input: string;
current_states: Map<string, TmState[]> = new Map();
accepted: TmState[] = [];
constructor(machine: Tm, input: string) {
this.machine = machine;
this.input = input;
}
step(): SimStepResult {
return "pending";
}
}

View file

@ -63,7 +63,7 @@ function setFlexFill(pane: HTMLElement) {
pane.style.flex = "1 1 auto"; pane.style.flex = "1 1 auto";
} }
export function enableFlexSplitters() { function enableFlexSplitters() {
// Horizontal: A | hSplit | B (top/split/bottom) // Horizontal: A | hSplit | B (top/split/bottom)
for (const splitter of document.querySelectorAll<HTMLElement>(".hSplit:not(.styleOnly)")) { for (const splitter of document.querySelectorAll<HTMLElement>(".hSplit:not(.styleOnly)")) {
const parent = splitter.parentElement as HTMLElement | null; const parent = splitter.parentElement as HTMLElement | null;

View file

@ -1,10 +1,14 @@
// deno-lint-ignore-file import { bus } from "./bus.ts";
import { bus.on("compiled", ({log, ansi_log}) => {
ViewPlugin, const term = document.getElementById("terminal");
} from "npm:@codemirror/view"; if (!term) return;
import { analysisField } from "./editor.ts"; let s = "";
s += `\x1b[90m[compile]\x1b[0m ${log.length} diagnostics\n`;
term.innerHTML = ansiToHtml(s + ansi_log);
})
function escapeHtml(s: string) { function escapeHtml(s: string) {
return s return s
@ -77,33 +81,4 @@ function ansiToHtml(input: string) {
out += openSpanIfNeeded(input.slice(lastIndex)); out += openSpanIfNeeded(input.slice(lastIndex));
return out; return out;
} }
// @ts-expect-error bad library
function formatTerminal(view) {
const term = document.getElementById("terminal");
if (!term) return;
const { log, log_formatted } = view.state.field(analysisField);
let s = "";
s += `\x1b[90m[compile]\x1b[0m ${log.length} diagnostics\n`;
term.innerHTML = ansiToHtml(s + log_formatted);
}
export const terminalPlugin = ViewPlugin.fromClass(
class {
// @ts-expect-error bad library
constructor(view) {
// @ts-expect-error bad library
this.view = view;
formatTerminal(view);
}
// @ts-expect-error bad library
update(update) {
if (update.docChanged) formatTerminal(update.view);
}
}
);

View file

@ -1,4 +1,4 @@
import { updateGraphTheme } from "./visualizer.ts"; import { bus } from "./bus.ts";
const themeBtn = document.getElementById("themeToggle") as HTMLButtonElement; const themeBtn = document.getElementById("themeToggle") as HTMLButtonElement;
@ -22,11 +22,11 @@ function setTheme(theme: Theme) {
// update button label // update button label
themeBtn.textContent = theme === "dark" ? "🌙 Dark" : "☀️ Light"; themeBtn.textContent = theme === "dark" ? "🌙 Dark" : "☀️ Light";
updateGraphTheme();
bus.emit("theme/update", undefined);
} }
bus.on("begin", _ => setTheme(getPreferredTheme()))
setTheme(getPreferredTheme());
themeBtn.addEventListener("click", toggleTheme); themeBtn.addEventListener("click", toggleTheme);
function toggleTheme() { function toggleTheme() {
@ -34,8 +34,6 @@ function toggleTheme() {
setTheme(current === "dark" ? "light" : "dark"); setTheme(current === "dark" ? "light" : "dark");
} }
globalThis.window.matchMedia?.("(prefers-color-scheme: light)") globalThis.window.matchMedia?.("(prefers-color-scheme: light)")
?.addEventListener("change", () => { ?.addEventListener("change", () => {
if (localStorage.getItem("theme")) return; if (localStorage.getItem("theme")) return;

View file

@ -1,143 +1,44 @@
// deno-lint-ignore-file no-unversioned-import // deno-lint-ignore-file no-unversioned-import
// deno-lint-ignore no-import-prefix // deno-lint-ignore no-import-prefix
import * as vis from "npm:vis-network/standalone"; import * as vis from "npm:vis-network/standalone";
import { automaton, setAutomaton, sim } from "./automata.ts";
export const nodes = new vis.DataSet<vis.Node>(); import { bus } from "./bus.ts";
export const edges = new vis.DataSet<vis.Edge>(); import type { Sim } from "./simulation.ts";
import type { Machine } from "./automata.ts";
type Color = string;
type GraphTheme = {
bg_0: Color;
bg_1: Color;
bg_2: Color;
fg_0: Color;
fg_1: Color;
fg_2: Color;
node_anchor: Color;
node_border: Color;
current_node_border: Color;
edge: Color;
edge_hover: Color;
edge_active: Color;
font_face: string
node_font_size: number;
node_font: string,
node_font_bold: string,
edge_font_size: number;
edge_font: string,
edge_font_bold: string,
};
let _graphTheme: GraphTheme | null = null;
function invalidateGraphThemeCache() {
_graphTheme = null;
}
function getGraphTheme(): GraphTheme {
function cssVar(name: string, fallback = ""): string {
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim() || fallback;
}
if (_graphTheme) return _graphTheme;
_graphTheme = {
bg_0: cssVar("--graph-bg-0"),
bg_1: cssVar("--graph-bg-1"),
bg_2: cssVar("--graph-bg-2"),
fg_0: cssVar("--graph-fg-0"),
fg_1: cssVar("--graph-fg-1"),
fg_2: cssVar("--graph-fg-2"),
node_anchor: cssVar("--graph-node-anchor"),
node_border: cssVar("--graph-node-border"),
current_node_border: cssVar("--graph-current-node-border"),
edge: cssVar("--graph-edge"),
edge_hover: cssVar("--graph-edge-hover"),
edge_active: cssVar("--graph-edge-active"),
font_face: cssVar("--graph-font"),
node_font_size: Number(cssVar("--graph-node-font-size")),
node_font: `${cssVar("--graph-node-font-size")}px ${cssVar("--graph-font")}`,
node_font_bold: `bold ${cssVar("--graph-node-font-size")}px ${cssVar("--graph-font")}`,
edge_font_size: Number(cssVar("--graph-edge-font-size")),
edge_font: `${Number(cssVar("--graph-edge-font-size"))}px ${cssVar("--graph-font")}`,
edge_font_bold: `bold ${Number(cssVar("--graph-edge-font-size"))}px ${cssVar("--graph-font")}`,
};
return _graphTheme;
}
export function updateGraphTheme() {
invalidateGraphThemeCache();
const gt = getGraphTheme();
network.setOptions({
nodes: {
labelHighlightBold: false,
font: {
color: gt.fg_0,
bold: {
color: gt.fg_1,
},
},
},
edges: {
labelHighlightBold: true,
font: {
align: "top",
face: gt.font_face,
size: gt.edge_font_size,
color: gt.fg_0,
strokeColor: gt.bg_0,
bold: {
color: gt.fg_1,
face: gt.font_face,
size: gt.edge_font_size,
mod: "bold",
},
},
color: {
color: gt.edge,
hover: gt.edge_hover,
highlight: gt.edge_active,
},
shadow: {
enabled: false,
},
},
});
setAutomaton(automaton)
}
let _measureCanvas: HTMLCanvasElement | null = null; bus.on("controls/physics", ({enabled}) => {
network.setOptions({ physics: { enabled } });
network.setOptions({edges: {smooth: enabled}});
});
bus.on("controls/reset_network", _ => {
try {
nodes.forEach((n) => {
n.physics = true;
n.x = undefined;
n.y = undefined;
});
network.setData({ nodes, edges });
} catch {
// Last resort
network.setData({ nodes, edges });
}
});
export function measureTextWidth(text: string, font: string): number { bus.on("automata/sim/after_step", _ => {
if (!_measureCanvas) { network.redraw();
_measureCanvas = document.createElement("canvas"); });
}
const ctx = _measureCanvas.getContext("2d")!; let simulation: Sim | null = null;
ctx.font = font; bus.on("automata/sim/update", ({simulation: sim}) => {
simulation = sim;
network.redraw();
});
return ctx.measureText(text).width; let automaton: Machine
}
export function updateVisualization() { bus.on("automata/update", ({automaton: auto}) => {
automaton = auto;
// Populate nodes // Populate nodes
for (const state of automaton.states.keys()) { for (const state of automaton.states.keys()) {
@ -187,17 +88,154 @@ export function updateVisualization() {
} }
} }
// delete old edges
for (const edge_id of edges.getIds()) { for (const edge_id of edges.getIds()) {
if (!automaton.edges.has(edge_id as string)) { if (!automaton.edges.has(edge_id as string)) {
edges.remove(edge_id); edges.remove(edge_id);
} }
} }
// delete old nodes
for (const node_id of nodes.getIds()) { for (const node_id of nodes.getIds()) {
if (!automaton.states.has(node_id as string)) { if (!automaton.states.has(node_id as string)) {
nodes.remove(node_id); nodes.remove(node_id);
} }
} }
});
const nodes = new vis.DataSet<vis.Node>();
const edges = new vis.DataSet<vis.Edge>();
let _graphTheme: GraphTheme | null = null;
bus.on("theme/update", _ => {
_graphTheme = null;
updateGraphTheme()
});
type Color = string;
type GraphTheme = {
bg_0: Color;
bg_1: Color;
bg_2: Color;
fg_0: Color;
fg_1: Color;
fg_2: Color;
node_anchor: Color;
node_border: Color;
current_node_border: Color;
edge: Color;
edge_hover: Color;
edge_active: Color;
font_face: string
node_font_size: number;
node_font: string,
node_font_bold: string,
edge_font_size: number;
edge_font: string,
edge_font_bold: string,
};
function getGraphTheme(): GraphTheme {
function cssVar(name: string, fallback = ""): string {
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim() || fallback;
}
if (_graphTheme) return _graphTheme;
_graphTheme = {
bg_0: cssVar("--graph-bg-0"),
bg_1: cssVar("--graph-bg-1"),
bg_2: cssVar("--graph-bg-2"),
fg_0: cssVar("--graph-fg-0"),
fg_1: cssVar("--graph-fg-1"),
fg_2: cssVar("--graph-fg-2"),
node_anchor: cssVar("--graph-node-anchor"),
node_border: cssVar("--graph-node-border"),
current_node_border: cssVar("--graph-current-node-border"),
edge: cssVar("--graph-edge"),
edge_hover: cssVar("--graph-edge-hover"),
edge_active: cssVar("--graph-edge-active"),
font_face: cssVar("--graph-font"),
node_font_size: Number(cssVar("--graph-node-font-size")),
node_font: `${cssVar("--graph-node-font-size")}px ${cssVar("--graph-font")}`,
node_font_bold: `bold ${cssVar("--graph-node-font-size")}px ${cssVar("--graph-font")}`,
edge_font_size: Number(cssVar("--graph-edge-font-size")),
edge_font: `${Number(cssVar("--graph-edge-font-size"))}px ${cssVar("--graph-font")}`,
edge_font_bold: `bold ${Number(cssVar("--graph-edge-font-size"))}px ${cssVar("--graph-font")}`,
};
return _graphTheme;
}
function updateGraphTheme() {
const gt = getGraphTheme();
network.setOptions({
nodes: {
labelHighlightBold: false,
font: {
color: gt.fg_0,
bold: {
color: gt.fg_1,
},
},
},
edges: {
labelHighlightBold: true,
font: {
align: "top",
face: gt.font_face,
size: gt.edge_font_size,
color: gt.fg_0,
strokeColor: gt.bg_0,
bold: {
color: gt.fg_1,
face: gt.font_face,
size: gt.edge_font_size,
mod: "bold",
},
},
color: {
color: gt.edge,
hover: gt.edge_hover,
highlight: gt.edge_active,
},
shadow: {
enabled: false,
},
},
});
network.redraw();
}
let _measureCanvas: HTMLCanvasElement | null = null;
function measureTextWidth(text: string, font: string): number {
if (!_measureCanvas) {
_measureCanvas = document.createElement("canvas");
}
const ctx = _measureCanvas.getContext("2d")!;
ctx.font = font;
return ctx.measureText(text).width;
} }
function chosen_edge( function chosen_edge(
@ -216,7 +254,7 @@ function chosen_node(
) { ) {
} }
export const network: vis.Network = createGraph(); const network: vis.Network = createGraph();
function createGraph(): vis.Network { function createGraph(): vis.Network {
const container = document.getElementById("graph")!; const container = document.getElementById("graph")!;
@ -278,6 +316,7 @@ function createGraph(): vis.Network {
return network; return network;
} }
function renderNode({ function renderNode({
ctx, ctx,
id, id,
@ -296,7 +335,7 @@ function renderNode({
const isFinal = automaton.final_states const isFinal = automaton.final_states
? automaton.final_states.has(id) ? automaton.final_states.has(id)
: false; : false;
const isActive = sim?sim.current_states.has(id):false; const isActive = simulation?simulation.current_states.has(id):false;
const fill = selected ? t.bg_2 : hover ? t.bg_1 : t.bg_0; const fill = selected ? t.bg_2 : hover ? t.bg_1 : t.bg_0;
const stroke = isActive ? t.current_node_border : t.node_border; const stroke = isActive ? t.current_node_border : t.node_border;
@ -339,7 +378,7 @@ function renderNode({
} }
if (isActive) { if (isActive) {
const paths = sim?.current_states.get(id)!; const paths = simulation?.current_states.get(id)!;
const padX = 8; const padX = 8;
const padY = 6; const padY = 6;
const lineH = 14; const lineH = 14;

View file

@ -52,27 +52,45 @@
} }
} }
.btn-blue { .btn-yellow {
background: color-mix(in srgb, var(--accent) 14%, transparent); background: color-mix(in srgb, var(--warning) 14%, transparent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent); border-color: color-mix(in srgb, var(--warning) 40%, transparent);
&:hover { &:hover {
background: color-mix(in srgb, var(--accent) 22%, transparent); background: color-mix(in srgb, var(--warning) 22%, transparent);
}
}
.btn-blue {
background: color-mix(in srgb, var(--focus) 14%, transparent);
border-color: color-mix(in srgb, var(--focus) 40%, transparent);
&:hover {
background: color-mix(in srgb, var(--focus) 22%, transparent);
} }
} }
.btn-grey { .btn-grey {
background: color-mix(in srgb, var(--accent) 12%, transparent); background: color-mix(in srgb, var(--ansi-fg-90) 12%, transparent);
border-color: color-mix(in srgb, var(--accent) 28%, transparent); border-color: color-mix(in srgb, var(--ansi-fg-90) 28%, transparent);
&:hover { &:hover {
background: color-mix(in srgb, var(--accent) 18%, transparent); background: color-mix(in srgb, var(--ansi-fg-90) 18%, transparent);
}
}
.btn-red {
background: color-mix(in srgb, var(--error) 12%, transparent);
border-color: color-mix(in srgb, var(--error) 28%, transparent);
&:hover {
background: color-mix(in srgb, var(--error) 18%, transparent);
} }
} }
.btn-toggle.active { .btn-toggle.active {
background: color-mix(in srgb, var(--warning) 14%, transparent); background: color-mix(in srgb, var(--success) 14%, transparent);
border-color: color-mix(in srgb, var(--warning) 30%, transparent); border-color: color-mix(in srgb, var(--success) 30%, transparent);
} }
@ -97,3 +115,33 @@
text-align: right; text-align: right;
opacity: 0.9; opacity: 0.9;
} }
.test-input {
width: 100%;
max-width: 420px;
align-self: center;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid var(--bg-1);
background: var(--bg-2);
font: var(--font-ui);
color: var(--fg-0);
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.test-input::placeholder {
color: var(--fg-0);
}
.test-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent);
}

View file

@ -31,7 +31,6 @@
.cm-lineNumbers .cm-gutterElement { .cm-lineNumbers .cm-gutterElement {
padding: 0 10px 0 6px; padding: 0 10px 0 6px;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px;
} }
.cm-activeLine { .cm-activeLine {

View file

@ -11,6 +11,7 @@ body {
margin: 0; margin: 0;
color: var(--fg-0); color: var(--fg-0);
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 14px;
background: #909090; background: #909090;
} }
@ -103,6 +104,7 @@ body {
border-radius: 12px; border-radius: 12px;
border: 1px solid var(bg-1); border: 1px solid var(bg-1);
background: var(--bg-2); background: var(--bg-2);
font: var(--font-ui)
} }
.share-toast { .share-toast {

View file

@ -4,7 +4,6 @@
padding: 1em; padding: 1em;
margin: 0px; margin: 0px;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12.5px;
line-height: 1.35; line-height: 1.35;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;

View file

@ -152,8 +152,8 @@ pub struct Graph<'a> {
#[wasm_bindgen(getter_with_clone)] #[wasm_bindgen(getter_with_clone)]
pub struct CompileResult { pub struct CompileResult {
pub log: Vec<CompileLog>, pub log: Vec<CompileLog>,
pub log_formatted: String, pub ansi_log: String,
pub graph: Option<String>, pub machine: Option<String>,
} }
#[wasm_bindgen] #[wasm_bindgen]
@ -161,10 +161,10 @@ pub fn compile(input: &str) -> CompileResult {
let mut ctx = Context::new(input); let mut ctx = Context::new(input);
let result = automata::loader::parse_universal(&mut ctx); let result = automata::loader::parse_universal(&mut ctx);
let graph = result.map(|result| serde_json::to_string(&result).unwrap()); let machine = result.map(|result| serde_json::to_string(&result).unwrap());
use std::fmt::Write; use std::fmt::Write;
let log_formatted = ctx.logs_display().fold(String::new(), |mut s, e| { let ansi_log = ctx.logs_display().fold(String::new(), |mut s, e| {
write!(&mut s, "{e}").unwrap(); write!(&mut s, "{e}").unwrap();
s s
}); });
@ -190,7 +190,7 @@ pub fn compile(input: &str) -> CompileResult {
CompileResult { CompileResult {
log, log,
log_formatted, ansi_log,
graph, machine,
} }
} }