481 lines
18 KiB
Svelte

<script>
import { tick } from 'svelte';
import { uniq, groupByArray, quantile_sorted, debounce, escapeHTML, escapeRegExp, strHashHSL, datetime_datestr, datetime_timestr, isInViewport, filter_pattern, filter_test, filter_mark, filter_snippets_lines, inherit } from '$lib/util.js';
import { get_api_measurements, get_api_labs } from '$lib/backend.js';
export let data;
let query = '', pattern = null, selection = null, component_items = decorate_measurements(data.measurements).concat(decorate_labs(data.labs));
let debounced_pattern = debounce((/*query*/) => (pattern = query ? filter_pattern(escapeHTML(query)) : null), 200);
$: debounced_pattern(query); // argument `query` is for reactivity hinting only
function decorate_measurements(xs) {
return xs.map(x => (x = Object.assign({ _content: escapeHTML(Object.values(x).join('\x00')), _ts: new Date(x.time) }, x), x.timestr = datetime_timestr(x._ts), x));
}
function decorate_labs(xs) {
return components(xs.map(x => Object.assign({ _content: escapeHTML(Object.values(x).join('\x00')), _ts: new Date(x.time_collected) }, x)));
}
function components(reports) {
const res = [], re_lab_test = /^(?<name>\w[^\r\n]{26})(?<value>[^\r\n]{8}) (?:(?<flag>\w[ \*])| ) (?<unit>[^\r\n]{10}) (?<range>[^\r\n]{16}) \[(?<site>\d+)\]$/gm;
reports.forEach(function(x) {
let datestr = datetime_datestr(x._ts), timestr = datetime_timestr(x._ts), m, r;
while(m = re_lab_test.exec(x.body)) {
m = m.groups;
for(let k in m) if(m[k]) m[k] = m[k].replace(/^\s+|\s+$/g, '');
if((r = m.range) && (r.includes(' - '))) {
r = r.split(' - ');
m.rangeL = r[0];
m.rangeH = r[1];
}
m.datestr = datestr;
m.timestr = timestr;
m.report = x;
Object.assign(m, x);
delete m.body;
delete m._content;
res.push(m);
}
});
return res;
}
function calculate(items) {
var snapshots = [], results = [], history, update, item;
groupByArray(items, x => x._ts).map(group => group.values.reduce(((acc, x) => (acc.values[x.name] = x, acc)), { key: group.key, values: {}})).sort((a, b) => (a.key > b.key) - (a.key < b.key)).forEach(function(group) {
snapshots.push({ key: group.key, values: history = Object.assign(snapshots.length > 0 ? inherit(snapshots[snapshots.length - 1].values) : inherit(calc_constants), update = group.values) });
history['Time'] = update['Time'] = { time: group.key, value: group.key };
for(var i = 0; i < calc_functions.length; ++i) {
var calculation = calc_functions[i], deps = calculation.deps;
for(var j = deps.length - 1, satisfied = true, updated = false; j >= 0; --j) if(!history[deps[j]]) { satisfied = false; break; }
else if(update[deps[j]]) updated = true;
if((satisfied) && (updated)) {
item = calculation.calc(...calculation.deps.map(x => history[x].value), history[calculation.name] && history[calculation.name].value);
if((item !== undefined) && (item !== null) && (item === item) && (item != 'NaN')) { // item === item if not NaN
results.push(history[calculation.name] = update[calculation.name] = Object.assign({ _ts: group.key, value: item }, calculation));
if((calculation.hasOwnProperty('rangeL')) && (item < calculation.rangeL)) update[calculation.name].flag = 'L';
else if((calculation.hasOwnProperty('rangeH')) && (item > calculation.rangeH)) update[calculation.name].flag = 'H';
}
}
}
});
return results;
}
const calc_constants = {
DOB: { _ts: null, value: new Date(data.facesheet_data.dob) },
Sex: { _ts: null, value: data.facesheet_data.sex }
};
const calc_functions = [
{ name: 'Age', unit: 'yr', deps: ['Time', 'DOB'], calc(Time, DOB, prev) { var x = Math.floor((Time - DOB.getTime())/3.15576e10); return x != prev ? x : undefined; } },
{ name: 'BMI', unit: 'kg/m²', rangeL: 18.5, rangeH: 24.9, range: '18.5 - 24.9', deps: ['Ht', 'Wt'], calc: (Ht, Wt) => (10000*Wt/(Ht*Ht)).toPrecision(3) },
{ name: 'BSA', unit: 'm²', deps: ['Ht', 'Wt'], calc: (Ht, Wt) => (0.007184*Math.pow(Ht, 0.725)*Math.pow(Wt, 0.425)).toPrecision(3) },
{ name: 'CrCl', unit: 'mL/min', deps: ['Age', 'Sex', 'Wt', 'CREATININE'], calc: (Age, Sex, Wt, CREATININE) => (((140 - Age) * Wt)/(72*CREATININE)*(Sex == 'MALE' ? 1 : 0.85)).toPrecision(4) },
{ name: 'RETICYLOCYTE#', unit: 'K/cmm', rangeL: 50, rangeH: 100, range: '50 - 100', deps: ['RBC', 'RETICULOCYTES'], calc: (RBC, RETICULOCYTES) => (10*RBC*RETICULOCYTES).toPrecision(3) }
];
$: component_calculated = component_items.concat(calculate(component_items));
$: component_groups = groupByArray(component_calculated, x => new Date(x._ts.getFullYear(), x._ts.getMonth(), x._ts.getDate())).map(function(group) {
group = group.values.reduce(((acc, x) => ((acc.values[x.name] || (acc.values[x.name] = [])).push(x), acc)), { key: group.key, datestr: datetime_datestr(group.key), datestr_year: group.key.getFullYear(), values: {}});
for(var k in group.values) if(group.values.hasOwnProperty(k)) {
var items = group.values[k].sort((a, b) => a._ts - b._ts);
var strings = items.map(item => item.timestr + ' • ' + item.value + (item.unit ? ' ' + item.unit : '') + (item.flag ? ' [' + item.flag + ']' : '')/* + (item.comment && item.comment.indexOf('\n') < 0 ? ' • ' + item.comment : '')*/);
var flags = uniq(items.map(item => item.flag).filter(x => x).map(x => x.charAt(0)));
//var comments = uniq(items.map(item => item.comment).filter(x => x && x.indexOf('\n') >= 0));
var numbers = uniq(items.map(item => item.value).filter(x => isNumeric(x)));
var min = Math.min.apply(null, numbers);
var max = Math.max.apply(null, numbers);
group.values[k] = {
time: group.key,
name: k,
unit: items[0].unit,
range: items[0].range,
value: numbers.length > 1 ? min + ' - ' + max : numbers.length == 1 ? numbers[0] : items.length == 1 ? items[0].value : 'MULTIPLE', min: min, max: max,
flag: flags.length > 1 ? '*' : flags.length == 1 ? flags[0] : null,
reports: items.map(item => item.report).filter(x => x),
comment: (strings.join('\n') /*+ '\n\n' + comments.join('\n\n')*/).replace(/^\s+|\s+$/g, '')
};
}
return group;
}).sort((a, b) => (a.key > b.key) - (a.key < b.key));
$: component_names = uniq(component_calculated.map(x => x.name));
$: component_stats = statistics(component_calculated);
function isNumeric(n) {
return !isNaN(parseFloat(n)) && isFinite(n);
}
function statistics(resultset) {
var res = {}, group, item;
for(var i = resultset.length - 1; i >= 0; --i) {
item = resultset[i];
if(isNumeric(item.value)) {
if(res[item.name]) res[item.name].push(+item.value);
else res[item.name] = [+item.value];
}
}
for(var k in res) if(res.hasOwnProperty(k)) {
item = res[k].sort((a, b) => a - b);
item = res[k] = {
n: item.length,
q25: quantile_sorted(item, 0.25),
q50: quantile_sorted(item, 0.50),
q75: quantile_sorted(item, 0.75)
}
item.range = item.q25 != item.q75 ? ('IQR: ' + item.q25 + ' - ' + item.q75) : ('Median: ' + item.q50);
}
return res;
}
function tooltip(item) {
if(item) {
var res = [], stat;
if(item.range) res.push('Ref: ' + item.range + ' ' + item.unit + (item.flag ? ' [' + item.flag + ']' : ''));
if(stat = component_stats[item.name]) res.push(stat.range + (item.range ? ' ' + item.unit : '') + (isNaN(parseFloat(item.value)) ? '' : item.value < stat.q25 ? ' [L]' : item.value > stat.q75 ? ' [H]' : ''));
if(item.comment) {
if(res.length > 0) res.push('');
res.push(item.comment);
}
return res.join('\n');
}
};
function abnormal_ref(item) {
return (item) && (item.flag);
};
function abnormal_ref_low(item) {
return (item) && (item.flag) && (item.flag.indexOf('L') >= 0);
};
function abnormal_ref_high(item) {
return (item) && (item.flag) && (item.flag.indexOf('H') >= 0);
};
function abnormal_iqr(item) {
var stat;
if((item) && (stat = component_stats[item.name]) && (stat.n > 2)) {
if((item.hasOwnProperty('min')) && (item.hasOwnProperty('max'))) return (item.min < stat.q25) || (item.max > stat.q75);
else if(isNumeric(item.value)) return (item.value < stat.q25) || (item.value > stat.q75);
}
};
function abnormal_iqr_low(item) {
var stat;
if((item) && (stat = component_stats[item.name]) && (stat.n > 2)) {
if((item.hasOwnProperty('min')) && (item.hasOwnProperty('max'))) return item.min < stat.q25;
else if(isNumeric(item.value)) return item.value < stat.q25;
}
};
function abnormal_iqr_high(item) {
var stat;
if((item) && (stat = component_stats[item.name]) && (stat.n > 2)) {
if((item.hasOwnProperty('min')) && (item.hasOwnProperty('max'))) return item.max > stat.q75;
else if(isNumeric(item.value)) return item.value > stat.q75;
}
};
function abstract(body) {
let re = /^(\w.*?)[ \t]{2,}\S.*?[ \t]{2,}/gm, m, res = [];
while(m = re.exec(body)) res.push(m[1]);
re = /^\*[ ]+((?:.+?) REPORT) =>/gm;
while(m = re.exec(body)) res.push(m[1]);
return res;
}
async function loadmore(evt, factor = 1.5) {
if(loadmore.loading) return;
loadmore.loading = true;
try {
const next_offset = data.offset + (loadmore.limit = (factor*loadmore.limit)|0);
const measurements = await get_api_measurements({ mrn: data.mrn, omega: 'T-' + (data.offset + 1), alpha: 'T-' + next_offset });
Array.prototype.push.apply(component_items, decorate_measurements(measurements));
const labs = await get_api_labs({ mrn: data.mrn, omega: 'T-' + (data.offset + 1), alpha: 'T-' + next_offset });
Array.prototype.push.apply(component_items, decorate_labs(labs));
component_items = component_items; // reactivity hint
data.offset = next_offset;
} finally {
loadmore.loading = false;
}
}
loadmore.loading = false;
loadmore.limit = 30;
let header;
(async function loadinit(target = 16, requests = 4) {
await tick();
for(let i = 0; (i < requests) && (component_groups.length < target); ++i) await loadmore();
await tick();
if((header) && (component_groups)) header.children[header.children.length - 1].scrollIntoView({ block: 'nearest', inline: 'end' });
})();
/*
const observer = new IntersectionObserver((entries) => { if((!query) && (entries[0].isIntersecting)) loadmore(null); }, { root: null, rootMargin: '0px', threshold: 0.5 });
let bottom = null;
$: {
observer.disconnect();
if(bottom) observer.observe(bottom);
}
let reportlist;
async function scroll(selection) {
if(selection) {
await tick();
const el = reportlist.querySelector('.active');
if((el) && (!isInViewport(el, true))) el.scrollIntoView({ block: 'center' });
} else {
const items = reportlist.children;
for(let i = 0, el; i < items.length; ++i) if(isInViewport(el = items[i])) {
await tick();
el.scrollIntoView({ block: 'start' });
break;
}
}
}
$: if(reportlist) scroll(selection);
*/
</script>
<svelte:head>
<title>Flowsheet</title>
</svelte:head>
{#if (selection) && (selection.length > 0)}
<div class="halfpane rightpane">
{#each selection as row, idx}
<nav class="navbar bg-body-secondary">
<div class="container-fluid">
<span class="navbar-brand">{datetime_datestr(row._ts)}@{datetime_timestr(row._ts)} {row.accession} {row.specimen}</span>
{#if idx == 0}<button type="button" class="btn btn-outline-light" on:click={() => selection = null}>❌</button>{/if}
</div>
</nav>
<div class="container-fluid report">{@html pattern ? filter_mark(pattern, escapeHTML(row.body)) : escapeHTML(row.body)}</div>
{/each}
</div>
{/if}
<div class={(selection) && (selection.length > 0) ? 'halfpane leftpane' : ''}>
<div style="font-family: monospace;" role="region" tabindex="0">
<table class="table-sticky table-data">
<thead>
<tr bind:this={header}>
<th class="corner">
<input type="text" class="form-control" placeholder="Filter..." bind:value={query}>
{#if !loadmore.loading}<span class="badge rounded-pill bg-primary position-absolute top-0 start-100 translate-middle-x" style="cursor: pointer;" on:click={loadmore}>More</span>{/if}
</th>
{#each component_groups as group, idx}<th class="date" class:first={ (idx == 0) || (group.key != component_groups[idx - 1].key) }><div class="year">{group.datestr.substring(0, 4)}</div><div class="monthdate">{group.datestr.substring(5)}</div>{#if false}<div class="hourminute daily">{datetime_timestr(group.values[0]._ts)}</div>{/if}</th>{/each}
</tr>
{#if pattern}
{#each component_names as name}
{#if filter_test(pattern, name)}
<tr class="match">
<th>{@html pattern ? filter_mark(pattern, escapeHTML(name)) : escapeHTML(name)}</th>
{#each component_groups as group, idx}
<td class:first={ (idx == 0) || (group.key != component_groups[idx - 1].key) } class:abnormal-ref={abnormal_ref(group.values[name])} class:abnormal-ref-low={abnormal_ref_low(group.values[name])} class:abnormal-ref-high={abnormal_ref_high(group.values[name])} class:abnormal-iqr={abnormal_iqr(group.values[name])} class:abnormal-iqr-low={abnormal_iqr_low(group.values[name])} class:abnormal-iqr-high={abnormal_iqr_high(group.values[name])} title={tooltip(group.values[name])} on:click={() => selection = (group.values[name]) && (group.values[name].reports)}>{group.values[name] ? group.values[name].value : ''}</td>
{/each}
</tr>
{/if}
{/each}
{/if}
</thead>
<tbody>
{#if pattern}
{#each component_names as name}
{#if !filter_test(pattern, name)}
<tr>
<th>{name}</th>
{#each component_groups as group, idx}
<td class:first={ (idx == 0) || (group.key != component_groups[idx - 1].key) } class:abnormal-ref={abnormal_ref(group.values[name])} class:abnormal-ref-low={abnormal_ref_low(group.values[name])} class:abnormal-ref-high={abnormal_ref_high(group.values[name])} class:abnormal-iqr={abnormal_iqr(group.values[name])} class:abnormal-iqr-low={abnormal_iqr_low(group.values[name])} class:abnormal-iqr-high={abnormal_iqr_high(group.values[name])} title={tooltip(group.values[name])} on:click={() => selection = (group.values[name]) && (group.values[name].reports)}>{group.values[name] ? group.values[name].value : ''}</td>
{/each}
</tr>
{/if}
{/each}
{:else}
{#each component_names as name}
<tr>
<th>{name}</th>
{#each component_groups as group, idx}
<td class:first={ (idx == 0) || (group.key != component_groups[idx - 1].key) } class:abnormal-ref={abnormal_ref(group.values[name])} class:abnormal-ref-low={abnormal_ref_low(group.values[name])} class:abnormal-ref-high={abnormal_ref_high(group.values[name])} class:abnormal-iqr={abnormal_iqr(group.values[name])} class:abnormal-iqr-low={abnormal_iqr_low(group.values[name])} class:abnormal-iqr-high={abnormal_iqr_high(group.values[name])} title={tooltip(group.values[name])} on:click={() => selection = (group.values[name]) && (group.values[name].reports)}>{group.values[name] ? group.values[name].value : ''}</td>
{/each}
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
<style>
:global(th mark) {
padding: 0;
font-weight: bold;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: auto;
}
}
[role="region"][tabindex] {
max-height: calc(100vh - 4.5rem);
overflow: auto;
}
table {
width: 100%;
}
table tr.match th:first-child {
background-color: #c6def1;
}
table th, table td {
padding: 0 0.5rem;
white-space: nowrap;
text-align: center;
}
table.table-sticky thead {
position: sticky;
z-index: 1020;
top: 0;
background-color: #fff;
}
table.table-sticky th:first-child {
position: sticky;
z-index: 1010;
left: 0;
background-color: #fff;
}
table.table-sticky thead th.corner {
padding: 0;
}
.navbar {
position: sticky;
z-index: 1020;
top: 3.5rem;
}
.leftpane {
display: none;
}
li.active {
scroll-margin-top: 3.5rem;
}
div.singleline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.abstract {
font-size: 0.8em;
}
.snippets {
font-family: monospace;
}
.report {
font-family: monospace;
white-space: pre-wrap;
}
table.table-sticky {
border: 2px solid #dee2e6;
}
table.table-sticky th:first-child {
border-right: 2px solid #dee2e6;
}
table.table-sticky tbody {
border-top: 2px solid #dee2e6;
}
table.table-sticky tbody tr {
border-top: 1px dashed #dee2e6;
}
table.table-sticky tbody tr:hover {
border: 1px solid #6c757d;
}
td:nth-of-type(odd) {
background-color: rgba(0, 0, 0, 0.05);
}
table.table-sticky tbody th, table.table-sticky th.name {
cursor: default;
font-weight: bold;
text-align: center;
padding-left: 1rem;
padding-right: 1rem;
}
table.table-sticky th.filtered {
background-color: #6c757d;
color: #fff;
}
table.table-sticky th.date {
cursor: default;
font-size: 80%;
font-weight: normal;
text-align: center;
}
table.table-sticky th.date .monthdate {
font-size: 125%;
font-weight: bold;
}
table.table-sticky th.date .hourminute.daily {
display: none;
}
table.table-sticky tbody td {
padding: 0 0.5rem;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
}
.first {
border-left: 1px solid #dee2e6;
}
.year, .monthdate {
visibility: hidden;
}
.first .year, .first .monthdate {
visibility: visible;
}
table.table-data .abnormal-ref::after {
content: ' *';
}
table.table-data .abnormal-ref-low::after {
content: ' L';
}
table.table-data .abnormal-ref-high::after {
content: ' H';
}
table.table-data .abnormal-iqr, table.table-data .abnormal-iqr-low.abnormal-iqr-high {
color: #f39a27;
}
table.table-data .abnormal-iqr-low {
color: #976ed7;
}
table.table-data .abnormal-iqr-high {
color: #c23b23;
}
table.table-data .abnormal-ref, table.table-data .abnormal-iqr {
background-color: #fbffde;
}
table.table-data .abnormal-ref-low.abnormal-iqr-low, table.table-data .abnormal-ref-high.abnormal-iqr-high {
font-weight: bold;
background-color: #ffd1d1;
}
@media screen and (min-width: 720px) {
.halfpane {
position: absolute;
top: 3.5rem;
bottom: 0;
width: 50%;
overflow: auto;
}
.leftpane {
display: block;
left: 0;
z-index: -1;
}
.leftpane [role="region"][tabindex] {
max-height: calc(100vh - 3.5rem);
direction: rtl;
}
.leftpane [role="region"][tabindex] > * {
direction: ltr;
}
.rightpane {
right: 0;
box-shadow: var(--bs-box-shadow);
}
.halfpane .navbar {
top: 0;
}
}
</style>