Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 25 additions & 12 deletions src/clopt/web/graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -602,19 +602,32 @@ export function buildGraph(svg, payload) {

for (const edge of network.edges) {
const bundle = handles.edges.get(edge.id);
/* Two texts share one anchor and one plate: the lane's static statistics
* (capacity, cost, risk) and the flow the solver put on it. They are
* separate elements rather than one because the layers that show them are
* switched independently. `flow` gets its content in a later ticket; the
* element exists from here so that nothing structural changes when it
* does. */
const plate = element("rect", { class: "anchor-plate" });
const stats = element("text", { class: "lane-label" });
/* Three texts on two rows, sharing one frozen anchor.
*
* The flow numeral takes the upper row and the lane's statistics the lower
* one. `caps` and `costrisk` are two elements on the SAME row rather than
* one text carrying all three numbers, because the catalog switches them
* independently: Flow & Cut wants a capacity beside its flow and Cost &
* Risk wants cost and risk instead, and a combined label put two unasked-for
* numbers on screen in both. They are never both on by default -- and if
* both are switched on by hand they overlap, which is one of the reasons
* all-layers-on is not a legal preset.
*
* Each row carries its own plate, so a row that is switched off leaves no
* unbacked rectangle behind. The whole lot lives in one group per lane so
* that the scaffold tier can dim a lane and its numerals together, from one
* class write. */
const group = element("g", { class: "lane-marks", "data-edge": edge.id });
const plateFlow = element("rect", { class: "anchor-plate plate-flow" });
const flow = element("text", { class: "anchor-label" });
marks.appendChild(plate);
marks.appendChild(stats);
marks.appendChild(flow);
bundle.canvasLabel = { plate, stats, flow };
const plateStat = element("rect", { class: "anchor-plate plate-stat" });
const caps = element("text", { class: "lane-label lane-caps" });
const costrisk = element("text", { class: "lane-label lane-costrisk" });
for (const part of [plateFlow, flow, plateStat, caps, costrisk]) {
group.appendChild(part);
}
marks.appendChild(group);
bundle.canvasLabel = { group, plateFlow, flow, plateStat, caps, costrisk };
}

return handles;
Expand Down
76 changes: 63 additions & 13 deletions src/clopt/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -110,17 +110,21 @@ <h2>Lanes</h2>
-->
<script type="module">
import { fetchDatasets, fetchScenario } from "./api.js";
import { state, setScenario, setDataset, setView, toggleLayer,
pinLane, clearPin } from "./state.js";
import { state, setScenario, setDataset, setView, toggleLayer, pinLane,
stepBeat, stepUnit, jumpToUnit, resetView,
resolveBeat } from "./state.js";
import { buildGraph } from "./graph.js";
import { render } from "./render.js";
import { VIEWS, LAYERS, applyLayerClasses } from "./views.js";
import { VIEWS, LAYERS, applyLayerClasses, viewForKey } from "./views.js";

const svg = document.getElementById("theater");

function repaint() {
applyLayerClasses(document.documentElement, state.layers);
render(state);
// The controls follow the state rather than driving it, so a beat that
// overrides a layer lights the same button a click on it would have.
syncControls();
}

function buildControls() {
Expand All @@ -132,7 +136,6 @@ <h2>Lanes</h2>
button.dataset.view = view.id;
button.addEventListener("click", () => {
setView(view.id);
syncControls();
repaint();
});
views.appendChild(button);
Expand All @@ -146,7 +149,6 @@ <h2>Lanes</h2>
button.dataset.layer = layer.id;
button.addEventListener("click", () => {
toggleLayer(layer.id);
syncControls();
repaint();
});
layers.appendChild(button);
Expand All @@ -166,16 +168,61 @@ <h2>Lanes</h2>
});
}

