前兩天的圖,我們都是在桌機上看的,
拿到手機上打開—六條線糊成一團。
今天要來調整這部分,過程中做了蠻多的取捨。

Day 21 說過,一張 800px 寬的圖畫 12,128 個點,多出來的畫了會太密集。
手機只有 300px,反差更明顯,所以第一個想法是:點數要跟著畫面走。
原本寫死 900 點,改成畫布寬度的八成:
const samples = computed(() => Math.max(120, Math.round(cw.value * 0.8)));
桌機 640 點、手機 272 點,少了七成。
然後發現沒什麼差別。
(這個算法後來被換掉了,見下面第三次,現在它只用來決定分箱要多大。)
線太密集,那就加大平滑窗口,GPS 逐秒配速本來就會在 4:30 和 5:10 之間跳,平滑得夠寬應該就順了。
結果是這樣:
| 平滑窗口 | 方向反轉次數 |
|---|---|
| 60 秒 | 292 |
| 120 秒 | 263 |
| 180 秒 | 301 |
(方向反轉 = 線往上又往下的次數,在 300px 寬的圖上,反轉越多視覺上越亂。)
窗口加大到 180 秒,反而更亂。
到這邊有點卡住,去看程式碼才發現:平滑完之後,還會跑一次 LTTB 降採樣。
而 LTTB 挑點的規則是「跟前後兩點圍成的三角形面積最大」—也就是偏離直線最遠的那些點。
我平滑掉的每一個峰和谷,它又原封不動挑了回來。
平滑 → 把雜訊壓平
LTTB → 在壓平的資料裡,專門找剩下最突出的點
兩個步驟的目的是相反的。
不要「先平滑再挑點」,直接按距離分箱取平均:
const meters = width < 480 ? 1000 : 250;
手機每 1 公里一格、桌機每 250 公尺一格,取那一段的平均。一場全馬因此是 42 個點或 168 個點,而且是算出來的,就不用從原始資料裡挑。
反轉從 292 降到 31。
線終於乾淨了—但六條線的顯示嚴重失準了。
因為那從來就不是「線抖不抖」的問題,是六條線共用同一個平面,交錯的地方就是讀不出來,再乾淨也一樣。
換成一種比較常見的做法:一場一條橫帶,顏色代表配速。
福岡馬 黃綠色為主 ── 全場穩定但慢
臺北馬 24 青綠色 ────── 最穩
國道馬 藍→綠,後段紅塊
臺北馬 25 藍綠→紅
東京馬 大片深藍 ──── 最快,紅只在最後
台東 CT 早早出現紅,而且是六場最大片

不用讀任何數字。 而「台東 CT 崩得最早最兇」「東京前面一路藍到底」這兩件事,在折線圖上要瞇著眼找。
好處是線之間不再互相遮蔽—每一場有自己的一條,而且色階的跳變比線的轉折更容易被眼睛抓到,崩掉的那一段會直接變成一塊紅色。
代價的部分也補充到:
代價是讀不出精確數值,色帶回答「什麼時候變快變慢、哪一場比較穩」,
要看某一點跑多少,點一下(或滑過去)看數字。
所以兩種都留著,窄螢幕預設色帶、桌機預設折線,而且使用者選過就不再自動切。
在桌機上游標滑過去會顯示那一點的配速,手機完全沒反應—因為手機沒有 hover 這個狀態,手指碰到螢幕就已經是按下去了。
照搬 touchmove 的話,得 preventDefault 擋掉捲動。而這張圖佔螢幕一大半,使用者會覺得頁面卡住。
所以改成點一下顯示、再點同處收起、點別處換位置:
function onTap(e: PointerEvent) {
if (e.pointerType === 'mouse') return; // 滑鼠走 mousemove 那條
...
}
touch-action 維持 auto,捲動完全不受影響。
而再點一次收起這個提示只給觸控裝置看,用的是 @media (hover: none) 不是螢幕寬度—有觸控筆的桌機、有滑鼠的平板都存在。
做完上面這些,手機上還是只有 300px 寬。
所以加了「放大」按鈕:蓋一層全螢幕,把內容轉 90 度,canvas 從 294px 變成 895px,三倍。

不能用 screen.orientation.lock()。iOS Safari 預設不支援—caniuse 上從 3.2 一路到現在全是 Not supported,只有使用者自己去「設定 → Safari → 進階 → 實驗性功能」把它打開才有。而且 MDN 寫著它「typically only enabled⋯when the browser context is full screen」,等於還要先進入全螢幕模式。
CSS 旋轉沒有這些條件:
:style="portrait ? {
width: '100vh', height: '100vw',
transform: 'rotate(90deg) translateY(-100%)',
transformOrigin: 'top left',
} : {}"
而且它跟著使用者的動作走:手機還是直的就轉 90 度,使用者自己把手機轉橫的話,matchMedia('(orientation: portrait)') 變 false,旋轉自動取消。 兩條路都得到橫的圖。
① sm: 看的是視窗,不是容器。
這邊測手機版的方法是用 CSS 把容器縮到 380px,但 Tailwind 的 sm: 是視窗媒體查詢,而視窗還是 1400px—所以我加的 hidden sm:inline 在測試裡根本沒觸發。
那些改動自己完全沒驗證到,看著一個縮窄的畫面,以為自己在看手機該有的樣子,但媒體查詢一次都沒觸發。
改用 container query(@container / @sm:)之後才對,而且那跟 canvas 量容器寬度的做法才一致。順帶抓到 flex-1 truncate 少了 min-w-0—賽事名稱實際只有 21px,兩場臺北馬分不出來。
② ResizeObserver 觸發了自己
這邊用 ResizeObserver 監看容器寬度來重畫,但 draw() 會設定 canvas 的高度,而 canvas 就在被觀察的容器裡—高度一變又觸發觀察器,再 draw、再觸發。
測試的時候整個分頁沒有回應,連 DevTools 都連不上。
ro = new ResizeObserver(([e]) => {
const w = Math.round(e.contentRect.width);
if (w === cw.value) return; // 高度變化不重畫
cw.value = w;
draw();
});
回頭看,前三次都在問同一個問題:怎麼讓線變乾淨。
而問題從來不在線乾不乾淨,在六條線共用一個平面。
第四次才換了問題:在小畫面中,不要讓它們共用。
色帶看得出六場各自的形狀,但看不出同一個時間點上六場的相對位置。
明天要讓它們同時起跑,做一個回放功能~