首頁要是「一張圖看完全貌」:十幾門課依先修關係連起來,用顏色分類,每個節點顯示我在那門課的完成度。點下去就進課程頁。
第一個問題:節點位置誰決定?
我認真評估過 d3-force 與 dagre:
| 方式 | 結果 |
|---|---|
| d3-force(力導向) | 每次載入位置都不同,使用者的空間記憶完全建立不起來。要固定就得把座標存下來——那不如一開始就手排 |
| dagre(分層 DAG) | 位置穩定、自動分層,但要多背一個 API,而且它排出來的「數學上合理」不等於「教學上合理」 |
| 手工座標 | 零依賴、完全可控,可以刻意把相關的課排在一起 |
決定性的理由是第三點:這張圖是教學設計,不是資料視覺化。 我要「初級統計」在最上面正中央(起點)、機率與迴歸並列在第二層(兩條分支)、雲端證照放最下面(終點)。這種語意排版沒有演算法會自己想出來。
十幾個節點手排完全可以接受。如果哪天變成兩百個節點,再回來考慮自動佈局——但那時問題也已經不同了。
MVC 的界線在這裡最容易破功。View 很容易忍不住直接讀 COURSES 和 Progress,但那樣它就同時綁死了兩個 Model,測試與重用都會變難。
所以 controller 先把資料攤平成 View 需要的形狀:
/* js/controllers/home.js */
const HomeController = {
init() {
const vm = {
viewBox: COURSE_MAP.viewBox,
zones: COURSE_MAP.zones,
edges: COURSE_MAP.edges,
nodes: COURSE_MAP.nodes.map(n => {
const course = findCourse(n.c);
const total = courseLessonCount(course);
const done = Progress.courseDone(course);
return {
course, x: n.x, y: n.y, lg: !!n.lg,
done, total, pct: total ? Math.round(done / total * 100) : 0,
};
}),
};
MapView.render(document.getElementById("course-map"), vm);
const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
set("stat-courses", COURSES.length);
set("stat-lessons", totalLessonCount());
set("stat-done", Progress.doneCount());
NavView.render();
},
};
if (typeof document !== "undefined") {
const boot = () => HomeController.init();
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
else boot();
}
MapView 收到的每個節點都已經有 x, y, done, total, pct——它不需要知道 Progress 存在,也不需要知道進度是怎麼算的。
底部那段自我啟動的樣板(readyState 判斷)看起來囉唆,但它取代了在 HTML 尾端寫一段行內 script、直接呼叫 HomeController.init() 的做法。第 10 天上嚴格 CSP 時,行內 script 一律會被擋掉,這段先寫好就不用回頭改。
直線連起來會像電路圖,很硬。柔和的樹狀連線用一條三次貝茲就夠:
const edges = vm.edges.map(([a, b]) => {
const p = vm.nodes.find(n => n.course.id === a);
const q = vm.nodes.find(n => n.course.id === b);
if (!p || !q) return "";
/* 垂直方向的貝茲曲線:兩個控制點都放在中線高度 */
const my = (p.y + q.y) / 2;
return `<path class="map-edge" d="M ${p.x} ${p.y} C ${p.x} ${my}, ${q.x} ${my}, ${q.x} ${q.y}"/>`;
}).join("");
關鍵在控制點的取法:(p.x, my) 與 (q.x, my)——兩個控制點都在中線高度,各自對齊起終點的 x。效果是線從節點正下方垂直離開、垂直進入下一個節點,中段平滑橫移。這是流程圖連線的標準手法,一行搞定,不需要任何函式庫。
(如果你的圖是水平流向,把 x/y 對調即可:控制點取 (mx, p.y)、(mx, q.y)。)
進度環是這張圖的靈魂——它讓地圖同時是「路線圖」和「儀表板」。做法是 stroke-dasharray:
nodeSVG(n) {
const { course, x, y } = n;
const color = CAT_META[course.cat].color;
const r = n.lg ? 46 : 32;
const wip = n.total === 0;
const ring = (!wip && n.pct > 0) ? (() => {
const C = 2 * Math.PI * (r + 5); // 環的周長
return `<circle class="ring" cx="${x}" cy="${y}" r="${r + 5}" stroke="${color}"
stroke-dasharray="${(C * n.pct / 100).toFixed(1)} ${C.toFixed(1)}"
transform="rotate(-90 ${x} ${y})"/>`; // 從 12 點鐘方向起算
})() : "";
const count = wip ? "備課中" : `${n.done}/${n.total} 課`;
const labelY = y + r + 22;
return `
<a class="map-node ${wip ? "wip" : ""}" href="course.html?c=${course.id}"
aria-label="${course.title}(${count})">
${ring}
<circle class="bg" cx="${x}" cy="${y}" r="${r}" stroke="${color}"/>
<text class="node-icon" x="${x}" y="${y}" text-anchor="middle"
dominant-baseline="central" fill="${color}">${course.icon}</text>
<text class="node-label" x="${x}" y="${labelY}" text-anchor="middle">${course.title}</text>
<text class="node-count" x="${x}" y="${labelY + 17}" text-anchor="middle">${count}</text>
</a>`;
}
三個細節:
stroke-dasharray="實線長度 剩餘長度":把實線長度設成 周長 × 百分比,第二個值給整個周長(確保不會重複),就得到一個圓形進度環。這是純 SVG,沒有動畫函式庫。transform="rotate(-90 x y)":SVG 圓的起點在 3 點鐘方向,逆轉 90° 讓進度從 12 點鐘開始,符合直覺。<a> 包住整個節點:不是加 onclick。這樣中鍵開新視窗、右鍵複製連結、鍵盤 Tab 聚焦全部免費得到。能用連結就不要用事件處理器——這是無障礙的第一課,Day 19 會回來延伸。dominant-baseline="central" 是 SVG 文字垂直居中的正解。text-anchor="middle" 管水平,兩個一起用才是真正的居中。
rootEl.innerHTML = `
<div class="map-legend">
${Object.values(CAT_META).map(m =>
`<span class="legend-chip"><i style="background:${m.color}"></i>${m.label}</span>`).join("")}
</div>
<div class="map-scroll">
<svg class="map-svg" viewBox="0 0 ${W} ${H}" role="img" aria-label="課程學習地圖">
<g>${edges}</g>
<g>${zones}</g>
<g>${nodes}</g>
</svg>
</div>`;
圖例直接從 CAT_META 展開——新增類別不用改 View,這就是 Day 2 把顏色下沉成資料的第一筆回報。
<g> 的順序就是繪製順序:連線在最底、分區標籤中間、節點最上。SVG 沒有 z-index,順序即層級。
手機處理:
.map-scroll { overflow-x: auto; }
.map-svg { min-width: 860px; width: 100%; height: auto; }
viewBox 讓 SVG 可縮放,但手機上等比縮到 375px 寬會讓字小到看不見。所以給一個 min-width: 860px 讓它維持可讀、改成橫向捲動。地圖本來就是需要探索的東西,橫捲是合理的互動。
innerHTML 組 SVG 有一個陷阱:如果你用 document.createElement("svg") 再 appendChild,元素會被建在 HTML 命名空間下,畫不出來(必須用 createElementNS)。用 innerHTML 讓瀏覽器自己解析字串,反而不會有命名空間問題。這是少數 innerHTML 比 DOM API 更不容易出錯的場景。
另外:課名與 icon 是我自己寫的資料,直接插進字串沒問題。但如果有任何一段字串來自使用者輸入或 localStorage,就必須先淨化——Day 9 會踩到這個雷的真實版本。
python3 -m http.server 8901
// DevTools console
document.querySelectorAll("a.map-node").length // 應等於課程數
document.querySelectorAll("path.map-edge").length // 應等於 edges 數
再手動確認:hero 三個統計數字有值了(不再是「—」)、點節點會跳到 course.html?c=...(Day 5 才會有內容,先看 URL 對不對)、縮到手機寬度時地圖可橫捲。
今天的三個要點:
stroke-dasharray,節點用 <a> 包住換來免費的無障礙。明天做課程頁,並且正式定下兩個會跟著專案一輩子的契約:URL 契約與 localStorage 儲存契約。後者一旦寫錯,未來任何改動都會清空使用者的學習進度。