Skip to content

Commit 388d955

Browse files
committed
List each level's library topics in order in the curriculum
A level used to show one or two repositories (a whole web book counted as one step). Each level now lists the library topics it covers, in study order, with the one or two materials to start from and a link to all of the topic's materials. All 24 topics sit in exactly one level; check_data.py enforces it. The repository steps and passport progress stay as they were, under 'follow along'. Clicks send level_topic (level, topic, target).
1 parent 025bd37 commit 388d955

5 files changed

Lines changed: 107 additions & 4 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ PR을 올리기 전에 `python3 scripts/check_data.py`를 실행해 주세요.
3030
| `data/term_links.json` | 자료실 용어집의 ‘팀 자료에서’ 목록. 손으로 고치지 않고, 팀 저장소를 한 폴더에 clone한 뒤 `python3 scripts/build_term_links.py <폴더> --check-urls`로 다시 만듭니다. 발표 자료(PDF, `pdftotext` 필요), 노트북과 마크다운에서 용어의 번역어·다른 표기·원어를 찾아, 용어마다 많이 다룬 문서를 4개까지(저장소당 2개) 고릅니다. 팀 저장소에 자료가 늘면 다시 돌려 커밋하세요. 용어의 한 줄 풀이는 `data/pseudo.json`에 있고 Pseudo와 용어집이 함께 씁니다. |
3131
| `data/repos.json` | 레포를 추가하거나 설명·단계·트랙·상태를 바꿀 때. 자료실 검색은 레포 이름·`title`·`tags`·`builders`와 검색어 `keys`로 팀 프로젝트를 찾아 주제 위에 보여 줍니다(예: `CausalML`, `LLM`). 주제에 넣기 어려운 프로젝트가 검색에 안 걸리면 `keys`를 늘리세요. |
3232
| `data/map.json` | 트랙 지도에서 레포의 위치를 정할 때. 책과 강의(`paid`)의 표지와 소개도 여기서 고칩니다. |
33-
| `data/levels.json` | 레벨의 설명과 볼 순서를 바꿀 때. `learn`(배우는 것), `outcome`(끝나면), `steps`(순서대로 볼 레포와 한 줄 `note`), `more`(웹북 장이나 허브 페이지 같은 더 해 보기 링크)를 적습니다. `steps`에 없는 같은 레벨의 레포는 ‘골라 보기’로 나옵니다. |
33+
| `data/levels.json` | 레벨의 설명과 볼 순서를 바꿀 때. `learn`(배우는 것), `outcome`(끝나면), `steps`(순서대로 볼 레포와 한 줄 `note`), `more`(웹북 장이나 허브 페이지 같은 더 해 보기 링크)를 적습니다. `steps`에 없는 같은 레벨의 레포는 ‘골라 보기’로 나옵니다. `topics`는 그 레벨에서 배우는 자료실 주제 id를 배우는 순서대로 적은 것이고, 커리큘럼은 주제마다 그 레벨에 맞는 자료 두 개를 바로 보여 줍니다(웹북 1–16장은 Level 1, 나머지는 레포의 레벨). 주제 하나는 한 레벨에만 넣고, 새 주제를 `topics.json`에 만들면 어느 레벨에 넣을지도 정합니다. |
3434
| `data/leveltest.json` | 레벨 진단 문제를 고칠 때. 문제마다 `band`(L0·L1·L2), 보기 `opts`, 정답 순서 `answer`(0부터), 해설 `why`, 다시 볼 장 `chapter`를 적습니다. `bands`의 `pass`보다 적게 맞힌 첫 구간이 시작 Level이 되고, 모두 넘으면 관심 분야에 따라 Level 3이나 4가 됩니다. |
3535
| `data/routes.json` | 관심 분야별로 이어서 볼 자료를 바꿀 때. 레벨 진단 결과의 ‘관심 분야에 맞춰 이어서’에 쓰입니다. |
3636
| `data/articles.json`, `articles/*.md` | 연재 글을 추가할 때. 아래 ‘연재 쓰기’를 보세요. |

