diff --git a/server/static/index.html b/server/static/index.html index 83d8041..9ca4760 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -332,7 +332,7 @@
-
RX Quality vs расстояние TX↔RX (распределение по отрезкам)
+
RX Quality vs расстояние TX↔RX · колёсико — шаг шкалы
@@ -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'); } diff --git a/server/static/quality-viz.js b/server/static/quality-viz.js index aab611b..d12234e 100644 --- a/server/static/quality-viz.js +++ b/server/static/quality-viz.js @@ -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);