[feat](trx-frontend-http): split app.js into ES modules with lazy loading

Extract map-core.js (3,483 lines) and screenshot.js (261 lines) from
the monolithic app.js, reducing it by ~30% (11,967 → 8,427 lines).

Modules communicate via a window.trx shared namespace with getter/setter-
backed state proxying. Map and statistics code lazy-loads on first tab
activation; screenshot code lazy-loads on first "S" keypress. All cross-
module calls use optional chaining for safe access before modules load.

Adds Rust infrastructure (include_str, gz_cache, Actix routes) for
serving the new JS assets.

https://claude.ai/code/session_01HgW8UpscRRA3CgSLqQDzdp
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-04-01 10:39:45 +00:00
committed by Stan Grams
parent c9167177e0
commit 25c59405b5
8 changed files with 3867 additions and 3634 deletions
+4 -2
View File
@@ -12,7 +12,9 @@ caching) from the Actix-Web server.
| File | Lines | Size | | File | Lines | Size |
|------|------:|-----:| |------|------:|-----:|
| `style.css` | 5,318 | 144 KB | | `style.css` | 5,318 | 144 KB |
| `app.js` | 11,928 | 428 KB | | `app.js` | 8,427 | 306 KB |
| `map-core.js` | 3,483 | 127 KB |
| `screenshot.js` | 261 | 10 KB |
| `index.html` | 1,564 | 96 KB | | `index.html` | 1,564 | 96 KB |
| `webgl-renderer.js` | 526 | 20 KB | | `webgl-renderer.js` | 526 | 20 KB |
| `decode-history-worker.js` | 176 | 8 KB | | `decode-history-worker.js` | 176 | 8 KB |
@@ -340,7 +342,7 @@ quadrantChart
9. ~~Replace `innerHTML` with DOM APIs in hot paths~~ **DONE** -- 15+ `innerHTML = ""` replaced with `replaceChildren()` 9. ~~Replace `innerHTML` with DOM APIs in hot paths~~ **DONE** -- 15+ `innerHTML = ""` replaced with `replaceChildren()`
### Longer-term ### Longer-term
10. Split `app.js` into ES modules with lazy loading -- **DEFERRED** (requires major refactor, tracked separately) 10. ~~Split `app.js` into modules with lazy loading~~ **DONE** -- `map-core.js` (3,480 lines, map/stats/geo) and `screenshot.js` (260 lines) extracted as IIFE modules communicating via `window.trx` namespace; lazy-loaded on tab activation and on-demand respectively; `app.js` reduced from 11,967 to 8,420 lines (30% reduction)
11. ~~Lazy-load plugin scripts and Leaflet on demand~~ **DONE** -- plugin scripts loaded on tab activation, core plugins loaded immediately 11. ~~Lazy-load plugin scripts and Leaflet on demand~~ **DONE** -- plugin scripts loaded on tab activation, core plugins loaded immediately
12. ~~Use `<template>` elements for deferred tab content~~ **DONE** -- map, statistics, about tabs wrapped in `<template>`, cloned on first activation 12. ~~Use `<template>` elements for deferred tab content~~ **DONE** -- map, statistics, about tabs wrapped in `<template>`, cloned on first activation
13. ~~Migrate to Brotli compression~~ **DONE** -- Brotli added alongside gzip, preferred when `Accept-Encoding: br` present 13. ~~Migrate to Brotli compression~~ **DONE** -- Brotli added alongside gzip, preferred when `Accept-Encoding: br` present
File diff suppressed because it is too large Load Diff
@@ -1563,7 +1563,8 @@
(function() { (function() {
var pluginScripts = { var pluginScripts = {
'digital-modes': ['/ft8.js', '/ft4.js', '/ft2.js', '/wspr.js', '/cw.js', '/background-decode.js'], 'digital-modes': ['/ft8.js', '/ft4.js', '/ft2.js', '/wspr.js', '/cw.js', '/background-decode.js'],
'map': ['/leaflet-ais-tracksymbol.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'], 'map': ['/map-core.js', '/leaflet-ais-tracksymbol.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'],
'statistics': ['/map-core.js'],
'bookmarks': ['/bookmarks.js'], 'bookmarks': ['/bookmarks.js'],
'recorder': ['/scheduler.js'], 'recorder': ['/scheduler.js'],
'settings': ['/vchan.js'] 'settings': ['/vchan.js']
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,261 @@
// Spectrum screenshot module (loaded on demand when user triggers screenshot).
// Communicates with app.js core via window.trx namespace.
(function () {
"use strict";
const T = window.trx;
function isVisibleForSnapshot(el) {
if (!el) return false;
const style = getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") return false;
const opacity = Number(style.opacity);
if (Number.isFinite(opacity) && opacity <= 0) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function drawRoundedRectPath(ctx, x, y, w, h, r) {
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
ctx.lineTo(x + w, y + h - radius);
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
ctx.lineTo(x + radius, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
if (!isVisibleForSnapshot(el)) return null;
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
const x = rect.left - rootRect.left;
const y = rect.top - rootRect.top;
const w = rect.width;
const h = rect.height;
const radius = parseFloat(style.borderTopLeftRadius) || 0;
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
const bgAlpha = Math.min(bg[3], maxAlpha);
if (bgAlpha > 0.01) {
drawRoundedRectPath(ctx, x, y, w, h, radius);
ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`;
ctx.fill();
}
const borderAlpha = Math.min(border[3], maxAlpha);
if (borderWidth > 0 && borderAlpha > 0.01) {
drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5));
ctx.lineWidth = borderWidth;
ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`;
ctx.stroke();
}
return { x, y, w, h, style };
}
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
const words = String(text || "").split(/\s+/).filter(Boolean);
if (!words.length) return;
let line = "";
let lineIdx = 0;
for (let i = 0; i < words.length; i += 1) {
const candidate = line ? `${line} ${words[i]}` : words[i];
if (ctx.measureText(candidate).width <= maxWidth || !line) {
line = candidate;
continue;
}
ctx.fillText(line, x, y + lineIdx * lineHeight);
lineIdx += 1;
if (lineIdx >= maxLines) return;
line = words[i];
}
if (line && lineIdx < maxLines) {
ctx.fillText(line, x, y + lineIdx * lineHeight);
}
}
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
if (!chrome) return;
const text = (fallbackText == null ? el.innerText : fallbackText) || "";
const clean = text.replace(/\s+\n/g, "\n").replace(/\n\s+/g, "\n").trim();
if (!clean) return;
const style = chrome.style;
const fontSize = parseFloat(style.fontSize) || 12;
const lineHeight = (parseFloat(style.lineHeight) || fontSize * 1.25);
const padX = 6;
const padY = 4;
const maxWidth = Math.max(20, chrome.w - padX * 2);
const maxLines = Math.max(1, Math.floor((chrome.h - padY * 2) / lineHeight));
ctx.fillStyle = style.color || "#ffffff";
ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
ctx.textBaseline = "top";
const lines = clean.split(/\n+/);
let lineCursor = 0;
for (const line of lines) {
if (lineCursor >= maxLines) break;
drawWrappedText(
ctx,
line,
chrome.x + padX,
chrome.y + padY + lineCursor * lineHeight,
maxWidth,
lineHeight,
maxLines - lineCursor,
);
lineCursor += 1;
}
}
function drawAxisLabels(ctx, axisEl, rootRect) {
if (!isVisibleForSnapshot(axisEl)) return;
for (const node of axisEl.children) {
if (!(node instanceof HTMLElement)) continue;
if (!(node.matches("span") || node.matches("button"))) continue;
if (!isVisibleForSnapshot(node)) continue;
const chrome = drawElementChrome(ctx, node, rootRect);
const text = (node.textContent || "").trim();
if (!chrome || !text) continue;
const style = chrome.style;
ctx.fillStyle = style.color || "#ffffff";
ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
ctx.textBaseline = "middle";
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
}
}
function buildSpectrumSnapshotCanvas() {
const rootEl = document.querySelector(".signal-visual-block");
const spectrumPanelEl = document.getElementById("spectrum-panel");
if (!rootEl || !isVisibleForSnapshot(rootEl) || !isVisibleForSnapshot(spectrumPanelEl)) {
return null;
}
for (const renderer of [T.overviewGl, T.spectrumGl, T.signalOverlayGl]) {
const gl = renderer?.gl;
if (!gl) continue;
try {
if (typeof gl.flush === "function") gl.flush();
if (typeof gl.finish === "function") gl.finish();
} catch (_) {
// Ignore transient WebGL state errors and capture the last good frame.
}
}
const rootRect = rootEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const out = document.createElement("canvas");
out.width = Math.max(1, Math.round(rootRect.width * dpr));
out.height = Math.max(1, Math.round(rootRect.height * dpr));
const ctx = out.getContext("2d");
if (!ctx) return null;
ctx.scale(dpr, dpr);
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
ctx.fillStyle = bg;
ctx.fillRect(0, 0, rootRect.width, rootRect.height);
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
for (const canvas of canvases) {
if (!canvas || !isVisibleForSnapshot(canvas)) continue;
const rect = canvas.getBoundingClientRect();
ctx.drawImage(
canvas,
rect.left - rootRect.left,
rect.top - rootRect.top,
rect.width,
rect.height,
);
}
// Decoder overlays over the signal view.
// Cap background alpha to avoid opaque blocks (backdrop-filter can't be
// replicated on canvas, so frosted-glass overlays would otherwise obscure
// the spectrum).
const decoderOverlayIds = [
"ais-bar-overlay",
"vdes-bar-overlay",
"ft8-bar-overlay",
"aprs-bar-overlay",
"rds-ps-overlay",
];
for (const id of decoderOverlayIds) {
const overlayEl = document.getElementById(id);
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
}
// Spectrum axis labels and bookmark chips (includes freq bar).
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
drawAxisLabels(ctx, spectrumDbAxis, rootRect);
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
return out;
}
function clickCanvasDownload(href, fileName) {
const a = document.createElement("a");
a.href = href;
a.download = fileName;
a.rel = "noopener";
a.style.display = "none";
document.body.appendChild(a);
a.click();
requestAnimationFrame(() => a.remove());
}
function saveCanvasAsPng(canvas, fileName) {
if (!canvas) return Promise.resolve(false);
if (typeof canvas.toBlob === "function") {
return new Promise((resolve) => {
try {
canvas.toBlob((blob) => {
if (!blob) {
resolve(false);
return;
}
const url = URL.createObjectURL(blob);
clickCanvasDownload(url, fileName);
setTimeout(() => URL.revokeObjectURL(url), 1000);
resolve(true);
}, "image/png");
} catch (_) {
resolve(false);
}
});
}
try {
clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
return Promise.resolve(true);
} catch (_) {
return Promise.resolve(false);
}
}
async function captureSpectrumScreenshot() {
const snapshotCanvas = buildSpectrumSnapshotCanvas();
if (!snapshotCanvas) {
T.showHint("Spectrum view not ready", 1300);
return false;
}
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
return saved;
}
// Register module API
window.trx.screenshot = {
captureSpectrumScreenshot,
buildSpectrumSnapshotCanvas,
saveCanvasAsPng,
};
})();
@@ -28,6 +28,8 @@ define_gz_cache!(gz_index_html, status::index_html(), "index.html");
define_gz_cache!(gz_style_css, status::STYLE_CSS, "style.css"); define_gz_cache!(gz_style_css, status::STYLE_CSS, "style.css");
define_gz_cache!(gz_themes_css, status::THEMES_CSS, "themes.css"); define_gz_cache!(gz_themes_css, status::THEMES_CSS, "themes.css");
define_gz_cache!(gz_app_js, status::APP_JS, "app.js"); define_gz_cache!(gz_app_js, status::APP_JS, "app.js");
define_gz_cache!(gz_map_core_js, status::MAP_CORE_JS, "map-core.js");
define_gz_cache!(gz_screenshot_js, status::SCREENSHOT_JS, "screenshot.js");
define_gz_cache!( define_gz_cache!(
gz_decode_history_worker_js, gz_decode_history_worker_js,
status::DECODE_HISTORY_WORKER_JS, status::DECODE_HISTORY_WORKER_JS,
@@ -166,6 +168,26 @@ pub(crate) async fn app_js(req: HttpRequest) -> impl Responder {
) )
} }
#[get("/map-core.js")]
pub(crate) async fn map_core_js(req: HttpRequest) -> impl Responder {
let c = gz_map_core_js();
static_asset_response(
&req,
"application/javascript; charset=utf-8",
c,
)
}
#[get("/screenshot.js")]
pub(crate) async fn screenshot_js(req: HttpRequest) -> impl Responder {
let c = gz_screenshot_js();
static_asset_response(
&req,
"application/javascript; charset=utf-8",
c,
)
}
#[get("/decode-history-worker.js")] #[get("/decode-history-worker.js")]
pub(crate) async fn decode_history_worker_js(req: HttpRequest) -> impl Responder { pub(crate) async fn decode_history_worker_js(req: HttpRequest) -> impl Responder {
let c = gz_decode_history_worker_js(); let c = gz_decode_history_worker_js();
@@ -646,6 +646,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::style_css) .service(assets::style_css)
.service(assets::themes_css) .service(assets::themes_css)
.service(assets::app_js) .service(assets::app_js)
.service(assets::map_core_js)
.service(assets::screenshot_js)
.service(assets::decode_history_worker_js) .service(assets::decode_history_worker_js)
.service(assets::webgl_renderer_js) .service(assets::webgl_renderer_js)
.service(assets::leaflet_ais_tracksymbol_js) .service(assets::leaflet_ais_tracksymbol_js)
@@ -12,6 +12,8 @@ const INDEX_HTML: &str = include_str!("../assets/web/index.html");
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css"); pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
pub const THEMES_CSS: &str = include_str!("../assets/web/themes.css"); pub const THEMES_CSS: &str = include_str!("../assets/web/themes.css");
pub const APP_JS: &str = include_str!("../assets/web/app.js"); pub const APP_JS: &str = include_str!("../assets/web/app.js");
pub const MAP_CORE_JS: &str = include_str!("../assets/web/map-core.js");
pub const SCREENSHOT_JS: &str = include_str!("../assets/web/screenshot.js");
pub const DECODE_HISTORY_WORKER_JS: &str = include_str!("../assets/web/decode-history-worker.js"); pub const DECODE_HISTORY_WORKER_JS: &str = include_str!("../assets/web/decode-history-worker.js");
pub const WEBGL_RENDERER_JS: &str = include_str!("../assets/web/webgl-renderer.js"); pub const WEBGL_RENDERER_JS: &str = include_str!("../assets/web/webgl-renderer.js");
pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str = pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str =