‎assets/css/hub.css‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1841,6 +1841,24 @@ a.ac-title:hover { text-decoration: underline; }
18411841
.lr-crew > span:first-child { margin-right: 6px; font-weight: 700; }
18421842
.lr-crew i { font-style: normal; margin-left: 4px; }
18431843
.lr-steps { grid-area: steps; min-width: 0; }
1844+
/* a level's topic order: number, topic (to the library), and the one or two materials to start from */
1845+
.lt-list { list-style: none; margin: 0 0 4px; padding: 0; display: grid; }
1846+
.lt { display: grid; grid-template-columns: 26px minmax(0, 1fr); gap: 10px; padding: 9px 0; border-top: 1px solid var(--rule); }
1847+
.lt-list > .lt:first-child { border-top: 0; }
1848+
.lt-n { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 800; color: var(--l-main); border: 2px solid currentColor; background: var(--surface); margin-top: 1px; }
1849+
.lt[data-shelf="exp"] .lt-n { color: var(--l-exp); }
1850+
.lt-b { min-width: 0; display: grid; gap: 3px; }
1851+
.lt-t { font-size: 15.5px; font-weight: 750; color: var(--ink); text-decoration: none; justify-self: start; }
1852+
.lt-t:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
1853+
.lt-go { display: flex; flex-wrap: wrap; gap: 2px 14px; font-size: 13.5px; }
1854+
.lt-go a { color: var(--ink); text-decoration: none; min-width: 0; }
1855+
.lt-go a i { font-style: normal; font-size: 11.5px; font-weight: 800; color: var(--muted); margin-right: 5px; }
1856+
.lt-go a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
1857+
.lt-go a.lt-all { color: var(--muted); font-weight: 650; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--rule); }
1858+
.lt-more summary { cursor: pointer; font-size: 13.5px; font-weight: 700; color: var(--accent); padding: 6px 0; list-style-position: inside; }
1859+
.lt-more summary:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 4px; }
1860+
.lt-more .lt-list > .lt:first-child { border-top: 1px solid var(--rule); }
1861+
.lr-steps .lt-list + .lr-k, .lr-steps .lt-more + .lr-k { margin-top: 16px; }
18441862
.ls-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
18451863
.ls { display: flex; align-items: center; gap: 10px; min-height: 52px; padding: 7px 8px 7px 10px; border-radius: 14px; background: var(--surface-2); }
18461864
.ls-n { flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; font-size: 14px; background: var(--surface); border: 2.5px solid var(--c, var(--l-main)); color: var(--ink); }

