Refactor GUI rendering and align RTA axis

This commit is contained in:
Mikei386
2026-07-22 11:35:51 +02:00
parent 2e868bfa3a
commit 890a47a272
16 changed files with 1199 additions and 1415 deletions
+13 -285
View File
@@ -1,300 +1,28 @@
// views/split_view.js — Split-View: zwei nebeneinander gerenderte Views (links/rechts)
import * as viewGoni from './goniometer_rtw.js';
import * as viewPhaseWheel from './phase_wheel.js';
import * as viewPanel from './panel.js';
import * as viewRealtime from './realtime.js';
import * as viewClassicNeedles from './classic_needles.js';
import * as viewPeakHistory from './peak_history.js';
import * as viewClock from './clock.js';
import * as viewWaveform from './waveform.js';
import * as viewSpectrogram from './spectrogram.js';
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
import { DEFAULT_TOP_INSET } from '../core/theme.js';
import {
CHILD_VIEWS,
computeMultiViewBaseLayout,
drawEmptyPlot,
drawMetersPanel,
readMultiViewMeters,
sanitizeChildId,
sanitizePlotId,
withClippedSubRect,
} from './multi_view_shared.js';
export const id = 'split-view';
const CONTENT_TOP = DEFAULT_TOP_INSET;
const CONTENT_BOTTOM = 0;
const CHILD_VIEWS = {
'realtime': viewRealtime,
'classic-needles': viewClassicNeedles,
'peak-history': viewPeakHistory,
'goniometer-rtw': viewGoni,
'phase-wheel': viewPhaseWheel,
'panel': viewPanel,
'clock': viewClock,
'waveform': viewWaveform,
'spectrogram': viewSpectrogram,
};
const ALLOWED_CHILD_VIEW_IDS = new Set(['none', ...Object.keys(CHILD_VIEWS)]);
const ALLOWED_SPLIT_PLOT_IDS = new Set(['none', 'phase-wheel', 'realtime', 'goniometer-rtw', 'peak-history', 'classic-needles', 'panel', 'clock', 'waveform', 'spectrogram']);
const ALLOWED_METER_IDS = new Set(['none', 'vu', 'ppm-ebu', 'ppm-din', 'tp', 'hifi-peak', 'rms', 'lufs', 'stopwatch']);
const ALLOWED_METER_POSITIONS = new Set(['left', 'center', 'right']);
const OUTER_GAP = 10;
const SIDE_INNER_GAP = 8;
const METER_GAP = 12;
const METER_W_DEFAULT = 140;
const METER_W_MIN = 90;
const MIN_PLOT_W = 240;
const METER_PAD_TOP = 15;
const METER_PAD_BOTTOM = 5;
const METER_SLOT_SHRINK = 24;
const METER_EXTRA_BOTTOM_PAD = 6;
function sanitizeChildId(val, fallback) {
return ALLOWED_CHILD_VIEW_IDS.has(val) ? val : fallback;
}
function sanitizePlotId(val, fallback) {
return ALLOWED_SPLIT_PLOT_IDS.has(val) ? val : fallback;
}
function sanitizeMeterId(val, fallback) {
const id = String(val || '');
return ALLOWED_METER_IDS.has(id) ? id : fallback;
}
function sanitizeMeterPos(val, fallback) {
const id = String(val || '');
return ALLOWED_METER_POSITIONS.has(id) ? id : fallback;
}
function clampMeterCount(val) {
const n = Number(val);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(3, n | 0));
}
async function withClippedSubRect(g, rect, fn) {
g.save();
g.translate(rect.x, rect.y);
g.beginPath();
g.rect(0, 0, rect.w, rect.h);
g.clip();
try { return await fn(); } finally { g.restore(); }
}
function drawSubframe(g, rect) {
g.save();
g.strokeStyle = FRAME_COLOR;
g.lineWidth = 2;
g.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1));
g.restore();
}
function createStaticLayerCanvas(width, height) {
const w = Math.max(1, width | 0);
const h = Math.max(1, height | 0);
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
}
function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
if (!state || !rect || rect.w <= 0 || rect.h <= 0) return;
if (!state.staticLayers) state.staticLayers = new Map();
const fullKey = `${layerId}:${key}:${Math.max(0, rect.w | 0)}x${Math.max(0, rect.h | 0)}`;
let layer = state.staticLayers.get(fullKey);
if (!layer) {
const canvas = createStaticLayerCanvas(rect.w, rect.h);
const ctx = canvas.getContext('2d');
build(ctx, rect);
layer = { canvas };
state.staticLayers.set(fullKey, layer);
}
g.drawImage(layer.canvas, rect.x, rect.y);
}
function drawEmptyPlot(state, g, rect, label) {
drawCachedStaticLayer(state, g, 'empty-plot', String(label || '(leer)'), rect, (lg) => {
lg.fillStyle = PANEL_BG;
lg.fillRect(0, 0, rect.w, rect.h);
lg.fillStyle = '#9aa';
lg.textAlign = 'left';
lg.font = 'bold 14px ui-monospace, monospace';
lg.fillText(label || '(leer)', 12, 32);
lg.textAlign = 'start';
});
}
function readSplitMeters(CONFIG) {
const count = clampMeterCount(CONFIG?.SPLIT_VIEW_METER_COUNT);
const slots = [
{
id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_1, 'vu'),
pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_1_POS, 'right'),
},
{
id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_2, 'ppm-din'),
pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_2_POS, 'right'),
},
{
id: sanitizeMeterId(CONFIG?.SPLIT_VIEW_METER_3, 'lufs'),
pos: sanitizeMeterPos(CONFIG?.SPLIT_VIEW_METER_3_POS, 'right'),
},
].slice(0, count);
return slots;
}
function groupMetersByPosition(slots) {
const out = { left: [], center: [], right: [] };
for (const s of slots || []) {
if (!s) continue;
if (s.pos === 'left') out.left.push(s.id);
else if (s.pos === 'center') out.center.push(s.id);
else out.right.push(s.id);
}
return out;
return readMultiViewMeters(CONFIG, 'SPLIT_VIEW');
}
function computeSplitLayout(rect, slots) {
const contentH = Math.max(0, rect.h - CONTENT_TOP - CONTENT_BOTTOM);
const hasContent = contentH >= 140;
const BLOCK_GAP = SIDE_INNER_GAP;
let effectiveSlots = hasContent ? (Array.isArray(slots) ? slots.slice(0, 3) : []) : [];
while (true) {
const grouped = groupMetersByPosition(effectiveSlots);
const leftCount = grouped.left.length;
const centerCount = grouped.center.length;
const rightCount = grouped.right.length;
const totalSlots = leftCount + centerCount + rightCount;
const hasLeftMeters = hasContent && leftCount > 0;
const hasCenterMeters = hasContent && centerCount > 0;
const hasRightMeters = hasContent && rightCount > 0;
const interBlockGaps =
(hasLeftMeters ? BLOCK_GAP : 0) +
(hasRightMeters ? BLOCK_GAP : 0) +
(hasCenterMeters ? (2 * BLOCK_GAP) : OUTER_GAP);
const internalGaps =
Math.max(0, leftCount - 1) * METER_GAP +
Math.max(0, centerCount - 1) * METER_GAP +
Math.max(0, rightCount - 1) * METER_GAP;
const minRequired = 2 * MIN_PLOT_W + interBlockGaps + internalGaps;
const remainingForMeters = rect.w - minRequired;
let slotW = 0;
if (totalSlots > 0) {
slotW = Math.floor(remainingForMeters / totalSlots);
slotW = Math.min(METER_W_DEFAULT, slotW);
}
if (totalSlots > 0 && slotW < METER_W_MIN && effectiveSlots.length) {
effectiveSlots = effectiveSlots.slice(0, -1);
continue;
}
if (totalSlots > 0) slotW = Math.max(METER_W_MIN, Math.min(METER_W_DEFAULT, slotW));
const leftW = hasLeftMeters ? (leftCount * slotW + Math.max(0, leftCount - 1) * METER_GAP) : 0;
const centerW = hasCenterMeters ? (centerCount * slotW + Math.max(0, centerCount - 1) * METER_GAP) : 0;
const rightW = hasRightMeters ? (rightCount * slotW + Math.max(0, rightCount - 1) * METER_GAP) : 0;
const metersTotalW = leftW + centerW + rightW;
const plotAvail = Math.max(0, rect.w - interBlockGaps - metersTotalW);
const leftPlotW = Math.floor(plotAvail / 2);
const rightPlotW = plotAvail - leftPlotW;
const plotFits = leftPlotW >= MIN_PLOT_W && rightPlotW >= MIN_PLOT_W;
if (!plotFits && effectiveSlots.length) {
effectiveSlots = effectiveSlots.slice(0, -1);
continue;
}
let x = 0;
const leftMetersRect = hasLeftMeters ? { x, y: CONTENT_TOP, w: leftW, h: contentH } : null;
if (leftMetersRect) x += leftW + BLOCK_GAP;
const leftPlotRect = { x, y: 0, w: leftPlotW, h: rect.h };
x += leftPlotW;
let centerMetersRect = null;
if (hasCenterMeters) {
x += BLOCK_GAP;
centerMetersRect = { x, y: CONTENT_TOP, w: centerW, h: contentH };
x += centerW + BLOCK_GAP;
} else {
x += OUTER_GAP;
}
const rightPlotRect = { x, y: 0, w: rightPlotW, h: rect.h };
x += rightPlotW;
let rightMetersRect = null;
if (hasRightMeters) {
x += BLOCK_GAP;
rightMetersRect = { x, y: CONTENT_TOP, w: rightW, h: contentH };
}
return {
plots: { left: leftPlotRect, right: rightPlotRect },
meters: { left: leftMetersRect, center: centerMetersRect, right: rightMetersRect },
meterIds: grouped,
slotW,
effectiveSlots,
};
}
}
async function drawMetersPanel(env, state, rect, meterIds, slotW) {
const { ctx: g, meters, config: CONFIG } = env;
if (!rect || rect.w <= 0 || rect.h <= 0) return;
const ids = Array.isArray(meterIds) ? meterIds.slice(0, 3) : [];
const count = ids.length;
if (!count) return;
drawCachedStaticLayer(state, g, 'meter-panel-shell', 'bg-frame', rect, (lg) => {
lg.fillStyle = PANEL_BG;
lg.fillRect(0, 0, rect.w, rect.h);
drawSubframe(lg, { x: 0, y: 0, w: rect.w, h: rect.h });
});
const gap = METER_GAP;
const n = Math.max(1, Math.min(3, count));
const usedW = n * slotW + (n - 1) * gap;
const startX = rect.x + Math.max(0, Math.floor((rect.w - usedW) / 2));
const shrink = Math.max(0, METER_SLOT_SHRINK);
const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - shrink - METER_EXTRA_BOTTOM_PAD);
const innerOffset = shrink / 2;
const slotY = rect.y + METER_PAD_TOP + innerOffset;
const slotH = innerHeight;
for (let i = 0; i < n; i++) {
const id = sanitizeMeterId(ids[i], 'none');
const r = { x: startX + i * (slotW + gap), y: slotY, w: slotW, h: slotH };
if (id === 'none') {
drawCachedStaticLayer(state, g, 'meter-slot-empty', `${slotW}x${slotH}`, r, (lg) => {
lg.strokeStyle = 'rgba(0,231,255,0.25)';
lg.setLineDash([6, 5]);
lg.strokeRect(0.5, 0.5, Math.max(0, r.w - 1), Math.max(0, r.h - 1));
lg.setLineDash([]);
lg.fillStyle = '#9aa';
lg.textAlign = 'center';
lg.font = '12px ui-monospace, monospace';
lg.fillText('(leer)', r.w / 2, 22);
lg.textAlign = 'start';
});
} else {
try {
g.save();
g.beginPath();
g.rect(rect.x + 1, rect.y + 1, Math.max(0, rect.w - 2), Math.max(0, rect.h - 2));
g.clip();
await meters.draw(g, r, id, CONFIG);
g.restore();
} catch (e) {
g.restore();
console.warn('Split meter draw error:', e);
}
}
}
return computeMultiViewBaseLayout(rect, slots, CONTENT_TOP, CONTENT_BOTTOM);
}
function destroyChild(child) {