diff --git a/js/filter.js b/js/filter.js
index 7ff5e61..f5bbd3b 100644
--- a/js/filter.js
+++ b/js/filter.js
@@ -58,3 +58,27 @@ export function movingAverage(measurements, windowSec) {
return measurements.map((_, i) =>
trailingAverageAt(measurements, i, windowSec));
}
+
+/**
+ * Trailing moving average of a scalar series aligned 1:1 with `measurements`,
+ * using the same time-window semantics as trailingAverageAt. Used for the
+ * magnitude overlay, which must smooth the (nonlinear) displayed magnitude
+ * directly — averaging the per-sample magnitudes — rather than take the
+ * magnitude of the averaged vector (which collapses under delta-B).
+ * @param {Measurement[]} measurements ascending by time
+ * @param {number[]} values one scalar per measurement
+ * @param {number} windowSec trailing window length, in seconds
+ * @returns {number[]} trailing means, aligned 1:1 with `measurements`
+ */
+export function trailingScalarMean(measurements, values, windowSec) {
+ return measurements.map((_, end) => {
+ const startMs = measurements[end].ts.getTime() - (windowSec * 1000);
+ let sum = 0, count = 0;
+ for (let i = end; i >= 0; i--) {
+ if (measurements[i].ts.getTime() < startMs) break;
+ sum += values[i];
+ count++;
+ }
+ return sum / count;
+ });
+}
diff --git a/js/index.js b/js/index.js
index 9f7fc9d..80ab60c 100644
--- a/js/index.js
+++ b/js/index.js
@@ -1,7 +1,7 @@
///
import Measurement from "./Measurement.js";
import { buildSparklineTraces, reduceBucket } from "./sparklines.js";
-import { trailingAverageAt, movingAverage } from "./filter.js";
+import { trailingAverageAt, movingAverage, trailingScalarMean } from "./filter.js";
import plotsInit from "./data/plots.json" with { type: "json" };
import slInit from "./data/sparklines.json" with { type: "json" };
import Vector from "./Vector.js";
@@ -275,39 +275,23 @@ function rotatedHEZ(m) {
}
/**
- * Applies a delta-Baseline to a vector.
- *
- * Assumption: v is in HEZ.
- * @param {Vector} v the vector to adjust
- * @param {number} [idx=1] the index in the array to get (only applicable if dB
- * is set to moving average)
- * @returns {Vector} v - dB
+ * Subtracts the active source's delta-B baseline from a (rotated HEZ) vector.
+ * The baseline is a fixed HEZ triple: for the Constant method it's the values
+ * the user typed; for the Moving Average method it's the trailing moving
+ * average snapshotted when the user saved (see the Save dB handler). Either way
+ * this is a cheap fixed subtraction — no per-point recomputation.
+ * @param {Vector} v a rotated HEZ vector
+ * @returns {Vector} v - baseline
*/
-function applyDeltaB(v, idx = 1) {
- const { transform: { x, y, z } } = activeSource();
- /** @type {number[]} */
- let deltaB;
- if (activeSource().dB.moving) {
- const { measurements } = activeSession();
- deltaB = trailingAverageAt(measurements,
- measurements.length - idx, settings.filter.windowSec)
- .HEZ
- .rotate("x", x, false)
- .rotate("y", y, false)
- .rotate("z", z, false);
- } else {
- const { dB: { h, e, z } } = activeSource();
- deltaB = [h, e, z];
- }
- return v.delta(...deltaB);
+function applyDeltaB(v) {
+ const { dB: { h, e, z } } = activeSource();
+ return v.delta(h, e, z);
}
-/**
- * @returns {boolean}
- */
+/** @returns {boolean} whether a non-zero delta-B baseline is set */
function usesDeltaB() {
- const { dB: { moving, h, e, z } } = activeSource();
- return moving || (h || e || z);
+ const { dB: { h, e, z } } = activeSource();
+ return h !== 0 || e !== 0 || z !== 0;
}
/** Resets both plots to their initial empty state (full re-init). */
@@ -321,20 +305,31 @@ function resetPlots() {
* the active source's buffer and redraws traces 5-9 in a single restyle.
*/
function recomputeFiltered() {
- const smoothed = movingAverage(
- activeSession().measurements, settings.filter.windowSec);
+ const ms = activeSession().measurements;
+ const windowSec = settings.filter.windowSec;
+ const db = usesDeltaB();
+ const smoothed = movingAverage(ms, windowSec);
const x = smoothed.map(m => m.ts);
- let vectors = smoothed.map(rotatedHEZ);
- if (usesDeltaB()) {
- vectors = vectors.map(applyDeltaB);
- }
+ // H/E/Z overlay: the moving average of the vector (linear, so this is the
+ // smoothing of each displayed component).
+ const vectors = db
+ ? smoothed.map(m => applyDeltaB(rotatedHEZ(m)))
+ : smoothed.map(rotatedHEZ);
+ // Magnitude overlay: smooth the displayed magnitude itself (average of the
+ // per-sample magnitudes), not |averaged vector| — the latter collapses
+ // under delta-B because the deviations cancel directionally.
+ const dispMag = ms.map(m => {
+ const v = db ? applyDeltaB(rotatedHEZ(m)) : rotatedHEZ(m);
+ return v.magnitude;
+ });
+ const magFilter = trailingScalarMean(ms, dispMag, windowSec);
Plotly.restyle(plotsDiv, {
x: FILTER_TRACES.map(() => x),
y: [
vectors.map(v => v[0]),
vectors.map(v => v[1]),
vectors.map(v => v[2]),
- vectors.map(v => parseFloat(v.magnitude.toFixed(3))),
+ magFilter.map(v => parseFloat(v.toFixed(3))),
smoothed.map(m => m.celsius),
],
}, FILTER_TRACES);
@@ -360,28 +355,19 @@ function refreshFilter() {
}
function updateCoordGraphs() {
- const vectors = activeSession().measurements.map(rotatedHEZ);
-
- /** @type {[0, 1, 2]} */
- const traces = [0, 1, 2];
-
- // Depending on what we have, what we update changes:
- // 1. If we only have rotation, then only coordinate graphs change.
- // 2. If we have any dB, all vector plots change.
- if (!usesDeltaB()) {
- Plotly.restyle(plotsDiv, {
- y: traces.map(t => vectors.map(v => v[t]))
- }, traces);
- } else {
- const vectorsdB = vectors.map(applyDeltaB);
- const newTraces = traces.map(t => vectorsdB.map(v => v[t]));
- newTraces.push(vectorsdB.map(v => v.magnitude));
- Plotly.restyle(plotsDiv, {
- y: newTraces
- }, traces.concat(3));
- }
-
- // The smoothed overlay depends on the same rotation, so rebuild it too.
+ const raw = activeSession().measurements.map(rotatedHEZ);
+ const vectors = usesDeltaB() ? raw.map(applyDeltaB) : raw;
+ // Always restyle H/E/Z and magnitude together, so toggling dB on or off
+ // never leaves a stale trace behind (magnitude in particular).
+ Plotly.restyle(plotsDiv, {
+ y: [
+ vectors.map(v => v[0]),
+ vectors.map(v => v[1]),
+ vectors.map(v => v[2]),
+ vectors.map(v => parseFloat(v.magnitude.toFixed(3))),
+ ],
+ }, [0, 1, 2, 3]);
+ // The smoothed overlay depends on the same data, so rebuild it too.
if (settings.filter.enabled) {
recomputeFiltered();
}
@@ -433,19 +419,31 @@ function extendAllTraces(measurement) {
// computed: we can append the newest one rather than rebuilding the series.
if (settings.filter.enabled) {
const ms = activeSession().measurements;
- const smoothed = trailingAverageAt(
- ms, ms.length - 1, settings.filter.windowSec);
- let filtVec = rotatedHEZ(smoothed);
- if (usesDeltaB()) {
- filtVec = applyDeltaB(filtVec);
+ const windowSec = settings.filter.windowSec;
+ const db = usesDeltaB();
+ const smoothed = trailingAverageAt(ms, ms.length - 1, windowSec);
+ // H/E/Z: vector moving average.
+ const filtVec = db
+ ? applyDeltaB(rotatedHEZ(smoothed))
+ : rotatedHEZ(smoothed);
+ // Magnitude: trailing average of the displayed magnitude (see
+ // recomputeFiltered), computed over the window ending at this sample.
+ const startMs = measurement.ts.getTime() - (windowSec * 1000);
+ let magSum = 0, magCount = 0;
+ for (let i = ms.length - 1; i >= 0; i--) {
+ if (ms[i].ts.getTime() < startMs) break;
+ const v = db ? applyDeltaB(rotatedHEZ(ms[i])) : rotatedHEZ(ms[i]);
+ magSum += v.magnitude;
+ magCount++;
}
+ const filtMag = magCount ? magSum / magCount : 0;
Plotly.extendTraces(plotsDiv, {
x: [[ts], [ts], [ts], [ts], [ts]],
y: [
[filtVec[0]],
[filtVec[1]],
[filtVec[2]],
- [parseFloat(filtVec.magnitude.toFixed(3))],
+ [parseFloat(filtMag.toFixed(3))],
[smoothed.celsius],
],
}, FILTER_TRACES);
@@ -462,13 +460,43 @@ function updateSparks() {
}
/**
- * Builds the spreadsheet table row for a measurement, applying the active
- * source's coordinate transform. The moving-average filter intentionally does
- * not affect the spreadsheet — it always shows the rotated raw reading.
+ * Rate of change of the field magnitude between two readings, in nT/s. Uses the
+ * rotated raw magnitude (independent of any delta-B baseline) over the actual
+ * elapsed time, so it stays correct even if the feed isn't exactly 1 Hz.
+ * @param {Measurement} curr
+ * @param {Measurement} prev
+ * @returns {number} nT/s (0 if the timestamps don't advance)
+ */
+function dBdtValue(curr, prev) {
+ const dtSec = (curr.ts.getTime() - prev.ts.getTime()) / 1000;
+ if (dtSec <= 0) {
+ return 0;
+ }
+ return (rotatedHEZ(curr).magnitude - rotatedHEZ(prev).magnitude) / dtSec;
+}
+
+/**
+ * Formats a value with an explicit sign (+, -, or ± for zero).
+ * @param {number} v
+ * @returns {string}
+ */
+function formatSigned(v) {
+ const s = v.toFixed(3);
+ if (parseFloat(s) === 0) {
+ return `±${(0).toFixed(3)}`;
+ }
+ return v > 0 ? `+${s}` : s;
+}
+
+/**
+ * Builds a spreadsheet row for a measurement, applying rotation and (if set)
+ * the delta-B baseline. The trailing dB/dt cell is computed against the
+ * previous reading and is shown/hidden via the #spreadsheet `.show-dbdt` class.
* @param {Measurement} measurement
+ * @param {Measurement} [prev] the previous reading, for dB/dt
* @returns {string} the `
` markup for this measurement
*/
-function spreadsheetRowHTML(measurement) {
+function spreadsheetRowHTML(measurement, prev) {
// Local time on the client end, for the operator's convenience.
const date = new Intl.DateTimeFormat("en-US", {
hour12: false,
@@ -484,6 +512,7 @@ function spreadsheetRowHTML(measurement) {
if (usesDeltaB()) {
dispVector = applyDeltaB(dispVector);
}
+ const dBdt = prev ? formatSigned(dBdtValue(measurement, prev)) : "–";
return `
@@ -493,6 +522,7 @@ function spreadsheetRowHTML(measurement) {
${dispVector[2].toFixed(3)}
${dispVector.magnitude.toFixed(3)}
${measurement.celsius.toFixed(2)}
+
${dBdt}
`;
}
@@ -501,16 +531,18 @@ function spreadsheetRowHTML(measurement) {
* @param {Measurement} measurement
*/
function addSpreadsheetRow(measurement) {
+ const ms = activeSession().measurements;
document.querySelector("#spreadsheet table tbody").insertAdjacentHTML(
- "afterbegin", spreadsheetRowHTML(measurement));
+ "afterbegin", spreadsheetRowHTML(measurement, ms[ms.length - 2]));
// TODO: Remove last row once we're at max buffer size?
}
/** Rebuilds every spreadsheet row from the active source's buffer. */
function rebuildSpreadsheet() {
// measurements run oldest -> newest, but the table shows newest at the top.
+ const ms = activeSession().measurements;
document.querySelector("#spreadsheet table tbody").innerHTML =
- activeSession().measurements.map(spreadsheetRowHTML).reverse().join("");
+ ms.map((m, i) => spreadsheetRowHTML(m, ms[i - 1])).reverse().join("");
}
/**
@@ -522,28 +554,24 @@ function updateCurrentTable(m) {
if (usesDeltaB()) {
dispVec = applyDeltaB(dispVec);
}
-
- let dB = "±0.000";
- if (ms.length > 1) {
- let prev = rotatedHEZ(ms[ms.length - 2]);
- if (usesDeltaB()) {
- prev = applyDeltaB(prev, 2);
- }
- const diff = dispVec.magnitude - prev.magnitude;
- dB = diff.toFixed(3);
- if (parseFloat(dB) === 0) {
- dB = `±${dB}`;
- } else if (dB.charAt(0) !== "-") {
- dB = `+${dB}`;
- }
- }
+ const dBdt = ms.length > 1
+ ? formatSigned(dBdtValue(m, ms[ms.length - 2]))
+ : "±0.000";
document.getElementById("h").textContent = dispVec[0].toFixed(3);
document.getElementById("e").textContent = dispVec[1].toFixed(3);
document.getElementById("z").textContent = dispVec[2].toFixed(3);
document.getElementById("mag").textContent = dispVec.magnitude.toFixed(3);
document.getElementById("temp").textContent = m.celsius.toFixed(2);
- document.getElementById("dBdt").textContent = dB;
+ document.getElementById("dBdt").textContent = dBdt;
+}
+
+/** Re-renders the Current Reading panel from the newest reading, if any. */
+function refreshCurrentReading() {
+ const ms = activeSession().measurements;
+ if (ms.length > 0) {
+ updateCurrentTable(ms[ms.length - 1]);
+ }
}
// ----------------------------------------------------------------------------
@@ -910,6 +938,7 @@ document.getElementById("saveRot").addEventListener("click", () => {
saveSettings();
updateCoordGraphs();
rebuildSpreadsheet();
+ refreshCurrentReading();
});
// ----------------------------------------------------------------------------
@@ -942,17 +971,45 @@ const dZ = document.getElementById("dZ");
document.getElementById("savedB").addEventListener("click", () => {
const src = activeSource();
- const field = document.querySelector("#dBType button:disabled").name;
- const isMoving = field === "moving";
- src.dB.moving = isMoving;
- if (!isMoving) {
- src.dB.h = dH.valueAsNumber;
- src.dB.e = dE.valueAsNumber;
- src.dB.z = dZ.valueAsNumber;
+ const method = document.querySelector("#dBType button:disabled").name;
+ src.dB.moving = method === "moving";
+ if (method === "moving") {
+ // Snapshot the current trailing moving average as a fixed baseline.
+ const ms = activeSession().measurements;
+ if (ms.length === 0) {
+ return; // nothing to baseline against yet
+ }
+ const base = rotatedHEZ(
+ trailingAverageAt(ms, ms.length - 1, settings.filter.windowSec));
+ src.dB.h = base[0];
+ src.dB.e = base[1];
+ src.dB.z = base[2];
+ dH.value = base[0];
+ dE.value = base[1];
+ dZ.value = base[2];
+ } else {
+ src.dB.h = dH.valueAsNumber || 0;
+ src.dB.e = dE.valueAsNumber || 0;
+ src.dB.z = dZ.valueAsNumber || 0;
}
saveSettings();
updateCoordGraphs();
rebuildSpreadsheet();
+ refreshCurrentReading();
+});
+
+// Reset delta-B back to zero (absolute view).
+document.getElementById("resetdB").addEventListener("click", () => {
+ const src = activeSource();
+ src.dB = { moving: false, h: 0, e: 0, z: 0 };
+ dH.value = "";
+ dE.value = "";
+ dZ.value = "";
+ showMethodFields("constant");
+ saveSettings();
+ updateCoordGraphs();
+ rebuildSpreadsheet();
+ refreshCurrentReading();
});
/**
@@ -1032,6 +1089,9 @@ function updatedBdt() {
row2.classList.remove("dB");
cell.style.display = "none";
}
+ // Show/hide the spreadsheet's dB/dt column to match.
+ document.getElementById("spreadsheet")
+ .classList.toggle("show-dbdt", settings.dBdt);
}
dBdtToggle.addEventListener("change", ev => {
settings.dBdt = ev.target.checked;
From eb517478ef90727c1cd66268c1764cfa1a689cb2 Mon Sep 17 00:00:00 2001
From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com>
Date: Mon, 6 Jul 2026 19:36:58 -0400
Subject: [PATCH 10/64] Update AI usage log with git hash for 34a998d
Co-Authored-By: Claude Opus 4.8 (1M context)
---
ai/ai_usage_log.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ai/ai_usage_log.md b/ai/ai_usage_log.md
index ceed4d2..5a1ef8d 100644
--- a/ai/ai_usage_log.md
+++ b/ai/ai_usage_log.md
@@ -79,4 +79,4 @@ Required per University of Scranton AI Policy, HamSCI Generative AI Use Agreemen
- **Sections/Files Affected**: `js/index.js` (delta-B snapshot baseline + O(1) applyDeltaB, Reset handler, updateCoordGraphs, dB/dt via real Δt, spreadsheet dB/dt column, magnitude filter via avg-of-magnitudes), `js/filter.js` (new trailingScalarMean helper), `index.html` (Reset (absolute) button, dB/dt table header), `css/index.css` (dB/dt column show/hide, scrollbar-gutter alignment for the fixed header + scrolling body)
- **Nature of Contribution**: Code generation (features + bug fixes)
- **Human Review Status**: Reviewed and verified (delta-B constant/moving, no overlay cancellation, reset, dB/dt value + column toggle, magnitude filter tracking the raw scatter, and header/data column alignment all verified via headless browser plus a numeric check against the real filter code; author confirmed the alignment fix)
-- **Git Hash**: [pending]
+- **Git Hash**: 34a998d
From b05a16a18aae2b8c27d4e5199b9fa0cb5975ce82 Mon Sep 17 00:00:00 2001
From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com>
Date: Mon, 6 Jul 2026 19:49:01 -0400
Subject: [PATCH 11/64] Add column styling
---
css/index.css | 1 +
index.html | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/css/index.css b/css/index.css
index 8675c2c..63e5f98 100755
--- a/css/index.css
+++ b/css/index.css
@@ -216,6 +216,7 @@ table {
overflow-y: hidden keeps the header itself unscrollable / gutter-only. */
overflow-y: hidden;
scrollbar-gutter: stable;
+ background-color: #333;
}
.scrolling {
diff --git a/index.html b/index.html
index fdf9c57..1bc708a 100755
--- a/index.html
+++ b/index.html
@@ -268,7 +268,7 @@
diff --git a/js/index.js b/js/index.js
index 80ab60c..c6ef6f4 100644
--- a/js/index.js
+++ b/js/index.js
@@ -835,6 +835,9 @@ function renderActive() {
updateLock = false;
}
+// ----------------------------------------------------------------------------
+// Import: Load JSONL
+// ----------------------------------------------------------------------------
/**
* Loads a JSONL .log file into the active session, replacing its buffer.
* @param {File} file the .log file selected by the user
@@ -888,6 +891,37 @@ function loadLogFile(file) {
});
}
+// ----------------------------------------------------------------------------
+// Spreadsheet: Export JSONL/CSV (Active Source)
+// ----------------------------------------------------------------------------
+function exportFile(filename, text, mime) {
+ const url = URL.createObjectURL(new Blob([text], { type: mime }));
+ const a = Object.assign(document.createElement("a"), {
+ href: url,
+ download: filename
+ });
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+}
+document.getElementById("jsonl").addEventListener("click", ev => {
+ const { name } = activeSource();
+ const { measurements } = activeSession();
+ const ts = measurements[measurements.length - 1].ts.toISOString();
+ const jsonls = measurements.map(m => m.toJSONL());
+ const buffer = jsonls.join("\n");
+ exportFile(`${name}_${ts}.log`, buffer, "text/plain");
+});
+document.getElementById("csv").addEventListener("click", ev => {
+ const { name } = activeSource();
+ const { measurements } = activeSession();
+ const ts = measurements[measurements.length - 1].ts.toISOString();
+ const csvs = activeSession().measurements.map(m => m.toCSV());
+ const buffer = `\uFEFFts,rt,x,y,z\n${csvs.join("\n")}`;
+ exportFile(`${name}_${ts}.csv`, buffer, "text/csv");
+});
+
// ----------------------------------------------------------------------------
// Sidebar: toggle
// ----------------------------------------------------------------------------
From eb039f3ac4a675eb64cffc42aed3fbb7aa5f3f08 Mon Sep 17 00:00:00 2001
From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com>
Date: Tue, 7 Jul 2026 15:17:01 -0400
Subject: [PATCH 15/64] [AI-assisted] Make day-size .log import performant:
O(N) filter + spreadsheet virtualization (#15)
Cut full-day (86,400-row) .log import from ~44s to ~3.2s:
- filter.js: slidingWindowMeans O(N) running-sum replaces O(N*W) recompute
- index.js: recomputeFiltered single-pass; virtualize spreadsheet (spacer
rows + visible window, shared date formatter) so import populates instantly
- css: .stripe-class striping and .spacer rows for the virtualized body
Co-Authored-By: Claude Opus 4.8 (1M context)
---
ai/ai_usage_log.md | 8 ++
css/index.css | 12 ++-
js/filter.js | 40 ++++++++++
js/index.js | 194 +++++++++++++++++++++++++++++++--------------
4 files changed, 193 insertions(+), 61 deletions(-)
diff --git a/ai/ai_usage_log.md b/ai/ai_usage_log.md
index 5a1ef8d..7208b23 100644
--- a/ai/ai_usage_log.md
+++ b/ai/ai_usage_log.md
@@ -80,3 +80,11 @@ Required per University of Scranton AI Policy, HamSCI Generative AI Use Agreemen
- **Nature of Contribution**: Code generation (features + bug fixes)
- **Human Review Status**: Reviewed and verified (delta-B constant/moving, no overlay cancellation, reset, dB/dt value + column toggle, magnitude filter tracking the raw scatter, and header/data column alignment all verified via headless browser plus a numeric check against the real filter code; author confirmed the alignment fix)
- **Git Hash**: 34a998d
+
+## [2026-07-07 15:15 EDT]
+- **Tool**: Claude (Anthropic), claude-opus-4-8
+- **Session Purpose**: Make historical .log file import performant at full-day size (86,400 rows) for issue #15. Two changes: (1) replaced the O(N·W) trailing moving-average recompute with an O(N) sliding-window running-sum pass (`slidingWindowMeans`), eliminating the per-point re-summation and transient-object GC churn that dominated a day-size load; (2) virtualized the spreadsheet so only the visible row window (plus overscan) is in the DOM, with off-screen height reserved by spacer `
`s and a single shared date formatter — so import populates the table instantly instead of building 86,400 DOM rows. Together these cut the full import (filter on) from ~44s to ~3.2s. `loadLogFile` now populates the spreadsheet; the filter is applied across the whole file and the plot range spans the whole dataset (goals #1–#3).
+- **Sections/Files Affected**: `js/filter.js` (new `slidingWindowMeans` O(N) helper), `js/index.js` (`recomputeFiltered` single-pass rewrite; spreadsheet virtualization — `spacerRow`/`windowRows`/`renderSpreadsheet`/`resetSpreadsheet`, rewritten `rebuildSpreadsheet`/`addSpreadsheetRow`, shared `DATE_FMT`, scroll handler; `loadLogFile`/`renderActive`/`clearActiveView` repointed at the virtualized renderer), `css/index.css` (`.stripe`-class striping and `.spacer` rows for the virtualized body)
+- **Nature of Contribution**: Code generation (performance optimization)
+- **Human Review Status**: Reviewed and verified (86,400-row import measured at ~3.2s with 35 DOM rows; filter=86,400 points, range=24h; scroll top/mid/bottom show newest/midday/oldest with the body bounded to 325px; live WebSocket path prepends newest-first and updates the Current Reading — all verified via headless Chromium against a day-size log and a fake WS host)
+- **Git Hash**: [fill in after committing]
diff --git a/css/index.css b/css/index.css
index 17fc9c5..0e2c3f2 100755
--- a/css/index.css
+++ b/css/index.css
@@ -269,11 +269,19 @@ tbody tr:hover {
background: #bef;
}
-/* Alternate row color slightly */
-tr:nth-of-type(2n) {
+/* Alternate row color slightly. The spreadsheet body is virtualized (only the
+ visible window is in the DOM), so striping is keyed to a per-row .stripe class
+ set from the stable display index rather than :nth-of-type. */
+.scrolling tr.stripe {
background-color: rgba(0, 0, 0, 0.05);
}
+/* Virtualization spacers reserve off-screen scroll height; never highlight. */
+.scrolling tr.spacer,
+.scrolling tr.spacer:hover {
+ background: none;
+}
+
#status {
text-align: right;
}
diff --git a/js/filter.js b/js/filter.js
index f5bbd3b..1549f67 100644
--- a/js/filter.js
+++ b/js/filter.js
@@ -82,3 +82,43 @@ export function trailingScalarMean(measurements, values, windowSec) {
return sum / count;
});
}
+
+/**
+ * O(N) trailing moving average over a time window for several parallel scalar
+ * series at once, using a running sum (advance-two-pointers) instead of
+ * re-summing the window per point. Same trailing-window semantics as
+ * trailingAverageAt: each output at index i averages every sample whose
+ * timestamp is within (times[i] - windowSec, times[i]].
+ *
+ * This is what the overlay recompute uses at day-size loads (86,400+ points),
+ * where the O(N·W) per-point approach and per-point object construction are the
+ * dominant cost.
+ *
+ * @param {number[]} times ascending millisecond timestamps
+ * @param {number[][]} series parallel scalar arrays, each aligned with `times`
+ * @param {number} windowSec trailing window length, in seconds
+ * @returns {number[][]} one averaged array per input series, aligned with `times`
+ */
+export function slidingWindowMeans(times, series, windowSec) {
+ const winMs = windowSec * 1000;
+ const k = series.length;
+ const sums = new Array(k).fill(0);
+ const out = series.map(() => new Array(times.length));
+ let left = 0;
+ for (let end = 0; end < times.length; end++) {
+ for (let s = 0; s < k; s++) {
+ sums[s] += series[s][end];
+ }
+ while (times[left] < times[end] - winMs) {
+ for (let s = 0; s < k; s++) {
+ sums[s] -= series[s][left];
+ }
+ left++;
+ }
+ const n = end - left + 1;
+ for (let s = 0; s < k; s++) {
+ out[s][end] = sums[s] / n;
+ }
+ }
+ return out;
+}
diff --git a/js/index.js b/js/index.js
index c6ef6f4..6999069 100644
--- a/js/index.js
+++ b/js/index.js
@@ -1,7 +1,7 @@
///
import Measurement from "./Measurement.js";
import { buildSparklineTraces, reduceBucket } from "./sparklines.js";
-import { trailingAverageAt, movingAverage, trailingScalarMean } from "./filter.js";
+import { trailingAverageAt, slidingWindowMeans } from "./filter.js";
import plotsInit from "./data/plots.json" with { type: "json" };
import slInit from "./data/sparklines.json" with { type: "json" };
import Vector from "./Vector.js";
@@ -308,30 +308,32 @@ function recomputeFiltered() {
const ms = activeSession().measurements;
const windowSec = settings.filter.windowSec;
const db = usesDeltaB();
- const smoothed = movingAverage(ms, windowSec);
- const x = smoothed.map(m => m.ts);
- // H/E/Z overlay: the moving average of the vector (linear, so this is the
- // smoothing of each displayed component).
- const vectors = db
- ? smoothed.map(m => applyDeltaB(rotatedHEZ(m)))
- : smoothed.map(rotatedHEZ);
- // Magnitude overlay: smooth the displayed magnitude itself (average of the
- // per-sample magnitudes), not |averaged vector| — the latter collapses
- // under delta-B because the deviations cancel directionally.
- const dispMag = ms.map(m => {
- const v = db ? applyDeltaB(rotatedHEZ(m)) : rotatedHEZ(m);
- return v.magnitude;
- });
- const magFilter = trailingScalarMean(ms, dispMag, windowSec);
+
+ // One O(N) pass: build per-sample displayed components + magnitude, then
+ // sliding-window-average all five series at once. H/E/Z are the vector
+ // average (linear); magnitude averages the per-sample magnitude (so it
+ // doesn't collapse under delta-B). No per-point Measurement construction.
+ const n = ms.length;
+ const times = new Array(n);
+ const x = new Array(n);
+ const H = new Array(n), E = new Array(n), Z = new Array(n);
+ const T = new Array(n), M = new Array(n);
+ for (let i = 0; i < n; i++) {
+ const m = ms[i];
+ let v = rotatedHEZ(m);
+ if (db) {
+ v = applyDeltaB(v);
+ }
+ times[i] = m.ts.getTime();
+ x[i] = m.ts;
+ H[i] = v[0]; E[i] = v[1]; Z[i] = v[2];
+ T[i] = m.celsius; M[i] = v.magnitude;
+ }
+ const [mh, me, mz, mt, mmag] =
+ slidingWindowMeans(times, [H, E, Z, T, M], windowSec);
Plotly.restyle(plotsDiv, {
x: FILTER_TRACES.map(() => x),
- y: [
- vectors.map(v => v[0]),
- vectors.map(v => v[1]),
- vectors.map(v => v[2]),
- magFilter.map(v => parseFloat(v.toFixed(3))),
- smoothed.map(m => m.celsius),
- ],
+ y: [mh, me, mz, mmag.map(v => parseFloat(v.toFixed(3))), mt],
}, FILTER_TRACES);
}
@@ -496,55 +498,130 @@ function formatSigned(v) {
* @param {Measurement} [prev] the previous reading, for dB/dt
* @returns {string} the `
` markup for this measurement
*/
-function spreadsheetRowHTML(measurement, prev) {
- // Local time on the client end, for the operator's convenience.
- const date = new Intl.DateTimeFormat("en-US", {
- hour12: false,
- month: "numeric",
- day: "numeric",
- year: "2-digit",
- hour: "numeric",
- minute: "2-digit",
- second: "2-digit"
- }).format(measurement.ts);
-
- let dispVector = rotatedHEZ(measurement);
+// One shared formatter — creating a new Intl.DateTimeFormat per row was a major
+// cost at day-size.
+const DATE_FMT = new Intl.DateTimeFormat("en-US", {
+ hour12: false, month: "numeric", day: "numeric", year: "2-digit",
+ hour: "numeric", minute: "2-digit", second: "2-digit",
+});
+
+/**
+ * Builds one spreadsheet row. `d` is the display index (newest-first), used for
+ * stable zebra striping under virtualization.
+ * @param {Measurement} measurement
+ * @param {Measurement} [prev] the previous reading in time, for dB/dt
+ * @param {number} d display index (0 = newest)
+ * @returns {string} the `
` markup
+ */
+function spreadsheetRowHTML(measurement, prev, d) {
+ const date = DATE_FMT.format(measurement.ts);
+ let v = rotatedHEZ(measurement);
if (usesDeltaB()) {
- dispVector = applyDeltaB(dispVector);
+ v = applyDeltaB(v);
}
const dBdt = prev ? formatSigned(dBdtValue(measurement, prev)) : "–";
-
+ const stripe = d % 2 ? ' class="stripe"' : "";
return `
-
+
${date}
-
${dispVector[0].toFixed(3)}
-
${dispVector[1].toFixed(3)}
-
${dispVector[2].toFixed(3)}
-
${dispVector.magnitude.toFixed(3)}
+
${v[0].toFixed(3)}
+
${v[1].toFixed(3)}
+
${v[2].toFixed(3)}
+
${v.magnitude.toFixed(3)}
${measurement.celsius.toFixed(2)}
${dBdt}
`;
}
+// ---- Spreadsheet virtualization ---------------------------------------------
+// The buffer can hold a full day (86,400 rows); rendering every row is ~7.5s +
+// huge memory. Instead we render only the rows visible in the scroll viewport
+// (plus a small overscan) and reserve the off-screen height with two spacer
+// rows. (Padding on the scrolling tbody can't be used here: as a flex child it
+// keeps its own padding in its min-size, so a day-size padding would blow past
+// the flex bound and defeat the scroll viewport — a spacer