iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Modern Web

教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard系列 第 23

Day 23|訓練 Dashboard 手機版:平均取樣拉長三倍,線反而更亂

  • 分享至 

  • xImage
  •  

前兩天的圖,我們都是在桌機上看的,

拿到手機上打開—六條線糊成一團。

今天要來調整這部分,過程中做了蠻多的取捨。

https://ithelp.ithome.com.tw/upload/images/20260908/20183598r3R8anTXnS.jpg


第一次:以為是點太多

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    早早出現紅,而且是六場最大片

https://ithelp.ithome.com.tw/upload/images/20260908/20183598UUBvSH5G7j.jpg

不用讀任何數字。 而「台東 CT 崩得最早最兇」「東京前面一路藍到底」這兩件事,在折線圖上要瞇著眼找。

好處是線之間不再互相遮蔽—每一場有自己的一條,而且色階的跳變比線的轉折更容易被眼睛抓到,崩掉的那一段會直接變成一塊紅色。

代價的部分也補充到:

代價是讀不出精確數值,色帶回答「什麼時候變快變慢、哪一場比較穩」,
要看某一點跑多少,點一下(或滑過去)看數字。

所以兩種都留著,窄螢幕預設色帶、桌機預設折線,而且使用者選過就不再自動切。


觸控沒有 hover

在桌機上游標滑過去會顯示那一點的配速,手機完全沒反應—因為手機沒有 hover 這個狀態,手指碰到螢幕就已經是按下去了。

照搬 touchmove 的話,得 preventDefault 擋掉捲動。而這張圖佔螢幕一大半,使用者會覺得頁面卡住。

所以改成點一下顯示、再點同處收起、點別處換位置:

function onTap(e: PointerEvent) {
  if (e.pointerType === 'mouse') return;   // 滑鼠走 mousemove 那條
  ...
}

touch-action 維持 auto,捲動完全不受影響。

而再點一次收起這個提示只給觸控裝置看,用的是 @media (hover: none) 不是螢幕寬度—有觸控筆的桌機、有滑鼠的平板都存在。


還是不夠:借用另一個方向

做完上面這些,手機上還是只有 300px 寬。

所以加了「放大」按鈕:蓋一層全螢幕,把內容轉 90 度,canvas 從 294px 變成 895px,三倍。

https://ithelp.ithome.com.tw/upload/images/20260908/20183598E07GFwtPSO.jpg

不能用 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();
});

修了四次的真正原因

回頭看,前三次都在問同一個問題:怎麼讓線變乾淨。

而問題從來不在線乾不乾淨,在六條線共用一個平面

第四次才換了問題:在小畫面中,不要讓它們共用。


明天:跑到一半的時候,誰在前面

色帶看得出六場各自的形狀,但看不出同一個時間點上六場的相對位置。

明天要讓它們同時起跑,做一個回放功能~



上一篇
Day 22|心率脫鉤:跑最快的那場,數字反而偏高
下一篇
Day 24|讓馬拉松再次起跑:賽事回放器
系列文
教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言