This commit is contained in:
Parker TenBroeck 2026-01-05 14:46:47 -05:00
parent f1b8c08e8f
commit 7971c61c74
15 changed files with 41485 additions and 252 deletions

View file

@ -106,10 +106,9 @@ body {
background: #111;
}
#canvas {
width: 100%;
height: 100%;
display: block;
.graph {
width: 100%;
height: 100%;
}
/* ---------- Bottom area (terminal + editor) ---------- */

View file

@ -8,44 +8,45 @@ import { oneDark } from "https://esm.sh/@codemirror/theme-one-dark";
import wasm from "./wasm.js"
import * as vis from "./js/vis-network.js"
function tokenize(text) {
try{
return wasm.lex(text);
}catch(e){
console.log(e)
return []
}
try {
return wasm.lex(text);
} catch (e) {
console.log(e)
return []
}
}
function compile(text) {
try{
return wasm.compile(text);
}catch(e){
console.log(e)
return []
try {
return wasm.compile(text);
} catch (e) {
console.log(e)
return []
}
}
/* ===================================================================== */
// Map token types -> CSS classes
const tokenClass = (t) =>
({
comment: "tok-comment",
keyword: "tok-keyword",
error: "tok-error",
ident: "tok-ident",
punc: "tok-punc",
string: "tok-string",
lpar: "rb-",
lbrace: "rb-",
lbracket: "rb-",
({
comment: "tok-comment",
keyword: "tok-keyword",
error: "tok-error",
ident: "tok-ident",
punc: "tok-punc",
string: "tok-string",
lpar: "rb-",
lbrace: "rb-",
lbracket: "rb-",
rpar: "rb-",
rbrace: "rb-",
rbracket: "rb-",
}[t] || "tok-ident");
rpar: "rb-",
rbrace: "rb-",
rbracket: "rb-",
}[t] || "tok-ident");
// ===================== Diagnostics helpers =====================
function severityClass(sev) {
const s = (sev || "error").toLowerCase();
if (s === "warning") return "cm-diag-warning";
@ -57,21 +58,11 @@ function sevRank(sev) {
if (sev === "warning") return 2;
return 1;
}
function sevLabel(sev) {
if (sev === "warning") return "warning";
if (sev === "info") return "info";
return "error";
}
function sevAnsiColorClass(sev) {
if (sev === "warning") return "ansi-yellow";
if (sev === "info") return "ansi-cyan";
return "ansi-red";
}
function buildAnalysis(text, doc) {
const tokens = tokenize(text);
const {log, log_formatted} = compile(text);
const { log, log_formatted } = compile(text);
// Build ONE Decoration set: syntax + diagnostics
const marks = [];
@ -81,8 +72,8 @@ function buildAnalysis(text, doc) {
const start = Math.max(0, Math.min(docLen, tok.start));
const end = Math.max(start, Math.min(docLen, tok.end));
var tc = tokenClass(tok.kind);
if (tc === "rb-"){
tc += tok.scope_level.toString();
if (tc === "rb-") {
tc += tok.scope_level.toString();
}
if (end > start) {
marks.push(Decoration.mark({ class: tc }).range(start, end));
@ -90,7 +81,7 @@ function buildAnalysis(text, doc) {
}
for (const d of log) {
if (d.start === undefined || d.end === undefined)continue;
if (d.start === undefined || d.end === undefined) continue;
const start = Math.max(0, Math.min(docLen, d.start));
const endRaw = d.end == null ? d.start : d.end;
const end = Math.max(start, Math.min(docLen, endRaw));
@ -238,7 +229,7 @@ function formatTerminal(view) {
let s = "";
s += `\x1b[90m[compile]\x1b[0m ${log.length} diagnostics\n`;
term.innerHTML = ansiToHtml(s+log_formatted);
term.innerHTML = ansiToHtml(s + log_formatted);
}
const terminalPlugin = ViewPlugin.fromClass(
@ -253,8 +244,8 @@ const terminalPlugin = ViewPlugin.fromClass(
}
);
// ===================== Build editor =====================
const initialText = `machine=NPDA
const initialText = `type=NPDA
Q = {q0, q1} // states
E = {a, b} // alphabet
T = {z0, A, B} // stack
@ -325,45 +316,24 @@ setDefaultLayoutWeights();
const app = document.getElementById("app");
const hSplit = document.getElementById("hSplit");
const vSplit = document.getElementById("vSplit");
const canvas = document.getElementById("canvas");
// const canvas = document.getElementById("canvas");
const canvasPane = document.getElementById("canvasPane");
let draggingH = false;
let draggingV = false;
// --- Canvas height splitter ---
hSplit.addEventListener("mousedown", (e) => {
draggingH = true;
document.body.style.cursor = "row-resize";
e.preventDefault();
});
// --- Terminal/editor width splitter ---
vSplit.addEventListener("mousedown", (e) => {
draggingV = true;
document.body.style.cursor = "col-resize";
e.preventDefault();
});
function resizeCanvasToPane() {
// Keep canvas resolution in sync with CSS size (crisp rendering)
const rect = canvasPane.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.floor(rect.width * dpr));
const h = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
// Optional: demo draw
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#1f6feb";
ctx.fillRect(20 * dpr, 20 * dpr, 140 * dpr, 60 * dpr);
ctx.fillStyle = "#c9d1d9";
ctx.font = `${14 * dpr}px ui-monospace, monospace`;
ctx.fillText("Canvas area", 30 * dpr, 55 * dpr);
}
window.addEventListener("mousemove", (e) => {
const rect = app.getBoundingClientRect();
@ -374,7 +344,6 @@ setDefaultLayoutWeights();
const maxCanvas = rect.height - 8 - minBottom;
const canvasH = Math.max(minCanvas, Math.min(maxCanvas, y));
app.style.setProperty("--canvasH", `${canvasH}px`);
resizeCanvasToPane();
}
if (draggingV) {
@ -383,7 +352,7 @@ setDefaultLayoutWeights();
const x = e.clientX - r.left;
const minTerm = 220;
const maxTerm = r.width - 8 - 220;
const termW = Math.max(minTerm, Math.min(maxTerm, r.width-x));
const termW = Math.max(minTerm, Math.min(maxTerm, r.width - x));
app.style.setProperty("--termW", `${termW}px`);
}
});
@ -393,8 +362,251 @@ setDefaultLayoutWeights();
draggingV = false;
document.body.style.cursor = "";
});
})();
// Keep canvas crisp on window resize too
window.addEventListener("resize", resizeCanvasToPane);
resizeCanvasToPane();
})();
let network = null;
const nodes = new vis.DataSet();
const edges = new vis.DataSet();
const automaton = {
states: ["q0", "q1"],
initialState: "q0",
acceptStates: ["q1"],
transitions: [
{
from: "q0",
to: "q0",
label: "ε, z0 → A z0\n"
},
{
from: "q0",
to: "q0",
label: "ε, z0 → B z0"
},
{
from: "q0",
to: "q1",
label: "ε, z0 → z0"
},
{
from: "q1",
to: "q1",
label: "a, A → ε"
},
{
from: "q1",
to: "q1",
label: "b, B → ε"
}
]
};
/**@param {{ctx: CanvasRenderingContext2D}} */
function renderNode({
ctx,
id,
x,
y,
state: { selected, hover },
style,
label,
}) {
return {
drawNode() {
ctx.save();
var r = style.size;
ctx.beginPath();
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.fillStyle = "red";
ctx.fill();
ctx.lineWidth = 4;
ctx.strokeStyle = "blue";
ctx.stroke();
ctx.fillStyle = "black";
ctx.textAlign = 'center';
ctx.fillText(label, x, y, r);
ctx.textAlign = 'center';
ctx.strokeStyle = 'white';
ctx.fillStyle = "black";
let cy = y - (r + 10);
for (const part of "meow[]\nbeeep".split("\n").reverse()) {
const metrics = ctx.measureText(part);
cy -= metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
ctx.strokeText(part, x, cy);
ctx.fillText(part, x, cy);
}
ctx.restore();
},
nodeDimensions: { width: 20, height: 20 },
};
}
// Populate nodes
for (const state of automaton.states) {
nodes.add({
id: state,
label: state,
});
}
// Populate edges
automaton.transitions.forEach((t, i) => {
edges.add({
id: `e${i}`,
from: t.from,
to: t.to,
label: t.label
});
});
// updateGraphFromText();
ensureGraph();
function updateGraphFromText() {
ensureGraph();
const trans = []
// Collect state ids
const stateSet = new Set();
for (const tr of trans) {
stateSet.add(tr.from);
stateSet.add(tr.to);
}
// Update nodes (add missing, remove stale)
const existingNodeIds = new Set(nodes.getIds());
const desiredNodeIds = new Set([...stateSet]);
// remove stale
for (const id of existingNodeIds) {
if (!desiredNodeIds.has(id)) nodes.remove(id);
}
// add/update desired
for (const id of desiredNodeIds) {
const pos = pinnedPositions.get(id);
if (!existingNodeIds.has(id)) {
nodes.add({
id,
label: id,
...(pos ? { x: pos.x, y: pos.y, fixed: true } : {})
});
} else if (pos) {
nodes.update({ id, x: pos.x, y: pos.y, fixed: true });
}
}
// Update edges (stable IDs so edits don't flicker)
const desiredEdgeIds = new Set();
const nextEdges = [];
for (let i = 0; i < trans.length; i++) {
const tr = trans[i];
const id = `${tr.from}::${tr.to}::${tr.label}::${i}`;
desiredEdgeIds.add(id);
nextEdges.push({ id, from: tr.from, to: tr.to, label: tr.label });
}
const existingEdgeIds = new Set(edges.getIds());
for (const id of existingEdgeIds) {
if (!desiredEdgeIds.has(id)) edges.remove(id);
}
// add/update in batch
for (const e of nextEdges) {
if (!existingEdgeIds.has(e.id)) edges.add(e);
else edges.update(e);
}
// If positions exist for all nodes, we can disable physics to “respect” manual layout
// Otherwise leave physics on to auto-layout new nodes.
const allPinned = [...desiredNodeIds].every((id) => pinnedPositions.has(id));
network.setOptions({ physics: { enabled: !allPinned } });
// Redraw nicely after updates
network.fit({ animation: { duration: 200, easingFunction: "easeInOutQuad" } });
}
// ---------- 4) Hook graph updates into your existing single-pass analysis ----------
const graphPlugin = ViewPlugin.fromClass(class {
constructor(view) {
updateGraphFromText(view.state.doc.toString());
}
update(update) {
if (update.docChanged) {
updateGraphFromText(update.state.doc.toString());
}
}
});
function chosen_node(values, id, selected, hovering) {
console.log(values, id, selected, hovering)
}
function ensureGraph() {
if (network) return;
const container = document.getElementById("graph");
network = new vis.Network(
container,
{ nodes, edges },
{
layout: { improvedLayout: true },
physics: {
enabled: true,
solver: "barnesHut",
barnesHut: { gravitationalConstant: -8000, springLength: 120, springConstant: 0.04 },
stabilization: { iterations: 200 }
},
interaction: {
dragNodes: true,
hover: true,
multiselect: true
},
nodes: {
shape: 'dot',
size: 14,
font: { color: "#c9d1d9" },
color: {
background: "#1f6feb",
border: "#79c0ff",
highlight: { background: "#388bfd", border: "#a5d6ff" }
},
chosen: {
node: chosen_node
},
shape: "custom",
ctxRenderer: renderNode,
size: 18,
},
edges: {
arrows: { to: { enabled: true, scaleFactor: 0.8 } },
arrowStrikethrough: false,
font: { align: "middle", color: "#000000ff" },
color: { color: "rgba(201,209,217,0.35)", highlight: "#c9d1d9" },
smooth: { type: "dynamic" },
arrows: "to",
}
}
);
// Save positions when user drags nodes
network.on("dragEnd", (params) => {
const pos = network.getPositions(params.nodes);
for (const id of params.nodes) {
pinnedPositions.set(id, pos[id]);
}
});
window.network = network;
}

