From 75bcfc82afc6432fca21175cdfeb18ba200aff44 Mon Sep 17 00:00:00 2001 From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:05:40 -0400 Subject: [PATCH 01/64] Add MQTT types --- deno.json | 1 + js/index.d.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/deno.json b/deno.json index f82535b..6c2a347 100644 --- a/deno.json +++ b/deno.json @@ -5,6 +5,7 @@ "imports": { "@std/assert": "jsr:@std/assert@1", "@types/plotly.js": "npm:@types/plotly.js@^3.0.10", + "mqtt": "npm:mqtt@^5.15.1", "plotly.js": "npm:plotly.js@^3.3.1" } } diff --git a/js/index.d.ts b/js/index.d.ts index 0610b00..01b9980 100644 --- a/js/index.d.ts +++ b/js/index.d.ts @@ -1,5 +1,6 @@ interface Global { Plotly: typeof import("@types/plotly.js"); + mqtt: typeof import("mqtt"); } interface Window extends Global {} \ No newline at end of file From 9cb30f5a06609089c180d88923655c8142ab8b93 Mon Sep 17 00:00:00 2001 From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:17:50 -0400 Subject: [PATCH 02/64] Start implementing dB --- css/index.css | 23 +++-- index.html | 59 ++++++++++--- js/Vector.js | 12 ++- js/index.d.ts | 79 ++++++++++++++++- js/index.js | 239 +++++++++++++++++++++++++++++++++++++++++++------- js/transit.js | 6 +- 6 files changed, 361 insertions(+), 57 deletions(-) diff --git a/css/index.css b/css/index.css index 6fdfb6b..52daae6 100755 --- a/css/index.css +++ b/css/index.css @@ -54,10 +54,6 @@ main { min-height: 0; } -/* main > div { - height: 20%; -} */ - #plots { height: 100%; } @@ -373,6 +369,7 @@ tr:nth-of-type(2n) { .field input[type="text"], .field input[type="password"], .field input[type="url"], +.field input[type="number"], .field select { width: 100%; border: 1px solid transparent; @@ -404,10 +401,21 @@ tr:nth-of-type(2n) { } /* Fix spacing issues for MQTT broker panel */ -.type-fields[data-type="mqtt"] > .field:not(:first-of-type) { +.type-fields[data-type="mqtt"] > .field:not(:first-of-type)/*, +.method-fields[data-type="constant"] > .field:not(:first-of-type)*/ { padding-top: 10px; } +.method-fields[data-type="constant"] { + display: flex; + flex-direction: row; + gap: 5px; +} + +#delta { + rotate: -90deg; +} + /* Subheads inside a panel body */ .subhead { display: flex; @@ -502,11 +510,12 @@ tr:nth-of-type(2n) { width: 100%; } .slider-group > span { - text-align: right; + text-align: center; font-variant-numeric: tabular-nums; } -.type-fields[hidden] { +.type-fields[hidden], +.method-fields[hidden] { display: none; } diff --git a/index.html b/index.html index c96874d..42bc660 100755 --- a/index.html +++ b/index.html @@ -89,6 +89,9 @@ + + NOTE: Large .log files will slow the dashboard! +
- - Moving average + + delta-B
-
- - +
+ Calculation Method +
+ + +
- +
+ + + +
+ +
@@ -165,6 +182,22 @@ +
+ + Moving average +
+
+ + +
+ diff --git a/js/Vector.js b/js/Vector.js index 3bef0ff..c6c31e7 100755 --- a/js/Vector.js +++ b/js/Vector.js @@ -96,7 +96,7 @@ export default class Vector { angle *= Math.PI / 180; } // JC: For the given use case, doing it this way instead of hardcoded - // axis flipping is a little bit more involved, but it give us more + // axis flipping is a little bit more involved, but it gives us more // flexibility. const { sin, cos } = Math; const [x, y, z] = this; @@ -152,4 +152,14 @@ export default class Vector { scale(k) { return new Vector(k * this[0], k * this[1], k * this[2]); } + + /** + * Returns the computation of `this` - `dB`. + * @param {number} dX delta-X + * @param {number} dY delta-Y + * @param {number} dZ delta-Z + */ + delta(dX, dY, dZ) { + return new Vector(this[0] - dX, this[1] - dY, this[2] - dZ); + } } \ No newline at end of file diff --git a/js/index.d.ts b/js/index.d.ts index 01b9980..4eb67e2 100644 --- a/js/index.d.ts +++ b/js/index.d.ts @@ -1,6 +1,81 @@ +import Measurement from "./Measurement.js"; + interface Global { - Plotly: typeof import("@types/plotly.js"); mqtt: typeof import("mqtt"); + MagConnection: { + create: (config: ConnectionConfig, handlers: { + onReading?: Function; + onStatus?: (code: number, retry: number) => void; + }) => { + connect: () => void; + disconnect: () => void; + }; + }; + Plotly: typeof import("@types/plotly.js"); +} + +interface Window extends Global {} + +interface MagUsbJson { + ts: string; + rt: number; + x: number; + y: number; + z: number; +} + +type ConnectionType = "websocket" | "mqtt"; +type SourceType = ConnectionType | "file"; + +interface MQTTSettings { + broker: string; + topic: string; + username?: string; + password?: string; +} + +interface ConnectionConfig { + type: ConnectionType; + websocket: { + url: string; + }; + mqtt: MQTTSettings; +} + +interface Source { + id: string; + name: string; + type: SourceType; + websocket: { + url: string; + }; + mqtt: MQTTSettings; + transform: { + x: number; + y: number; + z: number; + }; + dB: { + moving: boolean; + h: number; + e: number; + z: number; + }; +} + +interface DashSettings { + displayWindow: string; + filter: { + enabled: boolean; + windowSec: number; + }; + sources: Source[]; + activeSourceId: string; } -interface Window extends Global {} \ No newline at end of file +interface Session { + id: string; + measurements: Measurement[]; + sparklines: Measurement[]; + sBucket: Measurement[]; +} \ No newline at end of file diff --git a/js/index.js b/js/index.js index 7815d8a..863e48b 100644 --- a/js/index.js +++ b/js/index.js @@ -4,6 +4,7 @@ import { buildSparklineTraces, reduceBucket } from "./sparklines.js"; import { trailingAverageAt, movingAverage } 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"; const timeRanges = { "1m": 60, @@ -33,7 +34,9 @@ const RAW_FADED_OPACITY = 0.25; // Moving average and time window are shared across all sources; each source // (tab) owns its own connection config and coordinate transform. -/** @returns {string} a unique id (falls back when crypto is unavailable) */ +/** + * @returns {string} a unique id (falls back when crypto is unavailable) + */ function uid() { if (window.crypto && window.crypto.randomUUID) { return window.crypto.randomUUID(); @@ -53,10 +56,13 @@ function makeSource(name = "Source 1") { websocket: { url: "" }, mqtt: { broker: "", topic: "", username: "", password: "" }, transform: { x: 0, y: 0, z: 0 }, + dB: { moving: false, h: 0, e: 0, z: 0 }, }; } -/** @returns {DashSettings} */ +/** + * @returns {DashSettings} + */ function defaultSettings() { const src = makeSource(); return { @@ -95,6 +101,7 @@ function migrateSettings(s) { password: (old.mqtt && old.mqtt.password) || "", }, transform: s.transform || { x: 0, y: 0, z: 0 }, + dB: s.dB || { moving: false, h: 0, e: 0, z: 0 }, }]; s.activeSourceId = s.sources[0].id; } @@ -124,11 +131,16 @@ function saveSettings() { } saveSettings(); // persist the normalized shape -/** @param {string} id @returns {Source|undefined} */ +/** + * @param {string} id + * @returns {Source|undefined} + */ function getSource(id) { return settings.sources.find(s => s.id === id); } -/** @returns {Source} */ +/** + * @returns {Source} + */ function activeSource() { return getSource(settings.activeSourceId); } @@ -150,7 +162,10 @@ function activeSource() { /** @type {Map} */ const sessions = new Map(); -/** @param {Source} source @returns {Session} */ +/** + * @param {Source} source + * @returns {Session} + */ function makeSession(source) { return { id: source.id, @@ -248,13 +263,47 @@ drawSparkPlot(structuredClone(slInit.traces)); * @returns {Vector} the rotated HEZ vector ready for display */ function rotatedHEZ(m) { - const { x, y, z } = activeSource().transform; + const { transform: { x, y, z } } = activeSource(); return m.HEZ .rotate("x", x, false) .rotate("y", y, false) .rotate("z", z, false); } +/** + * Applies a delta-Baseline to a vector. + * + * Assumption: v is in HEZ. + * @param {Vector} v the vector to adjust + * @returns {Vector} v - dB + */ +function applyDeltaB(v) { + const { transform: { x, y, z } } = activeSource(); + /** @type {number[]} */ + let deltaB; + if (activeSource().dB.moving) { + const { measurements } = activeSession(); + deltaB = trailingAverageAt(measurements, + measurements.length - 1, 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); +} + +/** + * @returns {boolean} + */ +function usesDeltaB() { + const { dB: { moving, h, e, z } } = activeSource(); + return moving || (h || e || z); +} + /** Resets both plots to their initial empty state (full re-init). */ function resetPlots() { drawMainPlot(structuredClone(plotsInit.traces)); @@ -269,7 +318,10 @@ function recomputeFiltered() { const smoothed = movingAverage( activeSession().measurements, settings.filter.windowSec); const x = smoothed.map(m => m.ts); - const vectors = smoothed.map(rotatedHEZ); + let vectors = smoothed.map(rotatedHEZ); + if (usesDeltaB()) { + vectors = vectors.map(applyDeltaB); + } Plotly.restyle(plotsDiv, { x: FILTER_TRACES.map(() => x), y: [ @@ -303,12 +355,26 @@ function refreshFilter() { function updateCoordGraphs() { const vectors = activeSession().measurements.map(rotatedHEZ); - // Only the coordinate graphs actually change; magnitude is rotation-invariant. + /** @type {[0, 1, 2]} */ const traces = [0, 1, 2]; - Plotly.restyle(plotsDiv, { - y: traces.map(t => vectors.map(v => v[t])) - }, traces); + + // 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. if (settings.filter.enabled) { recomputeFiltered(); @@ -341,7 +407,10 @@ function updateRange() { * @param {Measurement} measurement */ function extendAllTraces(measurement) { - const dispVec = rotatedHEZ(measurement); + let dispVec = rotatedHEZ(measurement); + if (usesDeltaB()) { + dispVec = applyDeltaB(dispVec); + } const { ts } = measurement; Plotly.extendTraces(plotsDiv, { x: [[ts], [ts], [ts], [ts], [ts]], @@ -360,7 +429,10 @@ function extendAllTraces(measurement) { const ms = activeSession().measurements; const smoothed = trailingAverageAt( ms, ms.length - 1, settings.filter.windowSec); - const filtVec = rotatedHEZ(smoothed); + let filtVec = rotatedHEZ(smoothed); + if (usesDeltaB()) { + filtVec = applyDeltaB(filtVec); + } Plotly.extendTraces(plotsDiv, { x: [[ts], [ts], [ts], [ts], [ts]], y: [ @@ -402,7 +474,10 @@ function spreadsheetRowHTML(measurement) { second: "2-digit" }).format(measurement.ts); - const dispVector = rotatedHEZ(measurement); + let dispVector = rotatedHEZ(measurement); + if (usesDeltaB()) { + dispVector = applyDeltaB(dispVector); + } return ` @@ -436,11 +511,14 @@ function rebuildSpreadsheet() { * @param {Measurement} m */ function updateCurrentTable(m) { - const dispVec = rotatedHEZ(m); + let dispVec = rotatedHEZ(m); + if (usesDeltaB()) { + dispVec = applyDeltaB(dispVec); + } 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 = m.HEZ.magnitude.toFixed(3); + document.getElementById("mag").textContent = dispVec.magnitude.toFixed(3); document.getElementById("temp").textContent = m.celsius.toFixed(2); } @@ -592,7 +670,10 @@ function handleHardwareError(session) { } } -/** @param {string} id @returns {{onReading: function, onStatus: function}} */ +/** + * @param {string} id + * @returns {{onReading: function, onStatus: function}} + */ function connectionHandlers(id) { return { onReading: json => handleReading(id, json), @@ -668,7 +749,10 @@ function renderActive() { if (ms.length > 0) { const times = ms.map(m => m.ts); - const vecs = ms.map(rotatedHEZ); + let vecs = ms.map(rotatedHEZ); + if (usesDeltaB()) { + vecs = vecs.map(applyDeltaB); + } Plotly.update(plotsDiv, { x: [times, times, times, times, times], y: [ @@ -777,7 +861,10 @@ const xDel = document.getElementById("xDelta"); const yDel = document.getElementById("yDelta"); const zDel = document.getElementById("zDelta"); -/** Loads a source's transform into the rotation sliders. @param {Source} src */ +/** + * Loads a source's transform into the rotation sliders. + * @param {Source} src + */ function loadRotation(src) { rotX.value = src.transform.x; rotY.value = src.transform.y; @@ -801,6 +888,60 @@ document.getElementById("saveRot").addEventListener("click", () => { rebuildSpreadsheet(); }); +// ---------------------------------------------------------------------------- +// Processing: variations from delta-B (per source) +// ---------------------------------------------------------------------------- +const dBType = document.getElementById("dBType"); +const methodBtns = [...dBType.querySelectorAll("button")]; + +/** + * Shows only the fields for the given method type and marks its button active. + * @param {string} type "constant" | "moving" + */ +function showMethodFields(type) { + document.querySelectorAll("#config .method-fields").forEach(el => { + el.hidden = el.dataset.type !== type; + }); + methodBtns.forEach(b => { b.disabled = b.name === type; }); +} + +// Switch dB calc method (changes visible fields). +methodBtns.forEach(btn => { + btn.addEventListener("click", () => { + showMethodFields(btn.name); + }); +}); + +const dH = document.getElementById("dH"); +const dE = document.getElementById("dE"); +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; + } + saveSettings(); + updateCoordGraphs(); + rebuildSpreadsheet(); +}); + +/** + * Loads a source's delta-B config into the sidebar. + * @param {Source} src + */ +function loadDeltaB(src) { + dH.value = src.dB.h ?? 0; + dE.value = src.dB.e ?? 0; + dZ.value = src.dB.z ?? 0; + showMethodFields(src.dB.moving ? "moving" : "constant"); +} + // ---------------------------------------------------------------------------- // Processing: moving-average filter (shared) // ---------------------------------------------------------------------------- @@ -813,7 +954,9 @@ filterWindow.value = String(settings.filter.windowSec); filterOff.disabled = !settings.filter.enabled; filterOn.disabled = settings.filter.enabled; -/** @param {boolean} enabled */ +/** + * @param {boolean} enabled + */ function setFilterEnabled(enabled) { settings.filter.enabled = enabled; saveSettings(); @@ -885,7 +1028,10 @@ function clearConnErrors() { fileErr.textContent = ""; } -/** Loads a source's connection config into the panel. @param {Source} src */ +/** + * Loads a source's connection config into the panel. + * @param {Source} src + */ function loadConnForm(src) { srcName.value = src.name ?? ""; wsUrl.value = src.websocket?.url ?? ""; @@ -996,17 +1142,29 @@ document.querySelectorAll("#config .panel-header").forEach(header => { const tabsEl = document.getElementById("tabs"); const addTabBtn = document.getElementById("addTab"); -/** @param {number} code status code @returns {string} CSS class for the dot */ +/** + * @param {number} code status code + * @returns {string} CSS class for the dot + */ function statusClass(code) { switch (code) { - case 0: return "s-connecting"; - case 1: case 4: return "s-connected"; - case 3: return "s-failed"; - default: return "s-disconnected"; // 2 disconnected, 5 auth failed + case 0: + return "s-connecting"; // blue + case 1: + case 4: + return "s-connected"; // green + case 3: + return "s-failed"; // yellow + default: // 2 disconnected, 5 auth failed + return "s-disconnected"; // red } } -/** Updates just one tab's status dot. @param {string} id @param {number} code */ +/** + * Updates just one tab's status dot. + * @param {string} id + * @param {number} code + */ function updateTabStatus(id, code) { const dot = tabsEl.querySelector(`.tab[data-id="${id}"] .tab-status`); if (dot) { @@ -1014,7 +1172,9 @@ function updateTabStatus(id, code) { } } -/** Rebuilds the tab strip from settings.sources. */ +/** + * Rebuilds the tab strip from settings.sources. + */ function renderTabs() { tabsEl.innerHTML = ""; for (const src of settings.sources) { @@ -1048,14 +1208,19 @@ function renderTabs() { addTabBtn.disabled = settings.sources.length >= MAX_SOURCES; } -/** Opens the config sidebar (used when adding a source to configure). */ +/** + * Opens the config sidebar (used when adding a source to configure). + */ function openSidebar() { document.getElementById("config").style.transform = "translateX(0)"; sideToggle.classList.remove("fa-bars"); sideToggle.classList.add("fa-xmark"); } -/** Switches the UI to a different source. @param {string} id */ +/** + * Switches the UI to a different source. + * @param {string} id + */ function switchTab(id) { if (id === settings.activeSourceId || !sessions.has(id)) { return; @@ -1065,11 +1230,14 @@ function switchTab(id) { const src = activeSource(); loadConnForm(src); loadRotation(src); + loadDeltaB(src); renderActive(); renderTabs(); } -/** Adds a new, unconfigured source and switches to it. */ +/** + * Adds a new, unconfigured source and switches to it. + */ function addTab() { if (settings.sources.length >= MAX_SOURCES) { return; @@ -1081,13 +1249,17 @@ function addTab() { saveSettings(); loadConnForm(src); loadRotation(src); + loadDeltaB(src); renderActive(); renderTabs(); openSidebar(); srcName.focus(); } -/** Closes a source, its connection, and its session. @param {string} id */ +/** + * Closes a source, its connection, and its session. + * @param {string} id + */ function closeTab(id) { const session = sessions.get(id); if (session && session.connection) { @@ -1114,6 +1286,7 @@ function closeTab(id) { const src = activeSource(); loadConnForm(src); loadRotation(src); + loadDeltaB(src); renderActive(); renderTabs(); } @@ -1135,6 +1308,7 @@ srcName.addEventListener("input", () => { // ---------------------------------------------------------------------------- loadConnForm(activeSource()); loadRotation(activeSource()); +loadDeltaB(activeSource()); refreshFilter(); renderTabs(); for (const src of settings.sources) { @@ -1158,6 +1332,7 @@ for (const src of settings.sources) { * @prop {{url: string}} websocket * @prop {{broker: string, topic: string, username: string, password: string}} mqtt * @prop {{x: number, y: number, z: number}} transform + * @prop {{moving: boolean, h: number, e: number, z: number}} dB */ /** diff --git a/js/transit.js b/js/transit.js index cdd20f3..8c08c8c 100644 --- a/js/transit.js +++ b/js/transit.js @@ -1,3 +1,5 @@ +/// + /* Connection manager. Creates independent transports (WebSocket or MQTT), one * per data source, so several stations can stream at once. Each instance is * DOM-free and reports through callbacks; the app decides how to route readings @@ -73,7 +75,7 @@ } function connectMqtt(cfg) { - if (typeof mqtt === "undefined") { + if (typeof window.mqtt === "undefined") { console.error("MQTT.js failed to load."); onStatus(2); return; @@ -91,7 +93,7 @@ // Set once the broker rejects credentials, so reconnect churn does // not overwrite the clearer "Auth failed" status. let authFailed = false; - mqttClient = mqtt.connect(broker, opts); + mqttClient = window.mqtt.connect(broker, opts); mqttClient.on("connect", () => { onStatus(1); From 96b13c93375c6b9aaf2a4905fa74cc9d8894a3cf Mon Sep 17 00:00:00 2001 From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:18:50 -0400 Subject: [PATCH 03/64] Update Dockerfile --- Dockerfile | 3 +-- js/index.d.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 56f6c29..b726cda 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,8 +6,7 @@ RUN apt-get update -q && \ WORKDIR /local -# Prefer not to be on latest commit to ensure consistency. (This may change) -RUN git clone --depth 1 --branch v0.0.6-beta https://github.com/wittend/mag-usb +RUN git clone https://github.com/wittend/mag-usb RUN cd mag-usb && \ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DENABLE_WEBSOCKET=ON && \ diff --git a/js/index.d.ts b/js/index.d.ts index 4eb67e2..446ec7f 100644 --- a/js/index.d.ts +++ b/js/index.d.ts @@ -16,7 +16,7 @@ interface Global { interface Window extends Global {} -interface MagUsbJson { +declare interface MagUsbJson { ts: string; rt: number; x: number; @@ -27,14 +27,14 @@ interface MagUsbJson { type ConnectionType = "websocket" | "mqtt"; type SourceType = ConnectionType | "file"; -interface MQTTSettings { +declare interface MQTTSettings { broker: string; topic: string; username?: string; password?: string; } -interface ConnectionConfig { +declare interface ConnectionConfig { type: ConnectionType; websocket: { url: string; @@ -42,7 +42,7 @@ interface ConnectionConfig { mqtt: MQTTSettings; } -interface Source { +declare interface Source { id: string; name: string; type: SourceType; @@ -63,7 +63,7 @@ interface Source { }; } -interface DashSettings { +declare interface DashSettings { displayWindow: string; filter: { enabled: boolean; @@ -73,7 +73,7 @@ interface DashSettings { activeSourceId: string; } -interface Session { +declare interface Session { id: string; measurements: Measurement[]; sparklines: Measurement[]; From 3a555798d209cc8eeddbe8c7332db1baaa08359a Mon Sep 17 00:00:00 2001 From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:12:20 -0400 Subject: [PATCH 04/64] Consolidate TS files and add .env support --- deno.json | 1 + ts/common.ts | 6 -- ts/index.ts | 36 ------------ ts/main.ts | 9 ++- ts/object/Measurement.ts | 100 ------------------------------- ts/object/Vector.ts | 123 --------------------------------------- ts/types.ts | 7 --- 7 files changed, 9 insertions(+), 273 deletions(-) delete mode 100644 ts/common.ts delete mode 100755 ts/index.ts delete mode 100755 ts/object/Measurement.ts delete mode 100755 ts/object/Vector.ts delete mode 100644 ts/types.ts diff --git a/deno.json b/deno.json index 6c2a347..0e4ebe8 100644 --- a/deno.json +++ b/deno.json @@ -4,6 +4,7 @@ }, "imports": { "@std/assert": "jsr:@std/assert@1", + "@std/dotenv": "jsr:@std/dotenv@^0.225.7", "@types/plotly.js": "npm:@types/plotly.js@^3.0.10", "mqtt": "npm:mqtt@^5.15.1", "plotly.js": "npm:plotly.js@^3.3.1" diff --git a/ts/common.ts b/ts/common.ts deleted file mode 100644 index 4cc3e4c..0000000 --- a/ts/common.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function createMsgResp(status: number, msg: string): Response { - return new Response(JSON.stringify({ message: msg, }), { - status, - headers: { "Content-Type": "application/json" }, - }); -} \ No newline at end of file diff --git a/ts/index.ts b/ts/index.ts deleted file mode 100755 index 1af0ef6..0000000 --- a/ts/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import Measurement from "./object/Measurement.ts"; - -const measures: Measurement[] = []; - -// Deno.serve({ port: 8000, hostname: "0.0.0.0" }, req => { -// const { pathname } = new URL(req.url); - -// if (pathname === "/") { -// return new Response(Deno.readTextFileSync("")) -// } -// }) - -// Deno.serve({ port: 8000, hostname: "0.0.0.0" }, req => { -// const { pathname } = new URL(req.url); - -// if (pathname === "/") { // Index/homepage - -// } else { -// return new Response("400", { -// status: 404 -// }) -// } -// return new Response(); // Temp line -// }); - -/** - * @param {Measurement} m - */ -function logMeasurement(m: Measurement) { - console.log("Tms:\t", m.ts); - console.log("Cel:\t", m.celsius); - console.log("XYZ:\t", ...m.XYZ); - console.log("HEZ:\t", ...m.HEZ); - console.log("Mag:\t", m.XYZ.magnitude); - console.log("----------------------------------"); -} diff --git a/ts/main.ts b/ts/main.ts index 38a5a15..19df4ac 100644 --- a/ts/main.ts +++ b/ts/main.ts @@ -1,4 +1,11 @@ -import { createMsgResp } from "./common.ts"; +import "@std/dotenv/load"; + +function createMsgResp(status: number, msg: string): Response { + return new Response(JSON.stringify({ message: msg, }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} function getContentType(path: string): string { if (path === "/" || path.endsWith(".html")) { diff --git a/ts/object/Measurement.ts b/ts/object/Measurement.ts deleted file mode 100755 index e486ef7..0000000 --- a/ts/object/Measurement.ts +++ /dev/null @@ -1,100 +0,0 @@ -import Vector from "./Vector.ts"; - -/** - * An instance of this class represents a measurement reading from a TAPR - * ground magnetometer. A measurement contains an XYZ field, a remote - * temperature (in Celsius), and a logging timestamp. All properties of an - * instance are read-only after initialization. - * - * The XYZ field is treated as a 3-dimensional vector and can have various - * vector operations applied to it or its components. - */ -export default class Measurement { - #rfc; #xyz; #rt; - - /** - * Constructs a new Measurement. - * @param ts an RFC-2822 formatted timestamp - * @param rt remote temperature - * @param x x component - * @param y y component - * @param z z component - */ - constructor(ts: string, rt: number, x: number, y: number, z: number) { - this.#rfc = ts; - this.#rt = rt; - this.#xyz = new Vector(x, y, z); - } - - - get rfc(): string { - return this.#rfc; - } - - get date(): Date { - return this.#convertRFC(this.#rfc); - } - - /** - * Returns the remote temperature of this Measurement in Celsius. - * @return the temperature (in Celsius) - */ - get celsius(): number { - return this.#rt; - } - - /** - * Returns the remote temperature of this Measurement converted to - * Fahrenheit. - * @return the temperature in Fahrenheit - */ - get fahrenheit(): number { - return (this.celsius * 1.8) + 32; - } - - /** - * Returns this Measurement's field vector in XYZ form. - * @return the XYZ vector - */ - get XYZ(): Vector { - return this.#xyz; - } - - /** - * Converts the field of this Measurement from XYZ to HEZ. - * The specific conversions for the field are as follows: - * * Z*(-1) → H - * * Y → E - * * X → Z - * - * And are returned as a new Vector. - * @returns the HEZ vector - */ - get HEZ(): Vector { - return new Vector(-this.XYZ[2], this.XYZ[1], this.XYZ[0]); - } - - /** - * Converts an RFC timestamp to a native Date object. - * @param ts_str a timestamp string formatted according to - * RFC-2822 standard - * @returns a Date object of the corresponding timestamp string - */ - #convertRFC(ts_str: string): Date { - const [day, monName, year, time] = ts_str.split(" "); - const month = monName === "Jan" ? "01" - : monName === "Feb" ? "02" - : monName === "Mar" ? "03" - : monName === "Apr" ? "04" - : monName === "May" ? "05" - : monName === "Jun" ? "06" - : monName === "Jul" ? "07" - : monName === "Aug" ? "08" - : monName === "Sep" ? "09" - : monName === "Oct" ? "10" - : monName === "Nov" ? "11" - : "12"; // monName === "Dec" - const timeStr = `${year}-${month}-${day}T${time}.000Z`; - return new Date(timeStr); - } -} \ No newline at end of file diff --git a/ts/object/Vector.ts b/ts/object/Vector.ts deleted file mode 100755 index 1ac708e..0000000 --- a/ts/object/Vector.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * An instance of this class represents a 3-dimensional vector. Some basic - * operations can be applied to the vector such as finding its magnitude, - * unit vector, or angular direction. - * - * All components of a vector are readonly after initialization. - */ -export default class Vector { - #array: number[] = []; - - /** - * Constructs a new 3D vector. Omitted arguments are treated as 0. - * @param x the first component - * @param y the second component - * @param z the third component - */ - constructor(x = 0, y = 0, z = 0) { - this.#array[0] = x; - this.#array[1] = y; - this.#array[2] = z; - } - - get [0](): number { - return this.#array[0]; - } - get [1](): number { - return this.#array[1]; - } - get [2](): number { - return this.#array[2]; - } - - /** - * Returns an iterator for this Vector. - */ - *[Symbol.iterator](): Generator { - for (const c of this.#array) { - yield c; - } - } - - /** - * Calculates the magnitude of this Vector. - * - * The magnitude is calculated by the formula: - * > ||V|| = sqrt(x^2 + y^2 + z^2) - * - * @return the magnitude of this Vector - */ - get magnitude(): number { - return Math.sqrt(this[0]**2 + this[1]**2 + this[2]**2); - } - - /** - * Normalizes this Vector to its unit vector and returns the result as a - * new Vector. - * - * The unit vector is calculated by dividing each component of the vector - * by the vector's magnitude. - * > U = - * @return this Vector's unit vector - */ - normalize(): Vector { - const norms = this.#array.map(c => c / this.magnitude); - return new Vector(...norms); - } - - /** - * Returns the angle of this Vector (in radians). - * @return a 3-element array of the form [x, y, z] where each - * element is the angle this Vector makes with that particular axis - */ - get radians(): number[] { - return this.normalize().#array.map(d => Math.acos(d)); - } - - /** - * Returns the angle vector of this Vector (in euler angles). - * @return a 3-element array of the form [x, y, z] where each - * element is the angle this Vector makes with that particular axis - */ - get eulers(): number[] { - return this.radians.map(r => r * (180 / Math.PI)); - } - - /** - * Rotates this Vector on the given axis by the given angle and returns a - * new Vector. - * @param axis the axis label to rotate on ("x", "y", "z") - * @param angle the angle (in radians) to rotate this Vector by - * @returns a new Vector that is rotated - */ - rotate(axis: Axis, angle: number): Vector { - const { sin, cos } = Math; - const [x, y, z] = this; - let [newX, newY, newZ] = this; - if (axis === "x") { - // Rotate by a on the x-axis: - // x' = x - // y' = y*cos(a) - z*sin(a) - // z' = y*sin(a) + z*cos(a) - newY = (y * cos(angle)) - (z * sin(angle)); - newZ = (y * sin(angle)) + (z * cos(angle)); - } else if (axis === "y") { - // Rotate by a on the y-axis: - // x' = x*cos(a) + z*sin(a) - // y' = y - // z' =-x*sin(a) + z*cos(a) - newX = (x * cos(angle)) + (z * sin(angle)); - newZ =(-x * sin(angle)) + (z * cos(angle)); - } else if (axis === "z") { - // Rotate by a on the z-axis: - // x' = x*cos(a) - y*sin(a) - // y' = x*sin(a) + y*cos(a) - // z' = z - newX = (x * cos(angle)) - (y * sin(angle)); - newY = (x * sin(angle)) + (y * cos(angle)); - } - return new Vector(newX, newY, newZ); - } -} - -type Axis = "x" | "y" | "z"; \ No newline at end of file diff --git a/ts/types.ts b/ts/types.ts deleted file mode 100644 index cb19556..0000000 --- a/ts/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface MagUsbJson { - ts: string; - rt: number; - x: number; - y: number; - z: number; -} \ No newline at end of file From 64d65837d511a68941a68b2809941ad5ba2ddc65 Mon Sep 17 00:00:00 2001 From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:17:02 -0400 Subject: [PATCH 05/64] Add HTML for dB/dt setting --- README.md | 28 ++++++++++++++++++---------- css/index.css | 10 ++++++---- index.html | 11 +++++++++++ 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 01de5c3..62bc6fd 100644 --- a/README.md +++ b/README.md @@ -26,34 +26,45 @@ This repository is designed to be used alongside the HamSCI ground magnetometer. Instructions for setting up the magnetometer are available [here](https://hamsci.org/mag_install). -An additional computer running Linux or macOS is required to host the -magnetometer. +A computer running Linux or macOS is required to host the magnetometer. The +dashboard can be hosted on the same computer as the magnetometer or a separate +client. * If using Linux, Ubuntu is recommended, but other distributions should also work. * If using macOS, Docker Desktop is required to run mag-usb. -* Raspberry Pis and Windows are unsupported. +* Windows is unsupported. ### Software [Mag-usb](https://github.com/wittend/mag-usb) must be installed on the host machine with WebSocket mode enabled. +[Deno](https://deno.com) is used to serve the dashboard. Installing Deno is +preferred for Linux clients. + [Docker Desktop](https://www.docker.com/products/docker-desktop/) is preferred -for running the project. The dashboard is served via a Deno container. +for Windows or macOS clients. The project is run through a Deno container. ## Usage In the vendor folder, unzip the archive containing fontawesome. -In a CLI, navigate to the project's root directory. Use `docker compose` to -create a container: +In a CLI, navigate to the project's root directory. Start the dashboard with +Deno: + +```bash +deno task dev +``` + +Or use `docker compose` to create a container: ```bash docker compose up -d frontend ``` By default, the dashboard will be available at `localhost:8000`. However, the -hostname and port can be changed as needed. +hostname and port can be changed as needed through a .env file. A .env.example +file is included to demonstrate how to configure the environment. The dashboard must be given a host to connect to before any data is displayed. The host is the IP address of the computer running mag-usb. A simple `ifconfig` @@ -64,9 +75,6 @@ If your dashboard does not show a "Connected" status within a few seconds or switches to a "Failed" status, check that you entered the host correctly, then check mag-usb to make sure it is configured correctly. -The dashboard is currently limited to one host at a time. This can be mitigated -by opening a duplicate dashboard in another tab. - The dashboard will autoscroll with the most recently collected data. You can zoom in/pan on specific regions of the plots to disable the autoscroll behavior. The behavior can be re-enabled by double clicking on the plots. \ No newline at end of file diff --git a/css/index.css b/css/index.css index 52daae6..2cef8be 100755 --- a/css/index.css +++ b/css/index.css @@ -621,12 +621,13 @@ tr:nth-of-type(2n) { display: grid; gap: 5px; } -.row1 { - grid-template-columns: repeat(3, 1fr); -} .row2 { grid-template-columns: repeat(2, 1fr); } +.row1, .row2.dB { + grid-template-columns: repeat(3, 1fr); +} + .cell { padding: 5px; text-align: center; @@ -640,7 +641,8 @@ tr:nth-of-type(2n) { .row1 .cell:last-of-type { background-color: #023e8a; } .row2 .cell:first-of-type { background-color: #da1884; } -.row2 .cell:last-of-type { background-color: #ffb200; } +.row2 .cell:nth-of-type(2) { background-color: #ffb200; } +.row2 .cell:nth-of-type(3) { background-color: #11c6da; } .cell-head { text-align: left; diff --git a/index.html b/index.html index 42bc660..b612929 100755 --- a/index.html +++ b/index.html @@ -182,6 +182,13 @@ +
Moving average @@ -234,6 +241,10 @@
Temperature (°C)
-
+ From a0f12d926be40866e0f10e2f127ffbbc178f33a3 Mon Sep 17 00:00:00 2001 From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:08:08 -0400 Subject: [PATCH 06/64] Add dB/dt view (no functionality yet) --- css/index.css | 16 +++++++++++++++- index.html | 4 ++-- js/index.js | 30 ++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/css/index.css b/css/index.css index 2cef8be..d88422a 100755 --- a/css/index.css +++ b/css/index.css @@ -653,7 +653,7 @@ tr:nth-of-type(2n) { font-size: 18pt; } -/* Src: https: //www.geeksforgeeks.org/css/how-to-create-toggle-switch-by-using-html-and-css/ */ +/* Src: https://www.geeksforgeeks.org/css/how-to-create-toggle-switch-by-using-html-and-css/ */ .toggle-switch { position: relative; display: inline-block; @@ -661,6 +661,11 @@ tr:nth-of-type(2n) { height: 15px; } +.toggle-switch.lg { + width: 60px; + height: 30px; +} + .toggle-switch input { opacity: 0; width: 0; @@ -691,6 +696,11 @@ tr:nth-of-type(2n) { border-radius: 2px; } +.toggle-switch.lg.square > .slider::before { + height: 26px; + width: 26px; +} + .square input:checked + .slider { background-color: #4caf50; } @@ -699,6 +709,10 @@ tr:nth-of-type(2n) { transform: translateX(14px); } +.toggle-switch.lg.square input:checked + .slider::before { + transform: translateX(30px); +} + #ldWrapper { display: flex; margin-right: 7px; diff --git a/index.html b/index.html index b612929..9034616 100755 --- a/index.html +++ b/index.html @@ -184,7 +184,7 @@ - - +
+ + +
From ed1aefae970a82aea3d2d783b6c3c9c1902869fc Mon Sep 17 00:00:00 2001 From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:28:42 -0400 Subject: [PATCH 13/64] Add JSONL and CSV formatter functions --- js/Measurement.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/js/Measurement.js b/js/Measurement.js index b0e8a8d..7e3f5de 100755 --- a/js/Measurement.js +++ b/js/Measurement.js @@ -114,6 +114,16 @@ export default class Measurement { smoothFn(this[2], prev[2])); } + toJSONL() { + const { rfc: ts, celsius: rt, XYZ: [x, y, z] } = this; + return `{"ts":"${ts}", "rt":${rt}, "x":${x}, "y":${y}, "z":${z}}`; + } + + toCSV() { + const { rfc: ts, celsius: rt, XYZ: [x, y, z] } = this; + return `"${ts}",${rt},${x},${y},${z}`; + } + /** * Converts an RFC timestamp to a native Date object. * @param {string} ts_str a timestamp string formatted according to From f1e7cafbfb26dd17a15ae2e4fda0e25ef06acd4a Mon Sep 17 00:00:00 2001 From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:52:22 -0400 Subject: [PATCH 14/64] Add JSONL/CSV exporting (#13 #14) --- css/index.css | 6 ++++++ index.html | 10 ++++++++++ js/index.js | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/css/index.css b/css/index.css index 8c664c1..17fc9c5 100755 --- a/css/index.css +++ b/css/index.css @@ -474,6 +474,12 @@ tr:nth-of-type(2n) { font-weight: 700; cursor: pointer; } +.btn-primary.sm { + border-radius: 4px; + padding: 4px; + font-size: 9px; + font-weight: 500; +} .btn-primary { background-color: var(--col-btn-inactive); color: #fff; diff --git a/index.html b/index.html index 2b99e97..24e1fb5 100755 --- a/index.html +++ b/index.html @@ -258,6 +258,16 @@
Spreadsheet +
+ + +
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 is shrinkable.) +const spreadsheetBody = document.querySelector("#spreadsheet table tbody"); +const ROW_OVERSCAN = 8; +let rowHeight = 0; + +/** A spacer row reserving `px` of off-screen scroll height. */ +function spacerRow(px) { + return px > 0 + ? `` + : ""; +} + /** - * Prepends a measurement's row to the top of the spreadsheet (newest first). - * @param {Measurement} measurement + * Builds rows for display indices [first, last). Display index 0 is the newest + * reading (the table shows newest at the top). + * @param {Measurement[]} ms @param {number} n @param {number} first @param {number} last */ -function addSpreadsheetRow(measurement) { - const ms = activeSession().measurements; - document.querySelector("#spreadsheet table tbody").insertAdjacentHTML( - "afterbegin", spreadsheetRowHTML(measurement, ms[ms.length - 2])); - // TODO: Remove last row once we're at max buffer size? +function windowRows(ms, n, first, last) { + let s = ""; + for (let d = first; d < last; d++) { + const idx = n - 1 - d; + s += spreadsheetRowHTML(ms[idx], ms[idx - 1], d); + } + return s; } -/** Rebuilds every spreadsheet row from the active source's buffer. */ -function rebuildSpreadsheet() { - // measurements run oldest -> newest, but the table shows newest at the top. +/** Renders only the rows visible at the current scroll position. */ +function renderSpreadsheet() { const ms = activeSession().measurements; - document.querySelector("#spreadsheet table tbody").innerHTML = - ms.map((m, i) => spreadsheetRowHTML(m, ms[i - 1])).reverse().join(""); + const n = ms.length; + if (n === 0) { + spreadsheetBody.innerHTML = ""; + return; + } + if (!rowHeight) { + // Measure a real row once to size the virtual scroll. + spreadsheetBody.innerHTML = windowRows(ms, n, 0, Math.min(n, 40)); + rowHeight = (spreadsheetBody.firstElementChild && + spreadsheetBody.firstElementChild.offsetHeight) || 20; + } + const viewH = spreadsheetBody.clientHeight || 400; + const first = Math.max(0, + Math.floor(spreadsheetBody.scrollTop / rowHeight) - ROW_OVERSCAN); + const count = Math.ceil(viewH / rowHeight) + ROW_OVERSCAN * 2; + const last = Math.min(n, first + count); + spreadsheetBody.innerHTML = + spacerRow(first * rowHeight) + + windowRows(ms, n, first, last) + + spacerRow((n - last) * rowHeight); +} + +/** Re-renders the visible window from the active buffer (keeps scroll pos). */ +function rebuildSpreadsheet() { + renderSpreadsheet(); +} + +/** Renders from the top (newest) — used on file load and tab switch. */ +function resetSpreadsheet() { + spreadsheetBody.scrollTop = 0; + renderSpreadsheet(); } +/** Live: a newest reading arrived; keep the user's place unless at the top. */ +function addSpreadsheetRow() { + if (rowHeight && spreadsheetBody.scrollTop >= rowHeight) { + // Content grows at the top (newest-first); shift to stay on same rows. + spreadsheetBody.scrollTop += rowHeight; + } + renderSpreadsheet(); +} + +// Re-render the window as the user scrolls (throttled to one per frame). +let scrollRaf = 0; +spreadsheetBody.addEventListener("scroll", () => { + if (scrollRaf) { + return; + } + scrollRaf = requestAnimationFrame(() => { + scrollRaf = 0; + renderSpreadsheet(); + }); +}); + /** * @param {Measurement} m */ @@ -770,7 +847,7 @@ function connectSession(session) { /** Clears the shared view (spreadsheet + plots) for the active source. */ function clearActiveView() { - document.querySelector("#spreadsheet table tbody").innerHTML = ""; + resetSpreadsheet(); resetPlots(); // resetPlots() restores the overlay traces to their hidden default, so // re-apply the fade/visibility that matches the current filter setting. @@ -797,7 +874,7 @@ function renderActive() { const ms = session.measurements; resetPlots(); - document.querySelector("#spreadsheet table tbody").innerHTML = ""; + resetSpreadsheet(); if (ms.length > 0) { const times = ms.map(m => m.ts); @@ -815,7 +892,6 @@ function renderActive() { ms.map(m => m.celsius), ], }, {}, RAW_TRACES); - rebuildSpreadsheet(); updateCurrentTable(ms[ms.length - 1]); } else { document.getElementById("h").textContent = "-"; @@ -870,7 +946,7 @@ function loadLogFile(file) { session.sBucket.length = 0; resetPlots(); - document.querySelector("#spreadsheet table tbody").innerHTML = ""; + resetSpreadsheet(); const times = logs.map(({ ts }) => ts); const vecs = logs.map(rotatedHEZ); Plotly.update(plotsDiv, { From f1bd287d4bb7c3bba7209ad69883f7d7d1fb60cf Mon Sep 17 00:00:00 2001 From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:17:32 -0400 Subject: [PATCH 16/64] Update AI usage log with git hash for eb039f3 --- 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 7208b23..51db488 100644 --- a/ai/ai_usage_log.md +++ b/ai/ai_usage_log.md @@ -87,4 +87,4 @@ Required per University of Scranton AI Policy, HamSCI Generative AI Use Agreemen - **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] +- **Git Hash**: eb039f3 From 3b51ce10955aa81f513f4685463bc33d3cf65d6f Mon Sep 17 00:00:00 2001 From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:42:24 -0400 Subject: [PATCH 17/64] Remove file upload warning --- index.html | 3 --- 1 file changed, 3 deletions(-) diff --git a/index.html b/index.html index 24e1fb5..641954c 100755 --- a/index.html +++ b/index.html @@ -89,9 +89,6 @@ - - NOTE: Large .log files will slow the dashboard! -
- diff --git a/js/index.js b/js/index.js index 4043442..4638678 100644 --- a/js/index.js +++ b/js/index.js @@ -1095,15 +1095,29 @@ document.getElementById("csv").addEventListener("click", _ => { // Sidebar: toggle // ---------------------------------------------------------------------------- const sideToggle = document.getElementById("sideToggle"); +const layoutEl = document.querySelector(".layout"); + +/** Reflects the config-drawer open state on the layout and the toggle icon. */ +function setSidebarOpen(open) { + layoutEl.classList.toggle("config-open", open); + sideToggle.classList.toggle("fa-bars", !open); + sideToggle.classList.toggle("fa-xmark", open); +} + sideToggle.addEventListener("click", () => { - const sidebar = document.getElementById("config"); - if (sideToggle.classList.contains("fa-xmark")) { - sidebar.style.transform = "translateX(-100%)"; - } else { - sidebar.style.transform = "translateX(0)"; + setSidebarOpen(!layoutEl.classList.contains("config-open")); +}); + +// The drawer reserves a layout column (it constricts the plots instead of +// covering them), so re-fit Plotly to the new plot width once the width +// transition settles. Plotly's `responsive` only tracks window resizes, not +// container-only changes, so this must be explicit. +layoutEl.addEventListener("transitionend", ev => { + if (ev.propertyName === "grid-template-columns") { + // On tablets the drawer also narrows the data column, so re-fit both. + Plotly.Plots.resize(plotsDiv); + Plotly.Plots.resize(sparkDiv); } - sideToggle.classList.toggle("fa-bars"); - sideToggle.classList.toggle("fa-xmark"); }); // ---------------------------------------------------------------------------- @@ -1561,9 +1575,7 @@ function renderTabs() { * Opens the config sidebar (used when adding a source to configure). */ function openSidebar() { - document.getElementById("config").style.transform = "translateX(0)"; - sideToggle.classList.remove("fa-bars"); - sideToggle.classList.add("fa-xmark"); + setSidebarOpen(true); } /** From 9c15119076431ee127bb4b80875bd2fc65c5f9f5 Mon Sep 17 00:00:00 2001 From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:04:35 -0400 Subject: [PATCH 46/64] Update AI usage log with git hash for e653760 --- 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 8b3d572..a0eeaa6 100644 --- a/ai/ai_usage_log.md +++ b/ai/ai_usage_log.md @@ -159,4 +159,4 @@ Required per University of Scranton AI Policy, HamSCI Generative AI Use Agreemen - **Sections/Files Affected**: `css/index.css` (`.layout` 3-column grid drawer where the config spacer constricts the plots; `main`/`.right-column` grid columns; `@media (min-width: 901px) and (max-width: 1300px)` block — data column 350px, Current Reading + spreadsheet text scaling, icon-only export buttons, trimmed cell padding; `font-size` transitions on `.cell-value` and `#spreadsheet`), `index.html` (export button labels wrapped in `.btn-label` spans with `title` tooltips), `js/index.js` (`setSidebarOpen()` helper toggling `.config-open`; Plotly re-fit on the layout `transitionend`) - **Nature of Contribution**: Code generation and edit (CSS/HTML/JS) - **Human Review Status**: Reviewed and verified (headless WebKit + Chromium layout checks at portrait/landscape widths — drawer constricts plots with y-axis visible, no spreadsheet overflow at 1194px, portrait unaffected; 28/28 tests pass) -- **Git Hash**: [fill in after committing] +- **Git Hash**: e653760 From 5a572a155640be5f97069299a51180f7c93191d4 Mon Sep 17 00:00:00 2001 From: ContinuumLS <231798356+ContinuumLS@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:49:57 -0400 Subject: [PATCH 47/64] Minor styling changes --- css/index.css | 3 ++- index.html | 6 ++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/css/index.css b/css/index.css index db8bf0c..7483a57 100755 --- a/css/index.css +++ b/css/index.css @@ -55,7 +55,7 @@ --bg: #f0f2f5; --fg: #1a1a1a; --muted: #5a5f66; - --link: #0a6ebd; + --link: #69beff; /* Dark brushed silver (gunmetal): light steel highlights over a graphite body with a specular seam at the midline. Kept dark so the light chrome text and the colored connection-status indicator (green/red/yellow) stay @@ -273,6 +273,7 @@ footer { #sideToggle { margin-right: 12px; + font-size: 14pt; } .flex-row { diff --git a/index.html b/index.html index 3b72c92..af4d7ac 100755 --- a/index.html +++ b/index.html @@ -36,9 +36,7 @@
- - - +
Magnetometer Dash
@@ -319,7 +317,7 @@