refactor: extract typed frontend formatters

This commit is contained in:
sjg
2026-08-01 16:42:54 +02:00
parent 40accb9697
commit ab3fe4bbb7
4 changed files with 228 additions and 214 deletions
@@ -20,6 +20,15 @@ import {
login,
logout,
} from "./api/auth.js";
import {
formatByteSize as recorderFormatSize,
formatDuration as formatUptime,
formatFrequency as formatFreq,
formatFrequencyForHumans as formatFreqForHumans,
formatFrequencyForStep as formatFreqForStep,
formatWavelength,
parseFrequencyInput,
} from "./core/format.js";
// --- Decoder registry (fetched from /decoders on load) ---
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
@@ -1246,19 +1255,6 @@ let aboutUptimeStart = null;
let es;
let esHeartbeat;
function formatUptime(ms) {
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const parts = [];
if (d > 0) parts.push(`${d}d`);
if (h > 0 || d > 0) parts.push(`${h}h`);
parts.push(`${m}m`);
parts.push(`${sec}s`);
return parts.join(" ");
}
setInterval(() => {
if (!aboutUptimeStart) return;
const el = document.getElementById("about-uptime");
@@ -1738,32 +1734,6 @@ function waterfallLutWrite(texData, offset, db) {
texData[offset + 3] = _wfLut[p + 3];
}
function formatFreq(hz) {
if (!Number.isFinite(hz)) return "--";
if (hz >= 1_000_000_000) {
return `${(hz / 1_000_000_000).toFixed(3)} GHz`;
}
if (hz >= 10_000_000) {
return `${(hz / 1_000_000).toFixed(3)} MHz`;
}
return `${(hz / 1_000).toFixed(1)} kHz`;
}
function formatFreqForStep(hz, step) {
if (!Number.isFinite(hz)) return "--";
if (step >= 1_000_000) return (hz / 1_000_000).toFixed(6);
if (step >= 1_000) return (hz / 1_000).toFixed(3);
if (step >= 1) return String(Math.round(hz));
return formatFreq(hz);
}
function formatWavelength(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "--";
const meters = 299_792_458 / hz;
if (meters >= 1) return `${Math.round(meters)} m`;
return `${Math.round(meters * 100)} cm`;
}
function refreshWavelengthDisplay(hz) {
if (!wavelengthEl) return;
wavelengthEl.textContent = formatWavelength(hz);
@@ -2602,55 +2572,6 @@ function refreshCenterFreqDisplay() {
centerFreqEl.value = formatFreqForStep(lastSpectrumData.center_hz, jogUnit);
}
function parseFreqInput(val, defaultStep) {
if (!val) return null;
const trimmed = val.trim().toLowerCase();
const match = trimmed.match(/^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/);
if (!match) return null;
const rawNumber = match[1];
let num = parseFloat(rawNumber.replace(",", "."));
const unit = match[2] || "";
if (Number.isNaN(num)) return null;
if (unit.startsWith("gh") || unit === "g") {
num *= 1_000_000_000;
} else if (unit.startsWith("mh") || unit === "m") {
num *= 1_000_000;
} else if (unit.startsWith("kh") || unit === "k") {
num *= 1_000;
} else if (!unit) {
const mode = (modeEl?.value || "").toUpperCase();
const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(",");
if (mode === "WFM") {
if (hasDecimalSeparator && num >= 50 && num < 200) {
num *= 1_000_000;
return Math.round(num);
}
if (!hasDecimalSeparator && num >= 875 && num <= 1080) {
num = (num / 10) * 1_000_000;
return Math.round(num);
}
}
// Use currently selected input unit when user omits suffix.
if (defaultStep >= 1_000_000) {
num *= 1_000_000;
} else if (defaultStep >= 1_000) {
num *= 1_000;
} else if (defaultStep >= 1) {
// already Hz
} else {
// Fallback heuristic.
if (num >= 1_000_000) {
// Assume already Hz.
} else if (num >= 1_000) {
num *= 1_000;
} else {
num *= 1_000_000;
}
}
}
return Math.round(num);
}
function normalizeMinFreqStep(cap) {
const val = Number(cap && cap.min_freq_step_hz);
if (!Number.isFinite(val) || val < 1) return 1;
@@ -2740,14 +2661,6 @@ function unsupportedBandSummary() {
return `Supported ranges: ${ranges.join(", ")}`;
}
function formatFreqForHumans(hz) {
if (!Number.isFinite(hz)) return "--";
if (hz >= 1_000_000_000) return `${(hz / 1_000_000_000).toFixed(3)} GHz`;
if (hz >= 1_000_000) return `${(hz / 1_000_000).toFixed(3)} MHz`;
if (hz >= 1_000) return `${(hz / 1_000).toFixed(3)} kHz`;
return `${Math.round(hz)} Hz`;
}
function showUnsupportedFreqPopup(hz) {
const message = `Unsupported frequency: ${formatFreqForHumans(hz)}.\n\n${unsupportedBandSummary()}`;
showHint("Out of supported range", 1800);
@@ -3913,7 +3826,7 @@ pttBtn.addEventListener("click", async () => {
});
function applyFreqFromInput() {
const parsedRaw = parseFreqInput(freqEl.value, jogUnit);
const parsedRaw = parseFrequencyInput(freqEl.value, jogUnit, modeEl?.value || "");
const parsed = alignFreqToRigStep(parsedRaw);
if (parsed === null) {
showHint("Freq missing", 1500);
@@ -3930,7 +3843,7 @@ function applyFreqFromInput() {
async function applyCenterFreqFromInput() {
if (!centerFreqEl) return;
const parsedRaw = parseFreqInput(centerFreqEl.value, jogUnit);
const parsedRaw = parseFrequencyInput(centerFreqEl.value, jogUnit, modeEl?.value || "");
const parsed = alignFreqToRigStep(parsedRaw);
if (parsed === null) {
showHint("Central freq missing", 1500);
@@ -5839,12 +5752,6 @@ function renderRecorderActive(list) {
el.innerHTML = html;
}
function recorderFormatSize(bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / 1048576).toFixed(1) + " MB";
}
function recFilterAndSort() {
const filterEl = document.getElementById("recorder-filter");
const sortEl = document.getElementById("recorder-sort");
@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export function formatDuration(milliseconds: number): string {
const seconds = Math.floor(milliseconds / 1000);
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainder = seconds % 60;
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0 || days > 0) parts.push(`${hours}h`);
parts.push(`${minutes}m`, `${remainder}s`);
return parts.join(" ");
}
export function formatFrequency(frequencyHz: number): string {
if (!Number.isFinite(frequencyHz)) return "--";
if (frequencyHz >= 1_000_000_000) return `${(frequencyHz / 1_000_000_000).toFixed(3)} GHz`;
if (frequencyHz >= 10_000_000) return `${(frequencyHz / 1_000_000).toFixed(3)} MHz`;
return `${(frequencyHz / 1_000).toFixed(1)} kHz`;
}
export function formatFrequencyForStep(frequencyHz: number, stepHz: number): string {
if (!Number.isFinite(frequencyHz)) return "--";
if (stepHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(6);
if (stepHz >= 1_000) return (frequencyHz / 1_000).toFixed(3);
if (stepHz >= 1) return String(Math.round(frequencyHz));
return formatFrequency(frequencyHz);
}
export function formatFrequencyForHumans(frequencyHz: number): string {
if (!Number.isFinite(frequencyHz)) return "--";
if (frequencyHz >= 1_000_000_000) return `${(frequencyHz / 1_000_000_000).toFixed(3)} GHz`;
if (frequencyHz >= 1_000_000) return `${(frequencyHz / 1_000_000).toFixed(3)} MHz`;
if (frequencyHz >= 1_000) return `${(frequencyHz / 1_000).toFixed(3)} kHz`;
return `${Math.round(frequencyHz)} Hz`;
}
export function formatWavelength(frequencyHz: number): string {
if (!Number.isFinite(frequencyHz) || frequencyHz <= 0) return "--";
const meters = 299_792_458 / frequencyHz;
return meters >= 1 ? `${Math.round(meters)} m` : `${Math.round(meters * 100)} cm`;
}
export function parseFrequencyInput(
value: string,
defaultStepHz: number,
mode: string,
): number | null {
if (!value) return null;
const match = /^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/
.exec(value.trim().toLowerCase());
if (!match?.[1]) return null;
const rawNumber = match[1];
let frequency = Number.parseFloat(rawNumber.replace(",", "."));
const unit = match[2] ?? "";
if (Number.isNaN(frequency)) return null;
if (unit.startsWith("gh") || unit === "g") frequency *= 1_000_000_000;
else if (unit.startsWith("mh") || unit === "m") frequency *= 1_000_000;
else if (unit.startsWith("kh") || unit === "k") frequency *= 1_000;
else if (!unit) {
const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(",");
if (mode.toUpperCase() === "WFM") {
if (hasDecimalSeparator && frequency >= 50 && frequency < 200) {
return Math.round(frequency * 1_000_000);
}
if (!hasDecimalSeparator && frequency >= 875 && frequency <= 1080) {
return Math.round((frequency / 10) * 1_000_000);
}
}
if (defaultStepHz >= 1_000_000) frequency *= 1_000_000;
else if (defaultStepHz >= 1_000) frequency *= 1_000;
else if (defaultStepHz < 1) {
if (frequency < 1_000) frequency *= 1_000_000;
else if (frequency < 1_000_000) frequency *= 1_000;
}
}
return Math.round(frequency);
}
export function formatByteSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1_048_576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1_048_576).toFixed(1)} MB`;
}
@@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { build } from "esbuild";
async function loadFormats() {
const result = await build({
entryPoints: [new URL("../src/core/format.ts", import.meta.url).pathname],
bundle: true,
format: "cjs",
platform: "browser",
target: "es2022",
write: false,
});
const module = { exports: {} };
vm.runInNewContext(result.outputFiles[0].text, { module, exports: module.exports });
return module.exports;
}
test("frequency parsing respects explicit units and WFM shorthand", async () => {
const { parseFrequencyInput } = await loadFormats();
assert.equal(parseFrequencyInput("145.500 MHz", 1000, "FM"), 145_500_000);
assert.equal(parseFrequencyInput("99.5", 100_000, "WFM"), 99_500_000);
assert.equal(parseFrequencyInput("995", 100_000, "WFM"), 99_500_000);
assert.equal(parseFrequencyInput("not a frequency", 1000, "USB"), null);
});
test("frequency and byte formatters handle display boundaries", async () => {
const { formatByteSize, formatFrequencyForHumans, formatWavelength } = await loadFormats();
assert.equal(formatFrequencyForHumans(145_500_000), "145.500 MHz");
assert.equal(formatByteSize(1536), "1.5 KB");
assert.equal(formatWavelength(299_792_458), "1 m");
});