build: consolidate typed frontend module graph

This commit is contained in:
sjg
2026-08-01 21:54:00 +02:00
parent 8aeca613aa
commit d929ad3bde
31 changed files with 9065 additions and 9152 deletions
+10
View File
@@ -43,6 +43,13 @@ jobs:
working-directory: src/trx-client/trx-frontend/trx-frontend-http/frontend
steps:
- uses: actions/checkout@v4
- name: Cache npm downloads
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Install locked frontend dependencies
run: npm ci
- name: Type-check
@@ -57,6 +64,9 @@ jobs:
run: npm run test:browser
- name: Verify generated assets
run: npm run verify-generated
- name: Verify generated-file licensing
working-directory: .
run: reuse lint
reuse:
runs-on: ubuntu-latest
@@ -1,71 +0,0 @@
"use strict";
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
this.name = "ApiError";
}
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function isRigSnapshot(value) {
if (!isRecord(value) || !isRecord(value.info) || !isRecord(value.status)) {
return false;
}
const { info, status } = value;
return typeof value.initialized === "boolean" && typeof info.manufacturer === "string" && typeof info.model === "string" && isRecord(status.freq) && typeof status.freq.hz === "number" && (typeof status.mode === "string" || isRecord(status.mode)) && typeof status.tx_en === "boolean";
}
export function isRigListResponse(value) {
return isRecord(value) && (value.active_remote === null || typeof value.active_remote === "string") && Array.isArray(value.rigs) && value.rigs.every(
(rig) => isRecord(rig) && typeof rig.remote === "string" && typeof rig.manufacturer === "string" && typeof rig.model === "string" && Array.isArray(rig.supported_modes) && typeof rig.tx === "boolean" && typeof rig.filter_controls === "boolean" && typeof rig.initialized === "boolean"
);
}
export function isDecoderRegistry(value) {
return Array.isArray(value) && value.every(
(decoder) => isRecord(decoder) && typeof decoder.id === "string" && typeof decoder.label === "string" && (decoder.activation === "mode_bound" || decoder.activation === "toggle") && Array.isArray(decoder.active_modes) && decoder.active_modes.every((mode) => typeof mode === "string") && typeof decoder.background_decode === "boolean" && typeof decoder.bookmark_selectable === "boolean"
);
}
export class TrxApi {
constructor(baseUrl = "") {
this.baseUrl = baseUrl;
}
async get(path, validate) {
return this.request(path, { cache: "no-store" }, validate);
}
async post(path, body, validate) {
return this.request(
path,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
},
validate
);
}
async request(path, init, validate) {
const response = await fetch(`${this.baseUrl}${path}`, init);
if (!response.ok) {
const detail = await response.text();
throw new ApiError(response.status, detail || response.statusText);
}
const value = await response.json();
if (!validate(value)) {
throw new ApiError(response.status, `Malformed response from ${path}`);
}
return value;
}
}
export function decodeServerEvent(event, validate) {
let value;
try {
value = JSON.parse(event.data);
} catch (error) {
throw new TypeError("Server event is not valid JSON", { cause: error });
}
if (!validate(value)) {
throw new TypeError("Server event has an unexpected shape");
}
return value;
}
File diff suppressed because it is too large Load Diff
@@ -1,95 +0,0 @@
// src/leaflet-ais-tracksymbol.ts
(function() {
const leaflet = globalThis.L;
if (!leaflet) return;
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function finiteAngle(value) {
if (value === null || !Number.isFinite(value)) return null;
const normalized = (value % 360 + 360) % 360;
return normalized;
}
function svgColor(value, fallback) {
const text = value || fallback || "";
return text.replace(/"/g, """);
}
function buildSymbolHtml(options, zoom) {
const heading = finiteAngle(options.heading);
const course = finiteAngle(options.course);
const angle = heading != null ? heading : course;
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
const sizeBase = Number.isFinite(options.size) ? options.size : 22;
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
const size = clamp(sizeBase + zoomBoost, 16, 32);
const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
const color = svgColor(options.color, "#ff7559");
const outline = svgColor(options.outline, "#6b2118");
const body = angle != null ? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})"><path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" /></g>` : `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
const courseLine = course != null ? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})"><line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" /></g>` : "";
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` + courseLine + body + `</svg>`;
}
leaflet.TrxAisTrackSymbol = leaflet.Marker.extend({
options: {
heading: null,
course: null,
speed: null,
color: "#ff7559",
outline: "#6b2118",
size: 22,
interactive: true,
keyboard: true,
riseOnHover: true
},
initialize: function(latlng, options) {
const merged = leaflet.Util.extend({}, this.options, options || {});
merged.icon = leaflet.divIcon({
className: "trx-ais-track-symbol-icon",
html: "",
iconSize: [merged.size, merged.size],
iconAnchor: [merged.size / 2, merged.size / 2]
});
leaflet.Marker.prototype.initialize.call(this, latlng, merged);
},
onAdd: function(map) {
leaflet.Marker.prototype.onAdd.call(this, map);
this._refreshIcon();
this._boundZoomRefresh = this._refreshIcon.bind(this);
map.on("zoomend", this._boundZoomRefresh);
},
onRemove: function(map) {
if (this._boundZoomRefresh) {
map.off("zoomend", this._boundZoomRefresh);
this._boundZoomRefresh = null;
}
leaflet.Marker.prototype.onRemove.call(this, map);
},
setAisState: function(next) {
if ("heading" in next) this.options.heading = next.heading;
if ("course" in next) this.options.course = next.course;
if ("speed" in next) this.options.speed = next.speed;
if ("color" in next) this.options.color = next.color;
if ("outline" in next) this.options.outline = next.outline;
this._refreshIcon();
return this;
},
_refreshIcon: function() {
if (!this._icon) return;
const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
const html = buildSymbolHtml(this.options, zoom);
this._icon.innerHTML = html;
const sizeBase = Number.isFinite(this.options.size) ? this.options.size : 22;
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
const size = clamp(sizeBase + zoomBoost, 16, 32);
this._icon.style.width = `${size}px`;
this._icon.style.height = `${size}px`;
this._icon.style.marginLeft = `${-size / 2}px`;
this._icon.style.marginTop = `${-size / 2}px`;
}
});
leaflet.trxAisTrackSymbol = function(latlng, options) {
const Constructor = leaflet.TrxAisTrackSymbol;
if (!Constructor) throw new Error("AIS track symbol constructor is unavailable");
return new Constructor(latlng, options);
};
})();
@@ -1,45 +0,0 @@
"use strict";
const pluginGroups = {
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
recorder: [],
settings: ["/vchan.js", "/scheduler.js"]
};
const loaded = /* @__PURE__ */ new Set();
const loading = /* @__PURE__ */ new Map();
async function loadPlugin(path) {
if (loaded.has(path)) return;
const pending = loading.get(path);
if (pending) return pending;
const request = import(path).then(() => {
loaded.add(path);
loading.delete(path);
}).catch((error) => {
loading.delete(path);
throw new Error(`Failed to load plugin module: ${path}`, { cause: error });
});
loading.set(path, request);
return request;
}
async function loadPlugins(group) {
if (!(group in pluginGroups)) return;
for (const path of pluginGroups[group]) await loadPlugin(path);
}
function requestPlugins(group) {
void loadPlugins(group).catch((error) => {
console.error(error);
});
}
const loaderWindow = window;
loaderWindow.loadEagerPlugins = async () => {
await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins));
};
loaderWindow.loadPluginsForTab = loadPlugins;
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const tab = event.target.closest("[data-tab]")?.dataset.tab;
if (tab) requestPlugins(tab);
});
@@ -1,80 +0,0 @@
"use strict";
const decoders = /* @__PURE__ */ new Map();
const queued = /* @__PURE__ */ new Map();
const MAX_QUEUED_ACTIONS_PER_DECODER = 512;
function enqueue(id, action) {
const actions = queued.get(id) ?? [];
actions.push(action);
if (actions.length > MAX_QUEUED_ACTIONS_PER_DECODER) actions.splice(0, actions.length - MAX_QUEUED_ACTIONS_PER_DECODER);
queued.set(id, actions);
}
function deliver(plugin, action) {
if (action.kind === "message" && plugin.onMessage) {
plugin.onMessage(action.payload);
return true;
}
if (action.kind === "batch" && plugin.onBatch) {
plugin.onBatch(action.payload);
return true;
}
if (action.kind === "restore" && plugin.restore) {
plugin.restore(action.payload);
return true;
}
return false;
}
function dispatchOrQueue(id, action) {
const plugin = decoders.get(id);
if (!plugin) {
enqueue(id, action);
return false;
}
if (!deliver(plugin, action)) {
if (action.kind === "batch" && plugin.onMessage) {
for (const message of action.payload) plugin.onMessage(message);
return true;
}
if (action.kind === "restore" && plugin.onBatch) {
plugin.onBatch(action.payload);
return true;
}
return false;
}
return true;
}
const runtime = {
registerDecoder(plugin) {
if (decoders.has(plugin.id)) throw new Error(`Decoder plugin already registered: ${plugin.id}`);
const erased = plugin;
decoders.set(plugin.id, erased);
const pending = queued.get(plugin.id) ?? [];
queued.delete(plugin.id);
for (const action of pending) deliver(erased, action);
return () => {
if (decoders.get(plugin.id) === erased) decoders.delete(plugin.id);
};
},
dispatch: (id, message) => dispatchOrQueue(id, { kind: "message", payload: message }),
dispatchBatch: (id, messages) => dispatchOrQueue(id, { kind: "batch", payload: messages }),
restore: (id, messages) => dispatchOrQueue(id, { kind: "restore", payload: messages }),
reset(id) {
const plugin = decoders.get(id);
if (!plugin?.reset) return false;
plugin.reset();
return true;
},
resetAll() {
for (const plugin of decoders.values()) plugin.reset?.();
},
prune(id) {
const plugin = decoders.get(id);
if (!plugin?.prune) return false;
plugin.prune();
return true;
},
clearQueued() {
queued.clear();
},
hasDecoder: (id) => decoders.has(id)
};
window.trxPluginRuntime = runtime;
@@ -1,5 +1,5 @@
"use strict";
const screenshotWindow = window;
// src/screenshot.ts
var screenshotWindow = window;
(function() {
"use strict";
const T = screenshotWindow.trx;
@@ -1,360 +0,0 @@
"use strict";
const browserWindow = window;
const preparedTabLists = /* @__PURE__ */ new WeakSet();
function elementById(id) {
const element = document.getElementById(id);
if (!element) throw new Error(`Missing required UI element #${id}`);
return element;
}
(function initUiCore() {
const api = browserWindow.trxUi ?? {};
browserWindow.trxUi = api;
function ensureLiveRegions() {
if (!document.getElementById("toast-region")) {
const region = document.createElement("div");
region.id = "toast-region";
region.className = "toast-region";
region.setAttribute("aria-live", "polite");
region.setAttribute("aria-atomic", "false");
document.body.appendChild(region);
}
if (!document.getElementById("ui-confirm-dialog")) {
const dialog = document.createElement("dialog");
dialog.id = "ui-confirm-dialog";
dialog.className = "ui-dialog";
dialog.innerHTML = `
<form method="dialog" class="ui-dialog-card">
<h2 id="ui-confirm-title">Confirm action</h2>
<p id="ui-confirm-message"></p>
<div class="ui-dialog-actions">
<button value="cancel" type="submit">Cancel</button>
<button value="confirm" type="submit" class="danger">Confirm</button>
</div>
</form>`;
document.body.appendChild(dialog);
}
}
api.notify = function notify(message, options = {}) {
ensureLiveRegions();
const { kind = "info", duration = kind === "error" ? 7e3 : 3200, action = null } = options;
const toast = document.createElement("div");
toast.className = `toast toast-${kind}`;
toast.setAttribute("role", kind === "error" ? "alert" : "status");
const text = document.createElement("span");
text.textContent = message;
toast.appendChild(text);
if (action && typeof action.run === "function") {
const button = document.createElement("button");
button.type = "button";
button.textContent = action.label || "Retry";
button.addEventListener("click", () => {
action.run();
toast.remove();
});
toast.appendChild(button);
}
elementById("toast-region").appendChild(toast);
requestAnimationFrame(() => {
toast.classList.add("toast-visible");
});
if (duration > 0) setTimeout(() => {
toast.remove();
}, duration);
return toast;
};
api.confirm = function confirmAction(options = {}) {
ensureLiveRegions();
const dialog = elementById("ui-confirm-dialog");
elementById("ui-confirm-title").textContent = options.title || "Confirm action";
elementById("ui-confirm-message").textContent = options.message || "Continue?";
const confirmButton = dialog.querySelector('[value="confirm"]');
if (!confirmButton) throw new Error("Confirmation dialog has no confirm button");
confirmButton.textContent = options.confirmLabel || "Confirm";
confirmButton.classList.toggle("danger", options.danger !== false);
return new Promise((resolve) => {
const finish = () => {
resolve(dialog.returnValue === "confirm");
};
dialog.addEventListener("close", finish, { once: true });
dialog.showModal();
});
};
api.setButtonState = function setButtonState(button, options = {}) {
if (!button) return;
const { active = false, activeLabel, inactiveLabel, busy = false, disabled = false } = options;
button.classList.toggle("is-active", active);
button.classList.toggle("is-busy", busy);
button.setAttribute("aria-pressed", String(active));
button.setAttribute("aria-busy", String(busy));
button.disabled = disabled || busy;
const label = active ? activeLabel : inactiveLabel;
if (label) button.textContent = label;
};
api.prepareTabList = function prepareTabList(bar, kind = "primary") {
if (!bar) return;
if (preparedTabLists.has(bar)) return;
preparedTabLists.add(bar);
const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]";
const buttons = Array.from(bar.querySelectorAll(selector));
bar.setAttribute("role", "tablist");
buttons.forEach((button, index) => {
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(button.classList.contains("active")));
button.tabIndex = button.classList.contains("active") || !buttons.some((b) => b.classList.contains("active")) && index === 0 ? 0 : -1;
const key = button.dataset.tab || button.dataset.subtab;
if (!key) return;
button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`);
const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`);
if (panel) {
if (!button.id) button.id = `${kind}-tab-${key}`;
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", button.id);
}
});
bar.addEventListener("keydown", (event) => {
if (!(event.target instanceof HTMLElement) || !buttons.includes(event.target)) return;
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
if (!direction) return;
event.preventDefault();
const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length];
if (!next) return;
next.focus();
next.click();
});
};
api.syncSelectedTab = function syncSelectedTab(bar, selected) {
if (!bar) return;
bar.querySelectorAll('[role="tab"]').forEach((tab) => {
const active = tab === selected;
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
});
};
const layouts = {
compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" },
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" }
};
const layoutCapabilities = { broadcast: false, digital: false };
let activeRigId = null;
function layoutStorageKey() {
return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
}
function savedLayoutName() {
return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact";
}
function layoutAvailable(layout) {
return !layout.capability || layoutCapabilities[layout.capability];
}
function unavailableLayoutMessage() {
const unavailable = Object.values(layouts).filter((layout) => !layoutAvailable(layout) && layout.unavailable);
return unavailable.length ? `Unavailable: ${unavailable.map((layout) => layout.unavailable).join("; ")}.` : "";
}
function refreshLayoutOptions() {
const select = document.getElementById("operator-layout-select");
if (!select) return;
const previous = select.value || document.body.dataset.operatorLayout || "compact";
select.replaceChildren();
Object.entries(layouts).forEach(([value, layout]) => {
if (!layoutAvailable(layout)) return;
select.add(new Option(layout.label, value));
});
const available = Array.from(select.options).some((option) => option.value === previous);
select.value = available ? previous : "compact";
if (!available && previous !== "compact") api.applyLayout("compact", { persist: false });
select.title = unavailableLayoutMessage();
}
api.setLayoutCapabilities = function setLayoutCapabilities(capabilities = {}) {
Object.keys(layoutCapabilities).forEach((name) => {
if (name in capabilities) layoutCapabilities[name] = Boolean(capabilities[name]);
});
refreshLayoutOptions();
const select = document.getElementById("operator-layout-select");
const saved = savedLayoutName();
if (select && Array.from(select.options).some((option) => option.value === saved)) {
select.value = saved;
api.applyLayout(saved, { persist: false });
}
};
api.setActiveRig = function setActiveRig(rigId) {
activeRigId = typeof rigId === "string" && rigId ? rigId : null;
const saved = savedLayoutName();
const select = document.getElementById("operator-layout-select");
if (select) select.value = Array.from(select.options).some((option) => option.value === saved) ? saved : "compact";
api.applyLayout(select?.value || saved, { persist: false });
};
api.applyLayout = function applyLayout(name, options = {}) {
const requestedName = name in layouts ? name : "compact";
const requestedLayout = layouts[requestedName];
const permittedName = layoutAvailable(requestedLayout) ? requestedName : "compact";
const layout = layouts[permittedName];
document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
if (options.persist !== false) localStorage.setItem(layoutStorageKey(), permittedName);
const details = document.getElementById("advanced-radio-controls");
if (details) details.open = layout.advanced;
const audioDetails = document.getElementById("audio-controls");
if (audioDetails) audioDetails.open = layout.audio;
const schedulerDetails = document.getElementById("scheduler-controls");
if (schedulerDetails) schedulerDetails.open = layout.scheduler;
if (options.navigate && typeof browserWindow.navigateToTab === "function") {
browserWindow.navigateToTab(layout.preferredTab);
}
};
function installLayoutControls() {
const actions = document.querySelector(".top-bar-actions");
if (actions && !document.getElementById("operator-layout-select")) {
const label = document.createElement("label");
label.className = "operator-layout-picker";
label.innerHTML = '<span class="visually-hidden">Operator layout</span><select id="operator-layout-select" aria-label="Operator layout"></select>';
const select = label.querySelector("select");
if (!select) throw new Error("Operator layout picker has no select element");
const savedLayout = savedLayoutName();
actions.insertBefore(label, actions.firstChild);
select.value = savedLayout;
refreshLayoutOptions();
if (savedLayout !== "broadcast" && savedLayout in layouts) select.value = savedLayout;
select.addEventListener("change", () => {
api.applyLayout(select.value, { navigate: true });
});
api.applyLayout(select.value);
}
const tray = document.querySelector(".controls-tray");
if (tray && !document.getElementById("advanced-radio-controls")) {
const details = document.createElement("details");
details.id = "advanced-radio-controls";
details.className = "advanced-radio-controls";
details.innerHTML = '<summary>Advanced radio controls</summary><div class="advanced-radio-body"></div>';
const body = details.querySelector(".advanced-radio-body");
if (!body) throw new Error("Advanced controls have no body");
["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => {
const element = document.getElementById(id);
if (element) body.appendChild(element);
});
tray.appendChild(details);
api.applyLayout(savedLayoutName(), { persist: false });
}
}
function installMobileMore() {
const nav = document.querySelector(".tab-bar-nav");
if (!nav || document.getElementById("mobile-more-btn")) return;
const more = document.createElement("button");
more.id = "mobile-more-btn";
more.className = "tab mobile-more-btn";
more.type = "button";
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>';
more.setAttribute("aria-haspopup", "menu");
more.setAttribute("aria-expanded", "false");
const menu = document.createElement("div");
menu.id = "mobile-more-menu";
menu.className = "mobile-more-menu";
menu.setAttribute("role", "menu");
more.setAttribute("aria-controls", menu.id);
const closeMore = (restoreFocus = false) => {
if (!menu.classList.contains("is-open")) return;
menu.classList.remove("is-open");
more.setAttribute("aria-expanded", "false");
if (restoreFocus) more.focus();
};
api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return;
const item = document.createElement("button");
item.type = "button";
item.setAttribute("role", "menuitem");
item.dataset.navigateTab = tabName;
item.textContent = source.textContent.trim();
item.addEventListener("click", () => {
if (typeof browserWindow.navigateToTab === "function") browserWindow.navigateToTab(tabName);
closeMore();
});
menu.appendChild(item);
});
more.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
more.setAttribute("aria-expanded", String(open));
if (open) menu.querySelector('[role="menuitem"]')?.focus();
});
document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMore(true);
});
window.addEventListener("resize", () => {
closeMore();
});
window.addEventListener("popstate", () => {
closeMore();
});
nav.append(more, menu);
}
function installDecoderPicker() {
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
if (!bar || document.getElementById("decoder-tab-select")) return;
const select = document.createElement("select");
select.id = "decoder-tab-select";
select.className = "decoder-tab-select";
select.setAttribute("aria-label", "Decoder view");
const groups = [
["Overview", ["overview"]],
["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]],
["Broadcast & images", ["rds", "sat", "wefax"]]
];
groups.forEach(([label, ids]) => {
const group = document.createElement("optgroup");
group.label = label;
ids.forEach((id) => {
const button = bar.querySelector(`[data-subtab="${id}"]`);
if (button) group.appendChild(new Option(button.textContent.trim(), id));
});
select.appendChild(group);
});
select.addEventListener("change", () => bar.querySelector(`[data-subtab="${select.value}"]`)?.click());
bar.insertAdjacentElement("afterend", select);
}
function installDecoderBadges() {
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
if (!bar) return;
bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => {
const id = button.dataset.subtab;
if (!id) return;
if (id === "overview" || button.querySelector(".decoder-state-dot")) return;
const dot = document.createElement("span");
dot.className = "decoder-state-dot";
dot.setAttribute("aria-hidden", "true");
button.appendChild(dot);
const status = document.getElementById(`${id}-status`);
if (!status) return;
const sync = () => {
const value = status.textContent.toLowerCase();
const state = /receiv|decod|connected|listening/.test(value) ? "active" : /error|fail|disconnected/.test(value) ? "error" : "idle";
dot.dataset.state = state;
button.title = `${button.childNodes[0]?.textContent?.trim() || id}: ${status.textContent.trim()}`;
};
new MutationObserver(sync).observe(status, { childList: true, characterData: true, subtree: true });
sync();
});
}
api.init = function init() {
ensureLiveRegions();
installLayoutControls();
installMobileMore();
installDecoderPicker();
installDecoderBadges();
api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary");
document.querySelectorAll(".sub-tab-bar").forEach((bar) => {
api.prepareTabList(bar, "secondary");
});
window.addEventListener("unhandledrejection", (event) => {
const message = event.reason instanceof Error ? event.reason.message : "An operation failed unexpectedly";
api.notify(message, { kind: "error" });
});
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => {
api.init();
}, { once: true });
else api.init();
})();
@@ -1,498 +0,0 @@
"use strict";
(function initTrxWebGl(global) {
"use strict";
const cssColorCache = /* @__PURE__ */ new Map();
let cssColorProbe = null;
function clearCssColorCache() {
cssColorCache.clear();
}
function ensureCssColorProbe() {
if (cssColorProbe) return cssColorProbe;
const el = document.createElement("span");
el.style.position = "absolute";
el.style.left = "-9999px";
el.style.top = "-9999px";
el.style.pointerEvents = "none";
el.style.opacity = "0";
document.body.appendChild(el);
cssColorProbe = el;
return cssColorProbe;
}
function parseRgbString(value) {
const m = /^rgba?\(([^)]+)\)$/.exec(value.trim());
if (!m) return null;
const parts = m[1]?.split(",").map((p) => p.trim()) ?? [];
if (parts.length < 3) return null;
const r = Number(parts[0]);
const g = Number(parts[1]);
const b = Number(parts[2]);
const a = parts.length > 3 ? Number(parts[3]) : 1;
if (![r, g, b, a].every(Number.isFinite)) return null;
return [
Math.max(0, Math.min(1, r / 255)),
Math.max(0, Math.min(1, g / 255)),
Math.max(0, Math.min(1, b / 255)),
Math.max(0, Math.min(1, a))
];
}
function parseHexColor(value) {
const raw = value.trim();
if (!/^#([0-9a-f]{3,8})$/i.test(raw)) return null;
let hex = raw.slice(1);
if (hex.length === 3 || hex.length === 4) {
hex = hex.split("").map((ch) => ch + ch).join("");
}
if (!(hex.length === 6 || hex.length === 8)) return null;
const r = parseInt(hex.slice(0, 2), 16) / 255;
const g = parseInt(hex.slice(2, 4), 16) / 255;
const b = parseInt(hex.slice(4, 6), 16) / 255;
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return [r, g, b, a];
}
function parseCssColor(value) {
const key = value;
const cached = cssColorCache.get(key);
if (cached) return [...cached];
let parsed = parseHexColor(key) || parseRgbString(key);
if (!parsed) {
const probe = ensureCssColorProbe();
probe.style.color = "";
probe.style.color = key;
const computed = getComputedStyle(probe).color;
parsed = parseRgbString(computed) || [0, 0, 0, 1];
}
cssColorCache.set(key, [...parsed]);
return [...parsed];
}
function hslToRgba(h, s, l, a = 1) {
const hue = ((h || 0) % 360 + 360) % 360 / 360;
const sat = Math.max(0, Math.min(1, (s || 0) / 100));
const lig = Math.max(0, Math.min(1, (l || 0) / 100));
const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat;
const p = 2 * lig - q;
const hueToRgb = (t) => {
let tt = t;
if (tt < 0) tt += 1;
if (tt > 1) tt -= 1;
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
if (tt < 1 / 2) return q;
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
return p;
};
const r = sat === 0 ? lig : hueToRgb(hue + 1 / 3);
const g = sat === 0 ? lig : hueToRgb(hue);
const b = sat === 0 ? lig : hueToRgb(hue - 1 / 3);
return [r, g, b, Math.max(0, Math.min(1, a))];
}
function normalizeColor(input, alphaMul = 1) {
let rgba;
if (Array.isArray(input)) {
const arr = input;
if (arr.length >= 4) {
rgba = [arr[0] ?? 0, arr[1] ?? 0, arr[2] ?? 0, arr[3] ?? 1];
} else {
rgba = [0, 0, 0, 1];
}
} else if (typeof input === "string") {
rgba = parseCssColor(input);
} else {
rgba = [
input.r || 0,
input.g || 0,
input.b || 0,
input.a ?? 1
];
}
const out = [
Math.max(0, Math.min(1, rgba[0])),
Math.max(0, Math.min(1, rgba[1])),
Math.max(0, Math.min(1, rgba[2])),
Math.max(0, Math.min(1, rgba[3] * alphaMul))
];
return out;
}
function compileShader(gl, type, source) {
const shader = gl.createShader(type);
if (shader === null) throw new Error("Unable to create WebGL shader");
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(shader) || "shader compile error";
gl.deleteShader(shader);
throw new Error(log);
}
return shader;
}
function createProgram(gl, vertexSrc, fragmentSrc) {
const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc);
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc);
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(program) || "program link error";
gl.deleteProgram(program);
throw new Error(log);
}
return program;
}
function pushColoredVertex(target, x, y, rgba) {
target.push(x, y, rgba[0], rgba[1], rgba[2], rgba[3]);
}
function segmentToQuadVertices(out, x0, y0, x1, y1, halfW, rgba) {
const dx = x1 - x0;
const dy = y1 - y0;
const len = Math.hypot(dx, dy);
if (!(len > 1e-4)) return;
const nx = -dy / len * halfW;
const ny = dx / len * halfW;
const ax = x0 - nx, ay = y0 - ny;
const bx = x0 + nx, by = y0 + ny;
const cx = x1 + nx, cy = y1 + ny;
const dx2 = x1 - nx, dy2 = y1 - ny;
pushColoredVertex(out, ax, ay, rgba);
pushColoredVertex(out, bx, by, rgba);
pushColoredVertex(out, cx, cy, rgba);
pushColoredVertex(out, ax, ay, rgba);
pushColoredVertex(out, cx, cy, rgba);
pushColoredVertex(out, dx2, dy2, rgba);
}
class TrxWebGlRenderer {
canvas;
options;
gl;
ready;
textures = /* @__PURE__ */ new Map();
_colorScratch = new Float32Array(4096 * 6);
_colorGpuSize = 0;
_texScratch = new Float32Array(6 * 4);
colorProgram;
colorBuffer;
colorLoc;
textureProgram;
textureBuffer;
textureLoc;
constructor(canvas, options = {}) {
this.canvas = canvas;
this.options = { alpha: true, premultipliedAlpha: false, ...options };
this.gl = canvas.getContext("webgl", this.options) || canvas.getContext("experimental-webgl", this.options);
this.ready = !!this.gl;
if (!this.ready) return;
const gl = this.gl;
if (!gl) return;
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
const colorVertexSrc = "attribute vec2 a_pos;\nattribute vec4 a_color;\nuniform vec2 u_resolution;\nvarying vec4 v_color;\nvoid main() {\n vec2 zeroToOne = a_pos / u_resolution;\n vec2 clip = zeroToOne * 2.0 - 1.0;\n gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n v_color = a_color;\n}\n";
const colorFragmentSrc = "precision mediump float;\nvarying vec4 v_color;\nvoid main() {\n gl_FragColor = v_color;\n}\n";
const textureVertexSrc = "attribute vec2 a_pos;\nattribute vec2 a_uv;\nuniform vec2 u_resolution;\nvarying vec2 v_uv;\nvoid main() {\n vec2 zeroToOne = a_pos / u_resolution;\n vec2 clip = zeroToOne * 2.0 - 1.0;\n gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n v_uv = a_uv;\n}\n";
const textureFragmentSrc = "precision mediump float;\nvarying vec2 v_uv;\nuniform sampler2D u_tex;\nuniform float u_alpha;\nvoid main() {\n vec4 c = texture2D(u_tex, v_uv);\n gl_FragColor = vec4(c.rgb, c.a * u_alpha);\n}\n";
this.colorProgram = createProgram(gl, colorVertexSrc, colorFragmentSrc);
this.colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
this._colorGpuSize = this._colorScratch.length;
this.colorLoc = {
pos: gl.getAttribLocation(this.colorProgram, "a_pos"),
color: gl.getAttribLocation(this.colorProgram, "a_color"),
resolution: gl.getUniformLocation(this.colorProgram, "u_resolution")
};
this.textureProgram = createProgram(gl, textureVertexSrc, textureFragmentSrc);
this.textureBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
gl.bufferData(gl.ARRAY_BUFFER, this._texScratch, gl.DYNAMIC_DRAW);
this.textureLoc = {
pos: gl.getAttribLocation(this.textureProgram, "a_pos"),
uv: gl.getAttribLocation(this.textureProgram, "a_uv"),
resolution: gl.getUniformLocation(this.textureProgram, "u_resolution"),
alpha: gl.getUniformLocation(this.textureProgram, "u_alpha"),
tex: gl.getUniformLocation(this.textureProgram, "u_tex")
};
}
ensureSize(cssWidth, cssHeight, dpr = window.devicePixelRatio || 1) {
if (!this.gl) return false;
const nextW = Math.max(1, Math.round(cssWidth * dpr));
const nextH = Math.max(1, Math.round(cssHeight * dpr));
const changed = this.canvas.width !== nextW || this.canvas.height !== nextH;
if (changed) {
this.canvas.width = nextW;
this.canvas.height = nextH;
}
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
return changed;
}
clear(color) {
if (!this.gl) return;
const gl = this.gl;
const rgba = normalizeColor(color);
gl.clearColor(rgba[0], rgba[1], rgba[2], rgba[3]);
gl.clear(gl.COLOR_BUFFER_BIT);
}
drawTriangles(vertices) {
if (!this.gl) return;
this._drawColorGeometry(vertices, this.gl.TRIANGLES);
}
drawTriangleStrip(vertices) {
if (!this.gl) return;
this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP);
}
_drawColorGeometry(vertices, mode) {
if (!this.gl || vertices.length === 0) return;
const gl = this.gl;
const count = vertices.length;
if (count > this._colorScratch.length) {
let newLen = this._colorScratch.length;
while (newLen < count) newLen *= 2;
this._colorScratch = new Float32Array(newLen);
}
this._colorScratch.set(vertices);
const view = this._colorScratch.subarray(0, count);
gl.useProgram(this.colorProgram);
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
if (count > this._colorGpuSize) {
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
this._colorGpuSize = this._colorScratch.length;
} else {
gl.bufferSubData(gl.ARRAY_BUFFER, 0, view);
}
gl.enableVertexAttribArray(this.colorLoc.pos);
gl.vertexAttribPointer(this.colorLoc.pos, 2, gl.FLOAT, false, 24, 0);
gl.enableVertexAttribArray(this.colorLoc.color);
gl.vertexAttribPointer(this.colorLoc.color, 4, gl.FLOAT, false, 24, 8);
gl.uniform2f(this.colorLoc.resolution, this.canvas.width, this.canvas.height);
gl.drawArrays(mode, 0, count / 6);
}
fillRect(x, y, w, h, color) {
if (w <= 0 || h <= 0) return;
const rgba = normalizeColor(color);
const v = [];
pushColoredVertex(v, x, y, rgba);
pushColoredVertex(v, x + w, y, rgba);
pushColoredVertex(v, x + w, y + h, rgba);
pushColoredVertex(v, x, y, rgba);
pushColoredVertex(v, x + w, y + h, rgba);
pushColoredVertex(v, x, y + h, rgba);
this.drawTriangles(v);
}
fillGradientRect(x, y, w, h, colorTL, colorTR, colorBR, colorBL) {
if (w <= 0 || h <= 0) return;
const tl = normalizeColor(colorTL);
const tr = normalizeColor(colorTR);
const br = normalizeColor(colorBR);
const bl = normalizeColor(colorBL);
const v = [];
pushColoredVertex(v, x, y, tl);
pushColoredVertex(v, x + w, y, tr);
pushColoredVertex(v, x + w, y + h, br);
pushColoredVertex(v, x, y, tl);
pushColoredVertex(v, x + w, y + h, br);
pushColoredVertex(v, x, y + h, bl);
this.drawTriangles(v);
}
drawPolyline(points, color, width = 1) {
if (!Array.isArray(points) || points.length < 4) return;
const rgba = normalizeColor(color);
const halfW = Math.max(0.5, width || 1) / 2;
const verts = [];
for (let i = 0; i < points.length - 2; i += 2) {
segmentToQuadVertices(
verts,
points[i] ?? 0,
points[i + 1] ?? 0,
points[i + 2] ?? 0,
points[i + 3] ?? 0,
halfW,
rgba
);
}
this.drawTriangles(verts);
}
drawSegments(segments, color, width = 1) {
if (!Array.isArray(segments) || segments.length < 4) return;
const rgba = normalizeColor(color);
const halfW = Math.max(0.5, width || 1) / 2;
const verts = [];
for (let i = 0; i < segments.length - 3; i += 4) {
segmentToQuadVertices(
verts,
segments[i] ?? 0,
segments[i + 1] ?? 0,
segments[i + 2] ?? 0,
segments[i + 3] ?? 0,
halfW,
rgba
);
}
this.drawTriangles(verts);
}
drawFilledArea(points, baselineY, color) {
if (!Array.isArray(points) || points.length < 4) return;
const rgba = normalizeColor(color);
const verts = [];
for (let i = 0; i < points.length; i += 2) {
pushColoredVertex(verts, points[i] ?? 0, baselineY, rgba);
pushColoredVertex(verts, points[i] ?? 0, points[i + 1] ?? 0, rgba);
}
this.drawTriangleStrip(verts);
}
drawPoints(points, size, color) {
if (!Array.isArray(points) || points.length < 2) return;
const radius = Math.max(1, size || 1);
const rgba = normalizeColor(color);
const verts = [];
for (let i = 0; i < points.length; i += 2) {
const x = (points[i] ?? 0) - radius;
const y = (points[i + 1] ?? 0) - radius;
const w = radius * 2;
const h = radius * 2;
pushColoredVertex(verts, x, y, rgba);
pushColoredVertex(verts, x + w, y, rgba);
pushColoredVertex(verts, x + w, y + h, rgba);
pushColoredVertex(verts, x, y, rgba);
pushColoredVertex(verts, x + w, y + h, rgba);
pushColoredVertex(verts, x, y + h, rgba);
}
this.drawTriangles(verts);
}
drawDashedVerticalLine(x, y0, y1, dashLen, gapLen, color, width = 1) {
const dash = Math.max(1, dashLen || 1);
const gap = Math.max(1, gapLen || 1);
const top = Math.min(y0, y1);
const bottom = Math.max(y0, y1);
const segments = [];
for (let y = top; y < bottom; y += dash + gap) {
const segEnd = Math.min(bottom, y + dash);
segments.push(x, y, x, segEnd);
}
this.drawSegments(segments, color, width);
}
uploadRgbaTexture(name, width, height, data, filter = "linear") {
if (!this.gl || !name) return null;
const gl = this.gl;
let entry = this.textures.get(name);
if (!entry) {
const texture = gl.createTexture();
entry = { texture, width: 0, height: 0 };
this.textures.set(name, entry);
}
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
const mode = filter === "nearest" ? gl.NEAREST : gl.LINEAR;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mode);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, mode);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
if (entry.width !== width || entry.height !== height) {
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
width,
height,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
data
);
entry.width = width;
entry.height = height;
} else {
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
width,
height,
gl.RGBA,
gl.UNSIGNED_BYTE,
data
);
}
return entry.texture;
}
drawTexture(name, x, y, w, h, alpha = 1, flipY = true) {
if (!this.gl || !name || w <= 0 || h <= 0) return;
const entry = this.textures.get(name);
if (!entry) return;
const gl = this.gl;
const s = this._texScratch;
const x2 = x + w, y2 = y + h;
if (flipY) {
s[0] = x;
s[1] = y;
s[2] = 0;
s[3] = 1;
s[4] = x2;
s[5] = y;
s[6] = 1;
s[7] = 1;
s[8] = x2;
s[9] = y2;
s[10] = 1;
s[11] = 0;
s[12] = x;
s[13] = y;
s[14] = 0;
s[15] = 1;
s[16] = x2;
s[17] = y2;
s[18] = 1;
s[19] = 0;
s[20] = x;
s[21] = y2;
s[22] = 0;
s[23] = 0;
} else {
s[0] = x;
s[1] = y;
s[2] = 0;
s[3] = 0;
s[4] = x2;
s[5] = y;
s[6] = 1;
s[7] = 0;
s[8] = x2;
s[9] = y2;
s[10] = 1;
s[11] = 1;
s[12] = x;
s[13] = y;
s[14] = 0;
s[15] = 0;
s[16] = x2;
s[17] = y2;
s[18] = 1;
s[19] = 1;
s[20] = x;
s[21] = y2;
s[22] = 0;
s[23] = 1;
}
gl.useProgram(this.textureProgram);
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, s);
gl.enableVertexAttribArray(this.textureLoc.pos);
gl.vertexAttribPointer(this.textureLoc.pos, 2, gl.FLOAT, false, 16, 0);
gl.enableVertexAttribArray(this.textureLoc.uv);
gl.vertexAttribPointer(this.textureLoc.uv, 2, gl.FLOAT, false, 16, 8);
gl.uniform2f(this.textureLoc.resolution, this.canvas.width, this.canvas.height);
gl.uniform1f(this.textureLoc.alpha, Math.max(0, Math.min(1, alpha || 0)));
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
gl.uniform1i(this.textureLoc.tex, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
}
function createRenderer(canvas, options = {}) {
return new TrxWebGlRenderer(canvas, options);
}
global.trxParseCssColor = parseCssColor;
global.trxHslToRgba = hslToRgba;
global.createTrxWebGlRenderer = createRenderer;
global.trxClearCssColorCache = clearCssColorCache;
})(window);
@@ -1637,12 +1637,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
<script defer src="/vendor/leaflet.js"></script>
<script defer src="/leaflet-ais-tracksymbol.js"></script>
<script defer src="/webgl-renderer.js"></script>
<script defer src="/ui-core.js"></script>
<script defer src="/plugin-runtime.js"></script>
<script defer src="/plugin-loader.js"></script>
<script defer src="/app.js"></script>
<!-- Template cloning is handled by navigateToTab() in app.js -->
<script type="module" src="/app.js"></script>
<!-- Template cloning is handled by the typed application bundle. -->
</body>
</html>
@@ -15,43 +15,12 @@ await rm(outputDir, { recursive: true, force: true });
await build({
entryPoints: {
"api-client": path.join(sourceDir, "api", "client.ts"),
"ui-core": path.join(sourceDir, "ui-core.ts"),
"plugin-loader": path.join(sourceDir, "plugin-loader.ts"),
"plugin-runtime": path.join(sourceDir, "plugin-runtime.ts"),
screenshot: path.join(sourceDir, "screenshot.ts"),
"webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"),
},
outdir: outputDir,
bundle: false,
target: "es2022",
sourcemap: false,
legalComments: "inline",
charset: "utf8",
logLevel: "info",
});
await build({
entryPoints: { app: path.join(sourceDir, "bootstrap.ts") },
outdir: outputDir,
bundle: true,
format: "iife",
platform: "browser",
target: "es2022",
sourcemap: false,
legalComments: "inline",
charset: "utf8",
logLevel: "info",
});
await build({
entryPoints: {
app: path.join(sourceDir, "bootstrap.ts"),
ft2: path.join(sourceDir, "plugins", "ft2.ts"),
ft4: path.join(sourceDir, "plugins", "ft4.ts"),
wspr: path.join(sourceDir, "plugins", "wspr.ts"),
cw: path.join(sourceDir, "plugins", "cw.ts"),
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
"leaflet-ais-tracksymbol": path.join(sourceDir, "leaflet-ais-tracksymbol.ts"),
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
@@ -64,6 +33,7 @@ await build({
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
"map-core": path.join(sourceDir, "map-core.ts"),
screenshot: path.join(sourceDir, "screenshot.ts"),
},
outdir: outputDir,
bundle: true,
@@ -6,7 +6,45 @@ SPDX-License-Identifier: GPL-2.0-or-later
# Frontend source
This directory is the source of the browser assets embedded by
`trx-frontend-http`. Run `npm run build` from the parent `frontend` directory
after changing a source file. Cargo consumes the committed output under
`../assets/web/generated` and does not invoke Node.js.
This directory contains the strict TypeScript source for the browser assets
embedded by `trx-frontend-http`. `bootstrap.ts` is the production entry point;
esbuild follows its imports and emits `/app.js`. Feature plugins are ESM entry
points loaded on demand by `plugin-loader.ts`, and the decode-history worker is
built with its own Web Worker TypeScript configuration.
Run all frontend commands from the parent `frontend` directory:
```sh
npm ci
npm run typecheck
npm run lint
npm test
npm run test:browser
npm run build
npm run verify-generated
```
`npm run verify-generated` regenerates Rust wire contracts and browser bundles,
then rejects drift from the committed files. The browser smoke test needs a
local Chromium-family executable; set `CHROMIUM_PATH` when it is not installed
at a conventional system path.
Cargo consumes committed output under `../assets/web/generated`. It never
invokes Node.js, installs packages, or accesses the network. After changing a
source file, commit the deterministic generated asset changes together with the
source change.
## Boundaries
- `api/generated.ts` is generated from Rust and must not be edited manually.
- `api/client.ts` owns runtime validation at version-sensitive HTTP and SSE
boundaries.
- `core/` contains dependency-light shared helpers.
- `features/` contains application behavior grouped by responsibility.
- `plugins/` contains lazy decoder and high-coupling feature entries.
- Files under `assets/web/vendor/` are vendored JavaScript and are not part of
the TypeScript migration.
First-party features communicate through imports, the typed plugin runtime, or
the documented `window.trx` host interface. New standalone `window` callbacks
are not permitted.
@@ -46,6 +46,7 @@ import type { NumericBins } from "./features/spectrum/math.js";
import type { AuthRole } from "./api/auth.js";
import type { RdsData, RigCapabilities, RigListItem, RigSnapshot } from "./api/generated.js";
import type { TrxPluginRuntime } from "./plugins/runtime-contract.js";
import { loadEagerPlugins, loadPluginsForTab } from "./plugin-loader.js";
interface SpectrumFrame {
bins: NumericBins;
@@ -4530,7 +4531,7 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
updateTabHistory(name, replaceHistory);
}
scheduleSpectrumLayout();
if (typeof window.loadPluginsForTab === "function") window.loadPluginsForTab(name);
void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); });
if (name === "map") {
_initMapWhenReady();
}
@@ -4787,7 +4788,7 @@ window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules
// Load plugin scripts now that window.trx is populated. Dynamic scripts are
// async so they must not be created before the namespace they depend on exists.
if (typeof window.loadEagerPlugins === "function") window.loadEagerPlugins();
void loadEagerPlugins().catch((error: unknown) => { console.error(error); });
// Start the app
initializeApp();
@@ -7829,17 +7830,14 @@ window.addEventListener("keydown", (event) => {
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (shouldIgnoreGlobalShortcut(event.target)) return;
// S — spectrum screenshot (lazy-loads screenshot.js on first use)
// S — spectrum screenshot (lazy-loads its typed module on first use)
if (key === "s") {
event.preventDefault();
if (window.trx.modules.screenshot) {
void window.trx.modules.screenshot.captureSpectrumScreenshot();
} else {
const s = document.createElement("script");
s.src = "/screenshot.js";
s.onload = () => { void window.trx.modules.screenshot?.captureSpectrumScreenshot(); };
document.body.appendChild(s);
}
} else void import("./screenshot.js")
.then(() => window.trx.modules.screenshot?.captureSpectrumScreenshot())
.catch((error: unknown) => { console.error("Screenshot module failed to load", error); });
return;
}
@@ -2,4 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import "./app.ts";
import "./webgl-renderer.js";
import "./ui-core.js";
import "./plugin-runtime.js";
import "./leaflet-ais-tracksymbol.js";
import "./app.js";
@@ -36,21 +36,10 @@ async function loadPlugins(group: string): Promise<void> {
for (const path of pluginGroups[group as PluginGroup]) await loadPlugin(path);
}
function requestPlugins(group: string): void {
void loadPlugins(group).catch((error: unknown) => { console.error(error); });
export async function loadEagerPlugins(): Promise<void> {
await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins));
}
const loaderWindow = window as typeof window & {
loadEagerPlugins?: () => Promise<void>;
loadPluginsForTab?: (tab: string) => Promise<void>;
};
loaderWindow.loadEagerPlugins = async () => {
await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins));
};
loaderWindow.loadPluginsForTab = loadPlugins;
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const tab = event.target.closest<HTMLElement>("[data-tab]")?.dataset.tab;
if (tab) requestPlugins(tab);
});
export async function loadPluginsForTab(tab: string): Promise<void> {
await loadPlugins(tab);
}
@@ -21,6 +21,7 @@ interface TrxScreenshotBridge {
showHint(message: string, durationMs: number): void;
modules: { screenshot?: ScreenshotModule };
}
export {};
const screenshotWindow = window as typeof window & { trx: TrxScreenshotBridge };
(function () {
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
test("AIS entry forwards positioned vessels with normalized metadata", async () => {
const forwarded = [];
@@ -23,7 +24,7 @@ test("AIS entry forwards positioned vessels with normalized metadata", async ()
Map,
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/ais.js", import.meta.url), "utf8");
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -3,7 +3,6 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
@@ -26,7 +25,7 @@ test("APRS entry normalizes positioned packets without remote symbol assets", as
Reflect,
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await bundleEntry(new URL("../src/plugins/aprs.ts", import.meta.url));
assert.equal(source.includes("raw.githubusercontent.com"), false);
new vm.Script(runtime).runInContext(context);
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
class ElementFixture {
constructor() { this.children = []; this.textContent = ""; this.style = {}; this.classList = { toggle() {} }; }
@@ -37,7 +38,7 @@ test("CW entry appends server-decoded text and registers lifecycle callbacks", a
Math,
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/cw.js", import.meta.url), "utf8");
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -3,7 +3,6 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
@@ -30,7 +29,7 @@ test("FT2 entry normalizes audio offsets and registers typed callbacks", async (
Array,
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await bundleEntry(new URL("../src/plugins/ft2.ts", import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -62,7 +61,7 @@ test("FT8 entry installs shared parsing without relying on script globals", asyn
Set,
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await bundleEntry(new URL("../src/plugins/ft8.ts", import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -3,7 +3,6 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
@@ -22,7 +21,7 @@ test("HF APRS entry uses shared typed normalization and local symbols", async ()
Reflect,
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await bundleEntry(new URL("../src/plugins/hf-aprs.ts", import.meta.url));
assert.equal(source.includes("raw.githubusercontent.com"), false);
new vm.Script(runtime).runInContext(context);
@@ -3,9 +3,9 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
test("local AIS track-symbol adapter renders immediately on first add", async () => {
const listeners = new Map();
@@ -34,10 +34,7 @@ test("local AIS track-symbol adapter renders immediately on first add", async ()
Util: { extend: Object.assign },
divIcon: (options) => options,
};
const source = await readFile(
new URL("../../assets/web/generated/leaflet-ais-tracksymbol.js", import.meta.url),
"utf8",
);
const source = await bundleEntry(new URL("../src/leaflet-ais-tracksymbol.ts", import.meta.url));
new vm.Script(source).runInNewContext({ L, console });
Object.assign(L.TrxAisTrackSymbol.prototype, {
onAdd: markerPrototype.onAdd,
@@ -3,14 +3,14 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
test("plugin runtime queues lazy decoder traffic and flushes in order on registration", async () => {
const window = {};
const context = vm.createContext({ window, Map, Error });
const source = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const source = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
new vm.Script(source).runInContext(context);
assert.equal(window.trxPluginRuntime.dispatch("late", { sequence: 1 }), false);
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
test("satellite entry registers lifecycle callbacks and forwards georeferenced images", async () => {
const overlays = [];
@@ -26,7 +27,7 @@ test("satellite entry registers lifecycle callbacks and forwards georeferenced i
Error,
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/sat.js", import.meta.url), "utf8");
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -10,10 +10,11 @@ const indexPath = new URL("../../assets/web/index.html", import.meta.url);
const pluginLoaderPath = new URL("../src/plugin-loader.ts", import.meta.url);
const mapCorePath = new URL("../src/map-core.ts", import.meta.url);
test("index loads the shared UI before the application", async () => {
test("index loads one first-party application bootstrap", async () => {
const html = await readFile(indexPath, "utf8");
assert.ok(html.indexOf("/ui-core.js") < html.indexOf("/app.js"));
assert.ok(html.indexOf("/plugin-loader.js") < html.indexOf("/app.js"));
assert.equal((html.match(/<script[^>]+src="\/(?!vendor\/)[^"]+\.js"/g) ?? []).length, 1);
assert.match(html, /src="\/app\.js"/);
assert.doesNotMatch(html, /src="\/(?:ui-core|plugin-runtime|plugin-loader|webgl-renderer|leaflet-ais-tracksymbol)\.js"/);
assert.equal(html.includes("var pluginScripts"), false);
});
@@ -3,8 +3,8 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
class ClassList {
constructor() { this.values = new Set(); }
@@ -104,7 +104,7 @@ const context = vm.createContext({
console,
});
const source = await readFile(new URL("../../assets/web/generated/ui-core.js", import.meta.url), "utf8");
const source = await bundleEntry(new URL("../src/ui-core.ts", import.meta.url));
new vm.Script(source, { filename: "ui-core.js" }).runInContext(context);
const ui = window.trxUi;
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
test("VDES entry normalizes and forwards positioned server messages", async () => {
const forwarded = [];
@@ -22,7 +23,7 @@ test("VDES entry normalizes and forwards positioned server messages", async () =
Array,
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/vdes.js", import.meta.url), "utf8");
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -3,9 +3,9 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
test("WebGL color helpers normalize CSS and HSL colors", async () => {
const window = {};
@@ -22,10 +22,7 @@ test("WebGL color helpers normalize CSS and HSL colors", async () => {
String,
parseInt,
});
const source = await readFile(
new URL("../../assets/web/generated/webgl-renderer.js", import.meta.url),
"utf8",
);
const source = await bundleEntry(new URL("../src/webgl-renderer.ts", import.meta.url));
new vm.Script(source).runInContext(context);
assert.deepEqual(Array.from(window.trxParseCssColor("#ff800080")), [1, 128 / 255, 0, 128 / 255]);
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", async () => {
const status = { textContent: "", style: { color: "" } };
@@ -19,7 +20,7 @@ test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", a
Uint8Array,
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/wefax.js", import.meta.url), "utf8");
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
test("WSPR entry forwards decoded locators at their absolute frequency", async () => {
const forwarded = [];
@@ -25,7 +26,7 @@ test("WSPR entry forwards decoded locators at their absolute frequency", async (
Element: class Element {},
console,
});
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/wspr.js", import.meta.url), "utf8");
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -62,34 +62,18 @@ mod tests {
use super::*;
#[test]
fn shared_ui_core_is_embedded_and_loaded_before_app() {
fn typed_application_bundle_contains_shared_runtime() {
let html = index_html();
let ui_core = html.find("/ui-core.js").expect("ui-core script is loaded");
let app = html.find("/app.js").expect("application script is loaded");
assert!(
ui_core < app,
"UI primitives must load before application code"
);
assert!(generated_asset("ui-core.js").is_some_and(|asset| {
std::str::from_utf8(asset).is_ok_and(|script| script.contains("trxUi"))
}));
let plugin_loader = html
.find("/plugin-loader.js")
.expect("typed plugin loader is loaded");
assert!(plugin_loader < app, "plugin registry must load before app");
let plugin_runtime = html
.find("/plugin-runtime.js")
.expect("typed plugin runtime is loaded");
assert!(
plugin_runtime < plugin_loader,
"plugin runtime must load before the lazy loader"
);
assert!(generated_asset("plugin-runtime.js").is_some_and(|asset| {
std::str::from_utf8(asset).is_ok_and(|script| script.contains("registerDecoder"))
}));
assert!(generated_asset("plugin-loader.js").is_some_and(|asset| {
std::str::from_utf8(asset).is_ok_and(|script| script.contains("import(path)"))
assert_eq!(html.matches("/app.js").count(), 1);
assert!(!html.contains("/ui-core.js"));
assert!(!html.contains("/plugin-runtime.js"));
assert!(!html.contains("/plugin-loader.js"));
assert!(generated_asset("app.js").is_some_and(|asset| {
std::str::from_utf8(asset).is_ok_and(|script| {
script.contains("trxUi")
&& script.contains("registerDecoder")
&& script.contains("TrxAisTrackSymbol")
})
}));
assert!(html.contains("<summary>Scheduler controls</summary>"));
assert!(html.contains("<summary>Audio controls</summary>"));