generated from Grigo/AndroidTemplate
183 lines
5.9 KiB
JavaScript
183 lines
5.9 KiB
JavaScript
/** 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);
|