No SOFA trend chart or organ-system timeline
No GCS trend chart or component history
No qSOFA history view
This commit is contained in:
voltsrage
2026-06-23 18:00:43 +08:00
parent 729c19830c
commit 7751d6df06
30 changed files with 3866 additions and 41 deletions
@@ -0,0 +1,120 @@
<script setup>
import { computed, shallowRef, markRaw } from 'vue'
import { Line } from 'vue-chartjs'
import { Chart as ChartJS, registerables } from 'chart.js'
import { formatTime } from '@/composables/chartFormat'
ChartJS.register(...registerables)
const props = defineProps({ history: { type: Array, required: true } })
const COMPONENT_DATASETS = [
{ label: 'Eye', key: 'eyeScore', color: '#3b82f6' },
{ label: 'Verbal', key: 'verbalScore', color: '#8b5cf6' },
{ label: 'Motor', key: 'motorScore', color: '#06b6d4' },
]
function gcsRiskBorder(score) {
if (score <= 8) return '#dc2626'
if (score <= 12) return '#f59e0b'
return '#22c55e'
}
function gcsSeverityLabel(score) {
if (score <= 8) return 'Severe (38)'
if (score <= 12) return 'Moderate (912)'
return 'Mild (1315)'
}
const sortedHistory = computed(() =>
[...props.history].sort((a, b) => new Date(a.calculatedAt) - new Date(b.calculatedAt)),
)
const chartData = computed(() => {
const sorted = sortedHistory.value
const labels = sorted.map(h => formatTime(h.calculatedAt))
const componentDatasets = COMPONENT_DATASETS.map(({ label, key, color }) => ({
label,
data: sorted.map(h => h[key] ?? 0),
borderColor: color,
backgroundColor: 'transparent',
borderWidth: 1.5,
borderDash: [4, 3],
pointRadius: 2,
tension: 0.2,
order: 2,
}))
return {
labels,
datasets: [
{
label: 'GCS Total',
data: sorted.map(h => h.totalScore),
borderColor: '#111827',
backgroundColor: sorted.map(h => {
if (h.totalScore <= 8) return 'rgba(220, 38, 38, 0.15)'
if (h.totalScore <= 12) return 'rgba(245, 158, 11, 0.15)'
return 'rgba(34, 197, 94, 0.15)'
}),
pointBackgroundColor: sorted.map(h => gcsRiskBorder(h.totalScore)),
pointBorderColor: sorted.map(h => gcsRiskBorder(h.totalScore)),
pointRadius: 4,
borderWidth: 2,
fill: true,
tension: 0.3,
order: 1,
},
...componentDatasets,
],
}
})
const chartOptions = shallowRef(markRaw({
responsive: true,
maintainAspectRatio: true,
animation: {
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
},
interaction: { mode: 'index', intersect: false },
scales: {
y: {
min: 3,
max: 15,
title: { display: true, text: 'GCS Score' },
},
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: { boxWidth: 12, font: { size: 11 } },
},
tooltip: {
callbacks: {
footer(items) {
const total = items.find(i => i.dataset.label === 'GCS Total')
if (!total) return ''
return gcsSeverityLabel(total.parsed.y)
},
},
},
},
}))
</script>
<template>
<div class="w-full min-w-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<h3 class="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">GCS Over Time</h3>
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
Total score with Eye, Verbal, and Motor components.
<span class="text-green-600 dark:text-green-400">Mild 1315</span>,
<span class="text-amber-600 dark:text-amber-400">Moderate 912</span>,
<span class="text-red-600 dark:text-red-400">Severe 38</span>.
</p>
<div class="aspect-video w-full min-h-0 overflow-hidden">
<Line :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
@@ -0,0 +1,141 @@
<script setup>
import { computed, shallowRef, markRaw } from 'vue'
import { Line } from 'vue-chartjs'
import { Chart as ChartJS, registerables } from 'chart.js'
import { formatTime } from '@/composables/chartFormat'
ChartJS.register(...registerables)
const props = defineProps({ history: { type: Array, required: true } })
const CRITERION_LABELS = [
{ label: 'Resp rate', key: 'respRate', color: '#3b82f6' },
{ label: 'Systolic BP', key: 'systolicBp', color: '#8b5cf6' },
{ label: 'Altered mentation', key: 'avpu', color: '#ec4899' },
]
function criteriaColor(count) {
if (count >= 2) return '#dc2626'
if (count === 1) return '#f59e0b'
return '#22c55e'
}
function criteriaLabel(count) {
if (count >= 2) return 'Screen positive (≥2) — consider SOFA labs'
if (count === 1) return '1 criterion met'
return 'Screen negative'
}
function criterionMet(value) {
return value != null ? 1 : 0
}
const sortedHistory = computed(() =>
[...props.history].sort((a, b) => new Date(a.evaluatedAt) - new Date(b.evaluatedAt)),
)
const chartData = computed(() => {
const sorted = sortedHistory.value
const labels = sorted.map(h => formatTime(h.evaluatedAt))
const criterionDatasets = CRITERION_LABELS.map(({ label, key, color }) => ({
label,
data: sorted.map(h => criterionMet(h.criteria?.[key])),
borderColor: color,
backgroundColor: `${color}33`,
borderWidth: 1,
borderDash: [3, 3],
pointRadius: 0,
stepped: true,
yAxisID: 'criteria',
order: 2,
}))
return {
labels,
datasets: [
{
label: 'Active criteria',
data: sorted.map(h => h.activeCriteria),
borderColor: '#111827',
backgroundColor: sorted.map(h => {
if (h.activeCriteria >= 2) return 'rgba(220, 38, 38, 0.2)'
if (h.activeCriteria === 1) return 'rgba(245, 158, 11, 0.2)'
return 'rgba(34, 197, 94, 0.15)'
}),
pointBackgroundColor: sorted.map(h => criteriaColor(h.activeCriteria)),
pointBorderColor: sorted.map(h => criteriaColor(h.activeCriteria)),
pointRadius: sorted.map(h => (h.screenAlertFired ? 7 : 4)),
pointStyle: sorted.map(h => (h.screenAlertFired ? 'star' : 'circle')),
borderWidth: 2,
fill: true,
tension: 0.2,
yAxisID: 'count',
order: 1,
},
...criterionDatasets,
],
}
})
const chartOptions = shallowRef(markRaw({
responsive: true,
maintainAspectRatio: true,
animation: {
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
},
interaction: { mode: 'index', intersect: false },
scales: {
count: {
type: 'linear',
position: 'left',
min: 0,
max: 3,
ticks: { stepSize: 1 },
title: { display: true, text: 'Criteria count' },
},
criteria: {
type: 'linear',
position: 'right',
min: 0,
max: 1,
display: false,
},
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: { boxWidth: 12, font: { size: 11 } },
},
tooltip: {
callbacks: {
footer(items) {
const total = items.find(i => i.dataset.label === 'Active criteria')
if (!total) return ''
const idx = total.dataIndex
const entry = sortedHistory.value[idx]
const lines = [criteriaLabel(total.parsed.y)]
if (entry?.screenAlertFired) lines.push('qSOFA screen alert fired')
return lines.join(' · ')
},
},
},
},
}))
</script>
<template>
<div class="w-full min-w-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<h3 class="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">qSOFA Screen Over Time</h3>
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
Criteria count (03); star markers indicate when a screen alert fired.
<span class="text-green-600 dark:text-green-400">0</span>,
<span class="text-amber-600 dark:text-amber-400">1</span>,
<span class="text-red-600 dark:text-red-400">2</span>.
</p>
<div class="aspect-video w-full min-h-0 overflow-hidden">
<Line :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
@@ -0,0 +1,121 @@
<script setup>
import { computed, shallowRef, markRaw } from 'vue'
import { Chart } from 'vue-chartjs'
import { Chart as ChartJS, registerables } from 'chart.js'
import { formatTime } from '@/composables/chartFormat'
ChartJS.register(...registerables)
const props = defineProps({ history: { type: Array, required: true } })
const ORGAN_DATASETS = [
{ label: 'Respiratory', key: 'respiratoryScore', color: '#3b82f6' },
{ label: 'Coagulation', key: 'coagulationScore', color: '#8b5cf6' },
{ label: 'Liver', key: 'liverScore', color: '#f59e0b' },
{ label: 'Cardiovascular', key: 'cardiovascularScore', color: '#ef4444' },
{ label: 'CNS', key: 'cnsScore', color: '#ec4899' },
{ label: 'Renal', key: 'renalScore', color: '#06b6d4' },
]
function sofaRiskBorder(score) {
if (score >= 10) return '#dc2626'
if (score >= 6) return '#f59e0b'
return '#22c55e'
}
const sortedHistory = computed(() =>
[...props.history].sort((a, b) => new Date(a.calculatedAt) - new Date(b.calculatedAt)),
)
const chartData = computed(() => {
const sorted = sortedHistory.value
const labels = sorted.map(h => formatTime(h.calculatedAt))
const organDatasets = ORGAN_DATASETS.map(({ label, key, color }) => ({
type: 'line',
label,
data: sorted.map(h => h[key] ?? 0),
backgroundColor: `${color}66`,
borderColor: color,
borderWidth: 1,
fill: true,
stack: 'organs',
pointRadius: 0,
tension: 0.2,
order: 2,
}))
return {
labels,
datasets: [
...organDatasets,
{
type: 'line',
label: 'SOFA Total',
data: sorted.map(h => h.totalScore),
borderColor: '#111827',
backgroundColor: 'transparent',
pointBackgroundColor: sorted.map(h => sofaRiskBorder(h.totalScore)),
pointBorderColor: sorted.map(h => sofaRiskBorder(h.totalScore)),
pointRadius: 4,
borderWidth: 2,
fill: false,
tension: 0.3,
order: 1,
},
],
}
})
const chartOptions = shallowRef(markRaw({
responsive: true,
maintainAspectRatio: true,
animation: {
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
},
interaction: { mode: 'index', intersect: false },
scales: {
x: { stacked: true },
y: {
stacked: true,
min: 0,
max: 24,
title: { display: true, text: 'SOFA Score' },
},
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: { boxWidth: 12, font: { size: 11 } },
},
tooltip: {
callbacks: {
footer(items) {
const total = items.find(i => i.dataset.label === 'SOFA Total')
if (!total) return ''
const score = total.parsed.y
if (score >= 10) return 'Risk: High (≥10)'
if (score >= 6) return 'Risk: Moderate (69)'
return 'Risk: Low (05)'
},
},
},
},
}))
</script>
<template>
<div class="w-full min-w-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<h3 class="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">SOFA Score Over Time</h3>
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
Stacked areas show per-organ contributions; line shows total score.
<span class="text-green-600 dark:text-green-400">05</span>,
<span class="text-amber-600 dark:text-amber-400">69</span>,
<span class="text-red-600 dark:text-red-400">10+</span>.
</p>
<div class="aspect-video w-full min-h-0 overflow-hidden">
<Chart type="line" :data="chartData" :options="chartOptions" />
</div>
</div>
</template>