Files
LoraMapTester/server/static/quality-viz.js
T
2026-06-22 10:38:05 +03:00

190 lines
5.8 KiB
JavaScript

/** 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);