organizing frontend

This commit is contained in:
Parker TenBroeck 2026-01-11 11:40:54 -05:00
parent 2c836875d2
commit 2e6330196d
7 changed files with 203 additions and 183 deletions

View file

@ -1,6 +1,7 @@
// deno-lint-ignore-file // deno-lint-ignore-file
import type { Machine } from "./automata.ts"; import type { Machine } from "./automata.ts";
import type { Example } from "./examples.ts";
import type { Sim, SimStepResult } from "./simulation.ts"; import type { Sim, SimStepResult } from "./simulation.ts";
import type wasm from "./wasm.ts"; import type wasm from "./wasm.ts";
import type { Text } from "npm:@codemirror/state"; import type { Text } from "npm:@codemirror/state";
@ -73,13 +74,16 @@ type AppEvents = {
"automata/sim/after_step": { simulation: Sim, result: SimStepResult }; "automata/sim/after_step": { simulation: Sim, result: SimStepResult };
"automata/update": { automaton: Machine }; "automata/update": { automaton: Machine };
"controls/physics": {enabled: boolean}, "example/selected": {example: Example};
"controls/reset_network": void,
"controls/editor/set_text": {text: string};
"controls/step_simulation": void, "controls/vis/physics": {enabled: boolean};
"controls/reload_simulation": void, "controls/vis/reset_network": void;
"controls/clear_simulation": void,
"controls/sim/step": void;
"controls/sim/reload": void;
"controls/sim/clear": void;
"theme/update": void; "theme/update": void;
}; };

View file

