/** RX quality vs distance chart for TX/RX track compare. */ (function (global) { 'use strict'; const METER_STEP = 10; const QUALITY_BUCKET = 10; function niceMeterStep() { return METER_STEP; } function niceMeterTicks(minD, maxD) { const span = Math.max(maxD - minD, 1); const step = METER_STEP; const ticks = []; const start = Math.ceil(minD / step) * step; for (let d = start; d <= maxD + step * 0.01; d += step) { if (d >= minD - step * 0.01 && d <= maxD + step * 0.01) { ticks.push(d); } } if (!ticks.length || ticks[0] > minD + 0.5) { ticks.unshift(minD); } if (ticks[ticks.length - 1] < maxD - 0.5) { ticks.push(maxD); } return ticks; } function qualityBucket(quality) { const q = Math.round(Number(quality) / QUALITY_BUCKET) * QUALITY_BUCKET; return Math.min(100, Math.max(0, q)); } function colorWithAlpha(hex, alpha) { if (!hex || typeof hex !== 'string') { return `rgba(136, 136, 136, ${alpha})`; } const h = hex.replace('#', ''); if (h.length !== 6) { return `rgba(136, 136, 136, ${alpha})`; } const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } function drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span) { const ticks = niceMeterTicks(minD, minD + span); const minLabelGap = 28; const maxLabels = Math.max(2, Math.floor(plotW / minLabelGap)); const labelEvery = ticks.length > maxLabels ? Math.ceil(ticks.length / maxLabels) : 1; ctx.strokeStyle = '#2a2a3a'; ctx.lineWidth = 1; ticks.forEach((d, i) => { const x = margin.l + ((d - minD) / span) * plotW; ctx.beginPath(); ctx.moveTo(x, margin.t); ctx.lineTo(x, margin.t + plotH); ctx.stroke(); if (i % labelEvery === 0 || i === ticks.length - 1) { const label = `${Math.round(d)}m`; ctx.fillStyle = '#888'; ctx.font = '9px system-ui'; const tw = ctx.measureText(label).width; let lx = x - tw / 2; lx = Math.max(margin.l, Math.min(lx, margin.l + plotW - tw)); ctx.fillText(label, lx, h - 4); } }); } function histogramInBin(qualities) { const hist = new Map(); for (const q of qualities) { const bucket = qualityBucket(q); hist.set(bucket, (hist.get(bucket) || 0) + 1); } return hist; } /** Solid horizontal quality distribution inside one distance bin (0% left → 100% right). */ function drawBinQualityStrip(ctx, x0, barW, plotTop, plotH, hist, maxInBin, qualityColor, highlight) { const cellW = barW / (100 / QUALITY_BUCKET); const buckets = Array.from(hist.keys()).sort((a, b) => a - b); for (const bq of buckets) { const n = hist.get(bq); if (!n) continue; const frac = n / maxInBin; const cellX = x0 + (bq / 100) * barW; const fillH = Math.max(1, frac * plotH); const y = plotTop + plotH - fillH; const base = qualityColor ? qualityColor(bq) : '#888888'; const alpha = highlight ? 0.35 + 0.65 * frac : 0.18 + 0.72 * frac; ctx.fillStyle = colorWithAlpha(base, alpha); ctx.fillRect(cellX, y, Math.max(1, cellW + 0.5), fillH); } // Smooth overlap between neighbours for a continuous look if (buckets.length >= 2) { for (let i = 0; i < buckets.length - 1; i++) { const q0 = buckets[i]; const q1 = buckets[i + 1]; const n0 = hist.get(q0); const n1 = hist.get(q1); const h0 = (n0 / maxInBin) * plotH; const h1 = (n1 / maxInBin) * plotH; const x0p = x0 + (q0 / 100) * barW; const x1p = x0 + (q1 / 100) * barW; const midX = (x0p + x1p + cellW) / 2; const midH = Math.max(h0, h1) * 0.55; const midQ = (q0 + q1) / 2; const base = qualityColor ? qualityColor(midQ) : '#888888'; ctx.fillStyle = colorWithAlpha(base, highlight ? 0.22 : 0.12); ctx.fillRect(midX - cellW * 0.4, plotTop + plotH - midH, cellW * 0.8, midH); } } } function drawQualityDistChart(canvas, samples, qualityColor, highlightDist) { if (!canvas) return; const ctx = canvas.getContext('2d'); const w = canvas.clientWidth || 280; const h = canvas.clientHeight || 140; if (canvas.width !== w) canvas.width = w; if (canvas.height !== h) canvas.height = h; ctx.fillStyle = '#0a0a14'; ctx.fillRect(0, 0, w, h); if (!samples?.length) { ctx.fillStyle = '#888'; ctx.font = '11px system-ui'; ctx.fillText('нет данных качества', 12, h / 2); return; } const dists = samples.map(s => s.distM); const minD = Math.min(...dists); const maxD = Math.max(...dists); const span = Math.max(maxD - minD, 1); const binCount = Math.max(1, Math.ceil(span / METER_STEP)); const binWDist = span / binCount; const bins = Array.from({ length: binCount }, (_, i) => ({ min: minD + i * binWDist, max: minD + (i + 1) * binWDist, qualities: [], })); for (const s of samples) { let idx = Math.floor((s.distM - minD) / binWDist); if (idx >= binCount) idx = binCount - 1; if (idx < 0) idx = 0; bins[idx].qualities.push(s.quality); } const margin = { l: 36, r: 8, t: 16, b: 34 }; const plotW = w - margin.l - margin.r; const plotH = h - margin.t - margin.b; const barW = plotW / binCount; const plotTop = margin.t; ctx.strokeStyle = '#333'; ctx.beginPath(); ctx.moveTo(margin.l, plotTop); ctx.lineTo(margin.l, plotTop + plotH); ctx.lineTo(margin.l + plotW, plotTop + plotH); ctx.stroke(); ctx.fillStyle = '#888'; ctx.font = '9px system-ui'; ctx.fillText('0', 2, plotTop + plotH); ctx.fillText('max', 2, plotTop + 8); drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span); ctx.fillStyle = '#666'; ctx.font = '8px system-ui'; ctx.fillText('RX 0%', margin.l, h - 14); const rx100 = 'RX 100%'; const rx100w = ctx.measureText(rx100).width; ctx.fillText(rx100, margin.l + plotW - rx100w, h - 14); ctx.fillText('плотность ↑', 2, plotTop + plotH / 2 + 12); bins.forEach((b, i) => { const x0 = margin.l + i / binCount * plotW; const highlight = highlightDist != null && highlightDist >= b.min && highlightDist < b.max; const count = b.qualities.length; if (i > 0) { ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x0, plotTop); ctx.lineTo(x0, plotTop + plotH); ctx.stroke(); } if (count === 0) { ctx.fillStyle = 'rgba(255,255,255,0.04)'; ctx.fillRect(x0, plotTop + plotH - 1, barW, 1); return; } const hist = histogramInBin(b.qualities); let maxInBin = 1; hist.forEach(c => { if (c > maxInBin) maxInBin = c; }); if (highlight) { ctx.fillStyle = 'rgba(255,255,255,0.05)'; ctx.fillRect(x0, plotTop, barW, plotH); } drawBinQualityStrip( ctx, x0, barW, plotTop, plotH, hist, maxInBin, qualityColor, highlight); if (highlight) { ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.strokeRect(x0 + 0.5, plotTop + 0.5, barW - 1, plotH - 1); } }); } global.QualityViz = { drawQualityDistChart, }; })(typeof window !== 'undefined' ? window : globalThis);