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:
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user