/** Polar rose and quality-vs-distance charts for TX/RX track compare. */ (function (global) { 'use strict'; function bearingDeg(lat1, lon1, lat2, lon2) { const toRad = d => d * Math.PI / 180; const toDeg = r => r * 180 / Math.PI; const dLon = toRad(lon2 - lon1); const y = Math.sin(dLon) * Math.cos(toRad(lat2)); const x = Math.cos(toRad(lat1)) * Math.sin(toRad(lat2)) - Math.sin(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.cos(dLon); return (toDeg(Math.atan2(y, x)) + 360) % 360; } function drawQualityRose(canvas, samples, qualityColor) { if (!canvas) return; const ctx = canvas.getContext('2d'); const w = canvas.clientWidth || 140; 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); const sectors = 16; const cx = w / 2; const cy = h / 2; const maxR = Math.min(w, h) * 0.38; if (!samples?.length) { ctx.fillStyle = '#888'; ctx.font = '11px system-ui'; ctx.textAlign = 'center'; ctx.fillText('нет данных качества', cx, cy); return; } const bins = Array.from({ length: sectors }, () => ({ sum: 0, count: 0 })); const step = 360 / sectors; for (const s of samples) { const idx = Math.floor(((s.bearing % 360) + 360) % 360 / step) % sectors; bins[idx].sum += s.quality; bins[idx].count += 1; } const maxCount = Math.max(1, ...bins.map(b => b.count)); ctx.strokeStyle = '#333'; ctx.beginPath(); ctx.arc(cx, cy, maxR, 0, Math.PI * 2); ctx.stroke(); for (let i = 0; i < sectors; i++) { const start = (i * step - 90) * Math.PI / 180; const end = ((i + 1) * step - 90) * Math.PI / 180; const b = bins[i]; if (!b.count) continue; const avgQ = b.sum / b.count; const r = maxR * (0.15 + 0.85 * (b.count / maxCount)); const col = qualityColor ? qualityColor(avgQ) : '#888'; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, r, start, end); ctx.closePath(); ctx.fillStyle = col; ctx.globalAlpha = 0.85; ctx.fill(); ctx.globalAlpha = 1; ctx.strokeStyle = '#222'; ctx.lineWidth = 0.5; ctx.stroke(); } ctx.fillStyle = '#aaa'; ctx.font = '9px system-ui'; ctx.textAlign = 'center'; ctx.fillText('N', cx, cy - maxR - 4); ctx.fillText('S', cx, cy + maxR + 10); ctx.textAlign = 'left'; ctx.fillText('E', cx + maxR + 4, cy + 3); ctx.textAlign = 'right'; ctx.fillText('W', cx - maxR - 4, cy + 3); ctx.fillStyle = '#888'; ctx.font = '8px system-ui'; ctx.textAlign = 'center'; ctx.fillText('длина ∝ число точек', cx, 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(20, Math.max(5, Math.ceil(span / 10))); 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: 22 }; const plotW = w - margin.l - margin.r; const plotH = h - margin.t - margin.b; 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); ctx.fillText(`${Math.round(minD)}m`, margin.l, h - 2); ctx.fillText(`${Math.round(maxD)}m`, margin.l + plotW - 24, h - 2); ctx.fillStyle = '#ccc'; ctx.font = '10px system-ui'; ctx.fillText('RX Quality vs расстояние', margin.l, margin.t - 4); const barW = plotW / binCount * 0.75; bins.forEach((b, i) => { if (!b.qualities.length) return; const avg = b.qualities.reduce((a, v) => a + v, 0) / b.qualities.length; const cx = margin.l + (i + 0.5) / binCount * plotW; const barH = (avg / 100) * plotH; const x = cx - barW / 2; const y = margin.t + plotH - barH; const col = qualityColor ? qualityColor(avg) : '#888'; const highlight = highlightDist != null && highlightDist >= b.min && highlightDist < b.max; ctx.fillStyle = col; ctx.globalAlpha = highlight ? 1 : 0.75; ctx.fillRect(x, y, barW, barH); ctx.globalAlpha = 1; if (highlight) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.strokeRect(x, y, barW, barH); } }); ctx.fillStyle = 'rgba(255,255,255,0.25)'; 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.5, 0, Math.PI * 2); ctx.fill(); }); } global.QualityViz = { bearingDeg, drawQualityRose, drawQualityDistChart, }; })(typeof window !== 'undefined' ? window : globalThis);