diff --git a/NOTEPAD.md b/NOTEPAD.md index 7ac3e91..984fbaf 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -22,6 +22,13 @@ Deferring because out of scope is fine, but don't mention deferring than. pixels than eighty and a third chart may now fit — worth measuring against the panel before adding one. The temperature history was the one dropped; `history.climate_*` still answers, so it is a tile away. +- BUG/INFRA: `compose.yml` builds the frontend with `VITE_API_URL=https://api.${DOMAIN}` + and only `compose.local.yml` overrides it to `http`. Any `up --build` that does not layer + the local file therefore ships a bundle calling `https://api.localhost`, which fails with + `ERR_CERT_AUTHORITY_INVALID` and breaks login entirely — nothing terminates TLS locally. + It recurs every time the stack is brought up without the override, so it is not a stale + image but a default that is wrong for the local target. Either flip the default or make the + local target the one the Makefile always passes. - FEAT/UI: a bar's nested reading is captioned by its port name, which is chosen for the graph rather than for somebody reading it across a room. `Segment` now takes an optional `label`; the house dashboard sets one @@ -55,6 +62,15 @@ Deferring because out of scope is fine, but don't mention deferring than. zero rather than as unknown — which is how a deleted `failures_24h` showed as "0 failures" unnoticed. The per-card half is one line; Home sums across installations and has nowhere to say "3 of 4 reporting", so the two want doing together. +- PERF/API: enabling or disabling one flow calls `FlowController.reload()`, which tears down + and rebuilds *every* flow — reconnecting each node, including the ones that talk to hardware + over the network. On the tinyhouse installation (nineteen flows, MQTT + UniFi + aircon) a + single toggle takes 8-10s end to end. Same root as the seeding cost below: there is no way + to change one flow's runtime state without rebuilding the whole pipeline. +- CHORE/TEST: `runtime.spec.ts` "the home page lists flows and can stop one" asserts the row + reads "Stopped" within Playwright's 5s default. That is shorter than a real installation's + rebuild (above), so the spec passes on a small instance and fails on a populated one. It is + the rebuild that wants fixing, not the timeout — raising it would only hide the cost. - FEAT/SEC: `locked` is a read-only surface, not a permission — the server accepts a publish from a panel whose dashboard says locked. Making it real means carrying the flag into `_panel_may`. diff --git a/frontend/src/components/Common/UplotChart.tsx b/frontend/src/components/Common/UplotChart.tsx index 42a1055..593c365 100644 --- a/frontend/src/components/Common/UplotChart.tsx +++ b/frontend/src/components/Common/UplotChart.tsx @@ -86,32 +86,95 @@ export function slotsFor(count: number, palette?: string[]): string[] { } /** - * The pointer, put back into layout pixels on a CSS-scaled panel. + * What the pointer correction reads off a chart. + * + * Structural rather than `uPlot` itself, so the check beside this file can + * hand it a plain object. + */ +type Painted = { + rect: { left: number; top: number; width: number } + over: { clientWidth: number } +} + +/** + * A pointer event, put back into the panel's own pixels. * * A dashboard canvas is drawn at its panel's own pixel size and CSS-scaled to - * fit the screen, while uPlot maps the pointer with `clientX - rect.left` — - * visual pixels — against its own unscaled plot width. On a scaled panel the - * cursor then drifts further right the further into the chart it goes. - * `drawn` is the ratio the element is actually painted at; unscaled it is 1 - * and every call here is a no-op. + * fit the screen, while uPlot works in layout pixels throughout: it takes + * `clientX - rect.left` — visual pixels — and measures it against its own + * unscaled plot width. The cursor drifts further the deeper into a scaled + * chart it goes, and on a panel scaled *up* it does worse than drift. Past + * `1 / drawn` of the way across, the visual offset has passed the layout plot + * width, and uPlot's own edge snap (`cacheMouse`, uPlot.esm.js:5776) rounds it + * to that width outright: the readout stops advancing partway across and + * sticks to the last point. * - * Applied exactly once per position, which is the whole trick. uPlot writes - * what this returns back into the value it hands in next time and calls it - * again on every redraw — and a chart redraws on every render. Dividing twice - * would walk the cursor left while the pointer stood still, and would leave - * the position taken at mousedown disagreeing with the one at mouseup, which - * uPlot reads as a drag: it then swallows the click, and a chart in the - * dashboard editor cannot be selected at all. + * Correcting the event, before uPlot has done any arithmetic with it, is what + * makes that whole chain come out right — the snap included. It is also + * stateless, so it cannot be applied twice. `cursor.move`, the other seam, is + * handed its own output back and re-run on every redraw, so anything refined + * there can only stay right by recognising its own last answer. + * + * `drawn` is the ratio the element is painted at; unscaled it is 1 and this is + * a no-op. One ratio for both axes: the panel is scaled uniformly. */ -export function cursorRefiner() { - let placed: [number, number] = [-10, -10] - return (drawn: number, left: number, top: number): [number, number] => { - if (!(drawn > 0) || drawn === 1) return [left, top] - // Handed back what it was last given: already in layout pixels. - if (left === placed[0] && top === placed[1]) return placed - placed = [left / drawn, top / drawn] - return placed +export function inLayoutPixels( + self: Painted, + event: E, +): E { + const { rect } = self + const drawn = rect.width / self.over.clientWidth + if (!(drawn > 0) || drawn === 1) return event + return new Proxy(event, { + get(target, key) { + if (key === "clientX") + return rect.left + (event.clientX - rect.left) / drawn + if (key === "clientY") + return rect.top + (event.clientY - rect.top) / drawn + const value = Reflect.get(target, key) + // The event's own methods still need the event as their receiver. + return typeof value === "function" ? value.bind(target) : value + }, + }) +} + +/** + * uPlot's own listener filters — `filtBtn0` and `filtTarg` — with the pointer + * corrected on the way through. Only the three events that carry a position + * are wrapped; the others read no coordinates. + */ +const binder = + (button: boolean) => + ( + self: Painted, + target: object, + handle: (event: MouseEvent) => void, + onlyTarget = true, + ) => + (event: MouseEvent) => { + if (button && event.button !== 0) return + if (onlyTarget && event.target !== target) return + handle(inLayoutPixels(self, event)) } + +/** The cursor every chart is built with; exported so the check can drive it. */ +export const CURSOR: uPlot.Cursor = { + y: false, + // uPlot's shipped types drop the binder's fourth `onlyTarg` argument, which + // it does pass — the document-wide mouseup binding depends on it. + bind: { + mousedown: binder(true), + mouseup: binder(true), + mousemove: binder(false), + } as unknown as uPlot.Cursor.Bind, + drag: { + // No drag-to-zoom. `setData` re-ranges the scales from the data and runs + // on every render, so a dragged range was erased by the next reading — all + // it ever did here was flash a selection box over a live chart. + x: false, + y: false, + setScale: false, + }, } /** Room for the axis ticks; uPlot measures the rest of the box itself. */ @@ -249,7 +312,6 @@ export function UplotChart({ /** The x value the page was last told about, so a move within one bucket * does not re-render it. */ let told: number | null = null - const refine = cursorRefiner() // Resolved once for the whole chart: how many lines there are is part of // which slots they take, when nothing named them. const slots = slotsFor(labels.length, palette) @@ -264,32 +326,7 @@ export function UplotChart({ width: element.clientWidth || 320, height: canvasHeight(element), padding: PADDING, - cursor: { - y: false, - // See `cursorRefiner`: the panel is scaled, uPlot's pointer maths - // is not, and this must run exactly once per position. - move: (self, left, top) => - refine(self.rect.width / self.over.clientWidth, left, top), - drag: { - // No drag-to-zoom. `setData` below re-ranges the scales from the - // data and runs on every render, so a dragged range was erased by - // the next reading — all it ever did here was flash a selection - // box over a live chart. - // - // Which also settles the guard that comes with it. uPlot swallows - // the click that ends a drag, and decides one happened by - // comparing the position it took at mousedown — refined through - // `cursor.move` — against the one it holds at mouseup, which it - // re-reads raw and never refines. On a scaled panel those never - // agree, so *every* click on a plot read as a drag and was - // stopped: a chart tile could not be selected by clicking the - // chart. With no drag there is no click to protect. - x: false, - y: false, - setScale: false, - click: () => {}, - }, - }, + cursor: CURSOR, legend: { live: true, // Mounted in its own row under the plot rather than inside it: a diff --git a/frontend/src/components/Common/cursor.check.ts b/frontend/src/components/Common/cursor.check.ts index d2185e7..8179e2a 100644 --- a/frontend/src/components/Common/cursor.check.ts +++ b/frontend/src/components/Common/cursor.check.ts @@ -1,81 +1,166 @@ /** - * The cursor refiner, checked. + * The pointer on a CSS-scaled panel, checked. * * ponytail: a script rather than a suite, like the two beside `ColorWidget` — * the frontend's only runner is Playwright and this is arithmetic: * * cd frontend && bun run src/components/Common/cursor.check.ts * - * Written because the first version of this shipped a regression Playwright - * only caught two tests later. uPlot hands `cursor.move` its own output back - * and calls it again on every redraw, so refining a second time walked the - * cursor left and — worse — made a still pointer look like a drag, which uPlot - * answers by swallowing the click. The sequences below are uPlot's own: - * `mouseLeft1` is set raw from the event, refined in place by `updateCursor`, - * and then fed back in unchanged by every later redraw. + * Written because two versions of this shipped a regression the existing + * checks did not see. `pointer()` below replays uPlot 1.6.32's own pipeline — + * `cacheMouse` and `updateCursor`, transcribed with line numbers — against the + * cursor the component actually configures, so a correction made at the wrong + * seam fails here rather than on a live dashboard. */ import assert from "node:assert/strict" +import type uPlot from "uplot" -import { cursorRefiner } from "./UplotChart" +import { CURSOR } from "./UplotChart" -/** What a panel scaled to fit a 1280x720 window is drawn at. */ -const DRAWN = 0.64 +/** The plot's own, unscaled size: what uPlot measures everything against. */ +const WIDTH = 525 +const HEIGHT = 300 +/** Somewhere off the viewport corner, so an offset is never a coordinate. */ +const LEFT = 100 +const TOP = 40 -// Unscaled — Health and Home — is untouched, whatever the sequence. -{ - const refine = cursorRefiner() - for (const at of [0, 12, 300, -10]) { - assert.deepEqual(refine(1, at, at), [at, at], `${at} is left alone at 1:1`) +/** uPlot's `incrRound` (uPlot.esm.js:519). */ +const incrRound = (num: number, incr: number) => Math.round(num / incr) * incr + +/** + * A pointer over a chart whose panel is painted at `drawn`. + * + * uPlot's own sequence: the browser calls the listener `cursor.bind` returned, + * `cacheMouse` turns the event into a plot offset and snaps it at the edges, + * and `updateCursor` runs `cursor.move` over the result — again on every + * redraw, feeding back what it last returned. + */ +function pointer(drawn: number) { + const over = { clientWidth: WIDTH } + const self = { + rect: { + left: LEFT, + top: TOP, + width: WIDTH * drawn, + height: HEIGHT * drawn, + }, + over, + } + const refine = + CURSOR.move ?? + ((_self: uPlot, left: number, top: number) => + [left, top] as [number, number]) + const bind = CURSOR.bind as Required + + let seen = { clientX: 0, clientY: 0 } + const listen = (event: keyof uPlot.Cursor.Bind) => + bind[event]( + self as unknown as uPlot, + over as unknown as HTMLElement, + ((corrected: MouseEvent) => { + seen = corrected + return null + }) as uPlot.Cursor.MouseListener, + ) as uPlot.Cursor.MouseListener + + /** A press or a move at `across` of the way over the plot's *visual* box. */ + const at = (event: keyof uPlot.Cursor.Bind, across: number) => { + listen(event)({ + clientX: self.rect.left + self.rect.width * across, + clientY: self.rect.top + self.rect.height / 2, + target: over, + button: 0, + } as unknown as MouseEvent) + return [seen.clientX - self.rect.left, seen.clientY - self.rect.top] + } + + /** uPlot's edge snap, against the layout plot box (uPlot.esm.js:5776). */ + const snap = ([left, top]: number[]) => [ + left <= 1 || left >= WIDTH - 1 ? incrRound(left, WIDTH) : left, + top <= 1 || top >= HEIGHT - 1 ? incrRound(top, HEIGHT) : top, + ] + + let mouseLeft1 = -10 + let mouseTop1 = -10 + + const self_ = self as unknown as uPlot + return { + /** `updateCursor` (uPlot.esm.js:5305). Returns where the cursor lands. */ + redraw() { + ;[mouseLeft1, mouseTop1] = refine(self_, mouseLeft1, mouseTop1) + return mouseLeft1 + }, + /** A move to `across` of the visual box, and the redraw that follows it. */ + move(across: number) { + ;[mouseLeft1, mouseTop1] = snap(at("mousemove", across)) + return this.redraw() + }, + /** `mouseLeft0`: refined, never snapped (uPlot.esm.js:5786). */ + press(across: number) { + return refine(self_, ...(at("mousedown", across) as [number, number]))[0] + }, + /** `mouseLeft1` at mouseup: snapped, never refined (uPlot.esm.js:5790). */ + release(across: number) { + return snap(at("mouseup", across))[0] + }, + /** The pointer leaving the plot, parked off it (uPlot.esm.js:5942). */ + leave() { + mouseLeft1 = -10 + mouseTop1 = -10 + return this.redraw() + }, } } -// One pointer move, then the redraws a live dashboard does on every render. -// The position must not move while the pointer does not. -{ - const refine = cursorRefiner() - const [left, top] = refine(DRAWN, 128, 64) - assert.equal(left, 200, "128 visual pixels is 200 layout pixels at 0.64") - assert.equal(top, 100) +// Both directions. Every earlier version of this only ever tried a panel +// scaled down, which is the half that hid the bug: scaled *up*, the raw offset +// runs past the plot's own width and uPlot snaps it to the last point. +for (const drawn of [0.43, 0.64, 1, 1.13, 1.6]) { + const at = pointer(drawn) + + // A sweep across the whole visual box has to walk the whole plot — the last + // point included. Short of it is the drift; stuck is the snap. + assert.equal(at.move(0), 0, `${drawn}: the left edge is the plot's left edge`) + let last = -1 + for (let step = 1; step <= 20; step++) { + const left = at.move(step / 20) + assert.ok(left > last, `${drawn}: step ${step} did not advance`) + last = left + } + assert.equal( + last, + WIDTH, + `${drawn}: the sweep stopped short of the last point`, + ) + + // The user's case: a live chart calls `setData` constantly, and every one of + // those redraws re-runs `cursor.move` over its own last answer. + const held = at.move(0.5) for (let redraw = 0; redraw < 25; redraw++) { - const again = refine(DRAWN, left, top) - assert.deepEqual(again, [left, top], `redraw ${redraw} moved the cursor`) + assert.equal( + at.redraw(), + held, + `${drawn}: redraw ${redraw} moved the cursor`, + ) } -} -// What a click is, in uPlot's own terms: `mouseLeft1` follows the move and -// every redraw after it, `mouseLeft0` is refined from the raw press on its -// own, and uPlot calls it a drag when the two disagree. They must not, or the -// click is swallowed and the dashboard never hears it. -{ - const refine = cursorRefiner() - let [left1, top1] = refine(DRAWN, 128, 64) - for (let redraw = 0; redraw < 3; redraw++) { - ;[left1, top1] = refine(DRAWN, left1, top1) - } - const [left0, top0] = refine(DRAWN, 128, 64) - assert.deepEqual( - [left1, top1], - [left0, top0], - "a press where the pointer already was is not a drag", + // uPlot calls it a drag when the press and the release disagree + // (uPlot.esm.js:2954), and swallows the click that ends one — which is a + // chart tile that cannot be selected in the dashboard editor. + assert.equal( + at.press(0.5), + at.release(0.5), + `${drawn}: a press where the pointer already was reads as a drag`, ) } -// A real drag still reads as one. -{ - const refine = cursorRefiner() - const from = refine(DRAWN, 128, 64) - const to = refine(DRAWN, 192, 64) - assert.notDeepEqual(to, from, "a pointer that moved has moved") -} - // Leaving the plot: uPlot parks the cursor off-plot at -10, and it has to stay -// off-plot however it is scaled, or the line would stick where it was. +// off-plot however the panel is scaled, or the line would stick where it was. { - const refine = cursorRefiner() - refine(DRAWN, 128, 64) - const [left, top] = refine(DRAWN, -10, -10) - assert.ok(left < 0 && top < 0, "the parked cursor stays off the plot") + const at = pointer(0.64) + at.move(0.5) + assert.ok(at.leave() < 0, "the parked cursor stays off the plot") } console.log("cursor: ok")