前二十天處理的都是最近的資料—這週的課表、上週的達成率、近九十天的負荷。
但 Garmin 帳號裡還有另一批東西:六場全馬,橫跨十七個月。
| 日期 | 賽事 | 完賽 |
|---|---|---|
| 2024-11-10 | 福岡馬 | 3:37:24 |
| 2024-12-15 | 臺北馬 | 3:28:41 |
| 2025-03-09 | 國道馬 | 3:34:39 |
| 2025-12-21 | 臺北馬 | 3:30:40 |
| 2026-03-01 | 東京馬 | 3:22:07 |
| 2026-04-25 | 台東 CT | 3:56:54 |
每一場都有完整的逐秒紀錄。加起來 77,433 個點。
今天要先解決一個更基本的問題:這六場要怎麼畫在同一張圖上。

儀表板上的 LineChart.vue 是自己刻的 SVG,一條線一個 <path>:
const path = (vals: number[]) =>
vals.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i)},${y(v)}`).join(' ');
單場東京馬 12,128 點,這串 d 屬性算出來是 140 KB 的字串。六場疊起來接近 1 MB 的 DOM 屬性,而且每次切換橫軸都要整串重算、瀏覽器重新 parse。
更根本的問題是:螢幕上並沒有那麼多像素。 一張 800 px 寬的圖要畫 12,128 個點,平均每個像素擠 15 個,而一個像素只顯示得了一個顏色—多出來的十四個,畫了也看不見。
既然畫不出來,就得思考怎麼挑選呈現的點,
每條線壓到畫布寬度的八成左右。 不是剛好等於寬度,因為六條線疊在一起的時候每條都塞滿會糊成一片,留兩成的空隙線才分得開。
挑法用 LTTB(Largest-Triangle-Three-Buckets),把資料切成 N 個桶,每桶挑一個「跟前後兩點圍成的三角形面積最大」的點:
// 面積大 = 那個點偏離直線最多 = 視覺上的轉折
const area = Math.abs(
(px - ax) * (y(data[j]) - py) - (px - x(data[j])) * (ay - py)
);
if (area > best) { best = area; bestIdx = j; }
比起等距取樣,它挑的是轉折而不是固定位置。東京馬 35K 之後配速從 4:51 掉到 5:57,那個彎值得留下來。
降採樣之後六場只剩三千多點,SVG 其實撐得住了,但這邊還是換成 Canvas,因為切換橫軸的時候整張圖要重畫——DOM 的更新成本在那裡,Canvas 沒有 DOM。
補充換的代價:
代價是失去 DOM 帶來的東西:沒有 hover 事件、沒有 CSS、螢幕閱讀器讀不到。
所以 hover 要自己算最近點,無障礙靠底下的表格補。
這三件事都需要自己加上, hover 得手動找最近點:
let best = null, bd = Infinity;
for (const s of curves.value) {
for (const p of s.pts) {
const d = Math.abs(p.x - xv);
if (d < bd) { bd = d; best = { ... }; }
}
}
還有 Retina 的問題—canvas 的像素緩衝要乘上 devicePixelRatio,不然線是糊的:
const dpr = window.devicePixelRatio || 1;
cv.width = w * dpr; cv.height = h * dpr;
cv.style.width = `${w}px`;
c.setTransform(dpr, 0, 0, dpr, 0, 0);
這個問題我原本沒想到,是把六條線畫出來才發現的。
六場長度不一樣:
東京馬 42.65 km / 12,127 秒
台東 CT 41.99 km / 14,214 秒
台東 CT 比東京馬多花了 35 分鐘,GPS 測到的總距離還少了 660 公尺,所以六條線的終點不會落在同一個地方—而那正好是兩種看法的分界:
| 橫軸 | 用來看 | 代價 |
|---|---|---|
| 實際距離 | 「30K 的時候配速是多少」 | 終點不齊,六場差 660 公尺 |
| 拉齊終點 | 「掉速是不是都在同一個相對位置」 | 「35K 撞牆」這種絕對位置消失 |
兩個都對,只是回答不同問題,所以做成可切換的,而且把取捨直接印在圖底下:
export const X_AXIS: Record<XAxis, { label: string; hint: string }> = {
dist: {
label: '實際距離',
hint: '橫軸是跑了幾公尺,終點落在各自的實際位置。用來看…',
},
pct: {
label: '拉齊終點',
hint: '每場拉成 0–100%,同時起跑同時結束。用來看…',
},
};
按鈕的文字也是刻意的:原本我寫「距離」和「完賽比例」—那是在描述單位,不是在描述你會看到什麼。改成「實際距離」和「拉齊終點」之後,不用讀說明也猜得到差別在哪。
Day 28 會講 3D 的垂直軸也是設計決策,這裡是同一件事,只是換成橫軸。
六條線疊起來,我原本預期會看到「每一場都在後段掉速」。
實際上不是。把前 10K 和 32–42K 的平均拉出來:
| 賽事 | 前 10K | 32–42K | 掉速 | 心率變化 |
|---|---|---|---|---|
| 福岡馬 | 5:19 | 5:19 | −1 秒 | +23 |
| 臺北馬 2024 | 4:52 | 5:03 | +11 秒 | +16 |
| 國道馬 | 4:45 | 6:05 | +80 秒 | −6 |
| 臺北馬 2025 | 4:44 | 5:39 | +55 秒 | +4 |
| 東京馬 | 4:32 | 5:39 | +67 秒 | +4 |
| 台東 CT | 5:01 | 6:22 | +81 秒 | ±0 |
兩場幾乎沒掉速—福岡零、臺北馬 2024 只慢 11 秒。
而真正的規律不在掉速那欄,在心率那欄:
福岡 掉速 −1 秒 心率 +23
台東 掉速 +81 秒 心率 ±0
心率還爬得上去的場次,配速守得住;心率爬不上去的,配速就崩。
這件事單看一場看不出來,比較後會發現如果天氣條件沒有到很完美,並且設定的目標太積極,那後半段崩掉就是能預期的事。
因為六場的天氣差很多,Garmin 有存每場的當日天氣(get_activity_weather),拉出來按氣溫排:
東京馬 12.2° ← 最冷,但後半有到17度
臺北馬24 15.6°
國道馬 16.7°
福岡馬 18.3°
臺北馬25 20.0°
台東 CT 24.4° ← 最熱
所以圖例的顏色不照時間排,照氣溫排:越熱越紅。
這樣一眼看得出來:最快那場是最冷那場,最慢那場是最熱那場。
(同一份天氣資料還有露點和濕度,一併放進表格裡,但排序用氣溫—那是研究裡影響最顯著的變數。)
至於「天氣造成了多少差異」—那要下一篇才講得清楚,而且沒有那麼直觀(六場裡有一場是例外),今天先讓那條線可以被渲染出來。
六場一次全載是 3.5 MB。切到那個分頁才載,所以不影響首頁,但按下去還是要等一下。
而畫面上根本畫不到那麼多點——降採樣壓完剩三千多,等於下載了一堆丟掉的東西。比較好的做法是先載壓過的版本,要看逐秒細節再抓完整的,之後會再詳談。
今天處理的都是怎麼畫。
明天處理畫出來之後看到什麼—特別是台東 CT那場心率不動、配速卻崩掉的現象,以及那個天氣規律的特例。