@ -15,59 +15,6 @@ const speedLabel = document.getElementById("speedSimLabel") as HTMLSpanElement;
const reloadSimBtn = document.getElementById("reloadSim") as HTMLButtonElement; const reloadSimBtn = document.getElementById("reloadSim") as HTMLButtonElement;
const clearSimBtn = document.getElementById("clearSim") as HTMLButtonElement; const clearSimBtn = document.getElementById("clearSim") as HTMLButtonElement;
bus.on("controls/physics", ({ enabled }) => {
togglePhysicsBtn.classList.toggle("active", enabled);
togglePhysicsBtn.textContent = enabled ? "Physics: ON" : "Physics: OFF";
});
togglePhysicsBtn.onclick = () => {
const enabled = !togglePhysicsBtn.classList.contains("active");
bus.emit("controls/physics", { enabled });
};
bus.emit("controls/physics", {
enabled: togglePhysicsBtn.classList.contains("active"),
});
resetLayoutBtn.onclick = () => bus.emit("controls/reset_network", undefined);
clearSimBtn.onclick = () => bus.emit("controls/clear_simulation", undefined);
stepBtn.onclick = () => {
bus.emit("controls/step_simulation", undefined);
};
reloadSimBtn.onclick = () => bus.emit("controls/reload_simulation", undefined);
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();
});
bus.on("automata/sim/after_step", ({ result }) => {
if (result !== "pending") {
if (running) setRunning(false);
}
});
let simulation_active = false;
let running = false;
let timer: number | null = null;
// speed slider is "steps per second" // speed slider is "steps per second"
function getStepsPerSecond() { function getStepsPerSecond() {
return Math.max(1, Math.min(60, Number(speedSlider.value) || 10)); return Math.max(1, Math.min(60, Number(speedSlider.value) || 10));
@ -77,39 +24,82 @@ function updateSpeedUI() {
} }
updateSpeedUI(); updateSpeedUI();
speedSlider.addEventListener("input", () => { class Controls {
updateSpeedUI(); static simulation_active = false;
if (running) restartTimer(); static running = false;
}); static timer: number | null = null;
function stopTimer() { static updateButtons() {
if (timer !== null) { stepBtn.disabled = !Controls.simulation_active || Controls.running;
clearInterval(timer); playPauseBtn.disabled = !Controls.simulation_active;
timer = null; clearSimBtn.disabled = !Controls.simulation_active;
} }
} static setRunning(on: boolean) {
Controls.running = on;
playPauseBtn.textContent = Controls.running ? "⏸ Pause" : "▶ Play";
playPauseBtn.classList.toggle("btn-primary", !Controls.running);
playPauseBtn.classList.toggle("btn-secondary", Controls.running);
function restartTimer() { if (Controls.running) Controls.restartTimer();
stopTimer(); else Controls.stopTimer();
Controls.updateButtons();
}
static stop() {
if (Controls.running) Controls.setRunning(false);
}
static stopTimer() {
if (Controls.timer !== null) {
clearInterval(Controls.timer);
Controls.timer = null;
}
}
static restartTimer() {
Controls.stopTimer();
const sps = getStepsPerSecond(); const sps = getStepsPerSecond();
const intervalMs = Math.round(1000 / sps); const intervalMs = Math.round(1000 / sps);
timer = globalThis.window.setInterval(() => { Controls.timer = globalThis.window.setInterval(() => {
bus.emit("controls/step_simulation", undefined); bus.emit("controls/sim/step", undefined);
}, intervalMs); }, intervalMs);
}
static {
speedSlider.addEventListener("input", () => {
updateSpeedUI();
if (Controls.running) Controls.restartTimer();
});
playPauseBtn.onclick = () => Controls.setRunning(!Controls.running);
resetLayoutBtn.onclick = () =>
bus.emit("controls/vis/reset_network", undefined);
clearSimBtn.onclick = () => bus.emit("controls/sim/clear", undefined);
stepBtn.onclick = () => bus.emit("controls/sim/step", undefined);
reloadSimBtn.onclick = () => bus.emit("controls/sim/reload", undefined);
togglePhysicsBtn.onclick = () => {
const enabled = !togglePhysicsBtn.classList.contains("active");
bus.emit("controls/vis/physics", { enabled });
};
bus.on("controls/vis/physics", ({ enabled }) => {
togglePhysicsBtn.classList.toggle("active", enabled);
togglePhysicsBtn.textContent = enabled ? "Physics: ON" : "Physics: OFF";
});
bus.on("controls/sim/reload", (_) => {
if (Controls.running) Controls.setRunning(false);
});
bus.on("automata/sim/update", ({ simulation }) => {
Controls.simulation_active = !!simulation;
if (!simulation) Controls.stop();
});
bus.on("automata/sim/after_step", ({ result }) => {
if (result !== "pending") Controls.stop();
});
bus.emit("controls/vis/physics", {
enabled: togglePhysicsBtn.classList.contains("active"),
});
}
} }
function setRunning(on: boolean) {
running = on;
playPauseBtn.textContent = running ? "⏸ Pause" : "▶ Play";
playPauseBtn.classList.toggle("btn-primary", !running);
playPauseBtn.classList.toggle("btn-secondary", running);
// Disable step while running (optional, but feels nice)
stepBtn.disabled = running;
if (running) restartTimer();
else stopTimer();
}
playPauseBtn.onclick = () => setRunning(!running);

View file

@ -1,67 +1,71 @@
// deno-lint-ignore-file // deno-lint-ignore-file
import { import {
EditorView,
keymap,
hoverTooltip,
Decoration, Decoration,
lineNumbers, EditorView,
highlightActiveLine,
highlightActiveLineGutter, highlightActiveLineGutter,
highlightActiveLine hoverTooltip,
keymap,
lineNumbers,
} from "npm:@codemirror/view"; } from "npm:@codemirror/view";
import { EditorState, StateField, Text } from "npm:@codemirror/state"; import { EditorState, StateField, Text } from "npm:@codemirror/state";
import { defaultKeymap, history, historyKeymap } from "npm:@codemirror/commands"; import {
defaultKeymap,
history,
historyKeymap,
} from "npm:@codemirror/commands";
import { bracketMatching, indentOnInput } from "npm:@codemirror/language"; import { bracketMatching, indentOnInput } from "npm:@codemirror/language";
import { closeBrackets } from "npm:@codemirror/autocomplete"; import { closeBrackets } from "npm:@codemirror/autocomplete";
import wasm from "./wasm.ts";
import wasm from "./wasm.ts" import { Share } from "./share.ts";
import { sharedText } from "./share.ts";
import { examples } from "./examples.ts"; import { examples } from "./examples.ts";
import { bus } from "./bus.ts"; import { bus } from "./bus.ts";
function tokenize(text: string): wasm.Tok[] {
function tokenize(text: string) {
try { try {
return wasm.lex(text); return wasm.lex(text);
} catch (e) { } catch (e) {
console.log(e) console.log(e);
return [] return [];
} }
} }
function compile(text: string): wasm.CompileResult { function compile(
text: string,
): { log: wasm.CompileLog[]; ansi_log: string; machine: string | undefined } {
try { try {
return wasm.compile(text); return wasm.compile(text);
} catch (e) { } catch (e) {
console.log(e); console.log(e);
// @ts-expect-error wasm defines extra cleanup return { log: [], ansi_log: "", machine: "" };
return {log: [], log_formatted: "", graph: ""};
} }
} }
const eventBusConnection = 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}); 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}); 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, ansi_log, machine } = compile(text); const { log, ansi_log, machine } = compile(text);
bus.emit("compiled", {log, ansi_log, machine}) bus.emit("compiled", { log, ansi_log, machine });
const marks = []; const marks = [];
const docLen = doc.length; const docLen = doc.length;
@ -88,7 +92,9 @@ function buildAnalysis(text: string, doc: Text) {
marks.push(Decoration.mark({ class: cls }).range(start, end)); marks.push(Decoration.mark({ class: cls }).range(start, end));
} else { } else {
const end = Math.min(docLen, start + 1); const end = Math.min(docLen, start + 1);
if (end > start) marks.push(Decoration.mark({ class: cls }).range(start, end)); if (end > start) {
marks.push(Decoration.mark({ class: cls }).range(start, end));
}
} }
} }
@ -96,8 +102,7 @@ function buildAnalysis(text: string, doc: Text) {
return { tokens, log, ansi_log, deco }; return { tokens, log, ansi_log, deco };
} }
const tokenClass = (t: string) => const tokenClass = (t: string) => ({
({
comment: "tok-comment", comment: "tok-comment",
keyword: "tok-keyword", keyword: "tok-keyword",
error: "tok-error", error: "tok-error",
@ -113,7 +118,6 @@ const tokenClass = (t: string) =>
rbracket: "rb-", rbracket: "rb-",
}[t] || "tok-ident"); }[t] || "tok-ident");
function severityClass(sev: string) { function severityClass(sev: string) {
const s = (sev || "error").toLowerCase(); const s = (sev || "error").toLowerCase();
if (s === "warning") return "cm-diag-warning"; if (s === "warning") return "cm-diag-warning";
@ -129,10 +133,16 @@ 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(eventBusConnection); 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;
const top = hits.reduce((a, b) => (sevRank(b.level) > sevRank(a.level) ? b : a), hits[0]); const top = hits.reduce(
(a, b) => (sevRank(b.level) > sevRank(a.level) ? b : a),
hits[0],
);
return { return {
pos, pos,
@ -144,8 +154,9 @@ const diagHover = hoverTooltip((view, pos) => {
const title = document.createElement("div"); const title = document.createElement("div");
title.className = `tipTitle ${top.level}`; title.className = `tipTitle ${top.level}`;
title.textContent = title.textContent = hits.length === 1
hits.length === 1 ? top.level.toUpperCase() : `${top.level.toUpperCase()} (${hits.length})`; ? top.level.toUpperCase()
: `${top.level.toUpperCase()} (${hits.length})`;
const body = document.createElement("div"); const body = document.createElement("div");
body.className = "tipBody"; body.className = "tipBody";
@ -162,24 +173,20 @@ const diagHover = hoverTooltip((view, pos) => {
}; };
}); });
function save(text: string){ function save(text: string) {
globalThis.localStorage.save = text; globalThis.localStorage.save = text;
} }
function getSaved(): string | undefined{ function getSaved(): string | undefined {
return globalThis.localStorage.save; return globalThis.localStorage.save;
} }
export function setText(text: string){ function defaultText(): string {
editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: text } }); return Share.sharedText() ?? getSaved() ?? examples[0].machine;
}
export function getText(): string{
return editor.state.doc.toString()
} }
const state = EditorState.create({ const state = EditorState.create({
doc: "", doc: defaultText(),
extensions: [ extensions: [
lineNumbers(), lineNumbers(),
highlightActiveLineGutter(), highlightActiveLineGutter(),
@ -202,4 +209,17 @@ const editor = new EditorView({
parent: document.getElementById("editor")!, parent: document.getElementById("editor")!,
}); });
bus.on("begin", _ => setText(sharedText() ?? getSaved() ?? examples[0].machine)) bus.on(
"begin",
(_) => bus.emit("controls/editor/set_text", { text: defaultText() }),
);
bus.on("controls/editor/set_text", ({ text }) => {
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: text },
});
});
bus.on("example/selected", ({ example }) => {
bus.emit("controls/editor/set_text", { text: example.machine });
});

View file

@ -1,4 +1,4 @@
import { setText } from "./editor.ts"; import { bus } from "./bus.ts";
export type Category = export type Category =
| "Tutorial" | "Tutorial"
@ -241,6 +241,6 @@ function buildExamplesDropdown(
} }
const selectEl = document.getElementById("exampleSelect") as HTMLSelectElement; const selectEl = document.getElementById("exampleSelect") as HTMLSelectElement;
buildExamplesDropdown(selectEl, examples, (picked) => { buildExamplesDropdown(selectEl, examples, (example) => {
setText(picked.machine); bus.emit("example/selected", {example});
}); });

