本文同步發表於個人部落格:呈現臨床資料(一)
火線超人有一個給病人填的表單,五個欄位:收縮壓、舒張壓、脈搏、血糖、體重。
每個欄位都是選填。病人今天只量了血壓就只填血壓,有填值的那幾項才會被寫成 Observation。收縮壓 138、舒張壓 85、脈搏 72 送出去,會變成三筆各自獨立的資源。
把這台 sandbox 打開才發現,它的血壓不是這樣存的。收縮壓與舒張壓掛在同一筆資源上,用兩個 component 裝。
同一件事,兩種寫法。哪一種都可能遇到,不過今天這支程式只處理 sandbox 這種 component 結構。手上只有這台的資料可以驗,把另一種也寫進去等於寫一段沒跑過的程式碼。
昨天的 app 只讀了 Patient。今天要讀 Observation,得先多要一項權限。SCOPE 那一行加上 patient/Observation.rs 就行。
要補一句實話。這台 sandbox 對權限的執行很寬鬆,不加這個 scope 照樣讀得到。連 Authorization header 都不帶也一樣回 200。所以你在這裡拿掉它也不會壞。
但那是這台的行為,不是規範保證的行為。正式的伺服器會擋,該要的權限還是要要。
先看一筆血壓實際回來的樣子,這裡只留跟畫面有關的欄位:
{
"resourceType": "Observation",
"status": "final",
"code": {
"coding": [
{ "system": "http://loinc.org", "code": "55284-4", "display": "Blood Pressure" }
],
"text": "Blood Pressure"
},
"effectiveDateTime": "2020-12-04T23:37:54+00:00",
"component": [
{
"code": { "coding": [{ "code": "8462-4", "display": "Diastolic Blood Pressure" }] },
"valueQuantity": { "value": 86.44305537931304, "unit": "mm[Hg]" }
},
{
"code": { "coding": [{ "code": "8480-6", "display": "Systolic Blood Pressure" }] },
"valueQuantity": { "value": 129.75969944651848, "unit": "mm[Hg]" }
}
]
}
再看一筆體重:
{
"resourceType": "Observation",
"code": {
"coding": [
{ "system": "http://loinc.org", "code": "29463-7", "display": "Body Weight" }
]
},
"effectiveDateTime": "2020-12-04T23:37:54+00:00",
"valueQuantity": { "value": 93.29082484586738, "unit": "kg" }
}

原因是血壓本來就不是一個數字。收縮壓跟舒張壓是同一次量測的兩個結果。拆成兩筆資源不是不行,火線超人就是這樣做的。只是那兩筆的關聯要靠讀取端自己比對時間戳推出來。component 把它們綁在同一筆上,關聯是資料自己講的,不用猜。
第一個:血壓的代碼跟你在別處看到的不一樣。 不少文章講血壓都寫 85354-9,這台伺服器用的是 55284-4。
LOINC 是一套給檢驗與量測項目編號的國際代碼表。血壓、體重這類項目各有自己的號碼。
85354-9 是 FHIR 生命徵象 profile 指定的血壓代碼,那些文章照著規範寫沒有錯。這台 sandbox 用的 55284-4 不在那份 profile 裡。有意思的是內層對得上,component 用的 8480-6 與 8462-4 正是 profile 規定的那兩個。
所以代碼要以伺服器實際回的為準,連規範指定的都可能對不上。
第二個:component 的順序不保證。 上面那筆資料裡,舒張壓排在收縮壓前面。如果你寫 component[0] 當收縮壓,在這台伺服器上會拿到舒張壓。圖畫出來高低顛倒,而且不會有任何錯誤訊息。要認代碼,不要認索引。
第三個:那些小數。 129.75969944651848 是合成資料的產物,真實血壓計不會給你十四位小數。但程式不能假設它不會出現,畫上圖或印在畫面上之前都得先四捨五入。
既然代碼不能照抄,接手一台沒看過的伺服器時,第一件事就是盤點它到底用了哪些代碼。
做法是撈一批下來數:
GET /Observation?patient=<id>&category=vital-signs&_count=100
把回來的每一筆的 code.coding[].code 拉出來統計。撈回來的 50 筆共五種代碼:
| LOINC | 筆數 | display | 值的位置 |
|---|---|---|---|
| 39156-5 | 10 | Body Mass Index | valueQuantity |
| 55284-4 | 10 | Blood Pressure | component 兩項 |
| 29463-7 | 10 | Body Weight | valueQuantity |
| 8302-2 | 10 | Body Height | valueQuantity |
| 72514-3 | 10 | Pain severity - 0-10 verbal numeric rating [Score] - Reported | valueQuantity |
最後那一列的 display 就是 LOINC 的完整名稱,不是我沒截乾淨。真的接起資料來,這種長名稱很常見,要顯示到畫面上得自己決定截到哪裡。
十分鐘的事,換來的是知道自己要處理哪幾種結構。這張表也直接回答了「這台有沒有心跳」,答案是沒有。
_sort=date 不是為了整齊。不寫的話,這台伺服器回來的順序是這樣:
2012-10-19
2017-11-17
2019-11-29
2014-10-31
2013-10-25
完全沒有時間順序。FHIR 規範沒有規定搜尋結果的預設排序,伺服器愛怎麼排就怎麼排。趨勢圖的 x 軸照這個順序排,線會來回亂跳。你可以把那個參數拿掉重跑一次,畫面立刻壞給你看。
另一個是 status。Observation 有這個欄位,常見的有 final、preliminary 與 entered-in-error。最後那個的意思是「這筆記錄被標記為誤登」,畫上趨勢圖是危險的。
FHIR 把 status 標成 modifier element。這個標記的意思是它會改變整筆資料該怎麼被理解,不是一個可以順手跳過的欄位。同一組數字,final 是「這是量測結果」,entered-in-error 是「這筆不算數」。
這批 sandbox 資料全部是 final。這位病人全部類別合計 94 筆,我一筆一筆數過,沒有第二種值。全站查 entered-in-error 也是零筆。所以你在這裡加過濾看不出任何效果。真實系統可能會有,而且那是臨床安全問題不是顯示問題,所以待會的程式碼還是會擋。
component 裡找代碼:
function componentValue(observation, loincCode) {
const hit = observation.component?.find((one) =>
one.code?.coding?.some((coding) => coding.code === loincCode)
)
return hit?.valueQuantity?.value ?? null
}
三層可選鏈。component 可能不存在,code.coding 可能不存在,valueQuantity 也可能不存在。找不到就回 null。
這個 null 不只是防禦,它是有意義的。一筆血壓只量到收縮壓,舒張壓那一段缺了。畫圖時該點就是空的,不是零。血壓 0 跟沒量到血壓是完全不同的兩件事,混在一起會畫出一張很嚇人的圖。
體重那邊簡單得多,observation.valueQuantity?.value ?? null 就結束了。
把三條線畫在同一張圖上,會遇到一個單位問題。收縮壓落在 106 到 137,舒張壓落在 72 到 90,體重落在 87 到 93。數字看起來很接近,但單位一個是 mm[Hg] 一個是 kg。放在同一個刻度上是在比較兩件不可比較的事。

