From a2dcc1c644b16483ba583585aa8165817a7e64f0 Mon Sep 17 00:00:00 2001 From: Yurii Kucherenko Date: Fri, 16 Oct 2026 09:51:22 +0300 Subject: [PATCH 1/2] fix: 'all' chart range showed a huge negative time offset when empty With no visible sample (after a reload, Clear, or a preserve-log mode change) the 'all' x-axis had no min, so Chart.js fell back to 0..1 and every tick read as an offset from the Unix epoch ("-1790596532s"). With exactly one sample min === max, a zero-width axis Chart.js will not widen because both bounds are user-set. The axis now starts at the first visible sample, or at now when there is none, and spans at least 10 s (ALL_MIN_SPAN_MS) so the whole-second tick labels never repeat. Like a bounded window still filling, the trace grows rightward until the session is longer than that. Bump to 2.1.1. --- components/RealtimeChart.tsx | 14 ++++++++++++-- package.json | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/components/RealtimeChart.tsx b/components/RealtimeChart.tsx index 6d5eea1..a7b81bb 100644 --- a/components/RealtimeChart.tsx +++ b/components/RealtimeChart.tsx @@ -67,6 +67,11 @@ const OUTLIER_COLOR = 'rgba(245,158,11,0.7)'; // amber — under/over-range bin */ const ALL_INITIAL_BUCKET_MS = 50; +/** Shortest span the 'all' axis is drawn over, anchored at its first sample like a bounded + * window that is still filling. Tick labels are whole seconds, so a span much under ~10 s + * repeats them ('-1s, -1s, Now, Now'). */ +const ALL_MIN_SPAN_MS = 10_000; + export type Theme = 'dark' | 'light'; // Canvas colors cannot follow the CSS tokens, so the neutrals are mirrored here per theme @@ -603,8 +608,13 @@ export function RealtimeChart({ // arrive, so letting Chart.js auto-fit made the axis maximum jump every update: the // whole plot area re-laid-out and the right-hand label flickered between `Now` and // `-1s`. Real sample timestamps only ever move forward. - min = store.count > from ? store.tsAt(from) : undefined; - max = store.count > 0 ? store.tsAt(store.count - 1) : undefined; + // + // Never left undefined or zero-width: with no visible sample Chart.js falls back to + // 0..1 and the ticks read as minus the Unix epoch ('-1790596532s'), and with one + // sample min === max is a degenerate axis it will not widen (both are user bounds). + const visible = store.count > from; + min = visible ? store.tsAt(from) : now; + max = Math.max(visible ? store.tsAt(store.count - 1) : now, min + ALL_MIN_SPAN_MS); } // Assigned unconditionally, `undefined` included: Chart.js reuses this options // object across updates, so a bounded range's min/max left behind would pin 'all' diff --git a/package.json b/package.json index eda2ebf..259eb0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multimeter-live", - "version": "2.1.0", + "version": "2.1.1", "private": true, "engines": { "node": ">=22.18.0" From 61c97433ae98058690e0ff0da3d47a553fec5b2e Mon Sep 17 00:00:00 2001 From: Yurii Kucherenko Date: Fri, 16 Oct 2026 10:06:54 +0300 Subject: [PATCH 2/2] refactor: reduce cognitive complexity of five hotspots Behaviour-preserving extractions only, measured with fallow health: - Sidebar: status label/colour from a STATUS_VIEW table (cog 16 -> 10) - buildLsdHistogram: dominantValue() helper, Math.min/max in the window loop (26 -> 13) - DataLog filtered scan: one matches(r) predicate, same order and short-circuiting (20 -> 10) - readLoop: per-chunk handling in a deliver closure, errorText() shared with connect (31 -> 18) - chart update effect: pure xAxisBounds() and yAxisFloor() helpers (39 -> 27) Functions over the complexity thresholds 12 -> 9; project cognitive total 1034 -> 993. --- components/DataLog.tsx | 11 ++-- components/RealtimeChart.tsx | 114 +++++++++++++++++++---------------- components/Sidebar.tsx | 11 +++- lib/histogram.ts | 18 +++--- lib/useSerial.ts | 26 ++++---- 5 files changed, 104 insertions(+), 76 deletions(-) diff --git a/components/DataLog.tsx b/components/DataLog.tsx index 0a9ca80..734190e 100644 --- a/components/DataLog.tsx +++ b/components/DataLog.tsx @@ -203,14 +203,17 @@ export function DataLog({ // lib/search.ts so `scripts/check-search.mts` can assert it equals `localIso()`. const isoOf = createIsoFormatter(); + const matches = (r: RowData): boolean => + modeHit.get(r.mode) || unitMatches(r.unit) || + (r.note !== '' && r.note.toLowerCase().includes(q)) || + (testValue && rowValue(r.value, r.unit, r.decimals).includes(q)) || + (testIso && isoOf(r.ts).includes(upper)); + let scanned = 0; for (let i = store.count - 1; i >= 0 && out.length < MAX_RENDERED; i--) { scanned++; const r = read(i); - if (modeHit.get(r.mode) || unitMatches(r.unit)) { out.push(r); continue; } - if (r.note !== '' && r.note.toLowerCase().includes(q)) { out.push(r); continue; } - if (testValue && rowValue(r.value, r.unit, r.decimals).includes(q)) { out.push(r); continue; } - if (testIso && isoOf(r.ts).includes(upper)) { out.push(r); continue; } + if (matches(r)) out.push(r); } // `truncated` here means "there may be older matches", which is true exactly when the // scan stopped early rather than reaching the start of the store. diff --git a/components/RealtimeChart.tsx b/components/RealtimeChart.tsx index a7b81bb..ba5af3b 100644 --- a/components/RealtimeChart.tsx +++ b/components/RealtimeChart.tsx @@ -361,6 +361,62 @@ function buildChartConfig( }; } +/** X-axis bounds for one line-chart update. A bounded range spans its window + * (resolveTimeWindow); 'all' has no fixed window, so the axis fits the data — but "the + * data" must mean the STORE's bounds, not the tier's last emitted point. A bucket emits its + * min and max, and which of those is newest hops around inside the newest bucket as samples + * arrive, so letting Chart.js auto-fit made the axis maximum jump every update: the whole + * plot area re-laid-out and the right-hand label flickered between `Now` and `-1s`. Real + * sample timestamps only ever move forward. + * + * Never left undefined or zero-width: with no visible sample Chart.js falls back to 0..1 + * and the ticks read as minus the Unix epoch ('-1790596532s'), and with one sample + * min === max is a degenerate axis it will not widen (both are user bounds). */ +function xAxisBounds( + timeRange: TimeRange, + sessionStart: number | null, + now: number, + store: SampleStore, + from: number, +): { min?: number; max?: number; stepSize?: number } { + if (Number.isFinite(TIME_RANGE_MS[timeRange])) return resolveTimeWindow(timeRange, sessionStart, now); + const visible = store.count > from; + const min = visible ? store.tsAt(from) : now; + return { min, max: Math.max(visible ? store.tsAt(store.count - 1) : now, min + ALL_MIN_SPAN_MS) }; +} + +/** Auto-scale fits [dataMin, dataMax] — which on a settled reading IS the meter's own + * ±1-count dither, so one count of noise gets magnified to the full canvas height and a + * steady measurement is displayed as violent movement. Floor the span at + * ±STABLE_LSD_TOLERANCE counts around the window's midpoint: reusing the stability gate's + * constant keeps ONE number in the codebase for "a change smaller than this is not a + * measurement" (docs/gotchas.md explains why it is 20 and why 2 was too tight), so the + * chart stops resolving below the noise floor the app has already declared. + * + * suggestedMin/Max are soft — Chart.js takes min(dataMin, suggestedMin) — so real spread + * still widens the axis and a transient is never clipped. A hard min/max here would be + * wrong for a meter. Empty when a manual range is set, the resolution is unknown, or there + * is no data. */ +function yAxisFloor( + yMin: number | undefined, + yMax: number | undefined, + binWidth: number | undefined, + lo: number, + hi: number, +): { suggestedMin?: number; suggestedMax?: number } { + const floored = + yMin === undefined && + yMax === undefined && + typeof binWidth === 'number' && + binWidth > 0 && + Number.isFinite(binWidth) && + lo <= hi; + if (!floored) return {}; + const half = STABLE_LSD_TOLERANCE * binWidth; + const mid = (lo + hi) / 2; + return { suggestedMin: mid - half, suggestedMax: mid + half }; +} + function drawHistogram( chart: Chart, counts: Map, @@ -592,30 +648,12 @@ export function RealtimeChart({ // X range. A bounded range spans exactly its window, so pixels-per-second is a // constant: the axis is anchored at the session start while the window fills (the // trace grows rightward and nothing already drawn moves), then rolls with the clock. - // 'all' sets nothing and the axis fits the data instead — see resolveTimeWindow. + // 'all' fits the data instead — see xAxisBounds. const xScale = chart.options.scales?.x as | { min?: number; max?: number; ticks?: { stepSize?: number } } | undefined; if (xScale) { - const win = resolveTimeWindow(timeRange, sessionStart ?? null, now); - const { stepSize } = win; - let min = win.min; - let max = win.max; - if (!Number.isFinite(windowMs)) { - // 'all' has no fixed window, so the axis fits the data — but "the data" must mean - // the STORE's bounds, not the tier's last emitted point. A bucket emits its min and - // max, and which of those is newest hops around inside the newest bucket as samples - // arrive, so letting Chart.js auto-fit made the axis maximum jump every update: the - // whole plot area re-laid-out and the right-hand label flickered between `Now` and - // `-1s`. Real sample timestamps only ever move forward. - // - // Never left undefined or zero-width: with no visible sample Chart.js falls back to - // 0..1 and the ticks read as minus the Unix epoch ('-1790596532s'), and with one - // sample min === max is a degenerate axis it will not widen (both are user bounds). - const visible = store.count > from; - min = visible ? store.tsAt(from) : now; - max = Math.max(visible ? store.tsAt(store.count - 1) : now, min + ALL_MIN_SPAN_MS); - } + const { min, max, stepSize } = xAxisBounds(timeRange, sessionStart ?? null, now, store, from); // Assigned unconditionally, `undefined` included: Chart.js reuses this options // object across updates, so a bounded range's min/max left behind would pin 'all' // to a dead window — the same trap the y-axis floor below guards against. @@ -638,37 +676,11 @@ export function RealtimeChart({ // Histogram: it is the x-axis title, set in that branch above. yScale.min = yMin; // undefined => Chart.js auto-scales yScale.max = yMax; - - // Auto-scale fits [dataMin, dataMax] — which on a settled reading IS the meter's - // own ±1-count dither, so one count of noise gets magnified to the full canvas - // height and a steady measurement is displayed as violent movement. Floor the - // span at ±STABLE_LSD_TOLERANCE counts around the window's midpoint: reusing the - // stability gate's constant keeps ONE number in the codebase for "a change - // smaller than this is not a measurement" (docs/gotchas.md explains why it is 20 - // and why 2 was too tight), so the chart stops resolving below the noise floor - // the app has already declared. - // - // suggestedMin/Max are soft — Chart.js takes min(dataMin, suggestedMin) — so real - // spread still widens the axis and a transient is never clipped. A hard min/max - // here would be wrong for a meter. - const floored = - yMin === undefined && - yMax === undefined && - typeof binWidth === 'number' && - binWidth > 0 && - Number.isFinite(binWidth) && - lo <= hi; - // Cleared when not applicable: Chart.js reuses this options object across - // updates, so a stale suggestion would outlive the condition that set it. - if (floored) { - const half = STABLE_LSD_TOLERANCE * binWidth; - const mid = (lo + hi) / 2; - yScale.suggestedMin = mid - half; - yScale.suggestedMax = mid + half; - } else { - yScale.suggestedMin = undefined; - yScale.suggestedMax = undefined; - } + // Assigned unconditionally, `undefined` included: Chart.js reuses this options + // object across updates, so a stale suggestion would outlive the condition that set it. + const { suggestedMin, suggestedMax } = yAxisFloor(yMin, yMax, binWidth, lo, hi); + yScale.suggestedMin = suggestedMin; + yScale.suggestedMax = suggestedMax; } chart.update('none'); // `counts` is listed for the linter and for documentation only: its identity is stable diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 3b922f1..6daa834 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -24,6 +24,13 @@ const NAV_ICONS: Record = { settings: , }; +const STATUS_VIEW: Record = { + connected: { label: 'Connected', className: 'text-success' }, + connecting: { label: 'Connecting…', className: 'text-amber' }, + disconnected: { label: 'Disconnected', className: 'text-muted' }, + unsupported: { label: 'Disconnected', className: 'text-muted' }, +}; + export function Sidebar({ status, baud, @@ -73,9 +80,7 @@ export function Sidebar({
Status - - {connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected'} - + {STATUS_VIEW[status].label}
Interface diff --git a/lib/histogram.ts b/lib/histogram.ts index 2f55ea7..8dcc819 100644 --- a/lib/histogram.ts +++ b/lib/histogram.ts @@ -31,6 +31,14 @@ type Bin = { value: number; count: number; kind: 'under' | 'in' | 'over' }; /** `width` is the effective bin width, which the label decimals derive from. */ type Histogram = { bins: Bin[]; width: number }; +/** The most-counted value; the first one logged wins a tie, and an empty input gives 0. */ +function dominantValue(entries: readonly Entry[]): number { + let c = 0; + let best = -1; + for (const [v, n] of entries) if (n > best) { best = n; c = v; } + return c; +} + /** * LSD grid: bins are integer multiples of `width`, anchored at zero, so each bar is * centered on (and labeled with) an actual value the meter can display. The window @@ -45,11 +53,7 @@ function buildLsdHistogram(entries: readonly Entry[], width: number, centerValue // `entries[0]` is whichever value happened to be logged FIRST this session — session-order // noise, not a centre. The most-counted value is the same thing `centerValue` normally // carries, so falling back to it degrades gracefully instead of arbitrarily. - let c = Number.isFinite(centerValue) ? (centerValue as number) : 0; - if (!Number.isFinite(centerValue) && entries.length > 0) { - let best = -1; - for (const [v, n] of entries) if (n > best) { best = n; c = v; } - } + const c = Number.isFinite(centerValue) ? (centerValue as number) : dominantValue(entries); const centerBin = Math.round(c / width); const minHalf = Math.floor((MIN_BINS - 1) / 2); @@ -60,8 +64,8 @@ function buildLsdHistogram(entries: readonly Entry[], width: number, centerValue for (const [v] of entries) { const b = Math.round(v / width); if (b >= centerBin - MAX_HALF_BINS && b <= centerBin + MAX_HALF_BINS) { - if (b < lo) lo = b; - if (b > hi) hi = b; + lo = Math.min(lo, b); + hi = Math.max(hi, b); } } diff --git a/lib/useSerial.ts b/lib/useSerial.ts index a414184..3af824c 100644 --- a/lib/useSerial.ts +++ b/lib/useSerial.ts @@ -39,6 +39,10 @@ function isSupported(): boolean { // probe that is still walking the remaining candidates. type LoopControl = { stop: boolean; live: boolean }; +function errorText(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + /** * Web Serial lifecycle hook. All access to navigator.serial is guarded so that * SSR / `next build` prerendering never touches a browser-only API. @@ -68,6 +72,14 @@ export function useSerial(onReadings: (readings: Reading[]) => void) { const parser = createParser(); const decoder = new TextDecoder(); let sawData = false; + const deliver = (value: Uint8Array) => { + const readings = parser.push(decoder.decode(value, { stream: true })); + if (!readings.length) return; + // Readings parsed during a successful probe are real measurements, not + // test traffic — they go to the consumer like any other batch. + if (!sawData) { sawData = true; onFirstData?.(); } + onReadingsRef.current(readings); + }; while (port.readable && !ctl.stop) { const reader = port.readable.getReader(); @@ -76,15 +88,7 @@ export function useSerial(onReadings: (readings: Reading[]) => void) { for (;;) { const { value, done } = await reader.read(); if (done) break; - if (value) { - const readings = parser.push(decoder.decode(value, { stream: true })); - if (readings.length) { - // Readings parsed during a successful probe are real measurements, not - // test traffic — they go to the consumer like any other batch. - if (!sawData) { sawData = true; onFirstData?.(); } - onReadingsRef.current(readings); - } - } + if (value) deliver(value); } } catch (e) { // Suppress the AbortError thrown by reader.cancel() during a normal disconnect @@ -94,7 +98,7 @@ export function useSerial(onReadings: (readings: Reading[]) => void) { // — auto-reconnect is specified to end silently. Only the promoted loop, and // connect()'s own loop, may raise a banner. if (!ctl.stop && ctl.live) { - setError(e instanceof Error ? e.message : String(e)); + setError(errorText(e)); } } finally { reader.releaseLock(); @@ -207,7 +211,7 @@ export function useSerial(onReadings: (readings: Reading[]) => void) { void readLoop(port, ctl); // fire-and-forget; loop ends on disconnect } catch (e) { keepReadingRef.current = false; - const msg = e instanceof Error ? e.message : String(e); + const msg = errorText(e); // User dismissed the port picker — not an error worth surfacing. if (!msg.includes('No port selected')) { setError(msg);