View file

@ -1,27 +1,31 @@
import { getText } from "./editor.ts"; import { bus } from "./bus.ts";
const btn = document.getElementById("shareBtn")!; export class Share {
const toast = document.getElementById("shareToast")!; private static readonly btn: HTMLButtonElement = document.getElementById(
"shareBtn",
)! as HTMLButtonElement;
private static readonly toast: HTMLElement = document.getElementById(
"shareToast",
)!;
function generateShareLink() { private static docText: string;
return `${globalThis.window.location.href}?share=${encodeURIComponent(btoa(getText()))}`; private static shareText: string;
}
async function copy(text: string) { static {
await navigator.clipboard.writeText(text); bus.on("editor/change", ({ text }) => Share.docText = text);
}
btn.addEventListener("click", async () => { Share.btn.onclick = async (_) => {
await copy(generateShareLink()); const link = `${globalThis.window.location.href}?share=${
encodeURIComponent(btoa(Share.docText))
}`;
await navigator.clipboard.writeText(link);
toast.classList.remove("show"); Share.toast.classList.remove("show");
void toast.offsetWidth; void Share.toast.offsetWidth;
toast.classList.add("show"); Share.toast.classList.add("show");
}); };
try {
export function sharedText(): string|null {
try{
const url = new URL(globalThis.window.location.href); const url = new URL(globalThis.window.location.href);
let text: string | null = url.searchParams.get("share"); let text: string | null = url.searchParams.get("share");
if (text !== null) { if (text !== null) {
@ -30,12 +34,16 @@ export function sharedText(): string|null {
globalThis.window.history.replaceState( globalThis.window.history.replaceState(
{}, {},
document.title, document.title,
url.pathname + url.search + url.hash url.pathname + url.search + url.hash,
); );
Share.shareText = text;
} }
return text; } catch (e) {
}catch(e){ console.log(e);
console.log(e) }
}
public static sharedText(): string | null {
return Share.shareText;
} }
return null;
} }

View file

@ -1,13 +1,13 @@
import { bus } from "./bus.ts"; import { bus } from "./bus.ts";
import { import type {
Fa, Fa,
Machine, Machine,
parse_machine_from_json,
Pda, Pda,
State, State,
Symbol, Symbol,
Tm, Tm,
} from "./automata.ts"; } from "./automata.ts";
import {parse_machine_from_json} from "./automata.ts";
export type SimStepResult = "pending" | "accept" | "reject"; export type SimStepResult = "pending" | "accept" | "reject";
export type Sim = FaSim | PdaSim | TmSim; export type Sim = FaSim | PdaSim | TmSim;
@ -26,7 +26,7 @@ let automaton: Machine = {
bus.on("compiled", ({ machine }) => { bus.on("compiled", ({ machine }) => {
if (machine) { if (machine) {
try { try {
bus.emit("controls/clear_simulation", undefined); bus.emit("controls/sim/clear", undefined);
automaton = parse_machine_from_json(machine); automaton = parse_machine_from_json(machine);
bus.emit("automata/update", { automaton }); bus.emit("automata/update", { automaton });
} catch (e) { } catch (e) {
@ -34,11 +34,11 @@ bus.on("compiled", ({ machine }) => {
} }
} }
}); });
bus.on("controls/clear_simulation", (_) => { bus.on("controls/sim/clear", (_) => {
simulation = null; simulation = null;
bus.emit("automata/sim/update", { simulation: null }); bus.emit("automata/sim/update", { simulation: null });
}); });
bus.on("controls/step_simulation", (_) => { bus.on("controls/sim/step", (_) => {
if (simulation) { if (simulation) {
bus.emit("automata/sim/before_step", { simulation }); bus.emit("automata/sim/before_step", { simulation });
bus.emit("automata/sim/after_step", { bus.emit("automata/sim/after_step", {
@ -51,10 +51,10 @@ const machineInput = document.getElementById("machineInput") as HTMLInputElement
machineInput.addEventListener("input", () => bus.emit("automata/sim/update", {simulation: null})); machineInput.addEventListener("input", () => bus.emit("automata/sim/update", {simulation: null}));
machineInput.addEventListener("keydown", (e) => { machineInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
bus.emit("controls/reload_simulation", undefined) bus.emit("controls/sim/reload", undefined)
} }
}); });
bus.on("controls/reload_simulation", (_) => { bus.on("controls/sim/reload", (_) => {
const input = machineInput.value; const input = machineInput.value;
switch (automaton.type) { switch (automaton.type) {
case "fa": case "fa":

View file

@ -7,11 +7,11 @@ import type { Sim } from "./simulation.ts";
import type { Machine } from "./automata.ts"; import type { Machine } from "./automata.ts";
bus.on("controls/physics", ({enabled}) => { bus.on("controls/vis/physics", ({enabled}) => {
network.setOptions({ physics: { enabled } }); network.setOptions({ physics: { enabled } });
network.setOptions({edges: {smooth: enabled}}); network.setOptions({edges: {smooth: enabled}});
}); });
bus.on("controls/reset_network", _ => { bus.on("controls/vis/reset_network", _ => {
try { try {
nodes.forEach((n) => { nodes.forEach((n) => {
n.physics = true; n.physics = true;
@ -285,17 +285,16 @@ function createGraph(): vis.Network {
nodes: { nodes: {
shape: "custom", shape: "custom",
size: 18, size: 18,
// // @ts-expect-error bad library
// chosen: {
// node: chosen_node,
// },
// @ts-expect-error bad library // @ts-expect-error bad library
chosen: {
node: chosen_node,
},
ctxRenderer: renderNode, ctxRenderer: renderNode,
}, },
edges: { edges: {
chosen: { chosen: {
// // @ts-expect-error bad library // @ts-expect-error bad library
// edge: chosen_edge, edge: chosen_edge,
}, },
arrowStrikethrough: false, arrowStrikethrough: false,
arrows: "to", arrows: "to",
@ -304,9 +303,8 @@ function createGraph(): vis.Network {
); );
vis.DataSet; vis.DataSet;
network.on("doubleClick", (params: any) => { network.on("doubleClick", (params: {nodes: string[]}) => {
for (const node_id of params.nodes) { for (const node_id of params.nodes) {
// @ts-expect-error bad library
const node: vis.Node = nodes.get(node_id)!; const node: vis.Node = nodes.get(node_id)!;
node.physics = !node.physics; node.physics = !node.physics;
nodes.update(node); nodes.update(node);
@ -325,7 +323,7 @@ function renderNode({
state: { selected, hover }, state: { selected, hover },
style, style,
label, label,
}: {ctx: CanvasRenderingContext2D, id: string, x: number, y: number, state: {selected: boolean, hover: boolean}, style: any, label: string}) { }: {ctx: CanvasRenderingContext2D, id: string, x: number, y: number, state: {selected: boolean, hover: boolean}, style: vis.NodeOptions, label: string}) {
return { return {
drawNode() { drawNode() {
const t = getGraphTheme(); const t = getGraphTheme();