昨天把六場全馬疊成線圖及色塊圖,六條資料同時攤在畫面上。
圖回答的是整場的狀況長什麼樣,但它秀不出另一種問題:跑到第 90 分鐘的時候,誰在前面?以及該場的配速及距離目前是多少。
所以今天做回放—六場同時起跑,按時間軸重播一次。
在大綱中原本有規劃到「WebSocket vs SSE:即時推播技術選型」。
當初這樣排是因為之前想要練習 websocket,想說可以結合馬拉松資料的推播,來即時重現跑步當下的配速及心律,
但「即時」兩個字,回放看起來就是即時更新的東西,和馬拉松已發生的資料相比有點矛盾。
實作到一半才發現:沒有任何東西需要推播。
六場的逐秒資料在頁面載入的時候就全部進到瀏覽器裡了,回放做的事情,只是按時間軸重新讀一遍已經在記憶體裡的陣列。
伺服器沒有東西可以推。
推播是為了「資料在別的地方、而且會變」—即時比賽的定位、股價、聊天訊息,而我的資料是四個月前跑完的,它不會再改變。
所以需要的只有一個東西:一個跟著螢幕更新率走的計時器。
requestAnimationFrame,不是 setInterval
直覺會寫成這樣:
setInterval(() => { t += 1; }, 1000 / 60);
會遇到兩個問題:
第一,間隔不保證準。 setInterval 只承諾至少多久之後執行,實際上要排隊等主執行緒,畫面在忙的時候他可能就會被延遲,而遲到的量不會被補回來。
第二,分頁切到背景會被降頻。 Chrome 的文件寫得很明確:從 Chrome 11 開始,背景分頁的每個 timer「no more than once per second」。
所以切去別的分頁三十秒再回來,回放的時間軸只前進了三十次而不是一千八百次—畫面上的時間會跟以為的對不上。
(Chrome 88 之後還有更重的:分頁隱藏超過五分鐘又沒有聲音的話,變成一分鐘才檢查一次。)
requestAnimationFrame 兩個都解決了:它跟著螢幕的更新頻率走,而且背景分頁直接暫停,回來接著跑。
function tick() {
t += 1 / 60; // ❌
requestAnimationFrame(tick);
}
這行假設 rAF 一秒會呼叫 60 次,但它一秒呼叫幾次,是使用者的螢幕更新率決定的,不是這邊決定的。
120Hz 的螢幕一秒收到 120 次呼叫,這樣寫會播兩倍快。省電模式降到 30Hz 就變半速。
正確的做法是用 delta time—問「距離上一幀過了多久」:
function tick(now: number) {
if (!playing.value) return;
// 第一幀沒有前一幀可以比,先記下來就好
const dt = last ? (now - last) / 1000 : 0;
last = now;
t.value += dt * speed.value;
...
}
now 是 rAF 傳進來的時間戳,這樣不管螢幕幾赫茲,一秒就是一秒。
至於 last ? ... : 0 也是必要的:第一幀沒有前一幀,不處理的話 now - undefined 會得到 NaN,然後整個時間軸壞掉。
圖上六場各有一個記號,隨著播放各自往右走。
但只有點在動的話,你看得到誰在前面,看不到他當下跑得多快,所以清單那邊要顯示即時資訊。
第一版直接讀那一秒的值:
const s = stateAt(race.points, t); // 二分搜尋,逐秒陣列有一萬多筆
// s.p 就是那一秒的配速
結果數字閃到看不懂。
因為 GPS 算出來的瞬時配速抖得很兇—同一段路,逐秒的值可能在 4:30 和 5:10 之間跳。60 倍速播放的時候,那個數字每秒變一百次。
所以改成取區間平均:
/** 某場比賽在第 sec 秒前後 window 秒的平均配速與心率。
*
* 不能直接讀那一秒的值 — GPS 算出來的瞬時配速抖得嚴重,
* 播放的時候數字會閃到看不懂,取一個區間平均才讀得出來。 */
export function windowAt(pts, sec, window = 30) { ... }
前後 30 秒 ,這個數字是量出來的—窗口越寬數字越穩,但也越慢反應:
| 窗口 | 數字每秒跳動 | 落後真實變化 |
|---|---|---|
| 10 秒 | 1.00 秒/km | 5 秒 |
| 30 秒 | 0.58 | 15 秒 |
| 60 秒 | 0.31 | 30 秒 |
| 120 秒 | 0.17 | 60 秒 |
30 秒是自己測試幾次所決定的,60 秒更穩,但一場全馬裡三十秒的落後已經看得出來。
而且畫面上要標出來—清單底下寫著「距離 · 配速 · 心率(30 秒平均)」,那不是那一秒的真實值,提醒使用者情況。
之前提過六條線需要能個別關掉,所以有一份清單當圖例兼選擇器,回放的時候,那份清單整列換內容:
沒播放的時候,顯示的是這場比賽的結果:
● 東京馬 3:22:07 4:44 12.2°
● 臺北馬 24 3:28:41 4:54 15.6°
播放的時候,換成當下的狀態,而且依距離即時重排:
1 ● 東京馬 12.40k 4:41 162
2 ● 臺北馬 24 11.82k 4:58 171
3 ● 台東 CT 10.05k 5:34 168
因為溫度只有起跑時有記錄,所以起跑後不顯示溫度,顯示當下的心率資料。

當看到起跑後六場各自顯示這確認速度及排序順序,真的有種說不出的感動
每一場都是自己努力的過程,而自己也持續超越自己!
到今天為止我們把六場、七萬多個點、一秒一秒放回去,比較是把比賽攤開來看。
明天反過來—把一整場壓成一個數字 VDOT,用一場成績反推「跑力」,
再拿那個數字去預測其他距離,並且跟 Garmin 自己算的預測值對答案。