// 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); } } }