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
11 changes: 7 additions & 4 deletions components/DataLog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
// the CSV export); only rendering is windowed to the most recent N for performance.
const MAX_RENDERED = 500;

export function DataLog({

Check warning on line 78 in components/DataLog.tsx

View workflow job for this annotation

GitHub Actions / verify

High cognitive complexity (high)

Function 'DataLog' is hard to understand (cognitive: 30, threshold: 15). • Severity: high • Cyclomatic: 15 • Cognitive: 30 • Lines: 325 High cognitive complexity means deeply nested or interleaved logic. Consider flattening control flow or extracting helper functions.

Check warning on line 78 in components/DataLog.tsx

View workflow job for this annotation

GitHub Actions / verify

High cognitive complexity (high)

Function 'DataLog' is hard to understand (cognitive: 30, threshold: 15). • Severity: high • Cyclomatic: 15 • Cognitive: 30 • Lines: 325 High cognitive complexity means deeply nested or interleaved logic. Consider flattening control flow or extracting helper functions.
reading,
recording,
store,
Expand Down Expand Up @@ -203,14 +203,17 @@
// 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.
Expand Down
114 changes: 68 additions & 46 deletions components/RealtimeChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@
*/
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
Expand Down Expand Up @@ -356,6 +361,62 @@
};
}

/** 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<number, number>,
Expand Down Expand Up @@ -402,7 +463,7 @@
chart.update('none');
}

export function RealtimeChart({

Check warning on line 466 in components/RealtimeChart.tsx

View workflow job for this annotation

GitHub Actions / verify

High cognitive complexity (high)

Function 'RealtimeChart' is hard to understand (cognitive: 27, threshold: 15). • Severity: high • Cyclomatic: 6 • Cognitive: 27 • Lines: 308 High cognitive complexity means deeply nested or interleaved logic. Consider flattening control flow or extracting helper functions.

Check warning on line 466 in components/RealtimeChart.tsx

View workflow job for this annotation

GitHub Actions / verify

High cognitive complexity (high)

Function 'RealtimeChart' is hard to understand (cognitive: 27, threshold: 15). • Severity: high • Cyclomatic: 6 • Cognitive: 27 • Lines: 308 High cognitive complexity means deeply nested or interleaved logic. Consider flattening control flow or extracting helper functions.
store,
sampleVersion,
chartFromSeq,
Expand Down Expand Up @@ -506,7 +567,7 @@
}, [chartType, lineUnavailable, theme]);

/* eslint-disable react-hooks/immutability */
useEffect(() => {

Check warning on line 570 in components/RealtimeChart.tsx

View workflow job for this annotation

GitHub Actions / verify

High complexity (high)

Function '<arrow>' exceeds both complexity thresholds: • Severity: high • Cyclomatic: 21 (threshold: 20) • Cognitive: 27 (threshold: 15) • Lines: 121 Consider splitting this function into smaller, focused functions.

Check warning on line 570 in components/RealtimeChart.tsx

View workflow job for this annotation

GitHub Actions / verify

High complexity (high)

Function '<arrow>' exceeds both complexity thresholds: • Severity: high • Cyclomatic: 21 (threshold: 20) • Cognitive: 27 (threshold: 15) • Lines: 121 Consider splitting this function into smaller, focused functions.
const chart = chartRef.current;
// Written unconditionally, even when the bail-out below fires: keeps both refs live
// for whenever the chart next exists, rather than relying on "no chart is ever live
Expand Down Expand Up @@ -587,25 +648,12 @@
// 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.
min = store.count > from ? store.tsAt(from) : undefined;
max = store.count > 0 ? store.tsAt(store.count - 1) : undefined;
}
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.
Expand All @@ -628,37 +676,11 @@
// 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
Expand Down
11 changes: 8 additions & 3 deletions components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ const NAV_ICONS: Record<NavId, React.ReactNode> = {
settings: <Settings size={15} />,
};

const STATUS_VIEW: Record<SerialStatus, { label: string; className: string }> = {
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,
Expand Down Expand Up @@ -73,9 +80,7 @@ export function Sidebar({
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted">Status</span>
<span className={connected ? 'text-success' : connecting ? 'text-amber' : 'text-muted'}>
{connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected'}
</span>
<span className={STATUS_VIEW[status].className}>{STATUS_VIEW[status].label}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted">Interface</span>
Expand Down
18 changes: 11 additions & 7 deletions lib/histogram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);

Expand All @@ -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);
}
}

Expand Down
26 changes: 15 additions & 11 deletions lib/useSerial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,15 @@
// 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.
*/
export function useSerial(onReadings: (readings: Reading[]) => void) {

Check warning on line 50 in lib/useSerial.ts

View workflow job for this annotation

GitHub Actions / verify

High cognitive complexity (moderate)

Function 'useSerial' is hard to understand (cognitive: 16, threshold: 15). • Severity: moderate • Cyclomatic: 1 • Cognitive: 16 • Lines: 250 High cognitive complexity means deeply nested or interleaved logic. Consider flattening control flow or extracting helper functions.

Check warning on line 50 in lib/useSerial.ts

View workflow job for this annotation

GitHub Actions / verify

High cognitive complexity (moderate)

Function 'useSerial' is hard to understand (cognitive: 16, threshold: 15). • Severity: moderate • Cyclomatic: 1 • Cognitive: 16 • Lines: 250 High cognitive complexity means deeply nested or interleaved logic. Consider flattening control flow or extracting helper functions.
const [status, setStatus] = useState<SerialStatus>('disconnected');
const [error, setError] = useState<string | null>(null);

Expand All @@ -64,10 +68,18 @@
}, []);

const readLoop = useCallback(
async (port: SerialPort, ctl: LoopControl, onFirstData?: () => void) => {

Check warning on line 71 in lib/useSerial.ts

View workflow job for this annotation

GitHub Actions / verify

High cognitive complexity (moderate)

Function 'readLoop' is hard to understand (cognitive: 18, threshold: 15). • Severity: moderate • Cyclomatic: 12 • Cognitive: 18 • Lines: 48 High cognitive complexity means deeply nested or interleaved logic. Consider flattening control flow or extracting helper functions.

Check warning on line 71 in lib/useSerial.ts

View workflow job for this annotation

GitHub Actions / verify

High cognitive complexity (moderate)

Function 'readLoop' is hard to understand (cognitive: 18, threshold: 15). • Severity: moderate • Cyclomatic: 12 • Cognitive: 18 • Lines: 48 High cognitive complexity means deeply nested or interleaved logic. Consider flattening control flow or extracting helper functions.
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();
Expand All @@ -76,15 +88,7 @@
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
Expand All @@ -94,7 +98,7 @@
// — 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();
Expand Down Expand Up @@ -207,7 +211,7 @@
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);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "multimeter-live",
"version": "2.1.0",
"version": "2.1.1",
"private": true,
"engines": {
"node": ">=22.18.0"
Expand Down
Loading