feat: render apnea events as colored dots on pressure chart

Replace the event duration bars (HTML divs below the chart) with scatter
datasets plotted directly on the pressure line chart. Each event type
gets a colored dot (OA blue, CA teal, MA purple, H yellow) placed at
the event's time on the X-axis and its recorded pressure on the Y-axis.

This eliminates the alignment problem between the Chart.js plot area and
the external HTML container, and gives a cleaner visual: each dot sits
on the pressure waveform exactly where the event occurred, making
pressure-at-event immediately visible.

The legend shows event types (hiding the pressure line label) and
tooltips show "CA @ 8.2 hPa" on hover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 17:48:33 +03:00
parent 4351fd4604
commit 1411a39daa
2 changed files with 65 additions and 52 deletions

View File

@@ -352,39 +352,13 @@ function renderPeriod(period, periodIndex) {
table.appendChild(tbody);
body.appendChild(table);
// Pressure timeline chart with event overlay (per-night)
// Pressure timeline chart with event dots (per-night)
const hasPressure = period.sessions.some(s => s.pressure_timeline);
if (hasPressure) {
const firstStart = Math.min(...period.sessions.map(s => new Date(s.started_at).getTime() / 1000));
const lastEnd = Math.max(...period.sessions.map(s => new Date(s.started_at).getTime() / 1000 + s.duration_secs));
const totalRange = lastEnd - firstStart;
const timelineCanvas = el('canvas', {});
const chartWrap = el('div', { className: 'chart-container', style: 'height:180px;margin:0.5rem 0 0;' }, timelineCanvas);
body.appendChild(chartWrap);
// Event bars container — padding will be set after chart renders to align with plot area
const barsHTML = buildEventBarsHTML(period.sessions, firstStart, totalRange);
const barsDiv = barsHTML ? el('div', {}) : null;
if (barsDiv) {
barsDiv.innerHTML = barsHTML;
body.appendChild(barsDiv);
}
_deferredCharts.push(() => {
const chart = renderPressureTimeline(timelineCanvas, period.sessions, firstStart);
// Align event bars to the chart's plot area
if (barsDiv && chart.chartArea) {
const area = chart.chartArea;
const canvasWidth = timelineCanvas.offsetWidth;
if (canvasWidth > 0) {
const leftPx = area.left;
const rightPx = canvasWidth - area.right;
barsDiv.style.paddingLeft = leftPx + 'px';
barsDiv.style.paddingRight = rightPx + 'px';
}
}
});
body.appendChild(el('div', { className: 'chart-container', style: 'height:200px;margin:0.5rem 0 0;' }, timelineCanvas));
_deferredCharts.push(() => renderPressureTimeline(timelineCanvas, period.sessions, firstStart));
}
// Events table (per-night, all sessions combined)
@@ -721,15 +695,9 @@ function downloadPDF(report, filename) {
}
L.push('</tbody></table>');
// Pressure timeline chart image + event bars
// Pressure timeline chart image (event dots are baked into the chart)
if (timelineImgs[pi]) {
const tl = timelineImgs[pi];
L.push('<img class="chart-img" style="max-height:160px;" src="' + tl.img + '">');
const firstStart = Math.min(...period.sessions.map(s => new Date(s.started_at).getTime() / 1000));
const lastEnd = Math.max(...period.sessions.map(s => new Date(s.started_at).getTime() / 1000 + s.duration_secs));
L.push('<div style="padding-left:' + tl.leftPct + '%;padding-right:' + tl.rightPct + '%;">');
L.push(buildEventBarsHTML(period.sessions, firstStart, lastEnd - firstStart));
L.push('</div>');
L.push('<img class="chart-img" style="max-height:180px;" src="' + timelineImgs[pi].img + '">');
}
for (const sess of period.sessions) {

View File

@@ -300,6 +300,14 @@ function renderEventDensityChart(canvas, buckets, startedAt) {
function renderPressureTimeline(canvas, sessions, periodStartSecs) {
const c = _themeColors();
const EVENT_COLORS = {
ObstructiveApnea: '#3b82f6',
CentralApnea: '#14b8a6',
MixedApnea: PURPLE,
Hypopnea: c.yellow || '#eab308',
};
const EVENT_LABELS = { ObstructiveApnea: 'OA', CentralApnea: 'CA', MixedApnea: 'MA', Hypopnea: 'H' };
// Build pressure data points: {x: seconds from period start, y: hPa}
const pressureData = [];
let xMax = 0;
@@ -312,30 +320,63 @@ function renderPressureTimeline(canvas, sessions, periodStartSecs) {
for (let i = 0; i < sess.pressure_timeline.samples.length; i++) {
pressureData.push({ x: sessStart + i / rate, y: sess.pressure_timeline.samples[i] });
}
// Add a gap between sessions
pressureData.push({ x: NaN, y: NaN });
}
// Build event scatter datasets — one per event type, plotted at the event's
// pressure on the Y-axis so dots sit directly on the pressure line.
const eventsByType = {};
for (const sess of sessions) {
if (!sess.events) continue;
const sessStart = (new Date(sess.started_at).getTime() / 1000) - periodStartSecs;
for (const ev of sess.events) {
if (!EVENT_COLORS[ev.event_type]) continue;
if (!eventsByType[ev.event_type]) eventsByType[ev.event_type] = [];
const evStartSecs = sessStart + (ev.end_offset_cs - ev.duration_cs) / 100;
const pressure = ev.pressure_hpa != null ? ev.pressure_hpa : null;
if (pressure != null) {
eventsByType[ev.event_type].push({ x: evStartSecs, y: pressure });
}
}
}
const datasets = [{
label: t('chart.pressure'),
data: pressureData,
borderColor: c.accent || '#2563eb',
backgroundColor: (c.accent || '#2563eb') + '20',
borderWidth: 1,
pointRadius: 0,
fill: true,
spanGaps: false,
order: 1, // draw pressure line behind event dots
}];
for (const [type, color] of Object.entries(EVENT_COLORS)) {
if (!eventsByType[type] || eventsByType[type].length === 0) continue;
datasets.push({
type: 'scatter',
label: EVENT_LABELS[type],
data: eventsByType[type],
backgroundColor: color,
borderColor: color,
pointRadius: 4,
pointHoverRadius: 6,
order: 0, // draw dots on top of pressure line
});
}
return _track(new Chart(canvas, {
type: 'line',
data: {
datasets: [{
label: t('chart.pressure'),
data: pressureData,
borderColor: c.accent || '#2563eb',
backgroundColor: (c.accent || '#2563eb') + '20',
borderWidth: 1,
pointRadius: 0,
fill: true,
spanGaps: false,
}],
},
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: { display: false },
legend: {
labels: { color: c.fg, usePointStyle: true, pointStyle: 'circle', filter: (item) => item.datasetIndex > 0 },
},
tooltip: {
callbacks: {
title: (items) => {
@@ -345,7 +386,11 @@ function renderPressureTimeline(canvas, sessions, periodStartSecs) {
return d.getHours().toString().padStart(2, '0') + ':' +
d.getMinutes().toString().padStart(2, '0');
},
label: (item) => item.parsed.y.toFixed(1) + ' hPa',
label: (item) => {
const ds = item.dataset;
if (item.datasetIndex === 0) return item.parsed.y.toFixed(1) + ' hPa';
return ds.label + ' @ ' + item.parsed.y.toFixed(1) + ' hPa';
},
},
},
},