Merge branch 'main' of git.stroblme.de:Fluksio/app
This commit is contained in:
+16
@@ -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`.
|
||||
|
||||
@@ -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<E extends { clientX: number; clientY: number }>(
|
||||
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
|
||||
|
||||
@@ -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<uPlot.Cursor.Bind>
|
||||
|
||||
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`)
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
assert.equal(
|
||||
at.redraw(),
|
||||
held,
|
||||
`${drawn}: redraw ${redraw} moved the cursor`,
|
||||
)
|
||||
}
|
||||
|
||||
// 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")
|
||||
// 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`,
|
||||
)
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
Reference in New Issue
Block a user