238 lines
8.4 KiB
JavaScript
238 lines
8.4 KiB
JavaScript
// Gemeinsame, rein interne Bausteine für Split- und Quad-View.
|
|
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 { FRAME_COLOR, PANEL_BG } from '../core/theme.js';
|
|
import { drawCachedStaticLayer } from './static_layer.js';
|
|
|
|
export 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_IDS = new Set(['none', ...Object.keys(CHILD_VIEWS)]);
|
|
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 METER_GAP = 12;
|
|
const METER_PAD_TOP = 15;
|
|
const METER_PAD_BOTTOM = 5;
|
|
const METER_SLOT_SHRINK = 24;
|
|
const METER_EXTRA_BOTTOM_PAD = 6;
|
|
const OUTER_GAP = 10;
|
|
const SIDE_INNER_GAP = 8;
|
|
const METER_W_DEFAULT = 140;
|
|
const METER_W_MIN = 90;
|
|
const MIN_PLOT_W = 240;
|
|
|
|
export function sanitizeChildId(value, fallback) {
|
|
return ALLOWED_CHILD_IDS.has(value) ? value : fallback;
|
|
}
|
|
|
|
export function sanitizePlotId(value, fallback) {
|
|
return ALLOWED_CHILD_IDS.has(value) ? value : fallback;
|
|
}
|
|
|
|
export function sanitizeMeterId(value, fallback) {
|
|
const id = String(value || '');
|
|
return ALLOWED_METER_IDS.has(id) ? id : fallback;
|
|
}
|
|
|
|
export function sanitizeMeterPos(value, fallback) {
|
|
const id = String(value || '');
|
|
return ALLOWED_METER_POSITIONS.has(id) ? id : fallback;
|
|
}
|
|
|
|
function clampMeterCount(value) {
|
|
const n = Number(value);
|
|
if (!Number.isFinite(n)) return 0;
|
|
return Math.max(0, Math.min(3, n | 0));
|
|
}
|
|
|
|
export function readMultiViewMeters(config, prefix) {
|
|
const count = clampMeterCount(config?.[`${prefix}_METER_COUNT`]);
|
|
const defaults = ['vu', 'ppm-din', 'lufs'];
|
|
return defaults.map((fallback, index) => {
|
|
const number = index + 1;
|
|
return {
|
|
id: sanitizeMeterId(config?.[`${prefix}_METER_${number}`], fallback),
|
|
pos: sanitizeMeterPos(config?.[`${prefix}_METER_${number}_POS`], 'right'),
|
|
};
|
|
}).slice(0, count);
|
|
}
|
|
|
|
export function groupMetersByPosition(slots) {
|
|
const out = { left: [], center: [], right: [] };
|
|
for (const slot of slots || []) {
|
|
if (!slot) continue;
|
|
if (slot.pos === 'left') out.left.push(slot.id);
|
|
else if (slot.pos === 'center') out.center.push(slot.id);
|
|
else out.right.push(slot.id);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function computeMultiViewBaseLayout(rect, slots, contentTop, contentBottom = 0) {
|
|
const contentH = Math.max(0, rect.h - contentTop - contentBottom);
|
|
const hasContent = contentH >= 140;
|
|
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 = leftCount > 0;
|
|
const hasCenterMeters = centerCount > 0;
|
|
const hasRightMeters = rightCount > 0;
|
|
const interBlockGaps =
|
|
(hasLeftMeters ? SIDE_INNER_GAP : 0) +
|
|
(hasRightMeters ? SIDE_INNER_GAP : 0) +
|
|
(hasCenterMeters ? 2 * SIDE_INNER_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 remainingForMeters = rect.w - (2 * MIN_PLOT_W + interBlockGaps + internalGaps);
|
|
let slotW = totalSlots > 0 ? Math.min(METER_W_DEFAULT, Math.floor(remainingForMeters / totalSlots)) : 0;
|
|
|
|
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 groupWidth = (count) => count > 0 ? count * slotW + Math.max(0, count - 1) * METER_GAP : 0;
|
|
const leftW = groupWidth(leftCount);
|
|
const centerW = groupWidth(centerCount);
|
|
const rightW = groupWidth(rightCount);
|
|
const plotAvail = Math.max(0, rect.w - interBlockGaps - leftW - centerW - rightW);
|
|
const leftPlotW = Math.floor(plotAvail / 2);
|
|
const rightPlotW = plotAvail - leftPlotW;
|
|
if ((leftPlotW < MIN_PLOT_W || rightPlotW < MIN_PLOT_W) && effectiveSlots.length) {
|
|
effectiveSlots = effectiveSlots.slice(0, -1);
|
|
continue;
|
|
}
|
|
|
|
let x = 0;
|
|
const leftMetersRect = hasLeftMeters ? { x, y: contentTop, w: leftW, h: contentH } : null;
|
|
if (leftMetersRect) x += leftW + SIDE_INNER_GAP;
|
|
const leftPlotRect = { x, y: 0, w: leftPlotW, h: rect.h };
|
|
x += leftPlotW;
|
|
let centerMetersRect = null;
|
|
if (hasCenterMeters) {
|
|
x += SIDE_INNER_GAP;
|
|
centerMetersRect = { x, y: contentTop, w: centerW, h: contentH };
|
|
x += centerW + SIDE_INNER_GAP;
|
|
} else {
|
|
x += OUTER_GAP;
|
|
}
|
|
const rightPlotRect = { x, y: 0, w: rightPlotW, h: rect.h };
|
|
x += rightPlotW;
|
|
let rightMetersRect = null;
|
|
if (hasRightMeters) {
|
|
x += SIDE_INNER_GAP;
|
|
rightMetersRect = { x, y: contentTop, w: rightW, h: contentH };
|
|
}
|
|
return {
|
|
plots: { left: leftPlotRect, right: rightPlotRect },
|
|
meters: { left: leftMetersRect, center: centerMetersRect, right: rightMetersRect },
|
|
meterIds: grouped,
|
|
slotW,
|
|
effectiveSlots,
|
|
};
|
|
}
|
|
}
|
|
|
|
export 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(); }
|
|
}
|
|
|
|
export 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();
|
|
}
|
|
|
|
export 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';
|
|
});
|
|
}
|
|
|
|
export async function drawMetersPanel(env, state, rect, meterIds, slotW, ownerLabel) {
|
|
const { ctx: g, meters, 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 n = Math.max(1, Math.min(3, count));
|
|
const usedW = n * slotW + (n - 1) * METER_GAP;
|
|
const startX = rect.x + Math.max(0, Math.floor((rect.w - usedW) / 2));
|
|
const innerHeight = Math.max(40, rect.h - METER_PAD_TOP - METER_PAD_BOTTOM - METER_SLOT_SHRINK - METER_EXTRA_BOTTOM_PAD);
|
|
const slotY = rect.y + METER_PAD_TOP + METER_SLOT_SHRINK / 2;
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const id = sanitizeMeterId(ids[i], 'none');
|
|
const r = { x: startX + i * (slotW + METER_GAP), y: slotY, w: slotW, h: innerHeight };
|
|
if (id === 'none') {
|
|
drawCachedStaticLayer(state, g, 'meter-slot-empty', `${slotW}x${innerHeight}`, 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';
|
|
});
|
|
continue;
|
|
}
|
|
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 (error) {
|
|
g.restore();
|
|
console.warn(`${ownerLabel} meter draw error:`, error);
|
|
}
|
|
}
|
|
}
|