Complete TypeScript frontend migration #22

Merged
sjg merged 51 commits from feat/typescript-frontend-migration into main 2026-08-01 23:06:19 +02:00
4 changed files with 228 additions and 214 deletions
Showing only changes of commit ab3fe4bbb7 - Show all commits
@@ -191,6 +191,80 @@
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
}
// src/core/format.ts
function formatDuration(milliseconds) {
const seconds = Math.floor(milliseconds / 1e3);
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 = [];
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(" ");
}
function formatFrequency(frequencyHz) {
if (!Number.isFinite(frequencyHz)) return "--";
if (frequencyHz >= 1e9) return `${(frequencyHz / 1e9).toFixed(3)} GHz`;
if (frequencyHz >= 1e7) return `${(frequencyHz / 1e6).toFixed(3)} MHz`;
return `${(frequencyHz / 1e3).toFixed(1)} kHz`;
}
function formatFrequencyForStep(frequencyHz, stepHz) {
if (!Number.isFinite(frequencyHz)) return "--";
if (stepHz >= 1e6) return (frequencyHz / 1e6).toFixed(6);
if (stepHz >= 1e3) return (frequencyHz / 1e3).toFixed(3);
if (stepHz >= 1) return String(Math.round(frequencyHz));
return formatFrequency(frequencyHz);
}
function formatFrequencyForHumans(frequencyHz) {
if (!Number.isFinite(frequencyHz)) return "--";
if (frequencyHz >= 1e9) return `${(frequencyHz / 1e9).toFixed(3)} GHz`;
if (frequencyHz >= 1e6) return `${(frequencyHz / 1e6).toFixed(3)} MHz`;
if (frequencyHz >= 1e3) return `${(frequencyHz / 1e3).toFixed(3)} kHz`;
return `${Math.round(frequencyHz)} Hz`;
}
function formatWavelength(frequencyHz) {
if (!Number.isFinite(frequencyHz) || frequencyHz <= 0) return "--";
const meters = 299792458 / frequencyHz;
return meters >= 1 ? `${Math.round(meters)} m` : `${Math.round(meters * 100)} cm`;
}
function parseFrequencyInput(value, defaultStepHz, mode) {
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 *= 1e9;
else if (unit.startsWith("mh") || unit === "m") frequency *= 1e6;
else if (unit.startsWith("kh") || unit === "k") frequency *= 1e3;
else if (!unit) {
const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(",");
if (mode.toUpperCase() === "WFM") {
if (hasDecimalSeparator && frequency >= 50 && frequency < 200) {
return Math.round(frequency * 1e6);
}
if (!hasDecimalSeparator && frequency >= 875 && frequency <= 1080) {
return Math.round(frequency / 10 * 1e6);
}
}
if (defaultStepHz >= 1e6) frequency *= 1e6;
else if (defaultStepHz >= 1e3) frequency *= 1e3;
else if (defaultStepHz < 1) {
if (frequency < 1e3) frequency *= 1e6;
else if (frequency < 1e6) frequency *= 1e3;
}
}
return Math.round(frequency);
}
function formatByteSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
}
// src/app.js
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null;
@@ -872,7 +946,7 @@
document.title = originalTitle;
return;
}
const parts = [formatFreq(freqHz)];
const parts = [formatFrequency(freqHz)];
const ps = rds?.program_service;
if (ps && ps.length > 0) {
parts.push(ps);
@@ -1416,23 +1490,10 @@
var aboutUptimeStart = null;
var es;
var esHeartbeat;
function formatUptime(ms) {
const s = Math.floor(ms / 1e3);
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");
if (el) el.textContent = formatUptime(Date.now() - aboutUptimeStart);
if (el) el.textContent = formatDuration(Date.now() - aboutUptimeStart);
}, 1e3);
var reconnectTimer = null;
var overviewSignalSamples = [];
@@ -1857,36 +1918,13 @@
texData[offset + 2] = _wfLut[p + 2];
texData[offset + 3] = _wfLut[p + 3];
}
function formatFreq(hz) {
if (!Number.isFinite(hz)) return "--";
if (hz >= 1e9) {
return `${(hz / 1e9).toFixed(3)} GHz`;
}
if (hz >= 1e7) {
return `${(hz / 1e6).toFixed(3)} MHz`;
}
return `${(hz / 1e3).toFixed(1)} kHz`;
}
function formatFreqForStep(hz, step) {
if (!Number.isFinite(hz)) return "--";
if (step >= 1e6) return (hz / 1e6).toFixed(6);
if (step >= 1e3) return (hz / 1e3).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 = 299792458 / 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);
}
function refreshFreqDisplay() {
if (lastFreqHz == null || freqDirty) return;
freqEl.value = formatFreqForStep(lastFreqHz, jogUnit);
freqEl.value = formatFrequencyForStep(lastFreqHz, jogUnit);
refreshWavelengthDisplay(lastFreqHz);
}
function activeRdsChannelId() {
@@ -2296,7 +2334,7 @@
if (lastSpectrumData && Math.abs(nextCenterHz - Number(lastSpectrumData.center_hz)) < 1) return;
await postPath(`/set_center_freq?hz=${nextCenterHz}`);
if (centerFreqEl && !centerFreqDirty) {
centerFreqEl.value = formatFreqForStep(nextCenterHz, jogUnit);
centerFreqEl.value = formatFrequencyForStep(nextCenterHz, jogUnit);
}
}
var _freqOptimisticHz = null;
@@ -2535,7 +2573,7 @@
await postPath(`/set_center_freq?hz=${targetCenterHz}`);
}
if (centerFreqEl && !centerFreqDirty) {
centerFreqEl.value = formatFreqForStep(targetCenterHz, jogUnit);
centerFreqEl.value = formatFrequencyForStep(targetCenterHz, jogUnit);
}
if (Number.isFinite(originalCenterHz) && Math.abs(targetCenterHz - originalCenterHz) < 1) {
showHint("Already at sweet spot", 900);
@@ -2578,7 +2616,7 @@
showHint("Shifting spectrum…", 900);
await postPath(`/set_center_freq?hz=${nextCenterHz}`);
if (centerFreqEl && !centerFreqDirty) {
centerFreqEl.value = formatFreqForStep(nextCenterHz, jogUnit);
centerFreqEl.value = formatFrequencyForStep(nextCenterHz, jogUnit);
}
const nextFreqHz = tunedFrequencyForCenterCoverage(nextCenterHz);
if (Number.isFinite(nextFreqHz) && Math.abs(nextFreqHz - Number(lastFreqHz)) >= 1) {
@@ -2588,51 +2626,7 @@
}
function refreshCenterFreqDisplay() {
if (!centerFreqEl || !lastSpectrumData || centerFreqDirty) return;
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 *= 1e9;
} else if (unit.startsWith("mh") || unit === "m") {
num *= 1e6;
} else if (unit.startsWith("kh") || unit === "k") {
num *= 1e3;
} else if (!unit) {
const mode = (modeEl?.value || "").toUpperCase();
const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(",");
if (mode === "WFM") {
if (hasDecimalSeparator && num >= 50 && num < 200) {
num *= 1e6;
return Math.round(num);
}
if (!hasDecimalSeparator && num >= 875 && num <= 1080) {
num = num / 10 * 1e6;
return Math.round(num);
}
}
if (defaultStep >= 1e6) {
num *= 1e6;
} else if (defaultStep >= 1e3) {
num *= 1e3;
} else if (defaultStep >= 1) {
} else {
if (num >= 1e6) {
} else if (num >= 1e3) {
num *= 1e3;
} else {
num *= 1e6;
}
}
}
return Math.round(num);
centerFreqEl.value = formatFrequencyForStep(lastSpectrumData.center_hz, jogUnit);
}
function normalizeMinFreqStep(cap) {
const val = Number(cap && cap.min_freq_step_hz);
@@ -2698,18 +2692,11 @@
}
function unsupportedBandSummary() {
if (supportedBands.length === 0) return "No supported frequency ranges were reported by the rig.";
const ranges = supportedBands.slice().sort((a, b) => a.low - b.low).map((b) => `${formatFreqForHumans(b.low)} to ${formatFreqForHumans(b.high)}`);
const ranges = supportedBands.slice().sort((a, b) => a.low - b.low).map((b) => `${formatFrequencyForHumans(b.low)} to ${formatFrequencyForHumans(b.high)}`);
return `Supported ranges: ${ranges.join(", ")}`;
}
function formatFreqForHumans(hz) {
if (!Number.isFinite(hz)) return "--";
if (hz >= 1e9) return `${(hz / 1e9).toFixed(3)} GHz`;
if (hz >= 1e6) return `${(hz / 1e6).toFixed(3)} MHz`;
if (hz >= 1e3) return `${(hz / 1e3).toFixed(3)} kHz`;
return `${Math.round(hz)} Hz`;
}
function showUnsupportedFreqPopup(hz) {
const message = `Unsupported frequency: ${formatFreqForHumans(hz)}.
const message = `Unsupported frequency: ${formatFrequencyForHumans(hz)}.
${unsupportedBandSummary()}`;
showHint("Out of supported range", 1800);
@@ -3288,7 +3275,7 @@ ${unsupportedBandSummary()}`;
if (hz === null) return;
const mode = entry.mode ? normalizeMode(entry.mode) : "";
const modeText = mode ? ` [${mode}]` : "";
const label = `${entry.name || String.fromCharCode(65 + idx)}: ${formatFreq(hz)}${modeText}`;
const label = `${entry.name || String.fromCharCode(65 + idx)}: ${formatFrequency(hz)}${modeText}`;
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = label;
@@ -3738,7 +3725,7 @@ ${unsupportedBandSummary()}`;
}
});
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);
@@ -3753,7 +3740,7 @@ ${unsupportedBandSummary()}`;
}
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);
@@ -4594,11 +4581,11 @@ ${unsupportedBandSummary()}`;
loadSetting,
showHint,
escapeMapHtml: escapeHtml,
formatFreq,
formatFreqForHumans,
formatFreq: formatFrequency,
formatFreqForHumans: formatFrequencyForHumans,
formatWavelength,
formatBwLabel,
formatUptime,
formatUptime: formatDuration,
formatSigStrength,
formatSignal,
postPath,
@@ -5640,11 +5627,6 @@ ${unsupportedBandSummary()}`;
html += "</tbody></table>";
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");
@@ -5711,7 +5693,7 @@ ${unsupportedBandSummary()}`;
for (const f of page) {
const safeName = escapeHtml(f.name);
const encodedName = encodeURIComponent(f.name);
html += '<tr data-name="' + safeName + '"><td>' + safeName + "</td><td>" + recorderFormatSize(f.size) + '</td><td><div class="rec-file-actions"><button class="rec-file-btn rec-play-btn" data-name="' + safeName + '" data-url="/api/recorder/download/' + encodedName + '" type="button" aria-expanded="false">Play</button><a class="rec-file-btn" href="/api/recorder/download/' + encodedName + '" download="' + safeName + '">Download</a><button class="rec-file-btn rec-delete-btn" data-name="' + safeName + '" type="button">Remove</button></div></td></tr>';
html += '<tr data-name="' + safeName + '"><td>' + safeName + "</td><td>" + formatByteSize(f.size) + '</td><td><div class="rec-file-actions"><button class="rec-file-btn rec-play-btn" data-name="' + safeName + '" data-url="/api/recorder/download/' + encodedName + '" type="button" aria-expanded="false">Play</button><a class="rec-file-btn" href="/api/recorder/download/' + encodedName + '" download="' + safeName + '">Download</a><button class="rec-file-btn rec-delete-btn" data-name="' + safeName + '" type="button">Remove</button></div></td></tr>';
}
html += "</tbody></table>";
el.innerHTML = html;
@@ -6513,7 +6495,7 @@ ${unsupportedBandSummary()}`;
let peakIndex = 0;
for (let i = 1; i < bins.length; i += 1) if (bins[i] > bins[peakIndex]) peakIndex = i;
const peakHz = centerHz - sampleRate / 2 + peakIndex / Math.max(1, bins.length - 1) * sampleRate;
spectrumSummary.textContent = `Spectrum centered at ${formatFreqForHumans(centerHz)}, spanning ${formatFreqForHumans(sampleRate)}. Strongest visible bin near ${formatFreqForHumans(peakHz)} at ${bins[peakIndex]} dB.`;
spectrumSummary.textContent = `Spectrum centered at ${formatFrequencyForHumans(centerHz)}, spanning ${formatFrequencyForHumans(sampleRate)}. Strongest visible bin near ${formatFrequencyForHumans(peakHz)} at ${bins[peakIndex]} dB.`;
}
if (spectrumCenterPendingHz !== null && Math.abs(centerHz - spectrumCenterPendingHz) < 1e3) {
spectrumCenterPendingHz = null;
@@ -7458,7 +7440,7 @@ ${unsupportedBandSummary()}`;
return;
}
setRigFrequency(rounded);
showHint(`Rounded → ${formatFreq(rounded)}`, 1200);
showHint(`Rounded → ${formatFrequency(rounded)}`, 1200);
} else {
showHint("Already on step", 1200);
}
@@ -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");
});