feat: pressure timeline chart with event duration overlays
Add per-night pressure timeline chart showing the continuous pressure waveform from PLD data (0.5Hz) with respiratory events overlaid as colored duration bars. The chart appears in both the web UI and PDF for every therapy night, after the session table. The pressure line (Chart.js line chart) uses the session's raw pressure samples serialized as a new `pressure_timeline` field in the report JSON. Event bars are rendered as positioned HTML divs below the chart, color-coded by type: OA (blue), CA (teal), MA (purple), H (yellow). Both share the same time axis so pressure-at-event is visually obvious. Also add an events table to the web UI period cards (previously only available in the PDF and session detail expansion). For the PDF, pressure charts are pre-rendered to temporary canvases and captured as base64 images, following the same pattern used for the AHI trend and duration charts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -351,6 +351,62 @@ function renderPeriod(period, periodIndex) {
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
body.appendChild(table);
|
||||
|
||||
// Pressure timeline chart with event overlay (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', {});
|
||||
body.appendChild(el('div', { className: 'chart-container', style: 'height:180px;margin:0.5rem 0 0;' }, timelineCanvas));
|
||||
_deferredCharts.push(() => renderPressureTimeline(timelineCanvas, period.sessions, firstStart));
|
||||
|
||||
// Event bars below the chart
|
||||
const barsHTML = buildEventBarsHTML(period.sessions, firstStart, totalRange);
|
||||
if (barsHTML) {
|
||||
const barsDiv = el('div', {});
|
||||
barsDiv.innerHTML = barsHTML;
|
||||
body.appendChild(barsDiv);
|
||||
}
|
||||
}
|
||||
|
||||
// Events table (per-night, all sessions combined)
|
||||
const allEvents = [];
|
||||
for (const sess of period.sessions) {
|
||||
if (!sess.events) continue;
|
||||
for (const ev of sess.events) {
|
||||
allEvents.push({ ...ev, sessionStart: sess.started_at });
|
||||
}
|
||||
}
|
||||
if (allEvents.length > 0) {
|
||||
body.appendChild(el('h3', { style: 'margin-top:0.75rem;' }, t('events.heading', { count: allEvents.length })));
|
||||
const evTable = el('table', { className: 'event-table' });
|
||||
evTable.appendChild(el('thead', {},
|
||||
el('tr', {},
|
||||
el('th', {}, t('events.start')),
|
||||
el('th', {}, t('events.end')),
|
||||
el('th', { className: 'num' }, t('events.duration')),
|
||||
el('th', {}, t('events.type')),
|
||||
el('th', { className: 'num' }, t('events.pressure')),
|
||||
),
|
||||
));
|
||||
const evTbody = el('tbody', {});
|
||||
for (const ev of allEvents) {
|
||||
const startCs = ev.end_offset_cs - ev.duration_cs;
|
||||
evTbody.appendChild(el('tr', {},
|
||||
el('td', {}, offsetToTime(ev.sessionStart, startCs)),
|
||||
el('td', {}, offsetToTime(ev.sessionStart, ev.end_offset_cs)),
|
||||
el('td', { className: 'num' }, (ev.duration_cs / 100).toFixed(1) + 's'),
|
||||
el('td', {}, unCamel(ev.event_type)),
|
||||
el('td', { className: 'num' }, ev.pressure_hpa != null ? ev.pressure_hpa.toFixed(1) : ''),
|
||||
));
|
||||
}
|
||||
evTable.appendChild(evTbody);
|
||||
body.appendChild(evTable);
|
||||
}
|
||||
|
||||
card.appendChild(body);
|
||||
return card;
|
||||
}
|
||||
@@ -538,6 +594,22 @@ function downloadPDF(report, filename) {
|
||||
const ahiChartImg = _chartInstances.length > 0 && _chartInstances[0] ? _chartInstances[0].toBase64Image() : null;
|
||||
const durChartImg = _chartInstances.length > 1 && _chartInstances[1] ? _chartInstances[1].toBase64Image() : null;
|
||||
|
||||
// Pre-render pressure timeline charts for each period
|
||||
const timelineImgs = {};
|
||||
for (let pi = 0; pi < filtered.periods.length; pi++) {
|
||||
const period = filtered.periods[pi];
|
||||
if (!period.sessions.some(s => s.pressure_timeline)) continue;
|
||||
const tmpCanvas = document.createElement('canvas');
|
||||
tmpCanvas.width = 800; tmpCanvas.height = 180;
|
||||
document.body.appendChild(tmpCanvas);
|
||||
const firstStart = Math.min(...period.sessions.map(s => new Date(s.started_at).getTime() / 1000));
|
||||
const chart = renderPressureTimeline(tmpCanvas, period.sessions, firstStart);
|
||||
timelineImgs[pi] = chart.toBase64Image();
|
||||
chart.destroy();
|
||||
_chartInstances.pop(); // remove from tracking since we destroyed it
|
||||
document.body.removeChild(tmpCanvas);
|
||||
}
|
||||
|
||||
const L = [];
|
||||
L.push('<html><head><meta charset="utf-8">');
|
||||
L.push('<title>' + esc(pdfName) + '</title>');
|
||||
@@ -579,7 +651,8 @@ function downloadPDF(report, filename) {
|
||||
if (ahiChartImg) L.push('<img class="chart-img" src="' + ahiChartImg + '">');
|
||||
if (durChartImg) L.push('<img class="chart-img" src="' + durChartImg + '">');
|
||||
|
||||
for (const period of filtered.periods) {
|
||||
for (let pi = 0; pi < filtered.periods.length; pi++) {
|
||||
const period = filtered.periods[pi];
|
||||
L.push('<div style="page-break-before:always;"></div>');
|
||||
const s = period.summary;
|
||||
let ahiLabel = 'AHI ' + s.ahi.toFixed(1);
|
||||
@@ -630,6 +703,14 @@ function downloadPDF(report, filename) {
|
||||
}
|
||||
L.push('</tbody></table>');
|
||||
|
||||
// Pressure timeline chart image + event bars
|
||||
if (timelineImgs[pi]) {
|
||||
L.push('<img class="chart-img" style="max-height:160px;" src="' + timelineImgs[pi] + '">');
|
||||
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(buildEventBarsHTML(period.sessions, firstStart, lastEnd - firstStart));
|
||||
}
|
||||
|
||||
for (const sess of period.sessions) {
|
||||
if (!sess.events || sess.events.length === 0) continue;
|
||||
L.push('<h3>' + esc(fmtDateTime(sess.started_at)) + ' \u2014 ' + esc(sess.id) + '<span style="float:right;font-weight:400;color:#666;">' + esc(t('events.heading', { count: sess.events.length })) + '</span></h3>');
|
||||
|
||||
@@ -287,3 +287,139 @@ function renderEventDensityChart(canvas, buckets, startedAt) {
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a pressure timeline chart for a night (all sessions).
|
||||
* Returns the Chart instance (pressure line only). Event bars are rendered
|
||||
* separately as HTML by the caller using buildEventBarsHTML().
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {Array} sessions - ReportSession objects with pressure_timeline
|
||||
* @param {number} periodStartSecs - Unix-like seconds for the period start (for X-axis labels)
|
||||
*/
|
||||
function renderPressureTimeline(canvas, sessions, periodStartSecs) {
|
||||
const c = _themeColors();
|
||||
|
||||
// Build pressure data points: {x: seconds from period start, y: hPa}
|
||||
const pressureData = [];
|
||||
for (const sess of sessions) {
|
||||
if (!sess.pressure_timeline) continue;
|
||||
const sessStart = (new Date(sess.started_at).getTime() / 1000) - periodStartSecs;
|
||||
const rate = sess.pressure_timeline.sample_rate_hz;
|
||||
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 });
|
||||
}
|
||||
|
||||
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,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: (items) => {
|
||||
if (!items.length) return '';
|
||||
const secs = items[0].parsed.x;
|
||||
const d = new Date((periodStartSecs + secs) * 1000);
|
||||
return d.getHours().toString().padStart(2, '0') + ':' +
|
||||
d.getMinutes().toString().padStart(2, '0');
|
||||
},
|
||||
label: (item) => item.parsed.y.toFixed(1) + ' hPa',
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear',
|
||||
title: { display: false },
|
||||
ticks: {
|
||||
color: c.muted,
|
||||
callback: (val) => {
|
||||
const d = new Date((periodStartSecs + val) * 1000);
|
||||
return d.getHours().toString().padStart(2, '0') + ':' +
|
||||
d.getMinutes().toString().padStart(2, '0');
|
||||
},
|
||||
maxTicksLimit: 12,
|
||||
},
|
||||
grid: { color: c.border + '40' },
|
||||
},
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
title: { display: true, text: 'hPa', color: c.fg },
|
||||
ticks: { color: c.muted },
|
||||
grid: { color: c.border + '40' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTML string for event duration bars aligned to a time axis.
|
||||
* Returns raw HTML that can be inserted into both web UI and PDF.
|
||||
*
|
||||
* @param {Array} sessions - ReportSession objects
|
||||
* @param {number} periodStartSecs - Unix-like seconds for the period start
|
||||
* @param {number} totalRangeSecs - total time span of the X-axis
|
||||
*/
|
||||
function buildEventBarsHTML(sessions, periodStartSecs, totalRangeSecs) {
|
||||
const EVENT_COLORS = {
|
||||
ObstructiveApnea: '#3b82f6',
|
||||
CentralApnea: '#14b8a6',
|
||||
MixedApnea: '#a855f7',
|
||||
Hypopnea: '#eab308',
|
||||
};
|
||||
const LANE_ORDER = ['ObstructiveApnea', 'CentralApnea', 'MixedApnea', 'Hypopnea'];
|
||||
const LANE_LABELS = { ObstructiveApnea: 'OA', CentralApnea: 'CA', MixedApnea: 'MA', Hypopnea: 'H' };
|
||||
|
||||
// Collect events by type
|
||||
const byType = {};
|
||||
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 (!byType[ev.event_type]) byType[ev.event_type] = [];
|
||||
const startSecs = sessStart + (ev.end_offset_cs - ev.duration_cs) / 100;
|
||||
const endSecs = sessStart + ev.end_offset_cs / 100;
|
||||
byType[ev.event_type].push({ startSecs, endSecs });
|
||||
}
|
||||
}
|
||||
|
||||
const lanes = LANE_ORDER.filter(t => byType[t] && byType[t].length > 0);
|
||||
if (lanes.length === 0) return '';
|
||||
|
||||
let html = '<div class="event-bars" style="position:relative;">';
|
||||
for (const type of lanes) {
|
||||
html += '<div class="event-lane" style="display:flex;align-items:center;height:14px;margin:1px 0;">';
|
||||
html += '<span class="event-lane-label" style="width:24px;font-size:9px;color:#666;flex-shrink:0;">' + LANE_LABELS[type] + '</span>';
|
||||
html += '<div style="position:relative;flex:1;height:100%;background:#f5f5f5;border-radius:2px;">';
|
||||
for (const ev of byType[type]) {
|
||||
const left = Math.max(0, (ev.startSecs / totalRangeSecs) * 100);
|
||||
const width = Math.max(0.15, ((ev.endSecs - ev.startSecs) / totalRangeSecs) * 100);
|
||||
html += '<div style="position:absolute;left:' + left.toFixed(3) + '%;width:' + width.toFixed(3) +
|
||||
'%;height:100%;background:' + EVENT_COLORS[type] + ';border-radius:1px;opacity:0.8;"></div>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -162,6 +162,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': 'Hours',
|
||||
'chart.events': 'Events',
|
||||
'chart.pressure': 'Pressure',
|
||||
'chart.hour': 'Hour',
|
||||
'chart.therapyDuration': 'Therapy Duration',
|
||||
'chart.eventBreakdown': 'Event Breakdown',
|
||||
@@ -289,6 +290,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': '\u0427\u0430\u0441\u043E\u0432\u0435',
|
||||
'chart.events': 'Събития',
|
||||
'chart.pressure': 'Налягане',
|
||||
'chart.hour': 'Час',
|
||||
'chart.therapyDuration': '\u041F\u0440\u043E\u0434\u044A\u043B\u0436\u0438\u0442\u0435\u043B\u043D\u043E\u0441\u0442 \u043D\u0430 \u0442\u0435\u0440\u0430\u043F\u0438\u044F\u0442\u0430',
|
||||
'chart.eventBreakdown': '\u0420\u0430\u0437\u043F\u0440\u0435\u0434\u0435\u043B\u0435\u043D\u0438\u0435 \u043D\u0430 \u0441\u044A\u0431\u0438\u0442\u0438\u044F',
|
||||
@@ -413,6 +415,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': '\u0427\u0430\u0441\u044B',
|
||||
'chart.events': 'Событий',
|
||||
'chart.pressure': 'Давление',
|
||||
'chart.hour': 'Час',
|
||||
'chart.therapyDuration': '\u0414\u043B\u0438\u0442\u0435\u043B\u044C\u043D\u043E\u0441\u0442\u044C \u0442\u0435\u0440\u0430\u043F\u0438\u0438',
|
||||
'chart.eventBreakdown': '\u0420\u0430\u0441\u043F\u0440\u0435\u0434\u0435\u043B\u0435\u043D\u0438\u0435 \u0441\u043E\u0431\u044B\u0442\u0438\u0439',
|
||||
@@ -537,6 +540,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': 'Stunden',
|
||||
'chart.events': 'Ereignisse',
|
||||
'chart.pressure': 'Druck',
|
||||
'chart.hour': 'Stunde',
|
||||
'chart.therapyDuration': 'Therapiedauer',
|
||||
'chart.eventBreakdown': 'Ereignisverteilung',
|
||||
@@ -661,6 +665,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'IAH',
|
||||
'chart.hours': 'Horas',
|
||||
'chart.events': 'Eventos',
|
||||
'chart.pressure': 'Presión',
|
||||
'chart.hour': 'Hora',
|
||||
'chart.therapyDuration': 'Duraci\u00F3n de terapia',
|
||||
'chart.eventBreakdown': 'Desglose de eventos',
|
||||
@@ -785,6 +790,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'IAH',
|
||||
'chart.hours': 'Heures',
|
||||
'chart.events': 'Événements',
|
||||
'chart.pressure': 'Pression',
|
||||
'chart.hour': 'Heure',
|
||||
'chart.therapyDuration': 'Dur\u00E9e de th\u00E9rapie',
|
||||
'chart.eventBreakdown': 'R\u00E9partition des \u00E9v\u00E9nements',
|
||||
@@ -909,6 +915,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': 'Ore',
|
||||
'chart.events': 'Eventi',
|
||||
'chart.pressure': 'Pressione',
|
||||
'chart.hour': 'Ora',
|
||||
'chart.therapyDuration': 'Durata della terapia',
|
||||
'chart.eventBreakdown': 'Distribuzione eventi',
|
||||
@@ -1033,6 +1040,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': 'Uren',
|
||||
'chart.events': 'Gebeurtenissen',
|
||||
'chart.pressure': 'Druk',
|
||||
'chart.hour': 'Uur',
|
||||
'chart.therapyDuration': 'Therapieduur',
|
||||
'chart.eventBreakdown': 'Gebeurtenisverdeling',
|
||||
@@ -1157,6 +1165,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': 'Godziny',
|
||||
'chart.events': 'Zdarzenia',
|
||||
'chart.pressure': 'Ciśnienie',
|
||||
'chart.hour': 'Godzina',
|
||||
'chart.therapyDuration': 'Czas terapii',
|
||||
'chart.eventBreakdown': 'Podzia\u0142 zdarze\u0144',
|
||||
@@ -1281,6 +1290,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'IAH',
|
||||
'chart.hours': 'Horas',
|
||||
'chart.events': 'Eventos',
|
||||
'chart.pressure': 'Pressão',
|
||||
'chart.hour': 'Hora',
|
||||
'chart.therapyDuration': 'Dura\u00E7\u00E3o da terapia',
|
||||
'chart.eventBreakdown': 'Distribui\u00E7\u00E3o de eventos',
|
||||
@@ -1405,6 +1415,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': '\u6642\u9593',
|
||||
'chart.events': 'イベント',
|
||||
'chart.pressure': '圧力',
|
||||
'chart.hour': '時間',
|
||||
'chart.therapyDuration': '\u6CBB\u7642\u6642\u9593',
|
||||
'chart.eventBreakdown': '\u30A4\u30D9\u30F3\u30C8\u5185\u8A33',
|
||||
@@ -1529,6 +1540,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': '\uC2DC\uAC04',
|
||||
'chart.events': '이벤트',
|
||||
'chart.pressure': '압력',
|
||||
'chart.hour': '시간',
|
||||
'chart.therapyDuration': '\uCE58\uB8CC \uC2DC\uAC04',
|
||||
'chart.eventBreakdown': '\uC774\uBCA4\uD2B8 \uBD84\uB958',
|
||||
@@ -1653,6 +1665,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': 'Saat',
|
||||
'chart.events': 'Olaylar',
|
||||
'chart.pressure': 'Basınç',
|
||||
'chart.hour': 'Saat',
|
||||
'chart.therapyDuration': 'Terapi s\u00FCresi',
|
||||
'chart.eventBreakdown': 'Olay da\u011F\u0131l\u0131m\u0131',
|
||||
@@ -1777,6 +1790,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': '\u5C0F\u65F6',
|
||||
'chart.events': '事件',
|
||||
'chart.pressure': '压力',
|
||||
'chart.hour': '小时',
|
||||
'chart.therapyDuration': '\u6CBB\u7597\u65F6\u957F',
|
||||
'chart.eventBreakdown': '\u4E8B\u4EF6\u5206\u5E03',
|
||||
@@ -1901,6 +1915,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': '\u0918\u0902\u091F\u0947',
|
||||
'chart.events': 'घटनाएँ',
|
||||
'chart.pressure': 'दबाव',
|
||||
'chart.hour': 'घंटा',
|
||||
'chart.therapyDuration': '\u0925\u0947\u0930\u0947\u092A\u0940 \u0905\u0935\u0927\u093F',
|
||||
'chart.eventBreakdown': '\u0918\u091F\u0928\u093E \u0935\u093F\u092D\u093E\u091C\u0928',
|
||||
@@ -2025,6 +2040,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': 'Jam',
|
||||
'chart.events': 'Peristiwa',
|
||||
'chart.pressure': 'Tekanan',
|
||||
'chart.hour': 'Jam',
|
||||
'chart.therapyDuration': 'Durasi terapi',
|
||||
'chart.eventBreakdown': 'Rincian kejadian',
|
||||
@@ -2149,6 +2165,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': '\u0BAE\u0BA3\u0BBF\u0BA8\u0BC7\u0BB0\u0BAE\u0BCD',
|
||||
'chart.events': 'நிகழ்வுகள்',
|
||||
'chart.pressure': 'அழுத்தம்',
|
||||
'chart.hour': 'மணி',
|
||||
'chart.therapyDuration': '\u0B9A\u0BBF\u0B95\u0BBF\u0B9A\u0BCD\u0B9A\u0BC8 \u0B95\u0BBE\u0BB2\u0BAE\u0BCD',
|
||||
'chart.eventBreakdown': '\u0BA8\u0BBF\u0B95\u0BB4\u0BCD\u0BB5\u0BC1 \u0BAA\u0BBF\u0BB0\u0BBF\u0BB5\u0BC1',
|
||||
@@ -2273,6 +2290,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': '\u0633\u0627\u0639\u0627\u062A',
|
||||
'chart.events': 'الأحداث',
|
||||
'chart.pressure': 'الضغط',
|
||||
'chart.hour': 'ساعة',
|
||||
'chart.therapyDuration': '\u0645\u062F\u0629 \u0627\u0644\u0639\u0644\u0627\u062C',
|
||||
'chart.eventBreakdown': '\u062A\u0648\u0632\u064A\u0639 \u0627\u0644\u0623\u062D\u062F\u0627\u062B',
|
||||
@@ -2397,6 +2415,7 @@ const resources = {
|
||||
'chart.ahiTrend': 'AHI',
|
||||
'chart.hours': '\u0998\u09A3\u09CD\u099F\u09BE',
|
||||
'chart.events': 'ইভেন্ট',
|
||||
'chart.pressure': 'চাপ',
|
||||
'chart.hour': 'ঘণ্টা',
|
||||
'chart.therapyDuration': '\u099A\u09BF\u0995\u09BF\u09CE\u09B8\u09BE\u09B0 \u09B8\u09AE\u09AF\u09BC\u0995\u09BE\u09B2',
|
||||
'chart.eventBreakdown': '\u0998\u099F\u09A8\u09BE\u09B0 \u09AC\u09BF\u09AD\u09BE\u099C\u09A8',
|
||||
@@ -2498,6 +2517,7 @@ const resources = {
|
||||
|
||||
'chart.ahiTrend': 'AHI', 'chart.hours': '\u038F\u03C1\u03B5\u03C2',
|
||||
'chart.events': 'Συμβάντα',
|
||||
'chart.pressure': 'Πίεση',
|
||||
'chart.hour': 'Ώρα',
|
||||
'chart.therapyDuration': '\u0394\u03B9\u03AC\u03C1\u03BA\u03B5\u03B9\u03B1 \u03B8\u03B5\u03C1\u03B1\u03C0\u03B5\u03AF\u03B1\u03C2',
|
||||
'chart.eventBreakdown': '\u039A\u03B1\u03C4\u03B1\u03BD\u03BF\u03BC\u03AE \u03C3\u03C5\u03BC\u03B2\u03AC\u03BD\u03C4\u03C9\u03BD',
|
||||
@@ -2594,6 +2614,7 @@ const resources = {
|
||||
|
||||
'chart.ahiTrend': 'AHI', 'chart.hours': '\u05E9\u05E2\u05D5\u05EA',
|
||||
'chart.events': 'אירועים',
|
||||
'chart.pressure': 'לחץ',
|
||||
'chart.hour': 'שעה',
|
||||
'chart.therapyDuration': '\u05DE\u05E9\u05DA \u05D8\u05D9\u05E4\u05D5\u05DC',
|
||||
'chart.eventBreakdown': '\u05E4\u05D9\u05DC\u05D5\u05D7 \u05D0\u05D9\u05E8\u05D5\u05E2\u05D9\u05DD',
|
||||
@@ -2690,6 +2711,7 @@ const resources = {
|
||||
|
||||
'chart.ahiTrend': 'AHI', 'chart.hours': 'Masaa',
|
||||
'chart.events': 'Matukio',
|
||||
'chart.pressure': 'Shinikizo',
|
||||
'chart.hour': 'Saa',
|
||||
'chart.therapyDuration': 'Muda wa tiba',
|
||||
'chart.eventBreakdown': 'Mgawanyiko wa matukio',
|
||||
@@ -2783,6 +2805,7 @@ const resources = {
|
||||
|
||||
'chart.ahiTrend': 'AHI', 'chart.hours': '\u0633\u0627\u0639\u062A',
|
||||
'chart.events': 'رویدادها',
|
||||
'chart.pressure': 'فشار',
|
||||
'chart.hour': 'ساعت',
|
||||
'chart.therapyDuration': '\u0645\u062F\u062A \u062F\u0631\u0645\u0627\u0646', 'chart.eventBreakdown': '\u062A\u0648\u0632\u06CC\u0639 \u0631\u0648\u06CC\u062F\u0627\u062F\u0647\u0627',
|
||||
'chart.reference4h': '\u062A\u0637\u0627\u0628\u0642 4\u0633',
|
||||
@@ -2872,6 +2895,7 @@ const resources = {
|
||||
|
||||
'chart.ahiTrend': 'AHI', 'chart.hours': 'Jam', 'chart.therapyDuration': 'Tempoh terapi',
|
||||
'chart.events': 'Peristiwa',
|
||||
'chart.pressure': 'Tekanan',
|
||||
'chart.hour': 'Jam',
|
||||
'chart.eventBreakdown': 'Pecahan peristiwa', 'chart.reference4h': 'Pematuhan 4j',
|
||||
|
||||
@@ -2958,6 +2982,7 @@ const resources = {
|
||||
|
||||
'chart.ahiTrend': 'AHI', 'chart.hours': '\u0E0A\u0E31\u0E48\u0E27\u0E42\u0E21\u0E07', 'chart.therapyDuration': '\u0E23\u0E30\u0E22\u0E30\u0E40\u0E27\u0E25\u0E32\u0E01\u0E32\u0E23\u0E23\u0E31\u0E01\u0E29\u0E32',
|
||||
'chart.events': 'เหตุการณ์',
|
||||
'chart.pressure': 'ความดัน',
|
||||
'chart.hour': 'ชั่วโมง',
|
||||
'chart.eventBreakdown': '\u0E01\u0E32\u0E23\u0E41\u0E08\u0E01\u0E41\u0E08\u0E07\u0E40\u0E2B\u0E15\u0E38\u0E01\u0E32\u0E23\u0E13\u0E4C', 'chart.reference4h': '\u0E04\u0E27\u0E32\u0E21\u0E2A\u0E2D\u0E14\u0E04\u0E25\u0E49\u0E2D\u0E07 4\u0E0A\u0E21',
|
||||
|
||||
@@ -3045,6 +3070,7 @@ const resources = {
|
||||
|
||||
'chart.ahiTrend': 'AHI', 'chart.hours': '\u0413\u043E\u0434\u0438\u043D\u0438', 'chart.therapyDuration': '\u0422\u0440\u0438\u0432\u0430\u043B\u0456\u0441\u0442\u044C \u0442\u0435\u0440\u0430\u043F\u0456\u0457',
|
||||
'chart.events': 'Подій',
|
||||
'chart.pressure': 'Тиск',
|
||||
'chart.hour': 'Година',
|
||||
'chart.eventBreakdown': '\u0420\u043E\u0437\u043F\u043E\u0434\u0456\u043B \u043F\u043E\u0434\u0456\u0439', 'chart.reference4h': '\u0412\u0456\u0434\u043F\u043E\u0432\u0456\u0434\u043D\u0456\u0441\u0442\u044C 4\u0433\u043E\u0434',
|
||||
|
||||
@@ -3132,6 +3158,7 @@ const resources = {
|
||||
|
||||
'chart.ahiTrend': 'AHI', 'chart.hours': 'Gi\u1EDD', 'chart.therapyDuration': 'Th\u1EDDi gian \u0111i\u1EC1u tr\u1ECB',
|
||||
'chart.events': 'Sự kiện',
|
||||
'chart.pressure': 'Áp suất',
|
||||
'chart.hour': 'Giờ',
|
||||
'chart.eventBreakdown': 'Ph\u00E2n b\u1ED5 s\u1EF1 ki\u1EC7n', 'chart.reference4h': 'Tu\u00E2n th\u1EE7 4gi\u1EDD',
|
||||
|
||||
@@ -3219,6 +3246,7 @@ const resources = {
|
||||
|
||||
'chart.ahiTrend': 'AHI', 'chart.hours': 'Timer', 'chart.therapyDuration': 'Terapivarighed',
|
||||
'chart.events': 'Hændelser',
|
||||
'chart.pressure': 'Tryk',
|
||||
'chart.hour': 'Time',
|
||||
'chart.eventBreakdown': 'H\u00E6ndelsesfordeling', 'chart.reference4h': '4t compliance',
|
||||
|
||||
@@ -3306,6 +3334,7 @@ const resources = {
|
||||
|
||||
'chart.ahiTrend': 'AHI', 'chart.hours': 'Timer', 'chart.therapyDuration': 'Terapivarighet',
|
||||
'chart.events': 'Hendelser',
|
||||
'chart.pressure': 'Trykk',
|
||||
'chart.hour': 'Time',
|
||||
'chart.eventBreakdown': 'Hendelsesfordeling', 'chart.reference4h': '4t samsvar',
|
||||
|
||||
@@ -3393,6 +3422,7 @@ const resources = {
|
||||
|
||||
'chart.ahiTrend': 'AHI', 'chart.hours': 'Timmar', 'chart.therapyDuration': 'Terapitid',
|
||||
'chart.events': 'Händelser',
|
||||
'chart.pressure': 'Tryck',
|
||||
'chart.hour': 'Timme',
|
||||
'chart.eventBreakdown': 'H\u00E4ndelsef\u00F6rdelning', 'chart.reference4h': '4t compliance',
|
||||
|
||||
|
||||
@@ -105,6 +105,15 @@ pub struct ReportSession {
|
||||
pub signals: Option<Vec<ReportSignalSummary>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub event_density: Option<Vec<ReportHourBucket>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pressure_timeline: Option<ReportPressureTimeline>,
|
||||
}
|
||||
|
||||
/// Raw pressure time-series for rendering timeline charts.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReportPressureTimeline {
|
||||
pub sample_rate_hz: f32,
|
||||
pub samples: Vec<f32>, // pressure in hPa
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -259,6 +268,19 @@ fn build_report_session(session: &Session, show_events: bool, show_signals: bool
|
||||
} else {
|
||||
None
|
||||
},
|
||||
pressure_timeline: if show_signals {
|
||||
session.signals.as_ref().and_then(|block| {
|
||||
block.channels.iter()
|
||||
.find(|ch| matches!(ch.label, crate::entities::SignalLabel::Pressure)
|
||||
&& ch.encoding_confirmed && !ch.samples.is_empty())
|
||||
.map(|ch| ReportPressureTimeline {
|
||||
sample_rate_hz: ch.sample_rate_hz,
|
||||
samples: ch.samples.clone(),
|
||||
})
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user