refactor: convert screenshot support to TypeScript

This commit is contained in:
sjg
2026-08-01 12:08:44 +02:00
parent 9ac6d7f82c
commit e6f593b959
4 changed files with 63 additions and 37 deletions
@@ -1,7 +1,8 @@
"use strict"; "use strict";
const screenshotWindow = window;
(function() { (function() {
"use strict"; "use strict";
const T = window.trx; const T = screenshotWindow.trx;
function isVisibleForSnapshot(el) { function isVisibleForSnapshot(el) {
if (!el) return false; if (!el) return false;
const style = getComputedStyle(el); const style = getComputedStyle(el);
@@ -40,25 +41,26 @@
const bgAlpha = Math.min(bg[3], maxAlpha); const bgAlpha = Math.min(bg[3], maxAlpha);
if (bgAlpha > 0.01) { if (bgAlpha > 0.01) {
drawRoundedRectPath(ctx, x, y, w, h, radius); drawRoundedRectPath(ctx, x, y, w, h, radius);
ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`; ctx.fillStyle = `rgba(${String(Math.round(bg[0]))}, ${String(Math.round(bg[1]))}, ${String(Math.round(bg[2]))}, ${String(bgAlpha)})`;
ctx.fill(); ctx.fill();
} }
const borderAlpha = Math.min(border[3], maxAlpha); const borderAlpha = Math.min(border[3], maxAlpha);
if (borderWidth > 0 && borderAlpha > 0.01) { 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)); 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.lineWidth = borderWidth;
ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`; ctx.strokeStyle = `rgba(${String(Math.round(border[0]))}, ${String(Math.round(border[1]))}, ${String(Math.round(border[2]))}, ${String(borderAlpha)})`;
ctx.stroke(); ctx.stroke();
} }
return { x, y, w, h, style }; return { x, y, w, h, style };
} }
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) { function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
const words = String(text || "").split(/\s+/).filter(Boolean); const words = (text || "").split(/\s+/).filter(Boolean);
if (!words.length) return; if (!words.length) return;
let line = ""; let line = "";
let lineIdx = 0; let lineIdx = 0;
for (let i = 0; i < words.length; i += 1) { for (let i = 0; i < words.length; i += 1) {
const candidate = line ? `${line} ${words[i]}` : words[i]; const word = words[i] ?? "";
const candidate = line ? `${line} ${word}` : word;
if (ctx.measureText(candidate).width <= maxWidth || !line) { if (ctx.measureText(candidate).width <= maxWidth || !line) {
line = candidate; line = candidate;
continue; continue;
@@ -66,7 +68,7 @@
ctx.fillText(line, x, y + lineIdx * lineHeight); ctx.fillText(line, x, y + lineIdx * lineHeight);
lineIdx += 1; lineIdx += 1;
if (lineIdx >= maxLines) return; if (lineIdx >= maxLines) return;
line = words[i]; line = word;
} }
if (line && lineIdx < maxLines) { if (line && lineIdx < maxLines) {
ctx.fillText(line, x, y + lineIdx * lineHeight); ctx.fillText(line, x, y + lineIdx * lineHeight);
@@ -132,7 +134,7 @@
try { try {
if (typeof gl.flush === "function") gl.flush(); if (typeof gl.flush === "function") gl.flush();
if (typeof gl.finish === "function") gl.finish(); if (typeof gl.finish === "function") gl.finish();
} catch (_) { } catch {
} }
} }
const rootRect = rootEl.getBoundingClientRect(); const rootRect = rootEl.getBoundingClientRect();
@@ -188,7 +190,9 @@
a.style.display = "none"; a.style.display = "none";
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
requestAnimationFrame(() => a.remove()); requestAnimationFrame(() => {
a.remove();
});
} }
function saveCanvasAsPng(canvas, fileName) { function saveCanvasAsPng(canvas, fileName) {
if (!canvas) return Promise.resolve(false); if (!canvas) return Promise.resolve(false);
@@ -202,10 +206,12 @@
} }
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
clickCanvasDownload(url, fileName); clickCanvasDownload(url, fileName);
setTimeout(() => URL.revokeObjectURL(url), 1e3); setTimeout(() => {
URL.revokeObjectURL(url);
}, 1e3);
resolve(true); resolve(true);
}, "image/png"); }, "image/png");
} catch (_) { } catch {
resolve(false); resolve(false);
} }
}); });
@@ -213,7 +219,7 @@
try { try {
clickCanvasDownload(canvas.toDataURL("image/png"), fileName); clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
return Promise.resolve(true); return Promise.resolve(true);
} catch (_) { } catch {
return Promise.resolve(false); return Promise.resolve(false);
} }
} }
@@ -228,7 +234,7 @@
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800); T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
return saved; return saved;
} }
window.trx.modules.screenshot = { screenshotWindow.trx.modules.screenshot = {
captureSpectrumScreenshot, captureSpectrumScreenshot,
buildSpectrumSnapshotCanvas, buildSpectrumSnapshotCanvas,
saveCanvasAsPng saveCanvasAsPng
@@ -19,7 +19,7 @@ await build({
app: path.join(sourceDir, "app.js"), app: path.join(sourceDir, "app.js"),
"ui-core": path.join(sourceDir, "ui-core.ts"), "ui-core": path.join(sourceDir, "ui-core.ts"),
"map-core": path.join(sourceDir, "map-core.js"), "map-core": path.join(sourceDir, "map-core.js"),
screenshot: path.join(sourceDir, "screenshot.js"), screenshot: path.join(sourceDir, "screenshot.ts"),
"webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"), "webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"),
"leaflet-ais-tracksymbol": path.join(sourceDir, "leaflet-ais-tracksymbol.js"), "leaflet-ais-tracksymbol": path.join(sourceDir, "leaflet-ais-tracksymbol.js"),
ais: path.join(sourceDir, "plugins", "ais.js"), ais: path.join(sourceDir, "plugins", "ais.js"),
@@ -4,11 +4,30 @@
// Spectrum screenshot module (loaded on demand when user triggers screenshot). // Spectrum screenshot module (loaded on demand when user triggers screenshot).
// Communicates with app.js core via window.trx namespace. // Communicates with app.js core via window.trx namespace.
type Rgba255 = [number, number, number, number];
interface SnapshotRenderer { gl?: { flush?: () => void; finish?: () => void } }
interface ScreenshotModule {
captureSpectrumScreenshot(): Promise<boolean>;
buildSpectrumSnapshotCanvas(): HTMLCanvasElement | null;
saveCanvasAsPng(canvas: HTMLCanvasElement | null, fileName: string): Promise<boolean>;
}
interface TrxScreenshotBridge {
cssColorToRgba(color: string): Rgba255;
overviewGl?: SnapshotRenderer;
spectrumGl?: SnapshotRenderer;
signalOverlayGl?: SnapshotRenderer;
overviewCanvas?: HTMLCanvasElement;
spectrumCanvas?: HTMLCanvasElement;
showHint(message: string, durationMs: number): void;
modules: { screenshot?: ScreenshotModule };
}
const screenshotWindow = window as typeof window & { trx: TrxScreenshotBridge };
(function () { (function () {
"use strict"; "use strict";
const T = window.trx; const T = screenshotWindow.trx;
function isVisibleForSnapshot(el) { function isVisibleForSnapshot(el: Element | null): el is HTMLElement {
if (!el) return false; if (!el) return false;
const style = getComputedStyle(el); const style = getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") return false; if (style.display === "none" || style.visibility === "hidden") return false;
@@ -18,7 +37,7 @@
return rect.width > 0 && rect.height > 0; return rect.width > 0 && rect.height > 0;
} }
function drawRoundedRectPath(ctx, x, y, w, h, r) { function drawRoundedRectPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2)); const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x + radius, y); ctx.moveTo(x + radius, y);
@@ -33,7 +52,7 @@
ctx.closePath(); ctx.closePath();
} }
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) { function drawElementChrome(ctx: CanvasRenderingContext2D, el: HTMLElement | null, rootRect: DOMRect, maxAlpha = 1) {
if (!isVisibleForSnapshot(el)) return null; if (!isVisibleForSnapshot(el)) return null;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const style = getComputedStyle(el); const style = getComputedStyle(el);
@@ -49,26 +68,27 @@
const bgAlpha = Math.min(bg[3], maxAlpha); const bgAlpha = Math.min(bg[3], maxAlpha);
if (bgAlpha > 0.01) { if (bgAlpha > 0.01) {
drawRoundedRectPath(ctx, x, y, w, h, radius); drawRoundedRectPath(ctx, x, y, w, h, radius);
ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`; ctx.fillStyle = `rgba(${String(Math.round(bg[0]))}, ${String(Math.round(bg[1]))}, ${String(Math.round(bg[2]))}, ${String(bgAlpha)})`;
ctx.fill(); ctx.fill();
} }
const borderAlpha = Math.min(border[3], maxAlpha); const borderAlpha = Math.min(border[3], maxAlpha);
if (borderWidth > 0 && borderAlpha > 0.01) { 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)); 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.lineWidth = borderWidth;
ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`; ctx.strokeStyle = `rgba(${String(Math.round(border[0]))}, ${String(Math.round(border[1]))}, ${String(Math.round(border[2]))}, ${String(borderAlpha)})`;
ctx.stroke(); ctx.stroke();
} }
return { x, y, w, h, style }; return { x, y, w, h, style };
} }
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) { function drawWrappedText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number, lineHeight: number, maxLines: number) {
const words = String(text || "").split(/\s+/).filter(Boolean); const words = (text || "").split(/\s+/).filter(Boolean);
if (!words.length) return; if (!words.length) return;
let line = ""; let line = "";
let lineIdx = 0; let lineIdx = 0;
for (let i = 0; i < words.length; i += 1) { for (let i = 0; i < words.length; i += 1) {
const candidate = line ? `${line} ${words[i]}` : words[i]; const word = words[i] ?? "";
const candidate = line ? `${line} ${word}` : word;
if (ctx.measureText(candidate).width <= maxWidth || !line) { if (ctx.measureText(candidate).width <= maxWidth || !line) {
line = candidate; line = candidate;
continue; continue;
@@ -76,14 +96,14 @@
ctx.fillText(line, x, y + lineIdx * lineHeight); ctx.fillText(line, x, y + lineIdx * lineHeight);
lineIdx += 1; lineIdx += 1;
if (lineIdx >= maxLines) return; if (lineIdx >= maxLines) return;
line = words[i]; line = word;
} }
if (line && lineIdx < maxLines) { if (line && lineIdx < maxLines) {
ctx.fillText(line, x, y + lineIdx * lineHeight); ctx.fillText(line, x, y + lineIdx * lineHeight);
} }
} }
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) { function drawElementTextBlock(ctx: CanvasRenderingContext2D, el: HTMLElement, rootRect: DOMRect, fallbackText: string | null = null, maxAlpha = 1) {
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha); const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
if (!chrome) return; if (!chrome) return;
const text = (fallbackText == null ? el.innerText : fallbackText) || ""; const text = (fallbackText == null ? el.innerText : fallbackText) || "";
@@ -116,7 +136,7 @@
} }
} }
function drawAxisLabels(ctx, axisEl, rootRect) { function drawAxisLabels(ctx: CanvasRenderingContext2D, axisEl: HTMLElement | null, rootRect: DOMRect) {
if (!isVisibleForSnapshot(axisEl)) return; if (!isVisibleForSnapshot(axisEl)) return;
for (const node of axisEl.children) { for (const node of axisEl.children) {
if (!(node instanceof HTMLElement)) continue; if (!(node instanceof HTMLElement)) continue;
@@ -133,8 +153,8 @@
} }
} }
function buildSpectrumSnapshotCanvas() { function buildSpectrumSnapshotCanvas(): HTMLCanvasElement | null {
const rootEl = document.querySelector(".signal-visual-block"); const rootEl = document.querySelector<HTMLElement>(".signal-visual-block");
const spectrumPanelEl = document.getElementById("spectrum-panel"); const spectrumPanelEl = document.getElementById("spectrum-panel");
if (!rootEl || !isVisibleForSnapshot(rootEl) || !isVisibleForSnapshot(spectrumPanelEl)) { if (!rootEl || !isVisibleForSnapshot(rootEl) || !isVisibleForSnapshot(spectrumPanelEl)) {
return null; return null;
@@ -145,7 +165,7 @@
try { try {
if (typeof gl.flush === "function") gl.flush(); if (typeof gl.flush === "function") gl.flush();
if (typeof gl.finish === "function") gl.finish(); if (typeof gl.finish === "function") gl.finish();
} catch (_) { } catch {
// Ignore transient WebGL state errors and capture the last good frame. // Ignore transient WebGL state errors and capture the last good frame.
} }
} }
@@ -162,7 +182,7 @@
ctx.fillStyle = bg; ctx.fillStyle = bg;
ctx.fillRect(0, 0, rootRect.width, rootRect.height); ctx.fillRect(0, 0, rootRect.width, rootRect.height);
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas"); const signalOverlayCanvas = document.getElementById("signal-overlay-canvas") as HTMLCanvasElement | null;
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas]; const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
for (const canvas of canvases) { for (const canvas of canvases) {
if (!canvas || !isVisibleForSnapshot(canvas)) continue; if (!canvas || !isVisibleForSnapshot(canvas)) continue;
@@ -205,7 +225,7 @@
return out; return out;
} }
function clickCanvasDownload(href, fileName) { function clickCanvasDownload(href: string, fileName: string) {
const a = document.createElement("a"); const a = document.createElement("a");
a.href = href; a.href = href;
a.download = fileName; a.download = fileName;
@@ -213,10 +233,10 @@
a.style.display = "none"; a.style.display = "none";
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
requestAnimationFrame(() => a.remove()); requestAnimationFrame(() => { a.remove(); });
} }
function saveCanvasAsPng(canvas, fileName) { function saveCanvasAsPng(canvas: HTMLCanvasElement | null, fileName: string): Promise<boolean> {
if (!canvas) return Promise.resolve(false); if (!canvas) return Promise.resolve(false);
if (typeof canvas.toBlob === "function") { if (typeof canvas.toBlob === "function") {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -228,10 +248,10 @@
} }
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
clickCanvasDownload(url, fileName); clickCanvasDownload(url, fileName);
setTimeout(() => URL.revokeObjectURL(url), 1000); setTimeout(() => { URL.revokeObjectURL(url); }, 1000);
resolve(true); resolve(true);
}, "image/png"); }, "image/png");
} catch (_) { } catch {
resolve(false); resolve(false);
} }
}); });
@@ -239,7 +259,7 @@
try { try {
clickCanvasDownload(canvas.toDataURL("image/png"), fileName); clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
return Promise.resolve(true); return Promise.resolve(true);
} catch (_) { } catch {
return Promise.resolve(false); return Promise.resolve(false);
} }
} }
@@ -257,7 +277,7 @@
} }
// Register module API // Register module API
window.trx.modules.screenshot = { screenshotWindow.trx.modules.screenshot = {
captureSpectrumScreenshot, captureSpectrumScreenshot,
buildSpectrumSnapshotCanvas, buildSpectrumSnapshotCanvas,
saveCanvasAsPng, saveCanvasAsPng,
@@ -10,7 +10,7 @@
"allowJs": true, "allowJs": true,
"checkJs": false, "checkJs": false,
"noEmit": true, "noEmit": true,
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": [] "types": []
}, },
"include": ["src/**/*.ts", "src/**/*.js"] "include": ["src/**/*.ts", "src/**/*.js"]