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