/** RX quality vs distance chart for TX/RX track compare. */ (function (global) { 'use strict'; const QUALITY_BUCKET = 10; function niceMeterStep(span) { if (span <= 50) return 10; if (span <= 150) return 25; if (span <= 400) return 50; return 100; } function niceMeterTicks(minD, maxD) { const span = Math.max(maxD - minD, 1); const step = niceMeterStep(span); 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 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; } 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 meterStep = niceMeterStep(span); const binCount = Math.min(60, Math.max(1, Math.ceil(span / meterStep))); 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: 30 }; const plotW = w - margin.l - margin.r; const plotH = h - margin.t - margin.b; const barW = plotW / binCount; const markerH = Math.max(3, Math.min(6, barW * 0.45)); ctx.strokeStyle = '#333'; ctx.beginPath(); ctx.moveTo(margin.l, margin.t); ctx.lineTo(margin.l, margin.t + plotH); ctx.lineTo(margin.l + plotW, margin.t + plotH); ctx.stroke(); ctx.fillStyle = '#888'; ctx.font = '9px system-ui'; ctx.fillText('0%', 2, margin.t + plotH); ctx.fillText('100%', 2, margin.t + 8); drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span); bins.forEach((b, i) => { const x0 = margin.l + i / binCount * plotW; const cx = x0 + barW / 2; const highlight = highlightDist != null && highlightDist >= b.min && highlightDist < b.max; const count = b.qualities.length; if (count === 0) { ctx.fillStyle = 'rgba(255,255,255,0.06)'; ctx.fillRect(x0, margin.t + 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.06)'; ctx.fillRect(x0, margin.t, barW, plotH); } const sortedBuckets = Array.from(hist.keys()).sort((a, b) => a - b); for (const bq of sortedBuckets) { const n = hist.get(bq); const frac = n / maxInBin; const segW = Math.max(2, frac * (barW - 1)); const y = margin.t + plotH - (bq / 100) * plotH; const col = qualityColor ? qualityColor(bq) : '#888'; ctx.fillStyle = col; ctx.globalAlpha = highlight ? 0.95 : (0.45 + 0.55 * frac); ctx.fillRect(cx - segW / 2, y - markerH / 2, segW, markerH); ctx.globalAlpha = 1; } if (highlight) { ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.lineWidth = 1.5; ctx.strokeRect(x0 + 0.5, margin.t + 0.5, barW - 1, plotH - 1); } }); ctx.fillStyle = 'rgba(255,255,255,0.18)'; samples.forEach(s => { const x = margin.l + ((s.distM - minD) / span) * plotW; const y = margin.t + plotH - (s.quality / 100) * plotH; ctx.beginPath(); ctx.arc(x, y, 1.2, 0, Math.PI * 2); ctx.fill(); }); } global.QualityViz = { drawQualityDistChart, }; })(typeof window !== 'undefined' ? window : globalThis);