semi finalized theming

This commit is contained in:
Parker TenBroeck 2026-01-07 16:32:28 -05:00
parent c12f7b325f
commit 620415c824
14 changed files with 728 additions and 224 deletions

116
web/root/src/controls.ts Normal file
View file

@ -0,0 +1,116 @@
import {nodes, edges, network} from "./visualizer.ts"
const togglePhysicsBtn = document.getElementById("togglePhysics") as HTMLButtonElement;
const resetLayoutBtn = document.getElementById("resetLayout") as HTMLButtonElement;
const playPauseBtn = document.getElementById("playPause") as HTMLButtonElement;
const stepBtn = document.getElementById("step") as HTMLButtonElement;
const speedSlider = document.getElementById("speed") as HTMLInputElement;
const speedLabel = document.getElementById("speedLabel") as HTMLSpanElement;
const resetSimBtn = document.getElementById("resetSim") as HTMLButtonElement;
function stepSimulation(): void {
console.log("step");
}
function resetSimulation(): void {
console.log("reset");
}
// ---- Physics toggle (styled label) ----
function setPhysicsButtonUI(enabled: boolean) {
togglePhysicsBtn.classList.toggle("active", enabled);
togglePhysicsBtn.textContent = enabled ? "Physics: ON" : "Physics: OFF";
}
togglePhysicsBtn.onclick = () => {
const enabled = !togglePhysicsBtn.classList.contains("active");
setPhysicsButtonUI(enabled);
network.setOptions({ physics: { enabled } });
};
setPhysicsButtonUI(togglePhysicsBtn.classList.contains("active"));
resetLayoutBtn.onclick = () => {
try {
nodes.forEach((n) => {
n.physics = true;
n.x = undefined;
n.y = undefined;
});
network.setData({ nodes, edges });
} catch {
// Last resort
network.setData({ nodes, edges });
}
// If physics button is OFF, keep it OFF (dont surprise the user)
const physicsEnabled = togglePhysicsBtn.classList.contains("active");
network.setOptions({ physics: { enabled: physicsEnabled } });
};
// ---- Play/Pause + Speed ----
let running = false;
let timer: number | null = null;
// speed slider is "steps per second"
function getStepsPerSecond() {
return Math.max(1, Math.min(60, Number(speedSlider.value) || 10));
}
function updateSpeedUI() {
speedLabel.textContent = `${getStepsPerSecond()}×`;
}
updateSpeedUI();
speedSlider.addEventListener("input", () => {
updateSpeedUI();
if (running) restartTimer();
});
function stopTimer() {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
}
function restartTimer() {
stopTimer();
const sps = getStepsPerSecond();
const intervalMs = Math.round(1000 / sps);
timer = globalThis.window.setInterval(() => {
// If your step can throw, keep the interval alive:
try { stepSimulation(); } catch (e) { console.error(e); }
}, intervalMs);
}
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);
stepBtn.onclick = () => {
stepSimulation();
};
resetSimBtn.onclick = () => {
// Stop if running
if (running) setRunning(false);
// Reset
resetSimulation();
// Optional: re-enable Step after reset
stepBtn.disabled = false;
};

View file

