refactor: extract typed frontend formatters
This commit is contained in:
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user