/* The pin lasts until the next beat or `R`. The beat engine and the rest of
* the keyboard driver arrive with their own ticket; `R` is here because the
* rule the click lives under is that a pointer may never reach a state the
* keyboard cannot -- so the key that undoes it has to ship alongside it,
* not after it. */
/* The keyboard, which is the primary driver -- on-screen controls are
* pointer parity and never the other way round.
*
* ONE TABLE, and it is one table so that the pointer-only rule for layers
* is checkable rather than merely agreed: `tests/test_web_assets.py` reads
* this block and fails if a layer id or `toggleLayer` appears in it. The
* keys stay reserved for the controls used mid-narration.
*
* `event.code` rather than `event.key` throughout. The view jumps were
* chosen for being bottom-row, one-handed and modifier-free, which is a
* fact about where the key IS -- a layout that prints something else on the
* cap should still drive the tool.
*
* `PageDown` steps FORWARD and `PageUp` back, which is the way round a
* presenter remote sends them -- the alias exists so a remote drives the
* tool with no configuration, so the remote decides the direction, not the
* order the two keys are written down in.
*
* `ArrowDown` walks to the next coarse unit and `ArrowUp` to the previous
* one, matching every other transport control: down the list is forward. */
const KEY_ACTIONS = {
ArrowRight: () => stepBeat(1),
ArrowLeft: () => stepBeat(-1),
PageDown: () => stepBeat(1),
PageUp: () => stepBeat(-1),
ArrowDown: () => stepUnit(1),
ArrowUp: () => stepUnit(-1),
KeyR: () => resetView(),
};

const DIGIT = /^Digit([1-9])$/;

function bindKeys() {
window.addEventListener("keydown", (event) => {
if (event.key !== "r" && event.key !== "R") return;
// Modified keystrokes belong to the browser. Taking Ctrl-R would cost
// the instructor the reload that recovers from anything this page gets
// wrong, in exchange for a key `R` already provides.
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (event.target.matches("input, select, textarea")) return;
clearPin();

const digit = DIGIT.exec(event.code);
const view = viewForKey(event.code);
const action = KEY_ACTIONS[event.code];
// Digits jump straight to a coarse unit, 1-based as the instructor says
// it out loud. A digit above the view's unit count is a silent no-op --
// the mutators decline it rather than clamping, because a clamp moves
// the picture somewhere nobody asked for, mid-sentence.
if (digit) jumpToUnit(Number(digit[1]));
else if (view) setView(view);
else if (action) action();
else return;

// Only after the key is one of ours: the arrows and the page keys
// otherwise scroll the panel out from under the instructor.
event.preventDefault();
repaint();
});
}
Expand All @@ -201,7 +248,10 @@ <h2>Lanes</h2>
buildGraph(svg, payload);
document.getElementById("scenario-name").textContent = payload.name;
document.getElementById("scenario-description").textContent = payload.description;
syncControls();
// The beat is re-resolved against the new dataset rather than reset: the
// marks were cleared with the old theater's edge ids, and the current
// beat is the only thing that knows what should be marked instead.
resolveBeat();
repaint();
}

Expand Down
90 changes: 74 additions & 16 deletions src/clopt/web/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,28 @@ const PLATE_HEIGHT = 24;
const CENTRE_BASELINE = 6; /* drop a centred line to optical middle */
const LABEL_GAP = 12; /* air between a node's disc and its id */

/* The anchor's two rows, straddling the frozen point rather than starting at
* it: the flow numeral sits half a row above and the lane's statistics half a
* row below, so the PAIR is centred on the point the anchor solve certified
* clear. Hanging both rows downward would put the lower one somewhere the solve
* never checked.
*
* The one geometry consequence of splitting the lane label in two, and worth
* naming because it is a footprint change made outside the ticket that measured
* the clearances. Two rows are not optional -- Flow & Cut shows a capacity AND
* a flow, so the catalog requires both on screen at once, and the single-row
* layout that preceded this drew them on top of each other. The label block now
* spans the anchor +/-25 where it spanned +/-12, and 13 is near the minimum
* that keeps two 24-unit plates apart.
*
* That still clears every distance the anchor solve enforces -- 74 from a node
* (44 of it air beyond the disc), 74 from another anchor, 44 from a crossing.
* What it does exceed is the 14-unit lane half-band, which governs the casing
* width rather than the label, so nothing the crossing solve depends on moves.
* If the retune against the real projector shows a plate crowding a lane it is
* not attached to, this constant is the one to pull, not the anchor solve. */
const ROW_OFFSET = 13;