View file

@ -5,6 +5,10 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Automata</title>
<link rel="stylesheet" href="https://unpkg.com/vis-network/styles/vis-network.min.css">
<link href="editor.css" rel="stylesheet">
</head>
@ -20,7 +24,10 @@
<div class="app" style="display:none" id="app">
<section class="canvasPane" id="canvasPane">
<canvas id="canvas"></canvas>
<div id="graph" class="graph"></div>
<button id="togglePhysics">Toggle Physics</button>
<button id="resetLayout">Reset Layout</button>
</section>
<div class="hSplit" id="hSplit" title="Drag to resize canvas height"></div>

View file

@ -1,3 +1,4 @@
import wasm from "./wasm.js"
import "./editor.js"

40777
web/root/js/vis-network.js Normal file

File diff suppressed because one or more lines are too long

27
web/root/js/vis-network.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -142,6 +142,7 @@ pub fn lex(input: &str) -> Vec<Tok> {
| "sigma",
) => Kind::Keyword,
Token::Ident(_) => Kind::Ident,
Token::LineEnd => Kind::Punc,
};
let scope_level = match kind {
@ -183,7 +184,7 @@ pub struct CompileLog {
}
#[wasm_bindgen(getter_with_clone)]
pub struct CompileResult{
pub struct CompileResult {
pub log: Vec<CompileLog>,
pub log_formatted: String,
}
@ -196,9 +197,13 @@ pub fn compile(input: &str) -> CompileResult {
};
use std::fmt::Write;
let log_formatted = log.displayable().fold(String::new(), |mut s, e|{write!(&mut s, "{e}").unwrap(); s});
let log_formatted = log.displayable().fold(String::new(), |mut s, e| {
write!(&mut s, "{e}").unwrap();
s
});
let log = log.into_entries()
let log = log
.into_entries()
.map(|e| CompileLog {
level: match e.level {
loader::log::LogLevel::Info => LogLevel::Info,