generated from Grigo/AndroidTemplate
491 lines
15 KiB
JavaScript
491 lines
15 KiB
JavaScript
/** RX quality vs distance chart for TX/RX track compare. */
|
|
(function (global) {
|
|
'use strict';
|
|
|
|
const METER_STEPS = [5, 10, 20, 25, 50, 100];
|
|
const QUALITY_BUCKET = 10;
|
|
const DEFAULT_METER_STEP = 10;
|
|
const HIT_RADIUS_PX = 14;
|
|
|
|
let meterStep = DEFAULT_METER_STEP;
|
|
const attachedCanvases = new WeakSet();
|
|
const chartLayout = new WeakMap();
|
|
|
|
function getMeterStep() {
|
|
return meterStep;
|
|
}
|
|
|
|
function setMeterStep(step) {
|
|
if (METER_STEPS.includes(step)) {
|
|
meterStep = step;
|
|
}
|
|
}
|
|
|
|
function niceMeterTicks(minD, maxD, step) {
|
|
const span = Math.max(maxD - minD, 1);
|
|
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 qualitySlotIndex(bq) {
|
|
const slots = 100 / QUALITY_BUCKET;
|
|
return Math.min(slots - 1, Math.max(0, Math.floor(bq / QUALITY_BUCKET)));
|
|
}
|
|
|
|
function parseColor(color) {
|
|
if (!color || typeof color !== 'string') {
|
|
return { r: 136, g: 136, b: 136 };
|
|
}
|
|
const rgb = color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i);
|
|
if (rgb) {
|
|
return { r: +rgb[1], g: +rgb[2], b: +rgb[3] };
|
|
}
|
|
const h = color.replace('#', '');
|
|
if (h.length === 6) {
|
|
return {
|
|
r: parseInt(h.slice(0, 2), 16),
|
|
g: parseInt(h.slice(2, 4), 16),
|
|
b: parseInt(h.slice(4, 6), 16),
|
|
};
|
|
}
|
|
return { r: 136, g: 136, b: 136 };
|
|
}
|
|
|
|
function colorWithAlpha(color, alpha) {
|
|
const { r, g, b } = parseColor(color);
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
}
|
|
|
|
function normalizeOptions(highlightDistOrOptions) {
|
|
if (highlightDistOrOptions != null && typeof highlightDistOrOptions === 'object') {
|
|
return highlightDistOrOptions;
|
|
}
|
|
return { highlightDist: highlightDistOrOptions };
|
|
}
|
|
|
|
function isActiveSample(sample, activeSample) {
|
|
if (!sample || !activeSample) return false;
|
|
if (activeSample.stepIndex != null && sample.stepIndex === activeSample.stepIndex) {
|
|
return true;
|
|
}
|
|
if (activeSample.progress != null && sample.progress != null
|
|
&& Math.abs(sample.progress - activeSample.progress) < 1e-6) {
|
|
return true;
|
|
}
|
|
if (activeSample.t != null && sample.t != null
|
|
&& Math.abs(sample.t - activeSample.t) < 0.05) {
|
|
return true;
|
|
}
|
|
return sample === activeSample;
|
|
}
|
|
|
|
function sampleXY(sample, layout) {
|
|
const x = layout.margin.l + ((sample.distM - layout.minD) / layout.span) * layout.plotW;
|
|
const y = layout.plotBottom - (sample.quality / 100) * layout.plotH;
|
|
return { x, y };
|
|
}
|
|
|
|
function pickSampleAt(canvas, clientX, clientY) {
|
|
const layout = chartLayout.get(canvas);
|
|
if (!layout?.samples?.length) return null;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = canvas.width / rect.width;
|
|
const scaleY = canvas.height / rect.height;
|
|
const px = (clientX - rect.left) * scaleX;
|
|
const py = (clientY - rect.top) * scaleY;
|
|
const hitR = HIT_RADIUS_PX * Math.max(scaleX, scaleY);
|
|
|
|
let best = null;
|
|
let bestD = hitR;
|
|
for (const s of layout.samples) {
|
|
const { x, y } = sampleXY(s, layout);
|
|
const d = Math.hypot(px - x, py - y);
|
|
if (d < bestD) {
|
|
bestD = d;
|
|
best = s;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span, step, reserveRight) {
|
|
const ticks = niceMeterTicks(minD, minD + span, step);
|
|
const minLabelGap = 28;
|
|
const maxLabels = Math.max(2, Math.floor(plotW / minLabelGap));
|
|
const labelEvery = ticks.length > maxLabels
|
|
? Math.ceil(ticks.length / maxLabels)
|
|
: 1;
|
|
const labelRightLimit = margin.l + plotW - (reserveRight || 0);
|
|
|
|
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();
|
|
|
|
const isLast = i === ticks.length - 1;
|
|
if (i % labelEvery === 0 || isLast) {
|
|
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;
|
|
if (isLast) {
|
|
lx = Math.min(lx, labelRightLimit - tw);
|
|
} else {
|
|
lx = Math.max(margin.l, Math.min(lx, labelRightLimit - 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;
|
|
}
|
|
|
|
/** Horizontal quality distribution clipped inside a highlighted column. */
|
|
function drawBinDistribution(ctx, x0, barW, plotTop, plotH, hist, maxInBin, qualityColor) {
|
|
const slotCount = 100 / QUALITY_BUCKET;
|
|
const cellW = barW / slotCount;
|
|
const distBandH = Math.round(plotH * 0.68);
|
|
const distTop = plotTop + plotH - distBandH;
|
|
const buckets = Array.from(hist.keys()).sort((a, b) => a - b);
|
|
const innerPad = 1;
|
|
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
|
ctx.fillRect(x0, plotTop, barW, plotH);
|
|
|
|
ctx.fillStyle = '#aaa';
|
|
ctx.font = '8px system-ui';
|
|
ctx.fillText('0%', x0 + innerPad + 1, distTop + 9);
|
|
const lbl100 = '100%';
|
|
ctx.fillText(lbl100, x0 + barW - ctx.measureText(lbl100).width - innerPad - 1, distTop + 9);
|
|
|
|
for (const bq of buckets) {
|
|
const n = hist.get(bq);
|
|
if (!n) continue;
|
|
const frac = n / maxInBin;
|
|
const slot = qualitySlotIndex(bq);
|
|
const cellX = x0 + slot * cellW + innerPad;
|
|
const drawW = Math.max(1, cellW - innerPad * 2);
|
|
const fillH = Math.max(2, frac * (distBandH - 12));
|
|
const y = distTop + distBandH - fillH;
|
|
const base = qualityColor ? qualityColor(bq) : 'rgb(136,136,136)';
|
|
ctx.fillStyle = colorWithAlpha(base, 0.55 + 0.4 * frac);
|
|
ctx.fillRect(cellX, y, drawW, fillH);
|
|
}
|
|
|
|
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) * (distBandH - 12);
|
|
const h1 = (n1 / maxInBin) * (distBandH - 12);
|
|
const x0p = x0 + qualitySlotIndex(q0) * cellW + cellW / 2;
|
|
const x1p = x0 + qualitySlotIndex(q1) * cellW + cellW / 2;
|
|
const midX = (x0p + x1p) / 2;
|
|
const midH = Math.max(h0, h1) * 0.65;
|
|
const midQ = (q0 + q1) / 2;
|
|
const base = qualityColor ? qualityColor(midQ) : 'rgb(136,136,136)';
|
|
ctx.fillStyle = colorWithAlpha(base, 0.28);
|
|
const blendW = Math.min(cellW * 0.85, barW - innerPad * 2);
|
|
ctx.fillRect(midX - blendW / 2, distTop + distBandH - midH, blendW, midH);
|
|
}
|
|
}
|
|
|
|
function drawQualityEnvelope(ctx, bins, binCount, margin, plotW, plotH, plotBottom, qualityColor) {
|
|
const points = [];
|
|
bins.forEach((b, i) => {
|
|
if (!b.qualities.length) return;
|
|
const avg = b.qualities.reduce((a, v) => a + v, 0) / b.qualities.length;
|
|
points.push({
|
|
x: margin.l + (i + 0.5) / binCount * plotW,
|
|
y: plotBottom - (avg / 100) * plotH,
|
|
avg,
|
|
});
|
|
});
|
|
if (points.length < 2) return;
|
|
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.82)';
|
|
ctx.lineWidth = 1.75;
|
|
ctx.setLineDash([6, 4]);
|
|
ctx.lineJoin = 'round';
|
|
ctx.beginPath();
|
|
points.forEach((p, i) => {
|
|
if (i === 0) ctx.moveTo(p.x, p.y);
|
|
else ctx.lineTo(p.x, p.y);
|
|
});
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
points.forEach(p => {
|
|
const col = qualityColor ? qualityColor(p.avg) : '#ffffff';
|
|
ctx.fillStyle = col;
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
});
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawScatterPoints(
|
|
ctx, samples, margin, plotW, plotH, minD, span, plotTop, plotBottom,
|
|
activeSample, qualityColor
|
|
) {
|
|
samples.forEach(s => {
|
|
const x = margin.l + ((s.distM - minD) / span) * plotW;
|
|
const y = plotBottom - (s.quality / 100) * plotH;
|
|
const active = isActiveSample(s, activeSample);
|
|
|
|
if (active) {
|
|
const col = qualityColor ? qualityColor(s.quality) : '#ffffff';
|
|
ctx.strokeStyle = colorWithAlpha(col, 0.85);
|
|
ctx.lineWidth = 2;
|
|
ctx.setLineDash([4, 3]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, plotTop);
|
|
ctx.lineTo(x, plotBottom);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.strokeStyle = colorWithAlpha(col, 1);
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = colorWithAlpha(col, 0.35);
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 8, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
} else {
|
|
ctx.fillStyle = 'rgba(255,255,255,0.32)';
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
});
|
|
}
|
|
|
|
function drawQualityDistChart(canvas, samples, qualityColor, highlightDistOrOptions) {
|
|
if (!canvas) return;
|
|
const options = normalizeOptions(highlightDistOrOptions);
|
|
const highlightDist = options.highlightDist;
|
|
const activeSample = options.activeSample ?? null;
|
|
const step = options.meterStep ?? meterStep;
|
|
|
|
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) {
|
|
chartLayout.delete(canvas);
|
|
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 / step));
|
|
const binWDist = span / binCount;
|
|
const bins = Array.from({ length: binCount }, (_, i) => ({
|
|
min: minD + i * binWDist,
|
|
max: minD + (i + 1) * binWDist,
|
|
qualities: [],
|
|
samples: [],
|
|
}));
|
|
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);
|
|
bins[idx].samples.push(s);
|
|
}
|
|
|
|
const margin = { l: 36, r: 8, t: 22, b: 34 };
|
|
const plotW = w - margin.l - margin.r;
|
|
const plotH = h - margin.t - margin.b;
|
|
const barW = plotW / binCount;
|
|
const plotTop = margin.t;
|
|
const plotBottom = plotTop + plotH;
|
|
const hasHighlight = highlightDist != null;
|
|
|
|
const stepHint = `шаг ${step}м · колёсико`;
|
|
ctx.font = '8px system-ui';
|
|
const stepHintW = ctx.measureText(stepHint).width;
|
|
|
|
chartLayout.set(canvas, {
|
|
margin,
|
|
plotW,
|
|
plotH,
|
|
minD,
|
|
span,
|
|
plotTop,
|
|
plotBottom,
|
|
samples,
|
|
});
|
|
|
|
ctx.strokeStyle = '#333';
|
|
ctx.beginPath();
|
|
ctx.moveTo(margin.l, plotTop);
|
|
ctx.lineTo(margin.l, plotBottom);
|
|
ctx.lineTo(margin.l + plotW, plotBottom);
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = '#888';
|
|
ctx.font = '9px system-ui';
|
|
ctx.fillText('0%', 2, plotBottom);
|
|
ctx.fillText('100%', 2, plotTop + 8);
|
|
|
|
ctx.fillStyle = '#666';
|
|
ctx.font = '8px system-ui';
|
|
ctx.fillText(stepHint, margin.l, plotTop - 6);
|
|
|
|
drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span, step, stepHintW + 6);
|
|
|
|
bins.forEach((b, i) => {
|
|
const x0 = margin.l + i / binCount * plotW;
|
|
const highlight = hasHighlight
|
|
&& highlightDist >= b.min && highlightDist < b.max;
|
|
const count = b.qualities.length;
|
|
|
|
if (count === 0) {
|
|
ctx.fillStyle = 'rgba(255,255,255,0.05)';
|
|
ctx.fillRect(x0, plotBottom - 1, barW, 1);
|
|
return;
|
|
}
|
|
|
|
const avg = b.qualities.reduce((a, v) => a + v, 0) / count;
|
|
const mainH = (avg / 100) * plotH;
|
|
const mainY = plotBottom - mainH;
|
|
const mainCol = qualityColor ? qualityColor(avg) : 'rgb(136,136,136)';
|
|
|
|
if (hasHighlight && !highlight) {
|
|
ctx.globalAlpha = 0.32;
|
|
}
|
|
|
|
ctx.fillStyle = mainCol;
|
|
ctx.fillRect(x0, mainY, barW, mainH);
|
|
|
|
const grad = ctx.createLinearGradient(0, mainY, 0, plotBottom);
|
|
grad.addColorStop(0, colorWithAlpha(mainCol, 1));
|
|
grad.addColorStop(1, colorWithAlpha(mainCol, 0.5));
|
|
ctx.fillStyle = grad;
|
|
ctx.globalAlpha = (hasHighlight && !highlight) ? 0.18 : 0.22;
|
|
ctx.fillRect(x0, mainY, barW, mainH);
|
|
ctx.globalAlpha = 1;
|
|
|
|
if (highlight) {
|
|
const hist = histogramInBin(b.qualities);
|
|
let maxInBin = 1;
|
|
hist.forEach(c => {
|
|
if (c > maxInBin) maxInBin = c;
|
|
});
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(x0 + 0.5, plotTop + 0.5, barW - 1, plotH - 1);
|
|
ctx.clip();
|
|
drawBinDistribution(ctx, x0, barW, plotTop, plotH, hist, maxInBin, qualityColor);
|
|
ctx.restore();
|
|
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.strokeRect(x0 + 0.5, plotTop + 0.5, barW - 1, plotH - 1);
|
|
}
|
|
});
|
|
|
|
drawQualityEnvelope(
|
|
ctx, bins, binCount, margin, plotW, plotH, plotBottom, qualityColor);
|
|
|
|
drawScatterPoints(
|
|
ctx, samples, margin, plotW, plotH, minD, span, plotTop, plotBottom,
|
|
activeSample, qualityColor
|
|
);
|
|
}
|
|
|
|
function attachInteractions(canvas, handlers) {
|
|
if (!canvas || attachedCanvases.has(canvas)) return;
|
|
attachedCanvases.add(canvas);
|
|
|
|
const onRedraw = typeof handlers === 'function'
|
|
? handlers
|
|
: (handlers?.onMeterStepChange ?? handlers?.onRedraw);
|
|
const onSamplePick = typeof handlers === 'object' ? handlers?.onSamplePick : null;
|
|
|
|
canvas.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
let idx = METER_STEPS.indexOf(meterStep);
|
|
if (idx < 0) idx = METER_STEPS.indexOf(DEFAULT_METER_STEP);
|
|
if (e.deltaY < 0) {
|
|
idx = Math.max(0, idx - 1);
|
|
} else {
|
|
idx = Math.min(METER_STEPS.length - 1, idx + 1);
|
|
}
|
|
meterStep = METER_STEPS[idx];
|
|
onRedraw?.(meterStep);
|
|
}, { passive: false });
|
|
|
|
canvas.addEventListener('click', (e) => {
|
|
if (!onSamplePick) return;
|
|
const sample = pickSampleAt(canvas, e.clientX, e.clientY);
|
|
if (sample) {
|
|
onSamplePick(sample);
|
|
}
|
|
});
|
|
|
|
canvas.title = 'Клик по точке — перейти на таймлайне · колёсико — шаг шкалы';
|
|
canvas.style.cursor = 'pointer';
|
|
}
|
|
|
|
global.QualityViz = {
|
|
drawQualityDistChart,
|
|
attachInteractions,
|
|
pickSampleAt,
|
|
getMeterStep,
|
|
setMeterStep,
|
|
METER_STEPS,
|
|
};
|
|
})(typeof window !== 'undefined' ? window : globalThis);
|