昨天把畫框標號的疊層機制做出來了,也在最後留下一個沒解決的問題:圓標會擋到旁邊的東西。今天就來處理這件事,順便把「圖上的數字要怎麼跟正文對得起來」一起講完。
原本的擺放邏輯寫死在左上角外側,如果是空曠處就沒問題,但如果旁邊有東西時會出三種狀況:
這些都不會讓程式報錯,使用手冊一樣可以建立,只是品質沒那麼好,會讓人覺得不夠細心。

要解決這個問題,方法也不複雜。只要準備一串候選位置,依好看到將就排序,依序檢查,通過就用:
const R = 14 // 圓標半徑
/** 候選圓心,依序:外側角 → 內側角 → 左右中點。 */
function candidates(box: Rect) {
const { x, y, width: w, height: h } = box
return [
{ cx: x, cy: y }, // 左上角外側(預設)
{ cx: x + w, cy: y }, // 右上角
{ cx: x, cy: y + h }, // 左下角
{ cx: x + w, cy: y + h }, // 右下角
{ cx: x + R, cy: y + R }, // 左上角內側
{ cx: x + w - R, cy: y + R }, // 右上角內側
{ cx: x + R, cy: y + h - R }, // 左下角內側
{ cx: x + w - R, cy: y + h - R }, // 右下角內側
{ cx: x - R, cy: y + h / 2 }, // 左側
{ cx: x + w + R, cy: y + h / 2 }, // 右側
]
}
接著就是三條規則依序檢查:不超出畫布 → 不撞到已放好的圓標 → 不蓋到別的元件。
for (const item of items) {
const box = pad(item)
// 自己的元件不算障礙物:圓標壓在自己框角上是慣例。
const others = obstacles.filter((o) => o.testid !== item.testid)
const hit = candidates(box).find((c) => {
const r = badgeRect(c.cx, c.cy)
return inside(r, canvas) && !taken.some((t) => overlaps(r, t)) && !others.some((o) => overlaps(r, o))
})
// 全部候選都不行:退回預設位置並夾回畫布內
const { cx, cy } = hit ?? clampIntoCanvas(candidates(box)[0], canvas)
taken.push(badgeRect(cx, cy))
out.push({ ...item, cx, cy })
}
規則要依序檢查完,不能只做第一步。以下分享幾個例子。
範例的統計卡靠著面板左上角,clip 只留 8px padding,比圓標半徑還小,預設位置讓三個圓標都被切掉一角:

套上規則後,翻到「左上角內側」,圓標全部落在卡片裡:

容易寫錯的地方:畫布不是 viewport,是 clip 的範圍。如果用 viewportSize() 當依據,上面的圓標就會被判定為「沒出界」,因為它們確實在視窗裡,只是不在截圖裡。
對話框裡 ③ 壓到「取消」按鈕,把它也標註後,④ 直接騎在 ③ 的框上:

「取消」有 data-testid,進了障礙物清單,④ 的左上角外側被否決,改右上角:

「障礙物」是畫面上其他看得到的元件,每個要操作的元件都已補了 data-testid(Day 08),直接拿來用:
const obstacles = await page.evaluate(() =>
[...document.querySelectorAll('[data-testid]')]
// 只留葉節點:容器類 testid 範圍太大,會讓所有位置都不能用
.filter((el) => !el.querySelector('[data-testid]'))
// 只留真的在最上層的元件
.filter((el) => {
const r = el.getBoundingClientRect()
if (r.width === 0 || r.height === 0) return false
const top = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)
return top !== null && (top === el || el.contains(top))
})
.map((el) => ({ testid: el.dataset.testid, ...el.getBoundingClientRect().toJSON() })),
)
第二個 filter 是實際跑過才補上的:一開始只做葉節點過濾,④ 怎麼都不肯移動 —— 原因是遮罩蓋住的格子也被算成障礙物,它們在 DOM 裡有尺寸但看不到,導致每個位置都撞到東西。
這正是 Day 08 講過的:元件存在 DOM 不代表看得到。用 elementFromPoint() 對中心點做命中測試就能濾掉。
編號可按「閱讀順序」或「操作順序」排,就看對應的段落是在「介紹介面」還是「說明步驟」。
在設定檔裡,這個順序就是 annotate 陣列裡目標的排列順序,不需要額外開一個欄位來表達。
標號說明 (legend) 常見放法:圖片內部、圖片下方列出、或穿插正文引用,取捨在翻譯成本、版面與維護性。
這系列的選擇:legend 寫在設定檔裡,渲染成圖片下方一張小表格。
{
"screenshot": "camera-dialog-01.png",
"annotate": [
{ "key": "name", "testid": "camera-dialog-name", "legend": "攝影機顯示名稱" },
{ "key": "confirm", "testid": "camera-dialog-confirm", "legend": "儲存按鈕" }
]
}
渲染出來就是「正文 + 圖 + 圖說 + legend 表格」:

這樣做的最大優點是:翻譯不用重新截圖,換 legend 文字即可。使用手冊翻譯這件事會在後面找機會討論。
legend 基本上都是用名詞短語,不用祈使句 (e.g. 寫「儲存按鈕」而非「點這裡儲存」),操作說明是正文的事,legend 只負責標明「這是什麼」,也建議設字數上限。
有一個蠻常見的問題就是標號更新。原本 1、2、3,插入新元件變 1、2、3、4,正文的「編號 3」沒跟著改,讀者會卡住。
解決辦法就是 正文不寫數字,改引用 legend 的語意 key:
在 {{legend.name}} 填入攝影機名稱,確認無誤後按 {{legend.confirm}}。
渲染時才換成編號,插入新標註只讓數字重算,既有引用不會錯位。
這些規則可寫成腳本自動檢查,不用每次都靠肉眼核對:
annotate 都有對應目標候選位置全被否決是有可能會發生的事,例如:元件密集或 clip 很緊時,此時就乖乖退回預設位置夾回畫布內。可以的話,要留手動覆寫欄位(例如 "badge": "top-right"),讓人可以對難處理的圖直接指定。
有時候,中文 legend 6 個字,英文卻要 20 幾個字元。或者是,固定寬度表格對中文剛好,但是對英文卻要換行,變得難看。遇到這種狀況就只能先人工處理了 (排版彈性留到後面再找機會展開討論)。
到目前為止,一張時機、範圍、標註都正確的截圖就差不多完成了。剩下一個小部分就是「敏感資訊的遮蔽」,明天會再繼續接著介紹。