Chart.js 給每個 dataset 指定 yAxisID 就能分軸。畫圖那段獨立成一個函式,重畫之前先把舊的砍掉:
let chart = null
export function renderChart(canvas, data) {
chart?.destroy()
chart = new Chart(canvas, {
type: 'line',
data,
options: {
responsive: true,
scales: {
pressure: {
type: 'linear',
position: 'left',
title: { display: true, text: 'mm[Hg]' },
},
weight: {
type: 'linear',
position: 'right',
title: { display: true, text: 'kg' },
},
},
},
})
return chart
}
那個 chart?.destroy() 不能省。Chart.js 會在 canvas 上留一個實例,同一張畫第二次會直接報錯。
x 軸這邊用的是 category,不是時間軸。這批資料一年一筆,間隔本來就不平均。要畫成真正等距的時間軸,Chart.js 4 得另外掛一個日期轉接器。vendor 目錄要多放轉接器跟它依賴的日期函式庫。換來的好處在這種稀疏取樣上看不出來。
火線超人的表單有脈搏欄位,寫的是 8867-4。我原本要用心跳當第二條線。
實際查下去,這台 sandbox 的 8867-4 是零筆。不是我挑的那位病人沒有,是整批資料一筆都沒有。呼吸速率、血氧也一樣查無。我另外抽了 250 筆 vital-signs 來看,出現的代碼是六種。分別是血壓、體重、身高、BMI、疼痛評分、口溫。
所以第二條線改用體重。這不是退而求其次。體重的值直接掛在 valueQuantity,跟血壓的 component 剛好是一組對照。單位也真的不同,一篇文章要講的兩件事都齊了。
要提醒一件事:體重那條線從 2014 年之後七筆完全相同,圖上後半是一條水平線。那是合成資料的產物,不是你畫錯了。
起點是 day15 結束時的專案:index.html、app.js、patient.js、vendor/fhir-client.pure.min.js,跑起來畫面上有四欄基本資料。
curl -o vendor/chart.umd.js \
https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.js
208518 bytes,約 204 KB。這是目前這個專案最大的一個相依,比 fhirclient 大了將近四倍。
為什麼是 Chart.js?先被刷掉的是那些只出 ESM 或要靠打包工具的函式庫。這個系列從 day04 就定調不用建置步驟。vendor/ 裡的檔案要能用一個 script 標籤直接載進來。Chart.js 4 有出 chart.umd.js,上面那行 curl 抓的就是它。
剩下兩個理由這一篇剛好都用得到。雙 y 軸只要給每個 dataset 指定 yAxisID 就分好了。缺值要斷還是要接,spanGaps 一個選項就切換得了。這批資料稀疏,接過去比斷成一截一截好讀,所以待會三條線都會設成 true。
值不值得?自己刻 SVG 折線大概三十行就能動。但軸標籤、雙單位、缺值斷線會讓那三十行長到上百行,而那些行數跟 FHIR 無關。這裡選擇用檔案大小換教學焦點。正式專案有建置步驟,可以只打包用得到的那部分。
app.js 的 SCOPE 常數加上 patient/Observation.rs:
const SCOPE =
'launch/patient patient/Patient.r patient/Observation.rs openid fhirUser offline_access'
.rs 是 read 加 search。這次授權的同意畫面會從三行變五行,多出來的是 Read Observation records 跟 Search for Observation records。一個 scope 兩個字母,同意畫面上各給一行,day10 講的語法在這裡看得見。
export const BLOOD_PRESSURE = '55284-4'
export const BODY_WEIGHT = '29463-7'
const SYSTOLIC = '8480-6'
const DIASTOLIC = '8462-4'
const USABLE_STATUS = new Set(['final', 'amended', 'corrected'])
function usable(observation) {
return USABLE_STATUS.has(observation.status)
}
function componentValue(observation, loincCode) {
const hit = observation.component?.find((one) =>
one.code?.coding?.some((coding) => coding.code === loincCode)
)
return hit?.valueQuantity?.value ?? null
}
function day(observation) {
return observation.effectiveDateTime?.slice(0, 10) ?? ''
}
function round(value) {
return value == null ? null : Math.round(value)
}
export async function loadVitals(client) {
const query = (code) =>
client.request(
`Observation?patient=${client.patient.id}&code=${code}&_sort=date&_count=100`,
{ pageLimit: 0, flat: true }
)
const [bloodPressure, weight] = await Promise.all([
query(BLOOD_PRESSURE),
query(BODY_WEIGHT),
])
return {
bloodPressure: bloodPressure.filter(usable),
weight: weight.filter(usable),
}
}
USABLE_STATUS 就是前面講的那件事。final 是定稿,amended 與 corrected 是後來改過但仍然算數的結果,這三種可以畫。其餘的一律不畫,包括 entered-in-error,也包括還沒定稿的 preliminary 跟根本沒有值的 registered。
過濾放在 loadVitals() 而不是畫圖那一段,是因為資料一進來就該篩掉。後面組圖表資料的程式碼就不必每個地方都再想一次這件事。
client.request() 的兩個選項值得記一下。pageLimit: 0 是「跟到最後一頁」,flat: true 是「把 Bundle 的 entry 攤平成資源陣列」。這兩件事自己做要寫十幾行,細節 day20 會攤開來講。
兩個查詢沒有先後關係,用 Promise.all 一起發出去,不要一個等一個。
前面那個 renderChart() 也放進這個檔案一起 export,app.js 才叫得到。
export function toChartData({ bloodPressure, weight }) {
const labels = [...new Set([...bloodPressure, ...weight].map(day))]
.filter(Boolean)
.sort()
const pick = (list, getValue) =>
labels.map((label) => {
const hit = list.find((one) => day(one) === label)
return hit ? round(getValue(hit)) : null
})
return {
labels,
datasets: [
{
label: '收縮壓 (mm[Hg])',
data: pick(bloodPressure, (one) => componentValue(one, SYSTOLIC)),
yAxisID: 'pressure',
borderColor: '#a8483e',
spanGaps: true,
},
{
label: '舒張壓 (mm[Hg])',
data: pick(bloodPressure, (one) => componentValue(one, DIASTOLIC)),
yAxisID: 'pressure',
borderColor: '#d98b7f',
spanGaps: true,
},
{
label: '體重 (kg)',
data: pick(weight, (one) => one.valueQuantity?.value ?? null),
yAxisID: 'weight',
borderColor: '#2f4858',
spanGaps: true,
},
],
}
}
先把兩種量測的日期合成一組去重過的標籤,再讓每條線按這組標籤對齊。某條線在某個日期沒有資料就填 null,配上 spanGaps: true,圖會在那裡把線接過去而不是斷成兩截。
index.html 加一個 canvas 跟圖表庫:
<h2>生命徵象趨勢</h2>
<canvas id="vitals" height="140"></canvas>
<script src="vendor/fhir-client.pure.min.js"></script>
<script src="vendor/chart.umd.js"></script>
app.js 頂端加一行抓 canvas,然後在顯示完基本資料之後接上:
const vitalsCanvas = document.querySelector('#vitals')
async function showVitals(client) {
const data = toChartData(await loadVitals(client))
renderChart(vitalsCanvas, data)
const counted = data.datasets.map(
(one) => one.data.filter((value) => value !== null).length
)
console.log('趨勢圖:', data.labels.length, '個日期,各線', counted)
}
跑起來 console 應該印出:
趨勢圖: 10 個日期,各線 [10, 10, 10]

完整可跑的版本在 GitHub 上的 day16-clinical-data,想先看跑起來的樣子可以直接開線上版。那一份的 vitals.js 跟 app.js 比上面多了幾行註解,邏輯逐字相同,只有 FHIR_BASE_URL 那一行要換成自己的。
同樣是 Observation,值可能長在 component 裡,也可能直接掛在資源上。取值要認代碼不認索引,缺值要回 null 不要回零。
血壓體重是數字,畫成圖就懂了。明天那兩種資料沒這麼好對付。病況跟用藥是文字,而 FHIR 裡有三層可能放名字的地方,三層都可能是空的。