// 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'; 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; } 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); } } } } function destroyChild(child) { if (!child || child.id === 'none') return; const mod = CHILD_VIEWS[child.id]; if (mod && typeof mod.destroy === 'function') { try { mod.destroy(child.state); } catch (e) { console.warn('Split child destroy error:', e); } } } function initChild(env, childId) { const id = sanitizeChildId(childId, 'none'); if (id === 'none') return { id: 'none', state: {} }; const mod = CHILD_VIEWS[id]; const state = (mod && typeof mod.init === 'function') ? (mod.init(env) || {}) : {}; return { id, state }; } function resizeChild(env, rect, child) { if (!child || child.id === 'none') return; const mod = CHILD_VIEWS[child.id]; if (!mod || typeof mod.resize !== 'function') return; try { mod.resize({ rect }, child.state); } catch (e) { console.warn('Split child resize error:', e); } } async function renderChild(env, rect, child) { const { ctx: g } = env; if (!child || child.id === 'none') return; const mod = CHILD_VIEWS[child.id]; if (!mod || typeof mod.render !== 'function') return; await withClippedSubRect(g, rect, async () => { const plotOnlySlots = (viewId) => { if (viewId === 'peak-history' || viewId === 'classic-needles' || viewId === 'panel') { const configured = env?.slots?.(viewId); if (Array.isArray(configured) && configured.length) return configured; } if (viewId === 'peak-history') return ['ppm-din']; if (viewId === 'classic-needles') return ['vu']; if (viewId === 'panel') return ['vu', 'ppm-ebu', 'ppm-din', 'tp', 'rms']; return ['none']; }; const subEnv = Object.assign({}, env, { rect: { x: 0, y: 0, w: rect.w, h: rect.h }, embedded: true, containerView: 'split-view', slots: plotOnlySlots, embeddedOffsetX: rect.x, embeddedOffsetY: rect.y, }); await mod.render(subEnv, child.state); }); } export function init(env) { const leftId = sanitizePlotId(env?.config?.SPLIT_VIEW_LEFT, 'phase-wheel'); const rightId = sanitizePlotId(env?.config?.SPLIT_VIEW_RIGHT, 'realtime'); const state = { staticLayers: new Map(), left: initChild(env, leftId), right: initChild(env, rightId), popup: initChild(env, 'none'), lastLeftId: leftId, lastRightId: rightId, lastPopupId: 'none', lastRectSig: '', }; return state; } export function destroy(state) { if (state) state.staticLayers = null; destroyChild(state?.left); destroyChild(state?.right); destroyChild(state?.popup); } export function resize({ rect }, state) { if (!state || !rect) return; const sig = `${rect.w}x${rect.h}`; if (state.lastRectSig === sig) return; state.lastRectSig = sig; const layout = computeLayout(rect); resizeChild(null, { x: 0, y: 0, w: layout.left.w, h: layout.left.h }, state.left); resizeChild(null, { x: 0, y: 0, w: layout.right.w, h: layout.right.h }, state.right); resizeChild(null, { x: 0, y: 0, w: rect.w, h: rect.h }, state.popup); } function computeLayout(rect) { const gap = OUTER_GAP; const w = Math.max(0, rect.w); const h = Math.max(0, rect.h); const half = Math.floor((w - gap) / 2); const left = { x: 0, y: 0, w: Math.max(0, half), h }; const right = { x: Math.max(0, half + gap), y: 0, w: Math.max(0, w - (half + gap)), h }; return { left, right, gap }; } export async function render(env, state) { const { ctx: g, rect, config: CONFIG } = env; const popupCfg = env?.splitPopup; const popupWanted = popupCfg && popupCfg.open ? sanitizeChildId(popupCfg.viewId, 'none') : 'none'; // Popup-Modus: rendere die View in Originalgröße (vollflächig), // ohne Split-Hintergrund/Layout. So sieht es exakt wie die Einzel-View aus. if (popupWanted !== 'none' && state) { if (state.lastPopupId !== popupWanted) { destroyChild(state.popup); state.popup = initChild(env, popupWanted); state.lastPopupId = popupWanted; resizeChild(null, { x: 0, y: 0, w: rect.w, h: rect.h }, state.popup); } state._splitHit = { contentY: 0, plots: null, meters: [], popupBox: { x: 0, y: 0, w: rect.w, h: rect.h }, }; const mod = CHILD_VIEWS[state.popup.id]; if (mod && typeof mod.render === 'function') { const subEnv = Object.assign({}, env, { rect: { x: 0, y: 0, w: rect.w, h: rect.h }, embedded: false, }); await mod.render(subEnv, state.popup.state); } return; } if (state && state.lastPopupId !== 'none') { destroyChild(state.popup); state.popup = initChild(env, 'none'); state.lastPopupId = 'none'; } const desiredLeftId = sanitizePlotId(env?.config?.SPLIT_VIEW_LEFT, 'phase-wheel'); const desiredRightId = sanitizePlotId(env?.config?.SPLIT_VIEW_RIGHT, 'realtime'); if (state.lastLeftId !== desiredLeftId) { destroyChild(state.left); state.left = initChild(env, desiredLeftId); state.lastLeftId = desiredLeftId; } if (state.lastRightId !== desiredRightId) { destroyChild(state.right); state.right = initChild(env, desiredRightId); state.lastRightId = desiredRightId; } const slots = readSplitMeters(CONFIG); const layout = computeSplitLayout(rect, slots); const leftPlotRect = layout?.plots?.left || { x: 0, y: 0, w: Math.floor(rect.w / 2), h: rect.h }; const rightPlotRect = layout?.plots?.right || { x: Math.floor(rect.w / 2), y: 0, w: rect.w - Math.floor(rect.w / 2), h: rect.h }; if (state) { state._splitHit = { contentY: 0, plots: { left: { ...leftPlotRect, viewId: desiredLeftId }, right: { ...rightPlotRect, viewId: desiredRightId }, }, meters: [layout?.meters?.left, layout?.meters?.center, layout?.meters?.right].filter(Boolean).map((r) => ({ ...r })), popupBox: null, }; } if (state.left?.id === 'none') drawEmptyPlot(state, g, leftPlotRect, 'Links: (leer)'); else await renderChild(env, leftPlotRect, state.left); if (state.right?.id === 'none') drawEmptyPlot(state, g, rightPlotRect, 'Rechts: (leer)'); else await renderChild(env, rightPlotRect, state.right); if (layout?.meters?.left) await drawMetersPanel(env, state, layout.meters.left, layout.meterIds.left, layout.slotW); if (layout?.meters?.center) await drawMetersPanel(env, state, layout.meters.center, layout.meterIds.center, layout.slotW); if (layout?.meters?.right) await drawMetersPanel(env, state, layout.meters.right, layout.meterIds.right, layout.slotW); }