summaryrefslogtreecommitdiff
path: root/graph/web/viewer.js
diff options
context:
space:
mode:
authorCliff Click <cliffc@acm.org>2026-09-23 16:35:50 -0700
committerCliff Click <cliffc@acm.org>2026-09-23 16:35:50 -0700
commit0f32001b466def0cee753557ad6e296cf15e2504 (patch)
tree9e16038c574407bbd3f5c21f469c2df71e6835f8 /graph/web/viewer.js
parent695429df954b084d2226fe2a6c1a47e1861bcc01 (diff)
downloadSimple-0f32001b466def0cee753557ad6e296cf15e2504.tar.gz
Simple-0f32001b466def0cee753557ad6e296cf15e2504.zip
Keyboard shortcuts
Fast forward/reverse, pick-a-frame. Shorter type names.
Diffstat (limited to 'graph/web/viewer.js')
-rw-r--r--graph/web/viewer.js82
1 files changed, 67 insertions, 15 deletions
diff --git a/graph/web/viewer.js b/graph/web/viewer.js
index 4a88245b..77703b71 100644
--- a/graph/web/viewer.js
+++ b/graph/web/viewer.js
@@ -1,6 +1,8 @@
// Connection and playback state are independent of renderer initialization.
const program = document.getElementById("program");
const status = document.getElementById("status");
+const frameNo = document.getElementById("N");
+const scrub = document.getElementById("scrub");
let socket;
let renderer;
let layout;
@@ -8,6 +10,9 @@ let rendererReady = false;
let rendering = false;
let frames = [];
let current = -1;
+let wanted = -1;
+const folded = new Set();
+const foldKey = () => [...folded].sort((a,b) => a-b).join(",");
let generation = 0;
let done = false;
let compiling = false;
@@ -20,15 +25,22 @@ function updateUI() {
!rendererReady ? "Connected; initializing graph renderer..." :
busy ? busy : compiling ? "Compiling... " + frames.length + " frames received" :
done ? frames.length + " frames ready" : "Connected");
- document.getElementById("N").textContent = current < 0 ? "0" : String(current + 1);
+ const at = wanted >= 0 ? wanted : current;
+ if (document.activeElement !== frameNo) frameNo.value = String(at + 1);
+ frameNo.max = scrub.max = String(Math.max(1, frames.length));
+ frameNo.disabled = scrub.disabled = !rendererReady || !frames.length;
+ scrub.value = String(Math.max(1, at + 1));
+ scrub.setAttribute("aria-valuetext", `Frame ${at + 1} of ${frames.length}`);
document.getElementById("len").textContent = String(frames.length);
- document.getElementById("doPrev").disabled = rendering || current <= 0;
- document.getElementById("doNext").disabled =
- !rendererReady || rendering || current + 1 >= frames.length;
+ document.getElementById("doFirst").disabled = document.getElementById("doPrev").disabled =
+ !rendererReady || at <= 0;
+ document.getElementById("doLast").disabled = document.getElementById("doNext").disabled =
+ !rendererReady || !frames.length || at + 1 >= frames.length;
document.getElementById("compile").disabled =
!socket || socket.readyState !== WebSocket.OPEN || compiling || rendering;
document.getElementById("fit").disabled = rendering || current < 0;
document.getElementById("save").disabled = rendering || !frames[current]?.snap;
+ document.getElementById("unfold").disabled = !folded.size;
document.getElementById("assocs").disabled = !frames[current]?.snap;
document.getElementById("near").disabled = !frames[current]?.evt;
const evt = frames[current]?.evt;
@@ -47,6 +59,7 @@ function updateUI() {
function reportError(error) {
failure = "Viewer error: " + (error && error.message || error || "Graph rendering failed");
rendering = false;
+ wanted = -1;
busy = "";
updateUI();
console.error(error);
@@ -66,7 +79,9 @@ function get_program() {
generation++;
frames = [];
current = -1;
+ wanted = -1;
done = false;
+ folded.clear();
compiling = true;
failure = "";
document.getElementById("detail").hidden = true;
@@ -75,17 +90,31 @@ function get_program() {
}
async function render(index) {
- if (!rendererReady || rendering || index < 0 || index >= frames.length) return;
+ if (!rendererReady || index < 0 || index >= frames.length) return;
+ wanted = index;
+ if (rendering) { updateUI(); return; }
const frameGeneration = generation;
rendering = true;
- const frame = frames[index];
- busy = !frame.layout ? "Laying out frame " + (index + 1) + "..." : "Drawing...";
- updateUI();
try {
if (!layout) layout = new GraphLayout();
- if (!frame.layout) frame.layout = await layout.run(frame.snap, frame.evt);
- if (generation === frameGeneration) {
- renderer.show(frame.snap, frame.layout, frame.evt);
+ while (wanted >= 0 && generation === frameGeneration) {
+ index = wanted;
+ const frame = frames[index];
+ const key = foldKey();
+ let scene = key ? (frame.foldKey === key ? frame.foldLayout : null) : frame.layout;
+ busy = !scene ? "Laying out frame " + (index + 1) + "..." : "Drawing...";
+ updateUI();
+ if (!scene) {
+ scene = await layout.run(frame.snap, frame.evt, new Set(folded));
+ // Keep the ordinary layout plus only the most recent folded variant.
+ if (key) { frame.foldKey = key; frame.foldLayout = scene; }
+ else frame.layout = scene;
+ }
+ if (generation !== frameGeneration) break;
+ // Scrubbing can change the destination while ELK works. Draw only the
+ // latest request, without computing all the intervening frames.
+ if (wanted !== index || foldKey() !== key) continue;
+ renderer.show(frame.snap, scene, frame.evt);
if (frame.pos >= 0) {
// A newline or EOF has no visible character to highlight. Show the last
// parsed character there, rather than losing the position indicator.
@@ -93,20 +122,34 @@ async function render(index) {
while (pos > 0 && (!program.value[pos] || /\s/.test(program.value[pos]))) pos--;
program.setSelectionRange(pos, pos + 1);
// Focus after selecting, so the browser brings the new position into view.
- program.focus({preventScroll: true});
+ if (document.activeElement !== frameNo && document.activeElement !== scrub)
+ program.focus({preventScroll: true});
}
+ current = index;
+ wanted = -1;
}
rendering = false;
busy = "";
- if (generation === frameGeneration) current = index;
updateUI();
} catch (error) {
reportError(error);
}
}
-function doNext() { render(current + 1); }
-function doPrev() { render(current - 1); }
+function doFirst() { render(0); }
+function toggleFold(id) {
+ if (folded.has(id)) folded.delete(id); else folded.add(id);
+ render(wanted >= 0 ? wanted : current);
+}
+function doLast() { render(frames.length - 1); }
+function doNext() { render((wanted >= 0 ? wanted : current) + 1); }
+function doPrev() { render((wanted >= 0 ? wanted : current) - 1); }
+function jump() {
+ const n = frameNo.valueAsNumber;
+ const index = Number.isFinite(n) ? Math.max(0, Math.min(frames.length - 1, Math.trunc(n) - 1)) : current;
+ frameNo.value = String(index + 1);
+ render(index);
+}
function doExit() {
if (socket && socket.readyState === WebSocket.OPEN) socket.send("null");
}
@@ -119,6 +162,14 @@ program.addEventListener("keydown", event => {
}
});
document.getElementById("compile").addEventListener("click", get_program);
+document.getElementById("unfold").addEventListener("click", () => {
+ folded.clear(); render(wanted >= 0 ? wanted : current);
+});
+frameNo.addEventListener("change", jump);
+frameNo.addEventListener("keydown", event => {
+ if (event.key === "Enter") { event.preventDefault(); jump(); }
+});
+scrub.addEventListener("input", () => render(scrub.valueAsNumber - 1));
document.getElementById("fit").addEventListener("click", () => {
renderer.auto = true;
renderer.fit();
@@ -132,6 +183,7 @@ document.addEventListener("keydown", event => {
try {
renderer = new GraphView(document.getElementById("elk"));
+ renderer.onFold = toggleFold;
rendererReady = true;
socket = new WebSocket("ws://" + (location.hostname || "127.0.0.1") + ":12345");
socket.onopen = () => {