generated from Grigo/AndroidTemplate
offline track
This commit is contained in:
@@ -332,7 +332,7 @@
|
||||
</div>
|
||||
<div id="qualityVizPanel">
|
||||
<div class="quality-viz-box">
|
||||
<div class="quality-viz-title">RX Quality vs расстояние TX↔RX (распределение по отрезкам)</div>
|
||||
<div class="quality-viz-title">RX Quality vs расстояние TX↔RX · колёсико — шаг шкалы</div>
|
||||
<canvas id="qualityDistCanvas" width="800" height="140"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1191,7 +1191,13 @@
|
||||
const quality = rxQualityFromMeta(rxPos.meta);
|
||||
if (quality == null) continue;
|
||||
const distM = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
|
||||
samples.push({ distM, quality });
|
||||
samples.push({
|
||||
distM,
|
||||
quality,
|
||||
stepIndex: i,
|
||||
progress: cursor.progress,
|
||||
t: cursor.t,
|
||||
});
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
@@ -1203,12 +1209,52 @@
|
||||
timelineLinkRxPos.lat, timelineLinkRxPos.lon);
|
||||
}
|
||||
|
||||
function currentQualityActiveSample() {
|
||||
if (!qualitySamplesCache.length) return null;
|
||||
const cursor = timelineCursor();
|
||||
if (cursor.progress != null) {
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
for (const s of qualitySamplesCache) {
|
||||
if (s.progress == null) continue;
|
||||
const d = Math.abs(s.progress - cursor.progress);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = s;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
if (cursor.t != null) {
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
for (const s of qualitySamplesCache) {
|
||||
if (s.t == null) continue;
|
||||
const d = Math.abs(s.t - cursor.t);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = s;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function qualityChartOptions() {
|
||||
return {
|
||||
highlightDist: currentTimelineLinkDist(),
|
||||
activeSample: currentQualityActiveSample(),
|
||||
meterStep: QualityViz.getMeterStep(),
|
||||
};
|
||||
}
|
||||
|
||||
function redrawQualityDistHighlight() {
|
||||
if (!dualTracksActive) return;
|
||||
const distCanvas = document.getElementById('qualityDistCanvas');
|
||||
if (!distCanvas) return;
|
||||
QualityViz.drawQualityDistChart(
|
||||
distCanvas, qualitySamplesCache, qualityColor, currentTimelineLinkDist());
|
||||
distCanvas, qualitySamplesCache, qualityColor, qualityChartOptions());
|
||||
}
|
||||
|
||||
function rebuildQualityViz() {
|
||||
@@ -1220,8 +1266,9 @@
|
||||
}
|
||||
qualitySamplesCache = buildQualitySamples();
|
||||
const distCanvas = document.getElementById('qualityDistCanvas');
|
||||
QualityViz.attachInteractions(distCanvas, () => redrawQualityDistHighlight());
|
||||
QualityViz.drawQualityDistChart(
|
||||
distCanvas, qualitySamplesCache, qualityColor, currentTimelineLinkDist());
|
||||
distCanvas, qualitySamplesCache, qualityColor, qualityChartOptions());
|
||||
panel?.classList.add('visible');
|
||||
}
|
||||
|
||||
|
||||
+155
-39
@@ -2,12 +2,25 @@
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const METER_STEP = 10;
|
||||
const METER_STEPS = [5, 10, 20, 25, 50, 100];
|
||||
const QUALITY_BUCKET = 10;
|
||||
const DEFAULT_METER_STEP = 10;
|
||||
|
||||
function niceMeterTicks(minD, maxD) {
|
||||
let meterStep = DEFAULT_METER_STEP;
|
||||
const attachedCanvases = new WeakSet();
|
||||
|
||||
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 step = METER_STEP;
|
||||
const ticks = [];
|
||||
const start = Math.ceil(minD / step) * step;
|
||||
for (let d = start; d <= maxD + step * 0.01; d += step) {
|
||||
@@ -53,8 +66,31 @@
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
function drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span) {
|
||||
const ticks = niceMeterTicks(minD, minD + span);
|
||||
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 drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span, step) {
|
||||
const ticks = niceMeterTicks(minD, minD + span, step);
|
||||
const minLabelGap = 28;
|
||||
const maxLabels = Math.max(2, Math.floor(plotW / minLabelGap));
|
||||
const labelEvery = ticks.length > maxLabels
|
||||
@@ -91,22 +127,25 @@
|
||||
return hist;
|
||||
}
|
||||
|
||||
/** Semi-transparent horizontal distribution inside a column (quality 0% left → 100% right). */
|
||||
/** Horizontal quality distribution inside a highlighted column (0% left → 100% right). */
|
||||
function drawBinDistribution(ctx, x0, barW, plotTop, plotH, hist, maxInBin, qualityColor) {
|
||||
const cellW = barW / (100 / QUALITY_BUCKET);
|
||||
const distBandH = Math.round(plotH * 0.55);
|
||||
const distBandH = Math.round(plotH * 0.72);
|
||||
const distTop = plotTop + plotH - distBandH;
|
||||
const buckets = Array.from(hist.keys()).sort((a, b) => a - b);
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
||||
ctx.fillRect(x0, plotTop, barW, plotH);
|
||||
|
||||
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 * distBandH);
|
||||
const fillH = Math.max(2, frac * distBandH);
|
||||
const y = distTop + distBandH - fillH;
|
||||
const base = qualityColor ? qualityColor(bq) : 'rgb(136,136,136)';
|
||||
ctx.fillStyle = colorWithAlpha(base, 0.28 + 0.55 * frac);
|
||||
ctx.fillStyle = colorWithAlpha(base, 0.55 + 0.4 * frac);
|
||||
ctx.fillRect(cellX, y, Math.max(1, cellW + 0.5), fillH);
|
||||
}
|
||||
|
||||
@@ -120,16 +159,68 @@
|
||||
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.6;
|
||||
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.15);
|
||||
ctx.fillStyle = colorWithAlpha(base, 0.28);
|
||||
ctx.fillRect(midX - cellW * 0.45, distTop + distBandH - midH, cellW * 0.9, midH);
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#aaa';
|
||||
ctx.font = '8px system-ui';
|
||||
ctx.fillText('0%', x0 + 1, distTop + distBandH + 10);
|
||||
const lbl = '100%';
|
||||
ctx.fillText(lbl, x0 + barW - ctx.measureText(lbl).width - 1, distTop + distBandH + 10);
|
||||
}
|
||||
|
||||
function drawQualityDistChart(canvas, samples, qualityColor, highlightDist) {
|
||||
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;
|
||||
@@ -149,18 +240,20 @@
|
||||
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 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: 16, b: 34 };
|
||||
@@ -169,6 +262,7 @@
|
||||
const barW = plotW / binCount;
|
||||
const plotTop = margin.t;
|
||||
const plotBottom = plotTop + plotH;
|
||||
const hasHighlight = highlightDist != null;
|
||||
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.beginPath();
|
||||
@@ -182,17 +276,16 @@
|
||||
ctx.fillText('0%', 2, plotBottom);
|
||||
ctx.fillText('100%', 2, plotTop + 8);
|
||||
|
||||
drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span);
|
||||
drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span, step);
|
||||
|
||||
const stepHint = `шаг ${step}м · колёсико`;
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.font = '8px system-ui';
|
||||
ctx.fillText('0%', margin.l, h - 14);
|
||||
const lbl100 = '100%';
|
||||
ctx.fillText(lbl100, margin.l + plotW - ctx.measureText(lbl100).width, h - 14);
|
||||
ctx.fillText(stepHint, margin.l + plotW - ctx.measureText(stepHint).width, plotTop + 9);
|
||||
|
||||
bins.forEach((b, i) => {
|
||||
const x0 = margin.l + i / binCount * plotW;
|
||||
const highlight = highlightDist != null
|
||||
const highlight = hasHighlight
|
||||
&& highlightDist >= b.min && highlightDist < b.max;
|
||||
const count = b.qualities.length;
|
||||
|
||||
@@ -207,44 +300,67 @@
|
||||
const mainY = plotBottom - mainH;
|
||||
const mainCol = qualityColor ? qualityColor(avg) : 'rgb(136,136,136)';
|
||||
|
||||
// Layer 1: solid main quality bar (Y = average quality at this distance)
|
||||
if (hasHighlight && !highlight) {
|
||||
ctx.globalAlpha = 0.32;
|
||||
}
|
||||
|
||||
ctx.fillStyle = mainCol;
|
||||
ctx.globalAlpha = highlight ? 1 : 0.88;
|
||||
ctx.fillRect(x0, mainY, barW, mainH);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Subtle vertical gradient within main bar for depth
|
||||
const grad = ctx.createLinearGradient(0, mainY, 0, plotBottom);
|
||||
grad.addColorStop(0, colorWithAlpha(mainCol, 1));
|
||||
grad.addColorStop(1, colorWithAlpha(mainCol, 0.55));
|
||||
grad.addColorStop(1, colorWithAlpha(mainCol, 0.5));
|
||||
ctx.fillStyle = grad;
|
||||
ctx.globalAlpha = highlight ? 0.35 : 0.25;
|
||||
ctx.globalAlpha = (hasHighlight && !highlight) ? 0.18 : 0.22;
|
||||
ctx.fillRect(x0, mainY, barW, mainH);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
const hist = histogramInBin(b.qualities);
|
||||
let maxInBin = 1;
|
||||
hist.forEach(c => {
|
||||
if (c > maxInBin) maxInBin = c;
|
||||
});
|
||||
|
||||
// Layer 2: semi-transparent horizontal distribution inside the column
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(x0, plotTop, barW, plotH);
|
||||
ctx.clip();
|
||||
drawBinDistribution(ctx, x0, barW, plotTop, plotH, hist, maxInBin, qualityColor);
|
||||
ctx.restore();
|
||||
|
||||
if (highlight) {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
const hist = histogramInBin(b.qualities);
|
||||
let maxInBin = 1;
|
||||
hist.forEach(c => {
|
||||
if (c > maxInBin) maxInBin = c;
|
||||
});
|
||||
drawBinDistribution(ctx, x0, barW, plotTop, plotH, hist, maxInBin, qualityColor);
|
||||
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(x0 + 0.5, plotTop + 0.5, barW - 1, plotH - 1);
|
||||
}
|
||||
});
|
||||
|
||||
drawScatterPoints(
|
||||
ctx, samples, margin, plotW, plotH, minD, span, plotTop, plotBottom,
|
||||
activeSample, qualityColor
|
||||
);
|
||||
}
|
||||
|
||||
function attachInteractions(canvas, onChange) {
|
||||
if (!canvas || attachedCanvases.has(canvas)) return;
|
||||
attachedCanvases.add(canvas);
|
||||
|
||||
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];
|
||||
onChange?.(meterStep);
|
||||
}, { passive: false });
|
||||
|
||||
canvas.title = 'Колёсико мыши — изменить шаг шкалы (м)';
|
||||
canvas.style.cursor = 'crosshair';
|
||||
}
|
||||
|
||||
global.QualityViz = {
|
||||
drawQualityDistChart,
|
||||
attachInteractions,
|
||||
getMeterStep,
|
||||
setMeterStep,
|
||||
METER_STEPS,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
|
||||
Reference in New Issue
Block a user