diff --git a/server/static/index.html b/server/static/index.html index 9ca4760..7812cd3 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -1249,6 +1249,30 @@ }; } + function seekTimelineToQualitySample(sample) { + if (!sample) return; + const slider = document.getElementById('timeSlider'); + const steps = Math.max(1, qualitySamplesCache.length - 1); + if (sample.stepIndex != null) { + if (timelineUseProgress) { + slider.value = String(Math.round((sample.stepIndex / steps) * timelineSpanMs)); + } else if (sample.t != null) { + slider.value = String(Math.max( + 0, + Math.min(Math.round((sample.t - overlapMin) * 1000), timelineSpanMs) + )); + } + } else if (sample.progress != null && timelineUseProgress) { + slider.value = String(Math.round(sample.progress * timelineSpanMs)); + } else if (sample.t != null) { + slider.value = String(Math.max( + 0, + Math.min(Math.round((sample.t - overlapMin) * 1000), timelineSpanMs) + )); + } + updateTimelineAt(timelineCursor()); + } + function redrawQualityDistHighlight() { if (!dualTracksActive) return; const distCanvas = document.getElementById('qualityDistCanvas'); @@ -1266,7 +1290,10 @@ } qualitySamplesCache = buildQualitySamples(); const distCanvas = document.getElementById('qualityDistCanvas'); - QualityViz.attachInteractions(distCanvas, () => redrawQualityDistHighlight()); + QualityViz.attachInteractions(distCanvas, { + onMeterStepChange: () => redrawQualityDistHighlight(), + onSamplePick: (sample) => seekTimelineToQualitySample(sample), + }); QualityViz.drawQualityDistChart( distCanvas, qualitySamplesCache, qualityColor, qualityChartOptions()); panel?.classList.add('visible'); diff --git a/server/static/quality-viz.js b/server/static/quality-viz.js index d12234e..d502b61 100644 --- a/server/static/quality-viz.js +++ b/server/static/quality-viz.js @@ -5,9 +5,11 @@ 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; @@ -42,6 +44,11 @@ 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 }; @@ -89,13 +96,43 @@ return sample === activeSample; } - function drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span, step) { + 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; @@ -106,13 +143,18 @@ ctx.lineTo(x, margin.t + plotH); ctx.stroke(); - if (i % labelEvery === 0 || i === ticks.length - 1) { + 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; - lx = Math.max(margin.l, Math.min(lx, margin.l + plotW - tw)); + 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); } }); @@ -127,26 +169,36 @@ return hist; } - /** Horizontal quality distribution inside a highlighted column (0% left → 100% right). */ + /** Horizontal quality distribution clipped inside a highlighted column. */ function drawBinDistribution(ctx, x0, barW, plotTop, plotH, hist, maxInBin, qualityColor) { - const cellW = barW / (100 / QUALITY_BUCKET); - const distBandH = Math.round(plotH * 0.72); + 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 cellX = x0 + (bq / 100) * barW; - const fillH = Math.max(2, frac * distBandH); + 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, Math.max(1, cellW + 0.5), fillH); + ctx.fillRect(cellX, y, drawW, fillH); } for (let i = 0; i < buckets.length - 1; i++) { @@ -154,23 +206,57 @@ const q1 = buckets[i + 1]; const n0 = hist.get(q0); const n1 = hist.get(q1); - const h0 = (n0 / maxInBin) * distBandH; - const h1 = (n1 / maxInBin) * distBandH; - const x0p = x0 + (q0 / 100) * barW; - const x1p = x0 + (q1 / 100) * barW; - const midX = (x0p + x1p + cellW) / 2; + 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); - ctx.fillRect(midX - cellW * 0.45, distTop + distBandH - midH, cellW * 0.9, midH); + const blendW = Math.min(cellW * 0.85, barW - innerPad * 2); + ctx.fillRect(midX - blendW / 2, distTop + distBandH - midH, blendW, 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 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( @@ -230,6 +316,7 @@ 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); @@ -256,7 +343,7 @@ bins[idx].samples.push(s); } - const margin = { l: 36, r: 8, t: 16, b: 34 }; + 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; @@ -264,6 +351,21 @@ 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); @@ -276,12 +378,11 @@ ctx.fillText('0%', 2, plotBottom); ctx.fillText('100%', 2, plotTop + 8); - drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span, step); - - const stepHint = `шаг ${step}м · колёсико`; ctx.fillStyle = '#666'; ctx.font = '8px system-ui'; - ctx.fillText(stepHint, margin.l + plotW - ctx.measureText(stepHint).width, plotTop + 9); + 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; @@ -321,7 +422,13 @@ 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; @@ -329,16 +436,24 @@ } }); + drawQualityEnvelope( + ctx, bins, binCount, margin, plotW, plotH, plotBottom, qualityColor); + drawScatterPoints( ctx, samples, margin, plotW, plotH, minD, span, plotTop, plotBottom, activeSample, qualityColor ); } - function attachInteractions(canvas, onChange) { + 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); @@ -349,16 +464,25 @@ idx = Math.min(METER_STEPS.length - 1, idx + 1); } meterStep = METER_STEPS[idx]; - onChange?.(meterStep); + onRedraw?.(meterStep); }, { passive: false }); - canvas.title = 'Колёсико мыши — изменить шаг шкалы (м)'; - canvas.style.cursor = 'crosshair'; + 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,