572 lines
19 KiB
JavaScript
572 lines
19 KiB
JavaScript
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
||
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
|
||
import { drawCachedStaticLayer } from './static_layer.js';
|
||
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
|
||
|
||
// meters/ppm_din.js — Peak Programme Meter, DIN Scale (IEC 60268-10 Type I)
|
||
// Quasi-PPM mit optionalem Peak-Hold, DIN-Skala und bestehendem Farbschema.
|
||
|
||
export const id = 'ppm-din';
|
||
|
||
const DEFAULT_DECAY_DB_PER_S = 20 / 1.7; // 20 dB in ca. 1.7 s → ~11.76 dB/s
|
||
const DEFAULT_HOLD_MS = 1000;
|
||
|
||
const DIN_SCALE = [
|
||
{ db: -50, pos: 0.0205 },
|
||
{ db: -40, pos: 0.0564 },
|
||
{ db: -35, pos: 0.0974 },
|
||
{ db: -30, pos: 0.1538 },
|
||
{ db: -25, pos: 0.2308 },
|
||
{ db: -20, pos: 0.3179 },
|
||
{ db: -15, pos: 0.4359 },
|
||
{ db: -10, pos: 0.5538 },
|
||
{ db: -5, pos: 0.7026 },
|
||
{ db: 0, pos: 0.8513 },
|
||
{ db: +5, pos: 1.0000 },
|
||
];
|
||
|
||
const PERCENT_MARKS = [
|
||
{ label: '1', db: -40 },
|
||
{ label: '10', db: -20 },
|
||
{ label: '50', db: -6 },
|
||
{ label: '100', db: 0 },
|
||
{ label: '180', db: +5 },
|
||
];
|
||
|
||
const MAJOR_TICKS_DB = [-50, -40, -30, -20, -10, -5, 0, +5];
|
||
const MINOR_TICKS = [
|
||
{ db: -35 },
|
||
{ db: -25 },
|
||
{ db: -21, color: 'warn' },
|
||
{ db: -15 },
|
||
{ db: -9 },
|
||
{ db: -8 },
|
||
{ db: -7 },
|
||
{ db: -6 },
|
||
{ db: -4 },
|
||
{ db: -3 },
|
||
{ db: -2 },
|
||
{ db: -1 },
|
||
{ db: +1 },
|
||
{ db: +2 },
|
||
{ db: +3 },
|
||
{ db: +4 },
|
||
];
|
||
|
||
export function initShared(CONFIG = {}) {
|
||
const now = performance.now();
|
||
const bottom = Number.isFinite(CONFIG.PPM_DIN_BOTTOM) ? CONFIG.PPM_DIN_BOTTOM : -50;
|
||
const holdMs = Number.isFinite(CONFIG.PPM_DIN_HOLD_MS) ? CONFIG.PPM_DIN_HOLD_MS : DEFAULT_HOLD_MS;
|
||
return {
|
||
values: { L: bottom, R: bottom },
|
||
hold: { L: bottom, R: bottom },
|
||
loudnessDbfs: { L: null, R: null },
|
||
_loudSmooth: null,
|
||
_env: {
|
||
L_dbfs: -90,
|
||
R_dbfs: -90,
|
||
lastTs: now,
|
||
},
|
||
_holdState: {
|
||
L: createPeakHoldState(bottom, now, holdMs),
|
||
R: createPeakHoldState(bottom, now, holdMs),
|
||
},
|
||
_holdCfg: {
|
||
holdMs,
|
||
decayDbPerS: Number.isFinite(CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S)
|
||
? CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S
|
||
: (Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : DEFAULT_DECAY_DB_PER_S),
|
||
},
|
||
// Offset for DIN uses PPM_DIN_OFFSET so that 0 dB on the meter aligns
|
||
// properly with the Permitted Maximum Level (PML). According to the
|
||
// DIN Type I specification, 0 dBu (≈−15 dBFS peak) should read −9 dB.
|
||
offset: Number(CONFIG.PPM_DIN_OFFSET) || 0,
|
||
refDbfsFor0: Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU)
|
||
? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU
|
||
: -15,
|
||
};
|
||
}
|
||
|
||
export function update(packet, shared) {
|
||
const inL = Number.isFinite(packet?.ppmDinL) ? packet.ppmDinL : (Number.isFinite(packet?.ppmL) ? packet.ppmL : -90);
|
||
const inR = Number.isFinite(packet?.ppmDinR) ? packet.ppmDinR : (Number.isFinite(packet?.ppmR) ? packet.ppmR : -90);
|
||
const ppmBoxL = Number.isFinite(packet?.ppmBoxL) ? packet.ppmBoxL : null;
|
||
const ppmBoxR = Number.isFinite(packet?.ppmBoxR) ? packet.ppmBoxR : null;
|
||
shared._loudIsBox = Number.isFinite(ppmBoxL) || Number.isFinite(ppmBoxR);
|
||
const fallbackLoud = Number.isFinite(packet?.lufsM)
|
||
? packet.lufsM
|
||
: Number.isFinite(packet?.lufsS)
|
||
? packet.lufsS
|
||
: Number.isFinite(packet?.lufsI)
|
||
? packet.lufsI
|
||
: null;
|
||
const loudL = Number.isFinite(ppmBoxL)
|
||
? ppmBoxL
|
||
: (Number.isFinite(packet?.lufsML) ? packet.lufsML : fallbackLoud);
|
||
const loudR = Number.isFinite(ppmBoxR)
|
||
? ppmBoxR
|
||
: (Number.isFinite(packet?.lufsMR) ? packet.lufsMR : fallbackLoud);
|
||
|
||
const base = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -15;
|
||
const off = shared.offset || 0;
|
||
// Verwende Worklet-Pegel direkt; Ballistik liegt bereits im AudioWorklet.
|
||
shared._env.L_dbfs = inL;
|
||
shared._env.R_dbfs = inR;
|
||
const now = performance.now();
|
||
shared._env.lastTs = now;
|
||
shared.values.L = (inL - base) + off;
|
||
shared.values.R = (inR - base) + off;
|
||
|
||
// Smoothe Loudness pro Kanal (LUFS M bevorzugt) für ruhige Overlay-Bewegung.
|
||
if (!shared._loudSmooth) {
|
||
shared._loudSmooth = {
|
||
L: Number.isFinite(loudL) ? loudL : null,
|
||
R: Number.isFinite(loudR) ? loudR : null,
|
||
lastTs: now,
|
||
};
|
||
}
|
||
const dtL = Math.max(1e-3, (now - (shared._loudSmooth.lastTs || now)) / 1000);
|
||
const tau = 0.18; // ~180 ms Gleitzeit
|
||
const alphaL = 1 - Math.exp(-dtL / tau);
|
||
|
||
const smoothVal = (prev, target) => {
|
||
if (!Number.isFinite(target)) return { out: null, prev: prev };
|
||
const baseVal = Number.isFinite(prev) ? prev : target;
|
||
const out = baseVal + alphaL * (target - baseVal);
|
||
return { out, prev: out };
|
||
};
|
||
|
||
const resL = smoothVal(shared._loudSmooth.L, loudL);
|
||
const resR = smoothVal(shared._loudSmooth.R, loudR);
|
||
shared._loudSmooth.L = resL.prev;
|
||
shared._loudSmooth.R = resR.prev;
|
||
shared._loudSmooth.lastTs = now;
|
||
shared.loudnessDbfs = { L: resL.out, R: resR.out };
|
||
}
|
||
|
||
export function draw(g, rect, CONFIG = {}, shared) {
|
||
// Use the DIN-specific offset for display. This ensures that DIN and EBU
|
||
// scales do not influence each other. The effective offset is the user-
|
||
// adjustable correction on top of the meter's built-in offset.
|
||
const baseMode = (CONFIG.PPM_DIN_MODE === 'al_minus6') ? -6 : -9;
|
||
const __effOff = baseMode + (Number(CONFIG.PPM_DIN_TRIM_DB) || 0);
|
||
const __offCorr = __effOff - (shared.offset || 0);
|
||
|
||
const PPM_TOP = Number.isFinite(CONFIG.PPM_DIN_TOP) ? CONFIG.PPM_DIN_TOP : +5;
|
||
const PPM_BOTTOM = Number.isFinite(CONFIG.PPM_DIN_BOTTOM) ? CONFIG.PPM_DIN_BOTTOM : -50;
|
||
const EXT_BOTTOM = PPM_BOTTOM - 20; // Virtuelle Skalenverlängerung für Loudness-Boxen
|
||
const RED_START = Number.isFinite(CONFIG.PPM_DIN_RED_START) ? CONFIG.PPM_DIN_RED_START : 0;
|
||
const redOnly = CONFIG.PPM_RED_BAR_ONLY !== false;
|
||
|
||
const colNorm = CONFIG.PPM_DIN_COLOR_NORMAL || MID_COLOR;
|
||
const colWarn = CONFIG.PPM_DIN_COLOR_WARN || WARN_COLOR;
|
||
|
||
const mapNorm = (db) => {
|
||
const clamped = Math.max(PPM_BOTTOM, Math.min(PPM_TOP, db));
|
||
const table = DIN_SCALE;
|
||
let prev = table[0];
|
||
for (let i = 1; i < table.length; i++) {
|
||
const curr = table[i];
|
||
if (clamped <= curr.db) {
|
||
const span = curr.db - prev.db || 1;
|
||
const t = (clamped - prev.db) / span;
|
||
return prev.pos + t * (curr.pos - prev.pos);
|
||
}
|
||
prev = curr;
|
||
}
|
||
return table[table.length - 1].pos;
|
||
};
|
||
// DIN-Skala auf volle Höhe normalisieren: -50 dB sitzt am Rect-Boden, Proportionen bleiben.
|
||
const normBottom = mapNorm(PPM_BOTTOM);
|
||
const normSpan = Math.max(1e-6, 1 - normBottom);
|
||
const mapY = (db) => {
|
||
const n = mapNorm(db);
|
||
const t = Math.max(0, Math.min(1, (n - normBottom) / normSpan));
|
||
return rect.y + (1 - t) * rect.h;
|
||
};
|
||
|
||
const innerPad = 8;
|
||
const scaleW = 28;
|
||
const gap = 8;
|
||
const avail = rect.w - innerPad * 2 - scaleW - gap * 2;
|
||
let barW = Math.max(10, Math.floor(avail / 2));
|
||
barW = Math.floor(barW * (CONFIG.METER_BAR_THIN || 0.55));
|
||
|
||
const used = 2 * barW + scaleW + 2 * gap;
|
||
const extra = Math.max(0, (rect.w - innerPad * 2) - used);
|
||
const leftX = rect.x + innerPad + extra / 2;
|
||
const scaleX = leftX + barW + gap;
|
||
const rightX = scaleX + scaleW + gap;
|
||
const centerX = scaleX + scaleW / 2;
|
||
|
||
const bottomLoud = PPM_BOTTOM - 20; // Loudness-Boxen dürfen 20 dB tiefer fallen als die Skala
|
||
const clampDb = (v) => Math.max(PPM_BOTTOM, Math.min(PPM_TOP, v));
|
||
const rawL = shared.values.L + __offCorr;
|
||
const rawR = shared.values.R + __offCorr;
|
||
const dbValL = clampDb(rawL);
|
||
const dbValR = clampDb(rawR);
|
||
const smooth = smoothHeader(shared, rawL, rawR);
|
||
g.save();
|
||
const prevFont = g.font;
|
||
g.font = 'bold 12px ui-monospace, monospace';
|
||
g.fillStyle = HEADER_BG;
|
||
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
||
const headerWarn = CONFIG.PPM_DIN_HEADER_SHOW_VALUE && (rawL > RED_START || rawR > RED_START);
|
||
g.fillStyle = headerWarn ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||
g.textAlign = 'center';
|
||
if (CONFIG.PPM_DIN_HEADER_SHOW_VALUE) {
|
||
const yText = rect.y - 12;
|
||
const fmt = (v) => {
|
||
const sign = v >= 0 ? '+' : '-';
|
||
return `${sign}${Math.abs(v).toFixed(1)}`;
|
||
};
|
||
const centerLeft = leftX + barW / 2;
|
||
const centerRight = rightX + barW / 2;
|
||
const isRedL = rawL > RED_START;
|
||
const isRedR = rawR > RED_START;
|
||
g.textAlign = 'center';
|
||
g.fillStyle = isRedL ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||
g.fillText(fmt(smooth.L), centerLeft, yText);
|
||
g.fillStyle = colNorm;
|
||
g.fillText('|', centerX, yText);
|
||
g.fillStyle = isRedR ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
||
g.fillText(fmt(smooth.R), centerRight, yText);
|
||
} else {
|
||
g.fillText('PPM (DIN)', centerX, rect.y - 12);
|
||
}
|
||
g.font = prevFont;
|
||
g.restore();
|
||
|
||
const ppmL = mapClamp(dbValL, PPM_BOTTOM, PPM_TOP);
|
||
const ppmR = mapClamp(dbValR, PPM_BOTTOM, PPM_TOP);
|
||
const baseDbfs = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -15;
|
||
const loudOff = Number(CONFIG.PPM_DIN_LOUDNESS_OFFSET_DB) || 0;
|
||
const loudnessDisplayL = (CONFIG.PPM_DIN_LOUDNESS_BOXES && Number.isFinite(shared.loudnessDbfs?.L))
|
||
? (shared.loudnessDbfs.L - baseDbfs) + (shared.offset || 0) + __offCorr + loudOff
|
||
: null;
|
||
const loudnessDisplayR = (CONFIG.PPM_DIN_LOUDNESS_BOXES && Number.isFinite(shared.loudnessDbfs?.R))
|
||
? (shared.loudnessDbfs.R - baseDbfs) + (shared.offset || 0) + __offCorr + loudOff
|
||
: null;
|
||
|
||
const yBottom = mapY(PPM_BOTTOM);
|
||
const yRed = mapY(RED_START);
|
||
const yTop = mapY(PPM_TOP);
|
||
const innerW = Math.max(10, barW - 2);
|
||
const drawBar = (x0, dbVal) => {
|
||
const yVal = mapY(dbVal);
|
||
|
||
if (dbVal > RED_START) {
|
||
if (redOnly) {
|
||
const yNormTop = Math.min(yRed, yBottom);
|
||
if (yBottom - yNormTop > 0) {
|
||
g.fillStyle = colNorm;
|
||
g.fillRect(x0 + 1, yNormTop, innerW, yBottom - yNormTop);
|
||
}
|
||
const yWarnTop = Math.min(yVal, yRed);
|
||
if (yRed - yWarnTop > 0) {
|
||
g.fillStyle = colWarn;
|
||
g.fillRect(x0 + 1, yWarnTop, innerW, yRed - yWarnTop);
|
||
}
|
||
} else {
|
||
if (yBottom - yVal > 0) {
|
||
g.fillStyle = colWarn;
|
||
g.fillRect(x0 + 1, yVal, innerW, yBottom - yVal);
|
||
}
|
||
}
|
||
} else {
|
||
if (yBottom - yVal > 0) {
|
||
g.fillStyle = colNorm;
|
||
g.fillRect(x0 + 1, yVal, innerW, yBottom - yVal);
|
||
}
|
||
}
|
||
g.globalAlpha = 0.12;
|
||
g.fillStyle = '#ffffff';
|
||
g.fillRect(x0 + 1, yVal, innerW, 2);
|
||
g.globalAlpha = 1;
|
||
};
|
||
|
||
drawBar(leftX, ppmL);
|
||
drawBar(rightX, ppmR);
|
||
|
||
// Peak-Hold (optional, rein visuell; eigentliche Ballistik kommt aus dem Worklet)
|
||
const holdMs = Number.isFinite(CONFIG.PPM_DIN_HOLD_MS)
|
||
? Math.max(0, CONFIG.PPM_DIN_HOLD_MS)
|
||
: (shared?._holdCfg?.holdMs ?? DEFAULT_HOLD_MS);
|
||
const holdDecay = Number.isFinite(CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S)
|
||
? CONFIG.PPM_DIN_HOLD_DECAY_DB_PER_S
|
||
: (Number.isFinite(CONFIG.PPM_DIN_DECAY_DB_PER_S) ? CONFIG.PPM_DIN_DECAY_DB_PER_S : DEFAULT_DECAY_DB_PER_S);
|
||
if (holdMs > 0) {
|
||
if (!shared.hold) shared.hold = { L: PPM_BOTTOM, R: PPM_BOTTOM };
|
||
const cfgChanged = !shared._holdState
|
||
|| !shared._holdCfg
|
||
|| shared._holdCfg.holdMs !== holdMs
|
||
|| shared._holdCfg.decayDbPerS !== holdDecay;
|
||
if (cfgChanged) {
|
||
const resetNow = performance.now();
|
||
shared._holdState = {
|
||
L: createPeakHoldState(shared.hold.L ?? PPM_BOTTOM, resetNow, holdMs),
|
||
R: createPeakHoldState(shared.hold.R ?? PPM_BOTTOM, resetNow, holdMs),
|
||
};
|
||
shared._holdCfg = { holdMs, decayDbPerS: holdDecay };
|
||
}
|
||
const nowHold = performance.now();
|
||
const holdOpts = {
|
||
holdMs,
|
||
decayDbPerS: holdDecay,
|
||
floor: PPM_BOTTOM,
|
||
riseThreshold: 0.2,
|
||
};
|
||
shared.hold.L = stepPeakHold(ppmL, shared._holdState.L, nowHold, holdOpts);
|
||
shared.hold.R = stepPeakHold(ppmR, shared._holdState.R, nowHold, holdOpts);
|
||
g.save();
|
||
g.fillStyle = '#ffffff';
|
||
g.fillRect(leftX + 1, mapY(shared.hold.L) - 1, innerW, 2);
|
||
g.fillRect(rightX + 1, mapY(shared.hold.R) - 1, innerW, 2);
|
||
g.restore();
|
||
}
|
||
|
||
const drawLoudBox = (x0, loudVal) => {
|
||
if (!Number.isFinite(loudVal)) return;
|
||
const boxSize = barW; // exakt so breit wie der sichtbare Balken (keine 1px-Ränder)
|
||
// Messwert sitzt an der oberen Kante; Skala wird nach unten verlängert.
|
||
// Bei -50 dB (Bottom) liegt die Oberkante im sichtbaren Bereich,
|
||
// bei -70 dB ist sie eine volle Meterhöhe weiter unten (außerhalb des Sichtfelds).
|
||
const spanExt = Math.max(1e-6, PPM_BOTTOM - EXT_BOTTOM);
|
||
const yBottom = mapY(PPM_BOTTOM);
|
||
const yLoud = (loudVal < PPM_BOTTOM)
|
||
? yBottom + ((PPM_BOTTOM - loudVal) / spanExt) * rect.h
|
||
: mapY(loudVal);
|
||
// Zeichne nur den sichtbaren Teil: Box-Oberkante = Pegel, Unterkante nicht unter yBottom.
|
||
const boxY = yLoud;
|
||
const visibleHeight = Math.max(0, Math.min(boxSize, yBottom - boxY));
|
||
if (visibleHeight <= 0) return;
|
||
const x = x0;
|
||
g.save();
|
||
g.fillStyle = '#0b6ea8';
|
||
g.globalAlpha = 0.92;
|
||
g.fillRect(x, boxY, boxSize, visibleHeight);
|
||
g.globalAlpha = 1;
|
||
g.strokeStyle = '#094b73';
|
||
g.lineWidth = 1;
|
||
g.strokeRect(x + 0.5, boxY + 0.5, boxSize - 1, visibleHeight - 1);
|
||
g.restore();
|
||
};
|
||
|
||
drawLoudBox(leftX, loudnessDisplayL);
|
||
drawLoudBox(rightX, loudnessDisplayR);
|
||
|
||
drawPpmDinStaticOverlay(g, shared, rect, CONFIG, {
|
||
leftX,
|
||
rightX,
|
||
barW,
|
||
innerW,
|
||
scaleX,
|
||
scaleW,
|
||
centerX,
|
||
yRed,
|
||
yTop,
|
||
colWarn,
|
||
}, mapY, PPM_TOP, PPM_BOTTOM);
|
||
}
|
||
|
||
function drawPpmDinStaticOverlay(g, shared, rect, CONFIG, geom, mapY, PPM_TOP, PPM_BOTTOM) {
|
||
const {
|
||
leftX,
|
||
rightX,
|
||
barW,
|
||
innerW,
|
||
scaleX,
|
||
scaleW,
|
||
centerX,
|
||
yRed,
|
||
yTop,
|
||
colWarn,
|
||
} = geom;
|
||
const topPad = 10;
|
||
const sidePad = 0;
|
||
const layerX = rect.x - sidePad;
|
||
const layerY = rect.y - topPad;
|
||
const layerW = rect.w + sidePad * 2;
|
||
const layerH = rect.h + 24 + topPad;
|
||
const key = [
|
||
'scale-font-header-v1-top-pad',
|
||
Math.round(rect.w),
|
||
Math.round(rect.h),
|
||
topPad,
|
||
sidePad,
|
||
CONFIG.METER_BAR_THIN || 0.55,
|
||
CONFIG.PPM_DIN_MODE || 'al_minus9',
|
||
CONFIG.AL_MARKERS_ENABLED === false ? 0 : 1,
|
||
CONFIG.PPM_DIN_TOP ?? 5,
|
||
CONFIG.PPM_DIN_BOTTOM ?? -50,
|
||
CONFIG.PPM_DIN_RED_START ?? 0,
|
||
METER_HEADER_FONT,
|
||
colWarn,
|
||
].join('|');
|
||
drawCachedStaticLayer(g, shared, 'ppm-din-static', key, layerX, layerY, layerW, layerH, (cg) => {
|
||
cg.save();
|
||
cg.translate(-layerX, -layerY);
|
||
drawWarningEdges(cg, leftX, barW, rightX, centerX, yRed, yTop, colWarn);
|
||
drawBarTicks(cg, leftX, rightX, innerW, mapY, PPM_TOP, PPM_BOTTOM, colWarn, CONFIG);
|
||
drawCenterScale(cg, { x: scaleX, y: rect.y, w: scaleW, h: rect.h }, centerX, mapY, PPM_TOP, PPM_BOTTOM);
|
||
drawPercentScale(cg, leftX, rightX, barW, mapY, PPM_BOTTOM);
|
||
cg.fillStyle = LABEL_COLOR;
|
||
cg.textAlign = 'center';
|
||
const baseY = rect.y + rect.h + 16;
|
||
cg.fillText('L', leftX + barW / 2, baseY);
|
||
cg.fillText('R', rightX + barW / 2, baseY);
|
||
const prevFontLabels = cg.font;
|
||
cg.fillStyle = '#ffffff';
|
||
cg.font = 'bold 8.4px ui-monospace, monospace';
|
||
cg.fillText('dB', centerX, baseY);
|
||
cg.font = prevFontLabels;
|
||
cg.restore();
|
||
});
|
||
}
|
||
|
||
function mapClamp(db, bottom, top) {
|
||
return Math.max(bottom, Math.min(top, db));
|
||
}
|
||
|
||
function drawWarningEdges(g, leftX, barW, rightX, centerX, yWarn, yTop, color) {
|
||
const innerRight = leftX + barW;
|
||
const gapL = Math.abs(centerX - innerRight);
|
||
const insetL = Math.max(1, Math.floor(gapL * 0.35));
|
||
const xL = Math.round(innerRight + insetL) + 0.5;
|
||
|
||
const innerLeft = rightX;
|
||
const gapR = Math.abs(centerX - innerLeft);
|
||
const insetR = Math.max(1, Math.floor(gapR * 0.35));
|
||
const xR = Math.round(innerLeft - insetR) + 0.5;
|
||
|
||
const y1 = Math.min(yWarn, yTop);
|
||
const y2 = Math.max(yWarn, yTop);
|
||
|
||
g.save();
|
||
g.strokeStyle = color;
|
||
g.lineWidth = 1;
|
||
g.beginPath(); g.moveTo(xL, y1); g.lineTo(xL, y2); g.stroke();
|
||
g.beginPath(); g.moveTo(xR, y1); g.lineTo(xR, y2); g.stroke();
|
||
g.restore();
|
||
}
|
||
|
||
function drawBarTicks(g, leftX, rightX, widthPx, mapY, top, bottom, warnColor, CONFIG) {
|
||
g.save();
|
||
g.lineWidth = 1;
|
||
|
||
const DEFAULT_COLOR = 'rgb(0,0,255)';
|
||
const MAJOR_INSET = 2;
|
||
const MINOR_FRAC = 0.45;
|
||
|
||
const cxL = leftX + 1 + widthPx / 2;
|
||
const cxR = rightX + 1 + widthPx / 2;
|
||
|
||
const drawPair = (yPix, width, color = DEFAULT_COLOR) => {
|
||
g.save();
|
||
g.strokeStyle = color;
|
||
const span = Math.max(1, width);
|
||
const x1L = Math.round(cxL - span / 2) + 0.5;
|
||
const x2L = Math.round(cxL + span / 2) + 0.5;
|
||
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
||
const x1R = Math.round(cxR - span / 2) + 0.5;
|
||
const x2R = Math.round(cxR + span / 2) + 0.5;
|
||
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
||
g.restore();
|
||
};
|
||
|
||
const minorWidth = Math.max(1, Math.floor(widthPx * MINOR_FRAC));
|
||
const majorWidth = Math.max(1, widthPx - 4 * MAJOR_INSET);
|
||
|
||
const majors = MAJOR_TICKS_DB
|
||
.filter((db) => db >= bottom && db <= top)
|
||
.slice()
|
||
.sort((a, b) => a - b);
|
||
|
||
for (const db of majors) {
|
||
const y = Math.round(mapY(db)) + 0.5;
|
||
drawPair(y, majorWidth);
|
||
}
|
||
|
||
const warn = warnColor || DEFAULT_COLOR;
|
||
const showAl = CONFIG?.AL_MARKERS_ENABLED !== false;
|
||
const minors = MINOR_TICKS
|
||
.filter((tick) => tick.db >= bottom && tick.db <= top)
|
||
.slice()
|
||
.sort((a, b) => a.db - b.db);
|
||
|
||
const dynWarnDb = showAl
|
||
? ((CONFIG?.PPM_DIN_MODE === 'al_minus6') ? -6 : -9)
|
||
: null;
|
||
|
||
for (const tick of minors) {
|
||
const y = Math.round(mapY(tick.db)) + 0.5;
|
||
const isDynWarn = dynWarnDb !== null && tick.db === dynWarnDb;
|
||
const color = (tick.color === 'warn' || isDynWarn) ? warn : DEFAULT_COLOR;
|
||
drawPair(y, minorWidth, color);
|
||
}
|
||
g.restore();
|
||
}
|
||
|
||
function drawCenterScale(g, rect, centerX, mapY, top, bottom) {
|
||
drawHairlineGrid(g, rect, mapY, top, bottom, 1);
|
||
|
||
g.save();
|
||
const prevFont = g.font;
|
||
g.font = METER_HEADER_FONT;
|
||
g.fillStyle = LABEL_COLOR;
|
||
g.textAlign = 'center';
|
||
g.textBaseline = 'alphabetic';
|
||
|
||
const majors = MAJOR_TICKS_DB
|
||
.filter((db) => db >= bottom && db <= top)
|
||
.slice()
|
||
.sort((a, b) => b - a);
|
||
|
||
for (const db of majors) {
|
||
const y = mapY(db);
|
||
if (y < rect.y || y > rect.y + rect.h) continue;
|
||
const label = db > 0 ? `+${db}` : `${db}`;
|
||
g.fillText(label, centerX, y + 5);
|
||
}
|
||
|
||
g.font = prevFont;
|
||
g.restore();
|
||
}
|
||
|
||
function drawPercentScale(g, leftX, rightX, barW, mapY, bottomDb) {
|
||
g.save();
|
||
g.fillStyle = '#ffb347';
|
||
const prevFont = g.font;
|
||
g.font = 'bold 8px ui-monospace, monospace';
|
||
const marks = PERCENT_MARKS.slice().sort((a, b) => a.db - b.db);
|
||
|
||
g.textAlign = 'right';
|
||
for (const mark of marks) {
|
||
g.fillText(mark.label, leftX - 1, mapY(mark.db) + 4);
|
||
}
|
||
|
||
g.textAlign = 'left';
|
||
const rightTextX = rightX + barW + 1;
|
||
for (const mark of marks) {
|
||
g.fillText(mark.label, rightTextX, mapY(mark.db) + 4);
|
||
}
|
||
|
||
const percentYOffset = 12;
|
||
g.textAlign = 'right';
|
||
g.fillText('%', leftX - 1, mapY(bottomDb) + percentYOffset);
|
||
g.textAlign = 'left';
|
||
g.fillText('%', rightTextX, mapY(bottomDb) + percentYOffset);
|
||
g.font = prevFont;
|
||
g.restore();
|
||
}
|
||
|
||
function smoothHeader(shared, rawL, rawR, alpha = 0.2) {
|
||
if (!shared._header) {
|
||
shared._header = { L: rawL, R: rawR };
|
||
return shared._header;
|
||
}
|
||
shared._header.L += alpha * (rawL - shared._header.L);
|
||
shared._header.R += alpha * (rawR - shared._header.R);
|
||
return shared._header;
|
||
}
|