/** RX quality vs distance chart for TX/RX track compare. */ (function (global) { 'use strict'; 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 drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span, w) { 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 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.min(40, Math.max(8, Math.ceil(span / 5))); const binW = span / binCount; const bins = Array.from({ length: binCount }, (_, i) => ({ min: minD + i * binW, max: minD + (i + 1) * binW, qualities: [], })); for (const s of samples) { let idx = Math.floor((s.distM - minD) / binW); 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 distBandH = Math.round(plotH * 0.25); const qualBandH = plotH - distBandH - 4; const qualTop = margin.t; const distTop = margin.t + qualBandH + 4; const axisY = margin.t + plotH; ctx.strokeStyle = '#333'; ctx.beginPath(); ctx.moveTo(margin.l, qualTop); ctx.lineTo(margin.l, axisY); ctx.lineTo(margin.l + plotW, axisY); ctx.stroke(); ctx.fillStyle = '#888'; ctx.font = '9px system-ui'; ctx.fillText('0%', 2, distTop - 2); ctx.fillText('100%', 2, qualTop + 8); ctx.fillStyle = '#666'; ctx.fillText('точки', 2, distTop + distBandH - 2); drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span, w); const barW = plotW / binCount; let maxCount = 1; bins.forEach(b => { if (b.qualities.length > maxCount) maxCount = b.qualities.length; }); bins.forEach((b, i) => { const x = margin.l + i / binCount * plotW; const count = b.qualities.length; const highlight = highlightDist != null && highlightDist >= b.min && highlightDist < b.max; if (count === 0) { ctx.fillStyle = 'rgba(255,255,255,0.08)'; ctx.fillRect(x, distTop + distBandH - 1, barW, 1); } else { const distBarH = (count / maxCount) * distBandH; const intensity = 0.35 + 0.65 * (count / maxCount); ctx.fillStyle = `rgba(74, 85, 104, ${intensity})`; ctx.fillRect(x, distTop + distBandH - distBarH, barW, distBarH); const avg = b.qualities.reduce((a, v) => a + v, 0) / count; const qualBarH = (avg / 100) * qualBandH; const y = qualTop + qualBandH - qualBarH; const col = qualityColor ? qualityColor(avg) : '#888'; ctx.fillStyle = col; ctx.globalAlpha = highlight ? 1 : 0.75; ctx.fillRect(x, y, barW, qualBarH); ctx.globalAlpha = 1; if (highlight) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.strokeRect(x, y, barW, qualBarH); ctx.strokeRect(x, distTop + distBandH - distBarH, barW, distBarH); } } }); ctx.fillStyle = 'rgba(255,255,255,0.25)'; samples.forEach(s => { const x = margin.l + ((s.distM - minD) / span) * plotW; const y = qualTop + qualBandH - (s.quality / 100) * qualBandH; ctx.beginPath(); ctx.arc(x, y, 1.5, 0, Math.PI * 2); ctx.fill(); }); } global.QualityViz = { drawQualityDistChart, }; })(typeof window !== 'undefined' ? window : globalThis);