Files
Phoenix/www/meters/ppm_din.js
T

572 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.5; // RTW/DIN: 20 dB in 1.5 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
// selected alignment profile maps 0 dBu to either -6 dB or legacy -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;
}