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,