diff --git a/server/static/quality-viz.js b/server/static/quality-viz.js index 4438b01..87c4338 100644 --- a/server/static/quality-viz.js +++ b/server/static/quality-viz.js @@ -2,18 +2,16 @@ (function (global) { 'use strict'; + const METER_STEP = 10; const QUALITY_BUCKET = 10; - function niceMeterStep(span) { - if (span <= 50) return 10; - if (span <= 150) return 25; - if (span <= 400) return 50; - return 100; + function niceMeterStep() { + return METER_STEP; } function niceMeterTicks(minD, maxD) { const span = Math.max(maxD - minD, 1); - const step = niceMeterStep(span); + const step = METER_STEP; const ticks = []; const start = Math.ceil(minD / step) * step; for (let d = start; d <= maxD + step * 0.01; d += step) { @@ -35,6 +33,20 @@ return Math.min(100, Math.max(0, q)); } + function colorWithAlpha(hex, alpha) { + if (!hex || typeof hex !== 'string') { + return `rgba(136, 136, 136, ${alpha})`; + } + const h = hex.replace('#', ''); + if (h.length !== 6) { + return `rgba(136, 136, 136, ${alpha})`; + } + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + function drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span) { const ticks = niceMeterTicks(minD, minD + span); const minLabelGap = 28; @@ -73,6 +85,45 @@ return hist; } + /** Solid horizontal quality distribution inside one distance bin (0% left → 100% right). */ + function drawBinQualityStrip(ctx, x0, barW, plotTop, plotH, hist, maxInBin, qualityColor, highlight) { + const cellW = barW / (100 / QUALITY_BUCKET); + const buckets = Array.from(hist.keys()).sort((a, b) => a - b); + + 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 * plotH); + const y = plotTop + plotH - fillH; + const base = qualityColor ? qualityColor(bq) : '#888888'; + const alpha = highlight ? 0.35 + 0.65 * frac : 0.18 + 0.72 * frac; + ctx.fillStyle = colorWithAlpha(base, alpha); + ctx.fillRect(cellX, y, Math.max(1, cellW + 0.5), fillH); + } + + // Smooth overlap between neighbours for a continuous look + if (buckets.length >= 2) { + 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) * plotH; + const h1 = (n1 / maxInBin) * plotH; + 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.55; + const midQ = (q0 + q1) / 2; + const base = qualityColor ? qualityColor(midQ) : '#888888'; + ctx.fillStyle = colorWithAlpha(base, highlight ? 0.22 : 0.12); + ctx.fillRect(midX - cellW * 0.4, plotTop + plotH - midH, cellW * 0.8, midH); + } + } + } + function drawQualityDistChart(canvas, samples, qualityColor, highlightDist) { if (!canvas) return; const ctx = canvas.getContext('2d'); @@ -94,8 +145,7 @@ const minD = Math.min(...dists); const maxD = Math.max(...dists); const span = Math.max(maxD - minD, 1); - const meterStep = niceMeterStep(span); - const binCount = Math.min(60, Math.max(1, Math.ceil(span / meterStep))); + const binCount = Math.max(1, Math.ceil(span / METER_STEP)); const binWDist = span / binCount; const bins = Array.from({ length: binCount }, (_, i) => ({ min: minD + i * binWDist, @@ -109,36 +159,52 @@ bins[idx].qualities.push(s.quality); } - const margin = { l: 36, r: 8, t: 16, b: 30 }; + const margin = { l: 36, r: 8, t: 16, b: 34 }; const plotW = w - margin.l - margin.r; const plotH = h - margin.t - margin.b; const barW = plotW / binCount; - const markerH = Math.max(3, Math.min(6, barW * 0.45)); + const plotTop = margin.t; ctx.strokeStyle = '#333'; ctx.beginPath(); - ctx.moveTo(margin.l, margin.t); - ctx.lineTo(margin.l, margin.t + plotH); - ctx.lineTo(margin.l + plotW, margin.t + plotH); + ctx.moveTo(margin.l, plotTop); + ctx.lineTo(margin.l, plotTop + plotH); + ctx.lineTo(margin.l + plotW, plotTop + plotH); ctx.stroke(); ctx.fillStyle = '#888'; ctx.font = '9px system-ui'; - ctx.fillText('0%', 2, margin.t + plotH); - ctx.fillText('100%', 2, margin.t + 8); + ctx.fillText('0', 2, plotTop + plotH); + ctx.fillText('max', 2, plotTop + 8); drawMeterAxis(ctx, margin, plotW, plotH, h, minD, span); + ctx.fillStyle = '#666'; + ctx.font = '8px system-ui'; + ctx.fillText('RX 0%', margin.l, h - 14); + const rx100 = 'RX 100%'; + const rx100w = ctx.measureText(rx100).width; + ctx.fillText(rx100, margin.l + plotW - rx100w, h - 14); + ctx.fillText('плотность ↑', 2, plotTop + plotH / 2 + 12); + bins.forEach((b, i) => { const x0 = margin.l + i / binCount * plotW; - const cx = x0 + barW / 2; const highlight = highlightDist != null && highlightDist >= b.min && highlightDist < b.max; const count = b.qualities.length; + if (i > 0) { + ctx.strokeStyle = 'rgba(255,255,255,0.04)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x0, plotTop); + ctx.lineTo(x0, plotTop + plotH); + ctx.stroke(); + } + if (count === 0) { - ctx.fillStyle = 'rgba(255,255,255,0.06)'; - ctx.fillRect(x0, margin.t + plotH - 1, barW, 1); + ctx.fillStyle = 'rgba(255,255,255,0.04)'; + ctx.fillRect(x0, plotTop + plotH - 1, barW, 1); return; } @@ -149,38 +215,19 @@ }); if (highlight) { - ctx.fillStyle = 'rgba(255,255,255,0.06)'; - ctx.fillRect(x0, margin.t, barW, plotH); + ctx.fillStyle = 'rgba(255,255,255,0.05)'; + ctx.fillRect(x0, plotTop, barW, plotH); } - const sortedBuckets = Array.from(hist.keys()).sort((a, b) => a - b); - for (const bq of sortedBuckets) { - const n = hist.get(bq); - const frac = n / maxInBin; - const segW = Math.max(2, frac * (barW - 1)); - const y = margin.t + plotH - (bq / 100) * plotH; - const col = qualityColor ? qualityColor(bq) : '#888'; - ctx.fillStyle = col; - ctx.globalAlpha = highlight ? 0.95 : (0.45 + 0.55 * frac); - ctx.fillRect(cx - segW / 2, y - markerH / 2, segW, markerH); - ctx.globalAlpha = 1; - } + drawBinQualityStrip( + ctx, x0, barW, plotTop, plotH, hist, maxInBin, qualityColor, highlight); if (highlight) { - ctx.strokeStyle = 'rgba(255,255,255,0.85)'; + ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; - ctx.strokeRect(x0 + 0.5, margin.t + 0.5, barW - 1, plotH - 1); + ctx.strokeRect(x0 + 0.5, plotTop + 0.5, barW - 1, plotH - 1); } }); - - ctx.fillStyle = 'rgba(255,255,255,0.18)'; - samples.forEach(s => { - const x = margin.l + ((s.distM - minD) / span) * plotW; - const y = margin.t + plotH - (s.quality / 100) * plotH; - ctx.beginPath(); - ctx.arc(x, y, 1.2, 0, Math.PI * 2); - ctx.fill(); - }); } global.QualityViz = {