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