build: consolidate typed frontend module graph
This commit is contained in:
@@ -43,6 +43,13 @@ jobs:
|
|||||||
working-directory: src/trx-client/trx-frontend/trx-frontend-http/frontend
|
working-directory: src/trx-client/trx-frontend/trx-frontend-http/frontend
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Install locked frontend dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Type-check
|
- name: Type-check
|
||||||
@@ -57,6 +64,9 @@ jobs:
|
|||||||
run: npm run test:browser
|
run: npm run test:browser
|
||||||
- name: Verify generated assets
|
- name: Verify generated assets
|
||||||
run: npm run verify-generated
|
run: npm run verify-generated
|
||||||
|
- name: Verify generated-file licensing
|
||||||
|
working-directory: .
|
||||||
|
run: reuse lint
|
||||||
|
|
||||||
reuse:
|
reuse:
|
||||||
runs-on: ubuntu-latest
|
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
-95
@@ -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";
|
// src/screenshot.ts
|
||||||
const screenshotWindow = window;
|
var screenshotWindow = window;
|
||||||
(function() {
|
(function() {
|
||||||
"use strict";
|
"use strict";
|
||||||
const T = screenshotWindow.trx;
|
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>
|
</div>
|
||||||
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
|
<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="/vendor/leaflet.js"></script>
|
||||||
<script defer src="/leaflet-ais-tracksymbol.js"></script>
|
<script type="module" src="/app.js"></script>
|
||||||
<script defer src="/webgl-renderer.js"></script>
|
<!-- Template cloning is handled by the typed application bundle. -->
|
||||||
<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 -->
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -15,43 +15,12 @@ await rm(outputDir, { recursive: true, force: true });
|
|||||||
|
|
||||||
await build({
|
await build({
|
||||||
entryPoints: {
|
entryPoints: {
|
||||||
"api-client": path.join(sourceDir, "api", "client.ts"),
|
app: path.join(sourceDir, "bootstrap.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: {
|
|
||||||
ft2: path.join(sourceDir, "plugins", "ft2.ts"),
|
ft2: path.join(sourceDir, "plugins", "ft2.ts"),
|
||||||
ft4: path.join(sourceDir, "plugins", "ft4.ts"),
|
ft4: path.join(sourceDir, "plugins", "ft4.ts"),
|
||||||
wspr: path.join(sourceDir, "plugins", "wspr.ts"),
|
wspr: path.join(sourceDir, "plugins", "wspr.ts"),
|
||||||
cw: path.join(sourceDir, "plugins", "cw.ts"),
|
cw: path.join(sourceDir, "plugins", "cw.ts"),
|
||||||
ft8: path.join(sourceDir, "plugins", "ft8.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"),
|
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
|
||||||
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
||||||
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
||||||
@@ -64,6 +33,7 @@ await build({
|
|||||||
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
||||||
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
||||||
"map-core": path.join(sourceDir, "map-core.ts"),
|
"map-core": path.join(sourceDir, "map-core.ts"),
|
||||||
|
screenshot: path.join(sourceDir, "screenshot.ts"),
|
||||||
},
|
},
|
||||||
outdir: outputDir,
|
outdir: outputDir,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
|
|||||||
@@ -6,7 +6,45 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
|
|
||||||
# Frontend source
|
# Frontend source
|
||||||
|
|
||||||
This directory is the source of the browser assets embedded by
|
This directory contains the strict TypeScript source for the browser assets
|
||||||
`trx-frontend-http`. Run `npm run build` from the parent `frontend` directory
|
embedded by `trx-frontend-http`. `bootstrap.ts` is the production entry point;
|
||||||
after changing a source file. Cargo consumes the committed output under
|
esbuild follows its imports and emits `/app.js`. Feature plugins are ESM entry
|
||||||
`../assets/web/generated` and does not invoke Node.js.
|
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 { AuthRole } from "./api/auth.js";
|
||||||
import type { RdsData, RigCapabilities, RigListItem, RigSnapshot } from "./api/generated.js";
|
import type { RdsData, RigCapabilities, RigListItem, RigSnapshot } from "./api/generated.js";
|
||||||
import type { TrxPluginRuntime } from "./plugins/runtime-contract.js";
|
import type { TrxPluginRuntime } from "./plugins/runtime-contract.js";
|
||||||
|
import { loadEagerPlugins, loadPluginsForTab } from "./plugin-loader.js";
|
||||||
|
|
||||||
interface SpectrumFrame {
|
interface SpectrumFrame {
|
||||||
bins: NumericBins;
|
bins: NumericBins;
|
||||||
@@ -4530,7 +4531,7 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
|
|||||||
updateTabHistory(name, replaceHistory);
|
updateTabHistory(name, replaceHistory);
|
||||||
}
|
}
|
||||||
scheduleSpectrumLayout();
|
scheduleSpectrumLayout();
|
||||||
if (typeof window.loadPluginsForTab === "function") window.loadPluginsForTab(name);
|
void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); });
|
||||||
if (name === "map") {
|
if (name === "map") {
|
||||||
_initMapWhenReady();
|
_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
|
// 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.
|
// 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
|
// Start the app
|
||||||
initializeApp();
|
initializeApp();
|
||||||
@@ -7829,17 +7830,14 @@ window.addEventListener("keydown", (event) => {
|
|||||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||||
if (shouldIgnoreGlobalShortcut(event.target)) 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") {
|
if (key === "s") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (window.trx.modules.screenshot) {
|
if (window.trx.modules.screenshot) {
|
||||||
void window.trx.modules.screenshot.captureSpectrumScreenshot();
|
void window.trx.modules.screenshot.captureSpectrumScreenshot();
|
||||||
} else {
|
} else void import("./screenshot.js")
|
||||||
const s = document.createElement("script");
|
.then(() => window.trx.modules.screenshot?.captureSpectrumScreenshot())
|
||||||
s.src = "/screenshot.js";
|
.catch((error: unknown) => { console.error("Screenshot module failed to load", error); });
|
||||||
s.onload = () => { void window.trx.modules.screenshot?.captureSpectrumScreenshot(); };
|
|
||||||
document.body.appendChild(s);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,8 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// 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);
|
for (const path of pluginGroups[group as PluginGroup]) await loadPlugin(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestPlugins(group: string): void {
|
export async function loadEagerPlugins(): Promise<void> {
|
||||||
void loadPlugins(group).catch((error: unknown) => { console.error(error); });
|
await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins));
|
||||||
}
|
}
|
||||||
|
|
||||||
const loaderWindow = window as typeof window & {
|
export async function loadPluginsForTab(tab: string): Promise<void> {
|
||||||
loadEagerPlugins?: () => Promise<void>;
|
await loadPlugins(tab);
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface TrxScreenshotBridge {
|
|||||||
showHint(message: string, durationMs: number): void;
|
showHint(message: string, durationMs: number): void;
|
||||||
modules: { screenshot?: ScreenshotModule };
|
modules: { screenshot?: ScreenshotModule };
|
||||||
}
|
}
|
||||||
|
export {};
|
||||||
const screenshotWindow = window as typeof window & { trx: TrxScreenshotBridge };
|
const screenshotWindow = window as typeof window & { trx: TrxScreenshotBridge };
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
|
||||||
test("AIS entry forwards positioned vessels with normalized metadata", async () => {
|
test("AIS entry forwards positioned vessels with normalized metadata", async () => {
|
||||||
const forwarded = [];
|
const forwarded = [];
|
||||||
@@ -23,7 +24,7 @@ test("AIS entry forwards positioned vessels with normalized metadata", async ()
|
|||||||
Map,
|
Map,
|
||||||
console,
|
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");
|
const source = await readFile(new URL("../../assets/web/generated/ais.js", import.meta.url), "utf8");
|
||||||
new vm.Script(runtime).runInContext(context);
|
new vm.Script(runtime).runInContext(context);
|
||||||
new vm.Script(source).runInContext(context);
|
new vm.Script(source).runInContext(context);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
import { bundleEntry } from "./bundle-entry.mjs";
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
@@ -26,7 +25,7 @@ test("APRS entry normalizes positioned packets without remote symbol assets", as
|
|||||||
Reflect,
|
Reflect,
|
||||||
console,
|
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));
|
const source = await bundleEntry(new URL("../src/plugins/aprs.ts", import.meta.url));
|
||||||
assert.equal(source.includes("raw.githubusercontent.com"), false);
|
assert.equal(source.includes("raw.githubusercontent.com"), false);
|
||||||
new vm.Script(runtime).runInContext(context);
|
new vm.Script(runtime).runInContext(context);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
|
||||||
class ElementFixture {
|
class ElementFixture {
|
||||||
constructor() { this.children = []; this.textContent = ""; this.style = {}; this.classList = { toggle() {} }; }
|
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,
|
Math,
|
||||||
console,
|
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");
|
const source = await readFile(new URL("../../assets/web/generated/cw.js", import.meta.url), "utf8");
|
||||||
new vm.Script(runtime).runInContext(context);
|
new vm.Script(runtime).runInContext(context);
|
||||||
new vm.Script(source).runInContext(context);
|
new vm.Script(source).runInContext(context);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
import { bundleEntry } from "./bundle-entry.mjs";
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
@@ -30,7 +29,7 @@ test("FT2 entry normalizes audio offsets and registers typed callbacks", async (
|
|||||||
Array,
|
Array,
|
||||||
console,
|
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));
|
const source = await bundleEntry(new URL("../src/plugins/ft2.ts", import.meta.url));
|
||||||
new vm.Script(runtime).runInContext(context);
|
new vm.Script(runtime).runInContext(context);
|
||||||
new vm.Script(source).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,
|
Set,
|
||||||
console,
|
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));
|
const source = await bundleEntry(new URL("../src/plugins/ft8.ts", import.meta.url));
|
||||||
new vm.Script(runtime).runInContext(context);
|
new vm.Script(runtime).runInContext(context);
|
||||||
new vm.Script(source).runInContext(context);
|
new vm.Script(source).runInContext(context);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
import { bundleEntry } from "./bundle-entry.mjs";
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
@@ -22,7 +21,7 @@ test("HF APRS entry uses shared typed normalization and local symbols", async ()
|
|||||||
Reflect,
|
Reflect,
|
||||||
console,
|
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));
|
const source = await bundleEntry(new URL("../src/plugins/hf-aprs.ts", import.meta.url));
|
||||||
assert.equal(source.includes("raw.githubusercontent.com"), false);
|
assert.equal(source.includes("raw.githubusercontent.com"), false);
|
||||||
new vm.Script(runtime).runInContext(context);
|
new vm.Script(runtime).runInContext(context);
|
||||||
|
|||||||
+2
-5
@@ -3,9 +3,9 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
|
||||||
test("local AIS track-symbol adapter renders immediately on first add", async () => {
|
test("local AIS track-symbol adapter renders immediately on first add", async () => {
|
||||||
const listeners = new Map();
|
const listeners = new Map();
|
||||||
@@ -34,10 +34,7 @@ test("local AIS track-symbol adapter renders immediately on first add", async ()
|
|||||||
Util: { extend: Object.assign },
|
Util: { extend: Object.assign },
|
||||||
divIcon: (options) => options,
|
divIcon: (options) => options,
|
||||||
};
|
};
|
||||||
const source = await readFile(
|
const source = await bundleEntry(new URL("../src/leaflet-ais-tracksymbol.ts", import.meta.url));
|
||||||
new URL("../../assets/web/generated/leaflet-ais-tracksymbol.js", import.meta.url),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
new vm.Script(source).runInNewContext({ L, console });
|
new vm.Script(source).runInNewContext({ L, console });
|
||||||
Object.assign(L.TrxAisTrackSymbol.prototype, {
|
Object.assign(L.TrxAisTrackSymbol.prototype, {
|
||||||
onAdd: markerPrototype.onAdd,
|
onAdd: markerPrototype.onAdd,
|
||||||
|
|||||||
+2
-2
@@ -3,14 +3,14 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
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 () => {
|
test("plugin runtime queues lazy decoder traffic and flushes in order on registration", async () => {
|
||||||
const window = {};
|
const window = {};
|
||||||
const context = vm.createContext({ window, Map, Error });
|
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);
|
new vm.Script(source).runInContext(context);
|
||||||
|
|
||||||
assert.equal(window.trxPluginRuntime.dispatch("late", { sequence: 1 }), false);
|
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 { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
|
||||||
test("satellite entry registers lifecycle callbacks and forwards georeferenced images", async () => {
|
test("satellite entry registers lifecycle callbacks and forwards georeferenced images", async () => {
|
||||||
const overlays = [];
|
const overlays = [];
|
||||||
@@ -26,7 +27,7 @@ test("satellite entry registers lifecycle callbacks and forwards georeferenced i
|
|||||||
Error,
|
Error,
|
||||||
console,
|
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");
|
const source = await readFile(new URL("../../assets/web/generated/sat.js", import.meta.url), "utf8");
|
||||||
new vm.Script(runtime).runInContext(context);
|
new vm.Script(runtime).runInContext(context);
|
||||||
new vm.Script(source).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 pluginLoaderPath = new URL("../src/plugin-loader.ts", import.meta.url);
|
||||||
const mapCorePath = new URL("../src/map-core.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");
|
const html = await readFile(indexPath, "utf8");
|
||||||
assert.ok(html.indexOf("/ui-core.js") < html.indexOf("/app.js"));
|
assert.equal((html.match(/<script[^>]+src="\/(?!vendor\/)[^"]+\.js"/g) ?? []).length, 1);
|
||||||
assert.ok(html.indexOf("/plugin-loader.js") < html.indexOf("/app.js"));
|
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);
|
assert.equal(html.includes("var pluginScripts"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
|
||||||
class ClassList {
|
class ClassList {
|
||||||
constructor() { this.values = new Set(); }
|
constructor() { this.values = new Set(); }
|
||||||
@@ -104,7 +104,7 @@ const context = vm.createContext({
|
|||||||
console,
|
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);
|
new vm.Script(source, { filename: "ui-core.js" }).runInContext(context);
|
||||||
const ui = window.trxUi;
|
const ui = window.trxUi;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
|
||||||
test("VDES entry normalizes and forwards positioned server messages", async () => {
|
test("VDES entry normalizes and forwards positioned server messages", async () => {
|
||||||
const forwarded = [];
|
const forwarded = [];
|
||||||
@@ -22,7 +23,7 @@ test("VDES entry normalizes and forwards positioned server messages", async () =
|
|||||||
Array,
|
Array,
|
||||||
console,
|
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");
|
const source = await readFile(new URL("../../assets/web/generated/vdes.js", import.meta.url), "utf8");
|
||||||
new vm.Script(runtime).runInContext(context);
|
new vm.Script(runtime).runInContext(context);
|
||||||
new vm.Script(source).runInContext(context);
|
new vm.Script(source).runInContext(context);
|
||||||
|
|||||||
+2
-5
@@ -3,9 +3,9 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
|
||||||
test("WebGL color helpers normalize CSS and HSL colors", async () => {
|
test("WebGL color helpers normalize CSS and HSL colors", async () => {
|
||||||
const window = {};
|
const window = {};
|
||||||
@@ -22,10 +22,7 @@ test("WebGL color helpers normalize CSS and HSL colors", async () => {
|
|||||||
String,
|
String,
|
||||||
parseInt,
|
parseInt,
|
||||||
});
|
});
|
||||||
const source = await readFile(
|
const source = await bundleEntry(new URL("../src/webgl-renderer.ts", import.meta.url));
|
||||||
new URL("../../assets/web/generated/webgl-renderer.js", import.meta.url),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
new vm.Script(source).runInContext(context);
|
new vm.Script(source).runInContext(context);
|
||||||
|
|
||||||
assert.deepEqual(Array.from(window.trxParseCssColor("#ff800080")), [1, 128 / 255, 0, 128 / 255]);
|
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 { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
|
||||||
test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", async () => {
|
test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", async () => {
|
||||||
const status = { textContent: "", style: { color: "" } };
|
const status = { textContent: "", style: { color: "" } };
|
||||||
@@ -19,7 +20,7 @@ test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", a
|
|||||||
Uint8Array,
|
Uint8Array,
|
||||||
console,
|
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");
|
const source = await readFile(new URL("../../assets/web/generated/wefax.js", import.meta.url), "utf8");
|
||||||
new vm.Script(runtime).runInContext(context);
|
new vm.Script(runtime).runInContext(context);
|
||||||
new vm.Script(source).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 { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
|
||||||
test("WSPR entry forwards decoded locators at their absolute frequency", async () => {
|
test("WSPR entry forwards decoded locators at their absolute frequency", async () => {
|
||||||
const forwarded = [];
|
const forwarded = [];
|
||||||
@@ -25,7 +26,7 @@ test("WSPR entry forwards decoded locators at their absolute frequency", async (
|
|||||||
Element: class Element {},
|
Element: class Element {},
|
||||||
console,
|
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");
|
const source = await readFile(new URL("../../assets/web/generated/wspr.js", import.meta.url), "utf8");
|
||||||
new vm.Script(runtime).runInContext(context);
|
new vm.Script(runtime).runInContext(context);
|
||||||
new vm.Script(source).runInContext(context);
|
new vm.Script(source).runInContext(context);
|
||||||
|
|||||||
@@ -62,34 +62,18 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shared_ui_core_is_embedded_and_loaded_before_app() {
|
fn typed_application_bundle_contains_shared_runtime() {
|
||||||
let html = index_html();
|
let html = index_html();
|
||||||
let ui_core = html.find("/ui-core.js").expect("ui-core script is loaded");
|
assert_eq!(html.matches("/app.js").count(), 1);
|
||||||
let app = html.find("/app.js").expect("application script is loaded");
|
assert!(!html.contains("/ui-core.js"));
|
||||||
|
assert!(!html.contains("/plugin-runtime.js"));
|
||||||
assert!(
|
assert!(!html.contains("/plugin-loader.js"));
|
||||||
ui_core < app,
|
assert!(generated_asset("app.js").is_some_and(|asset| {
|
||||||
"UI primitives must load before application code"
|
std::str::from_utf8(asset).is_ok_and(|script| {
|
||||||
);
|
script.contains("trxUi")
|
||||||
assert!(generated_asset("ui-core.js").is_some_and(|asset| {
|
&& script.contains("registerDecoder")
|
||||||
std::str::from_utf8(asset).is_ok_and(|script| script.contains("trxUi"))
|
&& script.contains("TrxAisTrackSymbol")
|
||||||
}));
|
})
|
||||||
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!(html.contains("<summary>Scheduler controls</summary>"));
|
assert!(html.contains("<summary>Scheduler controls</summary>"));
|
||||||
assert!(html.contains("<summary>Audio controls</summary>"));
|
assert!(html.contains("<summary>Audio controls</summary>"));
|
||||||
|
|||||||
Reference in New Issue
Block a user