‎assets/js/hub.js‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,39 @@
417417
<li class="rail-layer learn" aria-hidden="true">배우기</li><li class="rail-layer build" aria-hidden="true">만들기</li>
418418
</ol>`;
419419
}
420+
/* the library topics a level covers, in order, each with the one or two materials to start from
421+
(data/levels.json topics + data/topics.json). A material's level follows the library: web book chapters
422+
1–16 are Level 1 and the rest Level 2; other materials take their repository's level. */
423+
const KIND_NAME = { video: "영상", read: "글", code: "코드", slides: "발표", case: "사례", post: "연재" };
424+
const KIND_RANK = { video: 0, read: 1, code: 2, slides: 3, case: 4, post: 5 };
425+
function itemLevel(x) {
426+
if (x.repo === "Causal-Inference-with-Python") { const m = /\/(\d+)-[^/]*\.html$/.exec(x.url || ""); return m && Number(m[1]) < 17 ? "L1" : "L2"; }
427+
const r = state.repos.get(x.repo);
428+
return r && r.level ? r.level : null;
429+
}
430+
function startWith(t, levelId) {
431+
const want = lvIndex(levelId);
432+
const dist = (x) => { const k = lvIndex(itemLevel(x)); return k < 0 ? 9 : Math.abs(k - want); };
433+
return [...(t.items || [])].filter((x) => x.kind !== "post")
434+
.sort((a, b) => dist(a) - dist(b) || (KIND_RANK[a.kind] ?? 9) - (KIND_RANK[b.kind] ?? 9)).slice(0, 2);
435+
}
436+
function topicOrder(lv) {
437+
const all = (state.topicData && state.topicData.topics) || [];
438+
const ts = (lv.topics || []).map((id) => all.find((t) => t.id === id)).filter(Boolean);
439+
if (!ts.length) return "";
440+
const ev = (topic, target) => `data-event="level_topic" data-event-params='${esc(JSON.stringify({ level: lv.id, topic, target }))}'`;
441+
const row = (t, k) => `<li class="lt" data-shelf="${esc(t.shelf)}">
442+
<span class="lt-n" aria-hidden="true">${k}</span>
443+
<div class="lt-b"><a class="lt-t" href="library.html#${esc(t.id)}" ${ev(t.id, "library")}>${esc(t.name)}</a>
444+
<span class="lt-go">${startWith(t, lv.id).map((x) => `<a href="${esc(x.url)}"${/^https?:/.test(x.url) && !/^https:\/\/causalinferencelab\.github\.io\//.test(x.url) ? ' target="_blank" rel="noopener"' : ""} ${ev(t.id, "material")}><i>${esc(KIND_NAME[x.kind] || "자료")}</i>${esc(x.title)}</a>`).join("")}
445+
<a class="lt-all" href="library.html#${esc(t.id)}" ${ev(t.id, "library_all")}>자료 ${(t.items || []).length}개</a></span></div>
446+
</li>`;
447+
const FIRST = 5, head = ts.slice(0, FIRST), rest = ts.slice(FIRST);
448+
return `<p class="lr-k">배우는 주제 <span>${ts.length}개, 순서대로</span></p>
449+
<ol class="lt-list">${head.map((t, k) => row(t, k + 1)).join("")}</ol>
450+
${rest.length ? `<details class="lt-more" data-topics="${lv.id}"><summary>주제 ${rest.length}개 더</summary><ol class="lt-list" start="${FIRST + 1}">${rest.map((t, k) => row(t, FIRST + k + 1)).join("")}</ol></details>` : ""}`;
451+
}
452+
420453
/* one level in full: who it is for, what it covers, what you can do after it, and the order to take it */
421454
function levelPanel(lv, i) {
422455
const mine = myLevel(), mi = mine ? lvIndex(mine.level) : -1;
@@ -426,12 +459,13 @@
426459
const extra = items.filter((r) => !inSteps.has(r.repo) && r.kind !== "paid");
427460
const books = items.filter((r) => r.kind === "paid");
428461
const build = (lv.quests || []).length > 0;
462+
const topics = topicOrder(lv);
429463
const crew = [...new Set(items.flatMap((r) => r.builders || []))];
430464
const count = `<span aria-label="${quests.length}개 중 ${done}개 클리어">${done} / ${quests.length}</span>`;
431465
const list = build
432466
? `<p class="lr-k">직접 해 보기 ${count}</p><ol class="ls-list">${quests.map((q, k) => questRow(q, k + 1)).join("")}</ol>
433467
<p class="lr-k">함께 만드는 레포</p><ul class="lr-repos">${[...steps.map((s) => [s.r, s.note]), ...extra.map((r) => [r, r.format || ""])].map(([r, note]) => `<li data-id="${esc(r.repo)}" data-line="${primaryLine(r)}"><button type="button" data-open="${esc(r.repo)}" title="${esc(note)}"><i aria-hidden="true"></i>${esc(r.title)}${r.status === "running" ? `<span class="ls-live">진행 중</span>` : ""}</button></li>`).join("")}</ul>`
434-
: `<p class="lr-k">이 순서로 ${count}</p><ol class="ls-list">${steps.map((s, k) => stepRow(s.r, k + 1, s.note)).join("")}</ol>
468+
: `<p class="lr-k">${topics ? "따라갈 자료" : "이 순서로"} ${count}</p><ol class="ls-list">${steps.map((s, k) => stepRow(s.r, k + 1, s.note)).join("")}</ol>
435469
${extra.length ? `<details class="lr-more" data-more="${lv.id}"><summary>골라 보기 ${extra.length}개</summary><ul class="ls-list">${extra.map((r) => stepRow(r, 0, "")).join("")}</ul></details>` : ""}`;
436470
const text = books.length ? `<p class="lr-also"><span>함께 보는 교재</span>${books.map((b) => `<button type="button" data-open="${esc(b.repo)}" data-id="${esc(b.repo)}">${esc(b.mapLabel || b.title)}</button>`).join("")}</p>` : "";
437471
const more = (lv.more || []).length ? `<p class="lr-also"><span>더 해 보기</span>${lv.more.map((m) => `<a href="${esc(m.href)}"${/^https?:/.test(m.href) ? ' target="_blank" rel="noopener"' : ""} data-event="click_level_extra" data-event-params='${esc(JSON.stringify({ level: lv.id, target: m.title }))}'>${esc(m.title)}</a>`).join("")}</p>` : "";
@@ -451,11 +485,11 @@
451485
${lv.for ? `<p class="lr-for"><b>이런 분께</b>${esc(lv.for)}</p>` : ""}
452486
</div>
453487
<div class="lr-body">
454-
${(lv.learn || []).length ? `<p class="lr-k">배우는 것</p><ul class="lr-learn">${lv.learn.map((t) => `<li>${esc(t)}</li>`).join("")}</ul>` : ""}
488+
${(lv.learn || []).length && !topics ? `<p class="lr-k">배우는 것</p><ul class="lr-learn">${lv.learn.map((t) => `<li>${esc(t)}</li>`).join("")}</ul>` : ""}
455489
${lv.outcome ? `<p class="lr-out"><b>끝나면</b>${esc(lv.outcome)}</p>` : ""}
456490
${crew.length ? `<p class="lr-crew"><span>만든 Builder</span>${crew.slice(0, 5).map((n) => `<span class="lr-who">${avatarHTML(n, "sm")}<span>${esc(n)}</span></span>`).join("")}${crew.length > 5 ? `<i>외 ${crew.length - 5}명</i>` : ""}</p>` : ""}
457491
</div>
458-
<div class="lr-steps">${list}${text}${more}${goal}</div>
492+
<div class="lr-steps">${topics}${list}${text}${more}${goal}</div>
459493
${nav}
460494
</article>`;
461495
}
@@ -466,8 +500,10 @@
466500
state.level = id;
467501
const keep = shelf.querySelector(`details[data-more="${id}"]`);
468502
const wasOpen = keep && keep.open;
503+
const keepT = shelf.querySelector(`details[data-topics="${id}"]`), topicsOpen = keepT && keepT.open;
469504
shelf.innerHTML = levelPanel(lv, i);
470505
if (wasOpen) { const d = shelf.querySelector("details[data-more]"); if (d) d.open = true; }
506+
if (topicsOpen) { const d = shelf.querySelector("details[data-topics]"); if (d) d.open = true; }
471507
if (dir && !reduceMotion) { const a = shelf.firstElementChild; a.classList.add(dir > 0 ? "enter-r" : "enter-l"); }
472508
wireAvatars(shelf);
473509
renderRail(reveal); renderHud(); renderTestCard();
@@ -1958,6 +1994,7 @@
19581994
renderGrowth();
19591995
renderContrib();
19601996
renderShelf(); renderWeek(); watchRail(); revealSections();
1997+
getJSON("data/topics.json").then((d) => { state.topicData = d; renderShelf(); }).catch(() => { /* the course shows without its topic order */ });
19611998
document.addEventListener("hub:stamp", onStamp);
19621999
syncStageStamps();
19632000
const header = $(".site-header");

‎data/levels.json‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
"교란",
1313
"반사실"
1414
],
15+
"topics": [
16+
"why-causal",
17+
"ab-basics"
18+
],
1519
"outcome": "상관과 인과가 왜 다른지 예를 들어 설명할 수 있습니다.",
1620
"steps": [
1721
{
@@ -56,6 +60,18 @@
5660
"통제집단합성법",
5761
"회귀 불연속"
5862
],
63+
"topics": [
64+
"dag",
65+
"regression",
66+
"propensity",
67+
"iv",
68+
"did",
69+
"synthetic",
70+
"rdd",
71+
"metrics",
72+
"stats",
73+
"trust"
74+
],
5975
"outcome": "기법마다 필요한 가정을 알고, 데이터에 맞는 추정 방법을 고를 수 있습니다.",
6076
"steps": [
6177
{
@@ -83,6 +99,17 @@
8399
"실험 플랫폼",
84100
"마케팅 효과 측정"
85101
],
102+
"topics": [
103+
"hte",
104+
"dml",
105+
"policy",
106+
"marketing",
107+
"variance",
108+
"interference",
109+
"long-term",
110+
"exp-analysis",
111+
"culture"
112+
],
86113
"outcome": "회사 데이터로 효과를 추정하고, 결과와 한계를 설명할 수 있습니다.",
87114
"steps": [
88115
{
@@ -127,6 +154,9 @@
127154
"분석 자동화",
128155
"PR과 코드 리뷰"
129156
],
157+
"topics": [
158+
"platform"
159+
],
130160
"outcome": "분석을 돕는 도구에 기여하고 리뷰를 주고받을 수 있습니다.",
131161
"steps": [
132162
{
@@ -157,6 +187,10 @@
157187
"적응형 설계",
158188
"외부 라이브러리 기여"
159189
],
190+
"topics": [
191+
"clinical",
192+
"discovery"
193+
],
160194
"outcome": "연구 소프트웨어를 만들거나 외부 인과추론 오픈소스에 기여할 수 있습니다.",
161195
"steps": [
162196
{

‎scripts/check_data.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,20 @@ def main():
7979
token(lv.get("id"), "levels.json", "id")
8080
for q in lv.get("quests") or []:
8181
token(q, f"levels.json {lv.get('id')!r}", "quests")
82+
# each library topic sits in one level's topic order (the curriculum shows them per level)
83+
topic_ids = [t.get("id") for t in (data.get("topics.json") or {}).get("topics") or []]
84+
if data.get("levels.json") and topic_ids:
85+
seen = {}
86+
for lv in data.get("levels.json"):
87+
for t in lv.get("topics") or []:
88+
if t not in topic_ids:
89+
err(f"levels.json {lv.get('id')!r}", f"topic {t!r} is not in topics.json")
90+
if t in seen:
91+
err(f"levels.json {lv.get('id')!r}", f"topic {t!r} is already in {seen[t]}")
92+
seen[t] = lv.get("id")
93+
for t in topic_ids:
94+
if t not in seen:
95+
warn("levels.json", f"topic {t!r} is in no level's topics; the curriculum will not show it")
8296
lines = {ln.get("id") for ln in data.get("lines.json") or []}
8397
for ln in data.get("lines.json") or []:
8498
token(ln.get("id"), "lines.json", "id")

0 commit comments

Comments
 (0)