/**
* Shorten a segment so it starts and ends clear of the discs it joins.
*
Expand All @@ -76,6 +98,29 @@ function place(line, segment) {
line.setAttribute("y2", segment.y2);
}

/**
* Place one row of an anchor's stacked text, and the plate behind it.
*
* `text` is what the plate is sized against, which is not always the row's own
* content -- two texts sharing a row share a plate, and it has to fit the wider
* of them whichever is switched on. Pass `null` for the plate on the second
* text of a shared row.
*/
function row(node, plate, anchor, offset, text) {
node.setAttribute("x", anchor.x);
node.setAttribute("y", anchor.y + offset);
if (!plate) return;
/* The plate is sized from the text it backs, which is geometry; its fill and
* opacity are the stylesheet's business. Deliberately NOT measured with
* `getBBox()`: the plate is `display: none` until its layer is switched on,
* and a hidden element measures as zero. */
const width = Math.max(text.length, PLATE_MIN_CHARS) * CHAR_ADVANCE;
plate.setAttribute("x", anchor.x - width / 2);
plate.setAttribute("y", anchor.y + offset - PLATE_HALF_HEIGHT);
plate.setAttribute("width", width);
plate.setAttribute("height", PLATE_HEIGHT);
}

/** Write one headline slot, showing the em dash rather than a gap when the
* active view does not own that number. */
function slot(id, value) {
Expand Down Expand Up @@ -186,8 +231,16 @@ export function render(state) {
}
/* The canvas lane carries the emphasis states too, so a pinned row and its
* lane light up together -- the click has to be visible in the room, not
* only on the instructor's display. */
* only on the instructor's display.
*
* `cold` is the scaffold tier, written onto the lane and its numerals from
* this one call so the two can never end up on opposite sides of it. Note
* that hot and cold are not complements: with an empty hot set neither
* class is written anywhere, which is the pristine screen -- the graph is
* never dimmed when the graph is itself the subject. */
bundle.group.classList.toggle("hot", states.hot);
bundle.group.classList.toggle("cold", states.cold);
bundle.canvasLabel.group.classList.toggle("cold", states.cold);

const removed = states.removed;
bundle.group.classList.toggle("removed", removed);
Expand Down Expand Up @@ -227,25 +280,30 @@ export function render(state) {
bundle.ledgerCells.cap.textContent = String(cap);
bundle.ledgerCells.risk.textContent = risk.toFixed(2);

/* -- the anchor's text ------------------------------------------------- */
const { plate, stats, flow } = bundle.canvasLabel;
/* -- the anchor's two rows --------------------------------------------- */
const label = bundle.canvasLabel;

stats.setAttribute("x", anchor.x);
stats.setAttribute("y", anchor.y);
/* Capacity, cost and risk, from the payload or from the server's `changes`
* block -- never arithmetic performed here. */
stats.textContent = `${cap}/${bundle.edge.cost}/${risk.toFixed(2)}`;
* block -- never arithmetic performed here. Two texts on the lower row,
* one per layer, so the catalog can show a capacity beside a flow without
* also putting cost and risk on the board. Both are written every pass
* whichever is visible: a text that is only updated while its layer is on
* is a text that shows a stale number the moment the layer comes back. */
label.caps.textContent = String(cap);
label.costrisk.textContent = `${bundle.edge.cost}/${risk.toFixed(2)}`;

flow.setAttribute("x", anchor.x);
flow.setAttribute("y", anchor.y);
/* The lower row's plate is sized to whichever of its two texts is longer,
* rather than to the one currently showing. Sizing it to the visible one
* would make the plate's width depend on the layer set -- a geometry write
* driven by appearance state, and a plate that changes size when a layer is
* toggled on a beat that changed nothing else. */
const statText = label.caps.textContent.length >= label.costrisk.textContent.length
? label.caps.textContent
: label.costrisk.textContent;

/* The plate is sized from the text it backs, which is geometry; its fill
* and opacity are the stylesheet's business. */
const width = Math.max(stats.textContent.length, PLATE_MIN_CHARS) * CHAR_ADVANCE;
plate.setAttribute("x", anchor.x - width / 2);
plate.setAttribute("y", anchor.y - PLATE_HALF_HEIGHT);
plate.setAttribute("width", width);
plate.setAttribute("height", PLATE_HEIGHT);
row(label.flow, label.plateFlow, anchor, -ROW_OFFSET, label.flow.textContent);
row(label.caps, label.plateStat, anchor, ROW_OFFSET, statText);
row(label.costrisk, null, anchor, ROW_OFFSET, statText);
}

renderHeadline(state, certificates);
Expand Down
Loading
Loading