lab-js/realtime/realtime-graph.jsjavascript
/**
* @module realtime-graph
* @description
* The left column: a scrollable distance plot with a FIXED time scale.
*
* Two things here are load-bearing and easy to undo by accident.
*
* 1. FIXED SCALE. The prototype stretched whatever history it had across the
* full canvas height, so ten points and two hundred points both filled the
* panel and a trace's steepness meant nothing. Every sample now occupies
* the same number of pixels. The visible window is 60 seconds, always.
*
* 2. CONTINUOUS QUALITY STATES. Descriptor values keep flowing when pose,
* framing or light are not comparable, but their background says why they
* are excluded from measurements. No-face ticks keep time without inventing
* a value. Each state can be hidden non-destructively from the legend.
*
* Rendering is virtualised: the canvas is sticky and sized to the scroll
* viewport, a spacer supplies the scroll height, and only the visible slice is
* drawn. Render work and canvas memory stay bounded; compact timeline entries
* grow with the session. Growing one tall canvas instead would hit the ~32k px
* dimension cap after ~45 minutes and hold ~30MB on the way there.
*/
const GAP_BLOCK_PX = 30;
const EVENT_BLOCK_PX = 24;
const AXIS_STRIP_PX = 17;
const PAD_LEFT = 6;
const GUTTER_RIGHT = 24;
const MARKER_RADIUS = 7;
const FOLLOW_SLACK_PX = 10;
/**
* Formats a duration in milliseconds as a compact human string.
*
* @param {number} ms Duration in milliseconds.
* @returns {string} Compact duration such as "8m 12s" or "4s".
*/
export function formatDuration(ms) {
const totalSeconds = Math.max(0, Math.round(ms / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours) return `${hours}h ${minutes}m`;
if (minutes) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
/**
* Formats a timestamp as a zero-padded wall clock label.
*
* @param {number} timestamp Epoch milliseconds.
* @returns {string} Clock label such as "14:32:07".
*/
function formatClock(timestamp) {
const date = new Date(timestamp);
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
/**
* Reads a resolved CSS custom property from an element with a fallback.
*
* @param {Element} el Element whose computed style is read.
* @param {string} name Custom property name including leading dashes.
* @param {string} fallback Value used when the property resolves empty.
* @returns {string} Resolved colour string.
*/
function token(el, name, fallback) {
const value = getComputedStyle(el).getPropertyValue(name).trim();
return value || fallback;
}
/**
* Creates the virtualised distance graph controller.
*
* @param {object} options Wiring and tuning options.
* @param {HTMLElement} options.scrollEl Scroll container element.
* @param {HTMLElement} options.spacerEl Spacer element supplying scroll height.
* @param {HTMLCanvasElement} options.canvasEl Sticky canvas element.
* @param {HTMLElement} options.followEl "Jump to now" button.
* @param {number} options.windowSeconds Seconds visible in one viewport.
* @param {number} options.samplesPerSecond Sample cadence used for layout.
* @param {number} options.maxDistance Right edge of the distance axis.
* @param {number} options.threshold Cited match threshold line.
* @param {function(string): void} options.onMarkerClick Marker click handler.
* @returns {object} Graph controller.
*/
export function createGraph(options) {
const {
scrollEl,
spacerEl,
canvasEl,
followEl,
windowSeconds,
samplesPerSecond,
maxDistance,
threshold,
onMarkerClick,
} = options;
const ctx = canvasEl.getContext('2d');
/** @type {Array<object>} */
let entries = [];
/** @type {number[]} */
let tops = [];
let contentHeight = 0;
let sampleSeq = 0;
let openGapIndex = -1;
let noiseFloor = null;
const qualityVisible = new Map([
['valid', true],
['outside-guide', true],
['lighting', true],
['no-face', true],
]);
let follow = true;
let programmaticScroll = false;
let viewportH = 0;
let viewportW = 0;
let pxPerSample = 4;
let labelEvery = samplesPerSecond * 10;
/**
* Height in CSS pixels contributed by one timeline entry.
*
* @param {object} entry Timeline entry.
* @returns {number} Entry height.
*/
function entryHeight(entry) {
if (entry.kind === 'gap') return GAP_BLOCK_PX;
if (entry.kind === 'event') return EVENT_BLOCK_PX;
if (qualityVisible.get(entry.status || 'valid') === false) return 0;
return pxPerSample;
}
/**
* Rebuilds cumulative offsets. O(n), called only on resize.
*
* @returns {void}
*/
function relayout() {
tops = new Array(entries.length);
let acc = 0;
for (let i = 0; i < entries.length; i += 1) {
tops[i] = acc;
acc += entryHeight(entries[i]);
}
contentHeight = acc;
syncSpacer();
}
/**
* Applies the current virtual height to the scroll spacer.
*
* @returns {void}
*/
function syncSpacer() {
spacerEl.style.height = `${Math.round(Math.max(contentHeight, viewportH))}px`;
}
/**
* Scrolls to the live edge without tripping the follow-disengage handler.
*
* @returns {void}
*/
function scrollToNow() {
programmaticScroll = true;
scrollEl.scrollTop = scrollEl.scrollHeight;
window.requestAnimationFrame(() => {
programmaticScroll = false;
});
}
/**
* Shows or hides the "Jump to now" affordance.
*
* @returns {void}
*/
function syncFollowButton() {
followEl.hidden = follow;
}
/**
* Binary search for the last entry whose top is at or above a virtual y.
*
* @param {number} y Virtual y offset in CSS pixels.
* @returns {number} Index of the entry containing y.
*/
function indexAt(y) {
let lo = 0;
let hi = tops.length - 1;
let best = 0;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (tops[mid] <= y) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return best;
}
/**
* Maps a distance value to a canvas x coordinate, clamped to the axis.
*
* @param {number} value Distance value.
* @returns {number} Canvas x in CSS pixels.
*/
function xFor(value) {
const plotWidth = viewportW - PAD_LEFT - GUTTER_RIGHT;
const normalized = Math.max(0, Math.min(1, value / maxDistance));
return PAD_LEFT + normalized * plotWidth;
}
/**
* Repaints the visible slice of the timeline.
*
* @returns {void}
*/
function draw() {
if (!ctx || viewportW <= 0 || viewportH <= 0) return;
const colors = {
plotBg: token(canvasEl, '--rt-plot-bg', '#2c2318'),
trace: token(canvasEl, '--rt-trace', '#ffe7a8'),
thresholdLine: token(canvasEl, '--rt-threshold', '#f3c747'),
noise: token(canvasEl, '--rt-noise', '#7fe3b0'),
grid: token(canvasEl, '--rt-grid', 'rgba(255,231,168,0.08)'),
gap: token(canvasEl, '--rt-gap', '#8a7b6a'),
faint: token(canvasEl, '--rt-gap', '#8a7b6a'),
applying: token(canvasEl, '--rt-phase-applying', 'rgba(242,106,27,0.13)'),
measuring: token(canvasEl, '--rt-phase-measuring', 'rgba(127,227,176,0.13)'),
eventApplying: token(canvasEl, '--rt-event-applying', '#f26a1b'),
eventMeasuring: token(canvasEl, '--rt-event-measuring', '#7fe3b0'),
outsideGuide: token(canvasEl, '--rt-quality-outside', 'rgba(242,106,27,0.22)'),
lighting: token(canvasEl, '--rt-quality-light', 'rgba(243,199,71,0.20)'),
noFace: token(canvasEl, '--rt-quality-no-face', 'rgba(138,123,106,0.24)'),
};
const scrollTop = scrollEl.scrollTop;
const plotWidth = viewportW - PAD_LEFT - GUTTER_RIGHT;
ctx.clearRect(0, 0, viewportW, viewportH);
ctx.fillStyle = colors.plotBg;
ctx.fillRect(0, 0, viewportW, viewportH);
// Axis ticks. These carry no claim; they exist so a value can be read off
// the trace. Kept at hairline weight so they never compete with the two
// reference lines below, which do carry claims.
ctx.strokeStyle = colors.grid;
ctx.lineWidth = 1;
for (const value of [0.25, 0.5, 0.75, 1.0, 1.25]) {
if (value > maxDistance) continue;
const x = Math.round(xFor(value)) + 0.5;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, viewportH);
ctx.stroke();
}
if (noiseFloor !== null) {
const x = Math.round(xFor(noiseFloor)) + 0.5;
ctx.strokeStyle = colors.noise;
ctx.lineWidth = 1.5;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, viewportH);
ctx.stroke();
ctx.setLineDash([]);
}
const thresholdX = Math.round(xFor(threshold)) + 0.5;
ctx.strokeStyle = colors.thresholdLine;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(thresholdX, 0);
ctx.lineTo(thresholdX, viewportH);
ctx.stroke();
if (entries.length) {
const firstIndex = indexAt(Math.max(0, scrollTop - GAP_BLOCK_PX));
const limit = scrollTop + viewportH;
// Session phases remain visible behind the trace. They are declared by
// the operator and therefore describe workflow, not an inferred cause.
for (let i = firstIndex; i < entries.length; i += 1) {
const top = tops[i];
if (top > limit) break;
const entry = entries[i];
const y = top - scrollTop;
if (entry.kind === 'sample' && qualityVisible.get(entry.status || 'valid') === false) {
continue;
}
if (entry.kind === 'sample' && entry.phase === 'applying') {
ctx.fillStyle = colors.applying;
ctx.fillRect(PAD_LEFT, y, plotWidth, pxPerSample);
} else if (entry.kind === 'sample' && entry.phase === 'measuring') {
ctx.fillStyle = colors.measuring;
ctx.fillRect(PAD_LEFT, y, plotWidth, pxPerSample);
} else if (entry.kind === 'event') {
drawEvent(entry, y, colors, plotWidth);
}
if (entry.kind === 'sample' && qualityVisible.get(entry.status) !== false) {
const qualityColor = {
'outside-guide': colors.outsideGuide,
lighting: colors.lighting,
'no-face': colors.noFace,
}[entry.status];
if (qualityColor) {
ctx.fillStyle = qualityColor;
ctx.fillRect(PAD_LEFT, y, plotWidth, pxPerSample);
}
}
}
ctx.lineWidth = 2;
ctx.strokeStyle = colors.trace;
ctx.lineJoin = 'round';
let penDown = false;
ctx.beginPath();
for (let i = firstIndex; i < entries.length; i += 1) {
const top = tops[i];
if (top > limit) break;
const entry = entries[i];
const y = top - scrollTop;
const hiddenQuality = entry.kind === 'sample' && qualityVisible.get(entry.status || 'valid') === false;
if (hiddenQuality) continue;
if (entry.kind === 'gap' || entry.kind === 'event' || entry.value == null) {
if (penDown) {
ctx.stroke();
ctx.beginPath();
penDown = false;
}
// Events were already painted in the phase-background pass. They
// interrupt the trace, but they are not absence blocks.
if (entry.kind === 'gap') drawGap(entry, y, colors, plotWidth);
continue;
}
const x = xFor(entry.value);
const yMid = y + pxPerSample / 2;
if (!penDown) {
ctx.moveTo(x, yMid);
penDown = true;
} else {
ctx.lineTo(x, yMid);
}
if (entry.value > maxDistance) {
ctx.stroke();
drawOverflow(yMid, colors.trace);
ctx.beginPath();
ctx.moveTo(x, yMid);
}
}
if (penDown) ctx.stroke();
// Second pass so markers and labels sit above the trace.
for (let i = firstIndex; i < entries.length; i += 1) {
const top = tops[i];
if (top > limit) break;
const entry = entries[i];
if (entry.kind !== 'sample') continue;
if (qualityVisible.get(entry.status || 'valid') === false) continue;
const yMid = top - scrollTop + pxPerSample / 2;
if (entry.seq % labelEvery === 0) {
ctx.fillStyle = colors.faint;
ctx.font = '9px ui-monospace, SFMono-Regular, Menlo, monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(formatClock(entry.t), PAD_LEFT + 2, yMid);
}
if (entry.captureId && entry.value != null && qualityVisible.get(entry.status) !== false) {
drawMarker(yMid, colors);
}
}
}
drawAxisStrip(colors);
}
/**
* Draws a collapsed absence block with its true duration.
*
* @param {object} entry Gap entry.
* @param {number} y Canvas y of the block top.
* @param {object} colors Resolved palette.
* @param {number} plotWidth Usable plot width in CSS pixels.
* @returns {void}
*/
function drawGap(entry, y, colors, plotWidth) {
const endT = entry.endT ?? Date.now();
ctx.save();
ctx.beginPath();
ctx.rect(PAD_LEFT, y, plotWidth, GAP_BLOCK_PX);
ctx.clip();
ctx.strokeStyle = colors.gap;
ctx.globalAlpha = 0.32;
ctx.lineWidth = 1;
for (let hx = -GAP_BLOCK_PX; hx < plotWidth + GAP_BLOCK_PX; hx += 7) {
ctx.beginPath();
ctx.moveTo(PAD_LEFT + hx, y + GAP_BLOCK_PX);
ctx.lineTo(PAD_LEFT + hx + GAP_BLOCK_PX, y);
ctx.stroke();
}
ctx.restore();
ctx.strokeStyle = colors.gap;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD_LEFT, Math.round(y) + 0.5);
ctx.lineTo(PAD_LEFT + plotWidth, Math.round(y) + 0.5);
ctx.moveTo(PAD_LEFT, Math.round(y + GAP_BLOCK_PX) + 0.5);
ctx.lineTo(PAD_LEFT + plotWidth, Math.round(y + GAP_BLOCK_PX) + 0.5);
ctx.stroke();
const label = `${entry.label || 'no face'} · ${formatDuration(endT - entry.t)}`;
ctx.font = '9px ui-monospace, SFMono-Regular, Menlo, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const metrics = ctx.measureText(label);
const cx = PAD_LEFT + plotWidth / 2;
const cy = y + GAP_BLOCK_PX / 2;
ctx.fillStyle = token(canvasEl, '--rt-plot-bg', '#2c2318');
ctx.fillRect(cx - metrics.width / 2 - 5, cy - 7, metrics.width + 10, 14);
ctx.fillStyle = colors.gap;
ctx.fillText(label, cx, cy);
}
/**
* Draws an operator-declared session event.
*
* @param {object} entry Timeline event.
* @param {number} y Canvas y.
* @param {object} colors Resolved palette.
* @param {number} plotWidth Plot width.
* @returns {void}
*/
function drawEvent(entry, y, colors, plotWidth) {
// Opaque base deliberately masks the vertical threshold/noise/grid lines;
// event labels must remain readable where those references cross them.
ctx.fillStyle = colors.plotBg;
ctx.fillRect(PAD_LEFT, y, plotWidth, EVENT_BLOCK_PX);
ctx.fillStyle = entry.phase === 'measuring' ? colors.measuring : colors.applying;
ctx.fillRect(PAD_LEFT, y, plotWidth, EVENT_BLOCK_PX);
const eventColor = entry.phase === 'measuring'
? colors.eventMeasuring
: colors.eventApplying;
ctx.strokeStyle = eventColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD_LEFT, Math.round(y) + 0.5);
ctx.lineTo(PAD_LEFT + plotWidth, Math.round(y) + 0.5);
ctx.moveTo(PAD_LEFT, Math.round(y + EVENT_BLOCK_PX) + 0.5);
ctx.lineTo(PAD_LEFT + plotWidth, Math.round(y + EVENT_BLOCK_PX) + 0.5);
ctx.stroke();
ctx.fillStyle = eventColor;
ctx.font = '9px ui-monospace, SFMono-Regular, Menlo, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(entry.label, PAD_LEFT + plotWidth / 2, y + EVENT_BLOCK_PX / 2);
}
/**
* Draws the off-axis indicator for a sample beyond the plotted ceiling.
*
* @param {number} y Canvas y of the sample.
* @param {string} color Trace colour.
* @returns {void}
*/
function drawOverflow(y, color) {
const x = viewportW - GUTTER_RIGHT;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x, y - 3.5);
ctx.lineTo(x + 5, y);
ctx.lineTo(x, y + 3.5);
ctx.closePath();
ctx.fill();
}
/**
* Draws a saved-capture marker in the right gutter with a connector.
*
* @param {number} y Canvas y of the marked sample.
* @param {object} colors Resolved palette.
* @returns {void}
*/
function drawMarker(y, colors) {
const cx = viewportW - GUTTER_RIGHT / 2;
ctx.strokeStyle = colors.grid;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(viewportW - GUTTER_RIGHT, y);
ctx.lineTo(cx - MARKER_RADIUS, y);
ctx.stroke();
ctx.fillStyle = token(canvasEl, '--rt-plot-bg', '#2c2318');
ctx.strokeStyle = colors.trace;
ctx.lineWidth = 1.25;
ctx.beginPath();
ctx.arc(cx, y, MARKER_RADIUS, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = colors.trace;
ctx.fillRect(cx - 3.5, y - 2, 7, 4.5);
ctx.fillStyle = token(canvasEl, '--rt-plot-bg', '#2c2318');
ctx.beginPath();
ctx.arc(cx, y + 0.25, 1.4, 0, Math.PI * 2);
ctx.fill();
}
/**
* Draws the pinned scale strip. The canvas is sticky, so anything painted at
* fixed canvas coordinates behaves as a fixed overlay while content scrolls.
*
* @param {object} colors Resolved palette.
* @returns {void}
*/
function drawAxisStrip(colors) {
ctx.fillStyle = token(canvasEl, '--rt-surface-solid', 'rgba(12,9,6,0.86)');
ctx.fillRect(0, 0, viewportW, AXIS_STRIP_PX);
ctx.strokeStyle = colors.grid;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, AXIS_STRIP_PX + 0.5);
ctx.lineTo(viewportW, AXIS_STRIP_PX + 0.5);
ctx.stroke();
ctx.font = '9px ui-monospace, SFMono-Regular, Menlo, monospace';
ctx.textBaseline = 'middle';
ctx.fillStyle = colors.faint;
const ticks = [0, 0.5, 1.0];
for (const value of ticks) {
if (value > maxDistance) continue;
const x = xFor(value);
ctx.textAlign = value === 0 ? 'left' : 'center';
ctx.fillText(value.toFixed(1), x, AXIS_STRIP_PX / 2);
}
ctx.textAlign = 'center';
ctx.fillStyle = colors.thresholdLine;
ctx.fillText('0.6', xFor(threshold), AXIS_STRIP_PX / 2);
}
/**
* Recomputes canvas backing store and layout for the current viewport.
*
* @returns {void}
*/
function resize() {
const rect = scrollEl.getBoundingClientRect();
const nextW = Math.max(Math.floor(rect.width), 1);
const nextH = Math.max(Math.floor(rect.height), 1);
const dpr = Math.max(window.devicePixelRatio || 1, 1);
viewportW = nextW;
viewportH = nextH;
pxPerSample = viewportH / (windowSeconds * samplesPerSecond);
labelEvery = samplesPerSecond * 10;
canvasEl.style.height = `${nextH}px`;
canvasEl.width = Math.floor(nextW * dpr);
canvasEl.height = Math.floor(nextH * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
relayout();
if (follow) scrollToNow();
draw();
}
/**
* Appends one measured sample at the live edge.
*
* @param {number} t Epoch milliseconds of the sample.
* @param {number|null} value Averaged raw distance, or null when no face exists.
* @param {string} [phase] Operator-declared session phase.
* @param {'valid'|'outside-guide'|'lighting'|'no-face'} [status] Comparability state.
* @returns {number} Index of the appended entry.
*/
function pushSample(t, value, phase = 'ready', status = 'valid') {
openGapIndex = -1;
const entry = { kind: 'sample', t, value, phase, status, seq: sampleSeq };
sampleSeq += 1;
tops.push(contentHeight);
entries.push(entry);
contentHeight += pxPerSample;
syncSpacer();
if (follow) scrollToNow();
draw();
return entries.length - 1;
}
/**
* Opens an absence block, or extends the one already open.
*
* @param {number} t Epoch milliseconds when the face was last seen.
* @param {string} [label] Human-readable exclusion reason.
* @returns {void}
*/
function beginGap(t, label = 'no face') {
if (openGapIndex !== -1) {
const open = entries[openGapIndex];
if (open.label !== label) {
endGap(t);
beginGap(t, label);
return;
}
draw();
return;
}
const entry = { kind: 'gap', t, endT: null, label };
tops.push(contentHeight);
entries.push(entry);
contentHeight += GAP_BLOCK_PX;
openGapIndex = entries.length - 1;
syncSpacer();
if (follow) scrollToNow();
draw();
}
/**
* Closes the open absence block.
*
* @param {number} t Epoch milliseconds when the face returned.
* @returns {void}
*/
function endGap(t) {
if (openGapIndex === -1) return;
entries[openGapIndex].endT = t;
openGapIndex = -1;
draw();
}
/**
* Adds an operator-declared phase marker to the timeline.
*
* @param {number} t Epoch milliseconds.
* @param {string} phase Session phase.
* @param {string} label Visible marker label.
* @returns {void}
*/
function addEvent(t, phase, label) {
endGap(t);
const entry = { kind: 'event', t, phase, label };
tops.push(contentHeight);
entries.push(entry);
contentHeight += EVENT_BLOCK_PX;
syncSpacer();
if (follow) scrollToNow();
draw();
}
/**
* Attaches a saved capture to the most recent sample.
*
* @param {string} captureId Identifier of the stored capture.
* @returns {void}
*/
function markLatestCapture(captureId) {
for (let i = entries.length - 1; i >= 0; i -= 1) {
if (entries[i].kind === 'sample') {
entries[i].captureId = captureId;
draw();
return;
}
}
}
/**
* Removes a capture marker after the capture is deleted.
*
* @param {string} captureId Identifier of the removed capture.
* @returns {void}
*/
function clearCapture(captureId) {
for (const entry of entries) {
if (entry.captureId === captureId) delete entry.captureId;
}
draw();
}
/**
* Sets the measured noise floor drawn as the lower reference line.
*
* @param {number|null} value Measured noise floor, or null to hide it.
* @returns {void}
*/
function setNoiseFloor(value) {
noiseFloor = value;
draw();
}
/** Shows or hides one quality state without deleting its samples. */
function setQualityVisible(status, visible) {
if (!qualityVisible.has(status)) return;
qualityVisible.set(status, Boolean(visible));
relayout();
if (follow) scrollToNow();
draw();
}
/**
* Clears the timeline and returns to the live edge.
*
* @returns {void}
*/
function reset() {
entries = [];
tops = [];
contentHeight = 0;
sampleSeq = 0;
openGapIndex = -1;
follow = true;
syncFollowButton();
syncSpacer();
scrollToNow();
draw();
}
/**
* Resolves a pointer event to a capture marker, if one was hit.
*
* @param {PointerEvent|MouseEvent} event Pointer event on the canvas.
* @returns {string|null} Capture id, or null when nothing was hit.
*/
function markerAt(event) {
const rect = canvasEl.getBoundingClientRect();
const x = event.clientX - rect.left;
if (x < viewportW - GUTTER_RIGHT - MARKER_RADIUS) return null;
const virtualY = scrollEl.scrollTop + (event.clientY - rect.top);
const index = indexAt(virtualY);
for (let i = Math.max(0, index - 3); i <= Math.min(entries.length - 1, index + 3); i += 1) {
const entry = entries[i];
if (entry.kind !== 'sample' || !entry.captureId) continue;
if (qualityVisible.get(entry.status || 'valid') === false) continue;
const centre = tops[i] + pxPerSample / 2;
if (Math.abs(centre - virtualY) <= MARKER_RADIUS + 3) return entry.captureId;
}
return null;
}
scrollEl.addEventListener('scroll', () => {
if (programmaticScroll) {
draw();
return;
}
const distanceFromBottom =
scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
follow = distanceFromBottom <= FOLLOW_SLACK_PX;
syncFollowButton();
draw();
});
canvasEl.addEventListener('click', (event) => {
const captureId = markerAt(event);
if (captureId && typeof onMarkerClick === 'function') onMarkerClick(captureId);
});
canvasEl.addEventListener('pointermove', (event) => {
canvasEl.classList.toggle('is-clickable', Boolean(markerAt(event)));
});
followEl.addEventListener('click', () => {
follow = true;
syncFollowButton();
scrollToNow();
draw();
});
const observer = new ResizeObserver(() => resize());
observer.observe(scrollEl);
return {
resize,
draw,
reset,
pushSample,
beginGap,
endGap,
addEvent,
markLatestCapture,
clearCapture,
setNoiseFloor,
setQualityVisible,
/**
* Reports timeline size for diagnostics and tests.
*
* @returns {object} Timeline counts and filter state.
*/
stats() {
let samples = 0;
let visibleSamples = 0;
let gaps = 0;
let events = 0;
const quality = { valid: 0, 'outside-guide': 0, lighting: 0, 'no-face': 0 };
for (const entry of entries) {
if (entry.kind === 'gap') gaps += 1;
else if (entry.kind === 'event') events += 1;
else {
samples += 1;
if (qualityVisible.get(entry.status || 'valid') !== false) visibleSamples += 1;
quality[entry.status || 'valid'] = (quality[entry.status || 'valid'] || 0) + 1;
}
}
return {
samples,
visibleSamples,
virtualHeight: contentHeight,
gaps,
events,
following: follow,
quality,
qualityVisible: Object.fromEntries(qualityVisible),
};
},
/**
* Disconnects observers held by the graph.
*
* @returns {void}
*/
destroy() {
observer.disconnect();
},
};
}