Refactor GUI rendering and align RTA axis
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-284
@@ -1,246 +1,29 @@
|
||||
// views/quad_view.js — Quad-View: vier Plots (2x2) + optionales Meter-Panel (wie Split-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 { 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 = 'quad-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_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 ROW_GAP = 10;
|
||||
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_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 readQuadMeters(CONFIG) {
|
||||
const count = clampMeterCount(CONFIG?.QUAD_VIEW_METER_COUNT);
|
||||
const slots = [
|
||||
{
|
||||
id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_1, 'vu'),
|
||||
pos: sanitizeMeterPos(CONFIG?.QUAD_VIEW_METER_1_POS, 'right'),
|
||||
},
|
||||
{
|
||||
id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_2, 'ppm-din'),
|
||||
pos: sanitizeMeterPos(CONFIG?.QUAD_VIEW_METER_2_POS, 'right'),
|
||||
},
|
||||
{
|
||||
id: sanitizeMeterId(CONFIG?.QUAD_VIEW_METER_3, 'lufs'),
|
||||
pos: sanitizeMeterPos(CONFIG?.QUAD_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, 'QUAD_VIEW');
|
||||
}
|
||||
|
||||
function computeBaseLayout(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 = leftCount > 0;
|
||||
const hasCenterMeters = centerCount > 0;
|
||||
const hasRightMeters = 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,
|
||||
};
|
||||
}
|
||||
return computeMultiViewBaseLayout(rect, slots, CONTENT_TOP, CONTENT_BOTTOM);
|
||||
}
|
||||
|
||||
function splitRectToRows(rect) {
|
||||
@@ -274,60 +57,6 @@ function ensureChildRectSize(env, rect, child) {
|
||||
resizeChild(env, { x: 0, y: 0, w: rect.w, h: rect.h }, child);
|
||||
}
|
||||
|
||||
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('Quad meter draw error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function destroyChild(child) {
|
||||
if (!child || child.id === 'none') return;
|
||||
const mod = CHILD_VIEWS[child.id];
|
||||
|
||||
+57
-31
@@ -1,5 +1,6 @@
|
||||
import { getRtwCenters, resolveRtwBpoValue } from '../core/rtw_centers.js';
|
||||
import { DEFAULT_TOP_INSET, FRAME_COLOR, MID_COLOR, PANEL_BG, WARN_COLOR } from '../core/theme.js';
|
||||
import { drawCachedStaticLayer } from './static_layer.js';
|
||||
|
||||
// views/realtime.js — IEC-konformer Real-Time Analyzer
|
||||
|
||||
@@ -115,6 +116,23 @@ export async function render(env, state) {
|
||||
const gutterL = Number.isFinite(CONFIG?.AXIS_GUTTER_LEFT)
|
||||
? Math.max(8, Number(CONFIG.AXIS_GUTTER_LEFT))
|
||||
: 14;
|
||||
const rtwBarGrid = CONFIG.RTA_BAR_LAYOUT === 'rtw'
|
||||
&& (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars';
|
||||
const rtwGridCenters = rtwBarGrid
|
||||
? (isVectorLike(displayRtaPacket?.centers)
|
||||
? Array.from(displayRtaPacket.centers)
|
||||
: getRtwCenters(CONFIG.RTA_BPO_MODE || '1_3'))
|
||||
.filter((center) => Number.isFinite(center)
|
||||
&& center >= freqBounds.min
|
||||
&& center <= freqBounds.max)
|
||||
: null;
|
||||
const freqTickPositions = rtwGridCenters
|
||||
? buildRtwTickPositions(rtwGridCenters, freqTicks)
|
||||
: null;
|
||||
const gridLeftExtension = rtwBarGrid ? gutterL : 0;
|
||||
const tickPositionKey = freqTickPositions
|
||||
? freqTicks.map((f) => `${f}:${freqTickPositions[String(f)] ?? 'log'}`).join(',')
|
||||
: 'log';
|
||||
const plotPadLeft = gutterL + STATIC_LABEL_PAD_LEFT;
|
||||
const plotPadBottom = STATIC_LABEL_PAD_BOTTOM;
|
||||
|
||||
@@ -133,6 +151,8 @@ export async function render(env, state) {
|
||||
refDb ?? 'none',
|
||||
hide20HzTick ? 1 : 0,
|
||||
plotPadLeft,
|
||||
gridLeftExtension,
|
||||
tickPositionKey,
|
||||
plotPadBottom,
|
||||
].join('|'),
|
||||
{ x: plotX - plotPadLeft, y: plotY, w: plotW + plotPadLeft, h: plotH + plotPadBottom },
|
||||
@@ -147,7 +167,9 @@ export async function render(env, state) {
|
||||
freqBounds.max,
|
||||
{
|
||||
freqTicks,
|
||||
freqTickPositions,
|
||||
freqMin: freqBounds.min,
|
||||
xLeftExtension: gridLeftExtension,
|
||||
refDb,
|
||||
refStyle: 'rgba(0,231,255,0.35)',
|
||||
refDash: [3, 4],
|
||||
@@ -197,7 +219,8 @@ export async function render(env, state) {
|
||||
range,
|
||||
freqBounds,
|
||||
ballisticsData?.overlay || null,
|
||||
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average'
|
||||
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average',
|
||||
gridLeftExtension,
|
||||
);
|
||||
}
|
||||
} else if (useNativeFftEngine) {
|
||||
@@ -230,7 +253,8 @@ export async function render(env, state) {
|
||||
range,
|
||||
freqBounds,
|
||||
ballisticsData?.overlay || null,
|
||||
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average'
|
||||
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average',
|
||||
gridLeftExtension,
|
||||
);
|
||||
}
|
||||
} else if (!analyser || !buf) {
|
||||
@@ -259,7 +283,8 @@ export async function render(env, state) {
|
||||
range,
|
||||
freqBounds,
|
||||
null,
|
||||
CONFIG.RTA_BALLISTICS_MODE || 'average'
|
||||
CONFIG.RTA_BALLISTICS_MODE || 'average',
|
||||
gridLeftExtension,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -552,7 +577,33 @@ function applyRealtimeBarBallistics(state, levels, CONFIG, range) {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks, ballisticsMode) {
|
||||
export function buildRtwTickPositions(centers, ticks = RTA_X_TICKS) {
|
||||
if (!isVectorLike(centers) || !centers.length) return {};
|
||||
const normalizedCenters = Array.from(centers, Number)
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
if (!normalizedCenters.length) return {};
|
||||
const positions = {};
|
||||
const count = normalizedCenters.length;
|
||||
for (const tick of ticks || []) {
|
||||
const frequency = Number(tick);
|
||||
if (!Number.isFinite(frequency) || frequency <= 0) continue;
|
||||
let bestIndex = -1;
|
||||
let bestRelativeError = Infinity;
|
||||
for (let index = 0; index < count; index++) {
|
||||
const relativeError = Math.abs(normalizedCenters[index] - frequency) / frequency;
|
||||
if (relativeError < bestRelativeError) {
|
||||
bestRelativeError = relativeError;
|
||||
bestIndex = index;
|
||||
}
|
||||
}
|
||||
if (bestIndex >= 0 && bestRelativeError <= 0.02) {
|
||||
positions[String(tick)] = (bestIndex + 0.5) / count;
|
||||
}
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlayPeaks, ballisticsMode, rtwLeftExtension = 0) {
|
||||
const layout = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'rtw' : 'iec';
|
||||
const overlay = (ballisticsMode === 'both' && isVectorLike(overlayPeaks) && overlayPeaks.length === state.mapping.length)
|
||||
? overlayPeaks
|
||||
@@ -560,7 +611,8 @@ function renderSpectrum(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, ra
|
||||
if ((CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'line') {
|
||||
renderLine(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay);
|
||||
} else if (layout === 'rtw') {
|
||||
renderRtwBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, overlay);
|
||||
const extension = Math.max(0, Number(rtwLeftExtension) || 0);
|
||||
renderRtwBars(g, state, levels, plotX - extension, plotY, plotW + extension, plotH, CONFIG, range, overlay);
|
||||
} else {
|
||||
renderIecBars(g, state, levels, plotX, plotY, plotW, plotH, CONFIG, range, freqBounds, overlay);
|
||||
}
|
||||
@@ -742,32 +794,6 @@ async function drawMeter(env, state, plotX, plotY, plotW, plotH) {
|
||||
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);
|
||||
layer = { canvas };
|
||||
state.staticLayers.set(fullKey, layer);
|
||||
}
|
||||
g.drawImage(layer.canvas, rect.x, rect.y);
|
||||
}
|
||||
|
||||
|
||||
function mapLogX(freq, x0, w, logMin, logSpan) {
|
||||
const clamped = Math.max(5, freq);
|
||||
const frac = (Math.log10(clamped) - logMin) / (logSpan || 1);
|
||||
|
||||
+13
-285
@@ -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) {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { createCanvasSurface } from '../core/canvas_surface.js';
|
||||
|
||||
const MAX_STATIC_LAYERS = 24;
|
||||
|
||||
export 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;
|
||||
return createCanvasSurface(width, height)?.canvas || null;
|
||||
}
|
||||
|
||||
export function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
|
||||
@@ -13,12 +11,21 @@ export function drawCachedStaticLayer(state, g, layerId, key, rect, build) {
|
||||
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) {
|
||||
// Map-Reihenfolge als kleine LRU-Liste verwenden.
|
||||
state.staticLayers.delete(fullKey);
|
||||
state.staticLayers.set(fullKey, layer);
|
||||
}
|
||||
if (!layer) {
|
||||
const canvas = createStaticLayerCanvas(rect.w, rect.h);
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
build(ctx, rect);
|
||||
layer = { canvas };
|
||||
state.staticLayers.set(fullKey, layer);
|
||||
while (state.staticLayers.size > MAX_STATIC_LAYERS) {
|
||||
state.staticLayers.delete(state.staticLayers.keys().next().value);
|
||||
}
|
||||
}
|
||||
g.drawImage(layer.canvas, rect.x, rect.y);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user