@ -8,13 +8,13 @@ import {
ViewPlugin,
lineNumbers,
highlightActiveLineGutter,
highlightActiveLine
} from "npm:@codemirror/view";
import { EditorState, StateField, Text } from "npm:@codemirror/state";
import { defaultKeymap, history, historyKeymap } from "npm:@codemirror/commands";
import { bracketMatching, indentOnInput } from "npm:@codemirror/language";
import { closeBrackets } from "npm:@codemirror/autocomplete";
import { oneDark } from "npm:@codemirror/theme-one-dark";
import wasm from "./wasm.ts"
@ -292,9 +292,9 @@ const state = EditorState.create({
history(),
indentOnInput(),
bracketMatching(),
highlightActiveLine(),
closeBrackets(),
keymap.of([...defaultKeymap, ...historyKeymap]),
oneDark,
analysisField,
diagHover,

View file

@ -1,4 +1,5 @@
import "./editor.ts"
import "./visualizer.ts"
import "./splitters.ts"
import "./splitters.ts"
import "./controls.ts"
import "./theme.ts"

View file

@ -65,7 +65,7 @@ function setFlexFill(pane: HTMLElement) {
export function enableFlexSplitters() {
// Horizontal: A | hSplit | B (top/split/bottom)
for (const splitter of document.querySelectorAll<HTMLElement>(".hSplit")) {
for (const splitter of document.querySelectorAll<HTMLElement>(".hSplit:not(.styleOnly)")) {
const parent = splitter.parentElement as HTMLElement | null;
if (!parent) continue;
@ -133,7 +133,7 @@ export function enableFlexSplitters() {
}
// Vertical: A | vSplit | B (left/split/right)
for (const splitter of document.querySelectorAll<HTMLElement>(".vSplit")) {
for (const splitter of document.querySelectorAll<HTMLElement>(".vSplit:not(.styleOnly)")) {
const parent = splitter.parentElement as HTMLElement | null;
if (!parent) continue;
@ -158,7 +158,7 @@ export function enableFlexSplitters() {
// --split-default: 30% (right pane width)
// --split-min-a: 220px (min left)
// --split-min-b: 220px (min right)
const defPct = getVarPct(splitter, "--split-default", 30);
const defPct = getVarPct(splitter, "--split-default", 50);
const minA = getVarPx(splitter, "--split-min-a", 220);
const minB = getVarPx(splitter, "--split-min-b", 220);

75
web/root/src/theme.ts Normal file
View file

@ -0,0 +1,75 @@
import { network } from "./visualizer.ts";
function cssVar(name: string, fallback = ""): string {
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim() || fallback;
}
const themeBtn = document.getElementById("themeToggle") as HTMLButtonElement;
type Theme = "dark" | "light";
function getPreferredTheme(): Theme {
// 1) saved preference
const saved = localStorage.getItem("theme");
if (saved === "dark" || saved === "light") return saved;
// 2) OS preference
const prefersLight = globalThis.window.matchMedia?.(
"(prefers-color-scheme: light)",
)?.matches;
return prefersLight ? "light" : "dark";
}
function setTheme(theme: Theme) {
document.documentElement.dataset.theme = theme;
localStorage.setItem("theme", theme);
// update button label
themeBtn.textContent = theme === "dark" ? "🌙 Dark" : "☀️ Light";
applyGraphTheme();
}
function toggleTheme() {
const current = (document.documentElement.dataset.theme as Theme) || "dark";
setTheme(current === "dark" ? "light" : "dark");
}
// init
setTheme(getPreferredTheme());
// click handler
themeBtn.addEventListener("click", toggleTheme);
// optional: respond to OS theme changes (only if user hasn't chosen a theme)
globalThis.window.matchMedia?.("(prefers-color-scheme: light)")
?.addEventListener("change", () => {
if (localStorage.getItem("theme")) return; // user has chosen, don't override
setTheme(getPreferredTheme());
});
export function applyGraphTheme() {
network.setOptions({
nodes: {
color: {
background: cssVar("--graph-node-bg"),
border: cssVar("--graph-node-border"),
highlight: {
background: cssVar("--graph-node-active-bg"),
border: cssVar("--graph-node-active-border"),
},
},
font: {
color: cssVar("--graph-node-text"),
},
},
edges: {
color: {
color: cssVar("--graph-edge"),
highlight: cssVar("--graph-edge-active"),
hover: cssVar("--graph-edge-hover"),
},
},
});
}

View file

@ -3,11 +3,8 @@
// deno-lint-ignore no-import-prefix
import * as vis from "npm:vis-network/standalone";
const nodes = new vis.DataSet<vis.Node>();
const edges = new vis.DataSet<vis.Edge>();
export const nodes = new vis.DataSet<vis.Node>();
export const edges = new vis.DataSet<vis.Edge>();
const automaton = {
states: ["q0", "q1"],
@ -18,29 +15,29 @@ const automaton = {
{
from: "q0",
to: "q0",
label: "ε, z0 → A z0\n"
label: "ε, z0 → A z0\n",
},
{
from: "q0",
to: "q0",
label: "ε, z0 → B z0"
label: "ε, z0 → B z0",
},
{
from: "q0",
to: "q1",
label: "ε, z0 → z0"
label: "ε, z0 → z0",
},
{
from: "q1",
to: "q1",
label: "a, A → ε"
label: "a, A → ε",
},
{
from: "q1",
to: "q1",
label: "b, B → ε"
}
]
label: "b, B → ε",
},
],
};
function renderNode({
@ -57,7 +54,6 @@ function renderNode({
ctx.save();
const r = style.size;
ctx.beginPath();
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.fillStyle = "red";
@ -67,29 +63,27 @@ function renderNode({
ctx.stroke();
ctx.fillStyle = "black";
ctx.textAlign = 'center';
ctx.textAlign = "center";
ctx.fillText(label, x, y, r);
ctx.textAlign = 'center';
ctx.strokeStyle = 'white';
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;
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({
@ -104,24 +98,31 @@ automaton.transitions.forEach((t, i) => {
id: `e${i}`,
from: t.from,
to: t.to,
label: t.label
label: t.label,
});
});
function chosen_edge(_: vis.ChosenNodeValues, id: vis.IdType,selected: boolean, hovered: boolean) {
console.log("edge", id, selected, hovered)
function chosen_edge(
_: vis.ChosenNodeValues,
id: vis.IdType,
selected: boolean,
hovered: boolean,
) {
console.log("edge", id, selected, hovered);
}
function chosen_node(_: vis.ChosenNodeValues, id: vis.IdType,selected: boolean, hovered: boolean) {
console.log("node", id, selected, hovered)
function chosen_node(
_: vis.ChosenNodeValues,
id: vis.IdType,
selected: boolean,
hovered: boolean,
) {
console.log("node", id, selected, hovered);
}
const network: vis.Network = createGraph();
export const network: vis.Network = createGraph();
function createGraph(): vis.Network {
const container = document.getElementById("graph")!;
const network = new vis.Network(
@ -129,13 +130,15 @@ function createGraph(): vis.Network {
{ nodes, edges },
{
layout: { improvedLayout: true },
autoResize: true,
width: "99%",
physics: {
enabled: true,
solver: "barnesHut",
barnesHut: { gravitationalConstant: -8000, springLength: 120, springConstant: 0.04 },
stabilization: { iterations: 200 }
barnesHut: {
gravitationalConstant: -8000,
springLength: 120,
springConstant: 0.04,
},
stabilization: { iterations: 200 },
},
interaction: {
dragNodes: true,
@ -149,7 +152,7 @@ function createGraph(): vis.Network {
color: {
background: "#1f6feb",
border: "#79c0ff",
highlight: { background: "#388bfd", border: "#a5d6ff" }
highlight: { background: "#388bfd", border: "#a5d6ff" },
},
// @ts-expect-error bad library
chosen: {
@ -162,7 +165,7 @@ function createGraph(): vis.Network {
edges: {
chosen: {
// @ts-expect-error bad library
edge: chosen_edge
edge: chosen_edge,
},
arrowStrikethrough: false,
font: { align: "middle", color: "#000000ff" },
@ -170,20 +173,19 @@ function createGraph(): vis.Network {
// @ts-expect-error bad library
smooth: { type: "dynamic" },
arrows: "to",
}
}
},
},
);
vis.DataSet
vis.DataSet;
network.on("doubleClick", (params: any) => {
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)!;
node.physics = !node.physics;
nodes.update(node)
nodes.update(node);
}
});
return network;
}
}