From 99ff3d41526964d0c8ff837b0e9a29f12603943f Mon Sep 17 00:00:00 2001 From: jsshin2022 <59598545+jsshin2022@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:28:47 +0000 Subject: [PATCH 1/4] Number the track map lines and show cleared quests on the map Lines carry their number at the start and in the picker, which now sits above the map. The reading guide is one row. Zones use the stage names, cleared stations turn yellow with a check, and the map and the phone strips mark the next quest to clear. Phone strips show the line's tagline and which lines each transfer station joins. --- assets/css/hub.css | 35 +++++++++++++++++++++++++++++++++++ assets/js/hub.js | 35 +++++++++++++++++++++++++++-------- index.html | 19 ++++++++----------- 3 files changed, 70 insertions(+), 19 deletions(-) diff --git a/assets/css/hub.css b/assets/css/hub.css index e63c23c..69d3a56 100644 --- a/assets/css/hub.css +++ b/assets/css/hub.css @@ -1599,3 +1599,38 @@ button.btn.sm { font-size: 14px; } .stage-goal.pop { animation: sg-pop .7s ease; } @keyframes sg-pop { 0% { transform: scale(.96); } 55% { transform: scale(1.02); } 100% { transform: scale(1); } } } + +/* ---------- track map: numbered lines, a one-row guide, the picker on top, quests cleared on the map ---------- */ +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } +.map-how { list-style: none; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 20px; margin: 4px 16px 12px; padding: 10px 14px; border-radius: 12px; background: var(--surface-2); } +.map-how li { gap: 8px; font-size: 13px; } +.ln-no { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: var(--c); color: var(--on-line); font-family: var(--f-display); font-size: 12.5px; font-weight: 800; line-height: 1; flex-shrink: 0; } +.ln-no.sm { width: 18px; height: 18px; font-size: 11.5px; margin-left: 3px; } +.line-legend { margin: 0 0 6px; align-items: center; } +.ll-k { font-size: 13px; font-weight: 800; color: var(--ink); margin-right: 4px; } +.ll { padding: 5px 12px 5px 5px; } +.ll[aria-pressed="true"] .ln-no { background: var(--on-line); color: var(--c); } +.line-desc { margin: 0 16px 8px; } +#metro .badge circle { stroke: var(--surface); stroke-width: 3; } +#metro .badge text { font-size: 15px; } +#metro .zone-n { font-weight: 800; } +#metro .st.cleared .ring, #metro .st.cleared .pill { fill: #FFC93C; } +#metro .st .done { fill: none; stroke: #14162B; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; } +#metro .pin rect, #metro .pin path { fill: var(--accent); } +#metro .pin text { fill: #fff; font-size: 12px; font-weight: 800; text-anchor: middle; dominant-baseline: central; } +@media (prefers-reduced-motion: no-preference) { + #metro .pin { animation: pin-bob 2.4s ease-in-out infinite; } + @keyframes pin-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } } +} +.vline h3 { flex-wrap: wrap; } +.vl-tag { font-size: 13px; font-weight: 600; color: var(--muted); } +.vline li { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; } +.vline .lv { margin-left: 0; padding: 1px 7px; border-radius: 999px; background: var(--surface-2); font-weight: 700; color: var(--muted); } +.vl-x { display: inline-flex; align-items: center; font-size: 12px; font-weight: 700; color: var(--muted); } +.vl-done { padding: 1px 8px; border-radius: 999px; background: #FFE9A8; color: #6B4500; font-size: 12px; font-weight: 800; } +.vline li.on::after { background: #FFC93C; } +.vl-pin { padding: 1px 8px; border-radius: 999px; background: var(--accent); color: #fff; font-size: 12px; font-weight: 800; } +@media (max-width: 860px) { .map-how li:nth-child(-n+2) { display: none; } } +/* pins keep white text readable in both themes */ +.st-pin, .vl-pin { background: #4C4FD0; } +#metro .pin rect, #metro .pin path { fill: #4C4FD0; } diff --git a/assets/js/hub.js b/assets/js/hub.js index fff6abb..04e396c 100644 --- a/assets/js/hub.js +++ b/assets/js/hub.js @@ -763,17 +763,25 @@ /* ---------- map legend ---------- */ + /* the next quest to clear on the map, where the roadmap pin points */ + function pinStationId() { + const onMap = new Set(state.map.stations.map((s) => s.id)); + return (questsOf(hereStage()).find((r) => !state.ridden.has(r.repo) && onMap.has(r.repo)) || {}).repo; + } function renderVLines() { + const pin = pinStationId(); $("#vlines").innerHTML = state.lineList.map((l) => { let ids = [...(state.order.get(l.id) || [])]; if (l.id === "main") { const at = ids.indexOf("causal-inference-in-python-code") + 1; ids.splice(at, 0, ...(state.map.express.stops || []).map((s) => s.id)); } - return `

${esc(l.name)}

    ${ids.map((id) => { + return `

    ${esc(l.name)}${esc(l.tagline || "")}

      ${ids.map((id) => { const r = state.repos.get(id); const cls = [r.kind === "paid" ? "paid" : "", state.ridden.has(id) ? "on" : ""].filter(Boolean).join(" "); - return `
    1. ${r.level ? `${r.level}` : ""}
    2. `; + const other = (r.lines || []).filter((x) => x !== l.id).map((x) => state.lines.get(x)).filter(Boolean); + const transfer = other.length ? `환승${other.map((x) => ``).join("")}${esc(other.map((x) => x.name).join(", "))}` : ""; + return `
    3. ${r.level ? `${r.level}` : ""}${transfer}${state.ridden.has(id) ? `클리어` : id === pin ? `지금 여기` : ""}
    4. `; }).join("")}
    `; }).join(""); } @@ -781,8 +789,7 @@ /* the legend doubles as a filter: pick a track to keep only its line */ function renderLegend() { const count = (id) => [...state.repos.values()].filter((r) => (r.lines || []).includes(id)).length; - $("#line-legend").innerHTML = state.lineList.map((l) => `
  1. `).join("") + - `
  2. 도서·강의 (유료)
  3. `; + $("#line-legend").innerHTML = `
  4. 노선 골라 보기
  5. ` + state.lineList.map((l) => `
  6. `).join(""); if (!$("#line-desc")) $("#line-legend").insertAdjacentHTML("afterend", `

    `); } function focusLine(id) { @@ -957,7 +964,7 @@ const g = svg("g", { class: "zone" + (lv.layer === "build" ? " build" : "") }, zones); svg("rect", { x: z.x, y: 8, width: z.w, height: m.viewBox[1] - 16, rx: 14 }, g); svg("text", { x: z.x + 14, y: 38, class: "zone-l" }, g).textContent = z.level; - svg("text", { x: z.x + 52, y: 36, class: "zone-n" }, g).textContent = lv.name; + svg("text", { x: z.x + 52, y: 36, class: "zone-n" }, g).textContent = lv.tier || lv.name; svg("text", { x: z.x + 14, y: 58, class: "zone-q" }, g).textContent = lv.question; }); @@ -968,13 +975,15 @@ }); const ex = m.express; svg("path", { d: `M${ex.from[0]} ${ex.from[1]} L${ex.to[0]} ${ex.to[1]}`, class: "express" }, tracks); - svg("text", { x: ex.from[0], y: ex.from[1] - 12, class: "express-l" }, tracks).textContent = ex.label; + svg("text", { x: ex.from[0], y: ex.from[1] - 12, class: "express-l" }, tracks).textContent = "교재 (유료)"; m.lines.forEach((ln) => { const g = svg("g", { class: "badge", "data-line": ln.line, "data-track": ln.line, "aria-hidden": "true" }, tracks); - svg("circle", { cx: ln.badge[0], cy: ln.badge[1], r: 9 }, g); + svg("circle", { cx: ln.badge[0], cy: ln.badge[1], r: 13 }, g); + svg("text", { x: ln.badge[0], y: ln.badge[1] + 0.5 }, g).textContent = String(state.lines.get(ln.line).number); }); const stations = svg("g", {}, root); + const pinStation = pinStationId(); (ex.stops || []).forEach((s) => { const r = state.repos.get(s.id); const g = stationGroup(stations, s.id, r, " paid"); @@ -994,13 +1003,23 @@ if (r.kind === "external") svg("circle", { cx: s.x, cy: s.y, r: 15, class: "halo" }, g); svg("circle", { cx: s.x, cy: s.y, r: r.kind === "external" ? 9.5 : 10, class: "ring" }, g); } - if (state.ridden.has(s.id)) svg("circle", { cx: s.x, cy: s.y2 ? (s.y + s.y2) / 2 : s.y, r: 4.5, class: "done" }, g); + if (state.ridden.has(s.id)) { + g.classList.add("cleared"); + svg("path", { d: `M${s.x - 4.5} ${(s.y2 ? (s.y + s.y2) / 2 : s.y) + 0.5} l3 3 l6 -6.5`, class: "done" }, g); + } const bottom = s.y2 || s.y; let lx = s.x, ly, anchor = "middle"; if (s.label === "above") ly = s.y - (s.y2 ? 40 : 32); else if (s.label === "right") { lx = s.x + 20; ly = s.y + 5; anchor = "start"; } else ly = bottom + 34; svg("text", { x: lx, y: ly, class: "label", "text-anchor": anchor }, g).textContent = r.title; + if (s.id === pinStation) { + const py = s.label === "above" ? ly - 30 : s.y - 34; + const pin = svg("g", { class: "pin", "aria-hidden": "true" }, g); + svg("rect", { x: s.x - 34, y: py - 12, width: 68, height: 22, rx: 11 }, pin); + svg("path", { d: `M${s.x - 5} ${py + 9} L${s.x} ${py + 15} L${s.x + 5} ${py + 9} Z` }, pin); + svg("text", { x: s.x, y: py - 0.5 }, pin).textContent = "지금 여기"; + } if (r.status === "running") { const tag = svg("g", { class: "tag" }, g); const tx = anchor === "start" ? lx + 34 : lx, ty = ly + 18; diff --git a/index.html b/index.html index 88884ad..bbcb89e 100644 --- a/index.html +++ b/index.html @@ -90,22 +90,19 @@

    커리큘럼

    From 66b3e637e52205c0606371d803d24ceccf456a92 Mon Sep 17 00:00:00 2001 From: jsshin2022 <59598545+jsshin2022@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:50:01 +0000 Subject: [PATCH 2/4] Keep progress in the passport and make WORLD 2 quests things to do Clearing every quest in a stage now stamps the passport, which gains a roadmap group of five stage stamps; the tracker beside the tab note opens the passport instead of keeping its own count. L3 and L4 quests are the passport's comment, first PR, runner and Builder stamps, with their repos listed below as the stage's repos. The goal chips give way to a single start-finder button, with a way back while a suggested route is shown. --- CONTRIBUTING.md | 2 +- README.md | 18 ++++----- assets/css/hub.css | 15 +++++++ assets/js/hub.js | 92 ++++++++++++++++++++++++++++++++----------- assets/js/passport.js | 10 ++++- data/levels.json | 12 +++++- index.html | 9 ++--- scripts/check_data.py | 2 + 8 files changed, 118 insertions(+), 42 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a378f6a..20c4d7b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,7 +83,7 @@ python3 scripts/builder_cards.py ## 활동 여권과 실험실 -스탬프 정의는 `assets/js/passport.js`의 `STAMPS`에 있습니다. `kind: "auto"`는 허브가 알아서 찍고(`window.hubStamp("post")`), `kind: "self"`는 행사 후기, 강의 후기, 레포 댓글처럼 허브 밖에서 하는 일이라 방문자가 ‘했어요’를 눌러 찍습니다. 이미 받은 스탬프는 다시 주지 않고, 기록은 `localStorage`에만 남습니다. 웹북 독자 스탬프는 같은 도메인의 웹북이 남기는 `cil-book-read`(읽은 장)를 보고 찍고, 역 정보 창의 '내 진도'와 '이어 읽기'는 `cil-book-read`, `cil-book-last`를 읽습니다. `/#passport`로 들어오면 여권이 바로 열립니다. 교란 실험실(`lab.html`, `assets/js/lab.js`)의 데이터는 페이지에서 만드는 가상 사용자입니다. +스탬프 정의는 `assets/js/passport.js`의 `STAMPS`에 있습니다. `kind: "auto"`는 허브가 알아서 찍고(`window.hubStamp("post")`), `kind: "self"`는 행사 후기, 강의 후기, 레포 댓글처럼 허브 밖에서 하는 일이라 방문자가 ‘했어요’를 눌러 찍습니다. 로드맵 스테이지 스탬프(`stage_L0`–`stage_L4`)는 그 스테이지의 퀘스트를 모두 클리어하면 허브가 찍습니다. L3·L4처럼 `data/levels.json`에 `quests`(스탬프 id 목록)가 있는 스테이지는 그 스탬프들이 퀘스트이고, 없는 스테이지는 무료 자료가 퀘스트입니다. 이미 받은 스탬프는 다시 주지 않고, 기록은 `localStorage`에만 남습니다. 웹북 독자 스탬프는 같은 도메인의 웹북이 남기는 `cil-book-read`(읽은 장)를 보고 찍고, 역 정보 창의 '내 진도'와 '이어 읽기'는 `cil-book-read`, `cil-book-last`를 읽습니다. `/#passport`로 들어오면 여권이 바로 열립니다. 교란 실험실(`lab.html`, `assets/js/lab.js`)의 데이터는 페이지에서 만드는 가상 사용자입니다. ## 이벤트 측정 diff --git a/README.md b/README.md index c44191e..17d52ca 100644 --- a/README.md +++ b/README.md @@ -41,26 +41,26 @@ - **교란 실험실:** 가상 사용자 800명으로 교란이 단순 비교를 얼마나 속이는지 슬라이더로 직접 움직여 봅니다. [실험실 열기](https://causalinferencelab.github.io/lab.html) - **연재:** 팀원이 쓰는 글을 모읍니다. 매주 월요일 ‘이번 주 포스트’가 바뀌고, 글쓴이를 작가 카드로 소개합니다. -- **활동 여권:** 자료 읽기, 웹북 한 장 완독 같은 배우기 스탬프와 함께, 행사 사진 후기, 강의·책 후기, 레포 댓글, 러너 참여, 첫 기여, Builder 되기 스탬프를 모읍니다. 기록은 내 브라우저에만 남습니다. +- **활동 여권:** 로드맵 스테이지 클리어 스탬프 다섯 개, 자료 읽기와 웹북 한 장 완독 같은 배우기 스탬프와 함께, 행사 사진 후기, 강의·책 후기, 레포 댓글, 러너 참여, 첫 기여, Builder 되기 스탬프를 모읍니다. 기록은 내 브라우저에만 남습니다. - **3문항으로 시작점 찾기:** 경험, 하는 일, 원하는 것을 고르면 볼 순서와 첫 자료를 추천합니다. [바로 해 보기](https://causalinferencelab.github.io/?quiz=1) - **상관일까, 인과일까?:** 데이터 상황 10개를 보고 상관인지 인과인지 고르는 2분짜리 게임입니다. 답마다 관련 웹북 장으로 이어집니다. [게임 하기](https://causalinferencelab.github.io/game.html) - **완주 카드:** 한 단계의 자료를 모두 학습 완료로 표시하면, 공유할 수 있는 완주 카드 이미지를 만들 수 있습니다. - **Builder 페이지:** 프로젝트를 연 Builder마다 이력, 만든 레포, 배지를 모은 페이지가 있습니다. [Builder 전체 보기](https://causalinferencelab.github.io/builders/) - **Builder 메달:** Builder로 연 기수가 쌓이면 메달이 바뀝니다. 첫 기수는 Builder, 2개 기수는 다시 온 Builder, 3개 기수 이상은 베테랑 Builder, 5개 기수 이상은 명예 Builder입니다. 러너 20명 이상과 함께한 멘토, 러너로 시작해 Builder가 된 사람에게는 특별 메달이 붙습니다. 메달 카드 이미지는 허브에서 바로 만들 수 있습니다. -## 커리큘럼: L0–L4 +## 커리큘럼: L0–L4 로드맵 -L0–L2는 **배우는 단계**로, 무료 자료만으로 끝까지 갈 수 있습니다. L3–L4는 **만드는 단계**입니다. +다섯 단계를 한 길 위의 스테이지로 엮었습니다. L0–L2(WORLD 1 · 배우기)는 무료 자료가 퀘스트이고, 무료 자료만으로 끝까지 갈 수 있습니다. L3–L4(WORLD 2 · 만들기)는 이슈 댓글, 첫 PR, 러너 참여, Builder 되기처럼 직접 하는 일이 퀘스트입니다. 스테이지의 퀘스트를 모두 클리어하면 활동 여권에 스테이지 스탬프가 찍힙니다. | 단계 | 질문 | 대표 자료 | | --- | --- | --- | -| L0 Foundation | 왜 인과추론인가 | [Causal Studio](https://github.com/CausalInferenceLab/causal-studio) | -| L1 Theory & Concept | 어떻게 추정하나 | [Brave & True 인과추론 번역서](https://github.com/CausalInferenceLab/Causal-Inference-with-Python), [온라인 실험 이해하기](https://github.com/CausalInferenceLab/OCE-Materials) | -| L2 Application | 내 데이터에 어떻게 쓰나 | [책 예제 코드](https://github.com/CausalInferenceLab/causal-inference-in-python-code), [산업 사례 템플릿](https://github.com/CausalInferenceLab/causal-inference-practice), [특강·발표 자료](https://github.com/CausalInferenceLab/causal-inference-lecture) 외 | -| L3 Amplification | 분석을 어떻게 키우나 | [Lang2SQL](https://github.com/CausalInferenceLab/Lang2SQL), [정책 효과 분석 에이전트](https://github.com/CausalInferenceLab/policy-effect-analytics-agent) | -| L4 Research | 무엇이 아직 안 풀렸나 | [ccwr](https://github.com/CausalInferenceLab/ccwr), RWE 가이드라인·적응형 설계 스터디, 외부 오픈소스 기여 | +| L0 입문 | 왜 인과추론인가 | [Causal Studio](https://github.com/CausalInferenceLab/causal-studio) | +| L1 개념 완성 | 어떻게 추정하나 | [Brave & True 인과추론 번역서](https://github.com/CausalInferenceLab/Causal-Inference-with-Python), [온라인 실험 이해하기](https://github.com/CausalInferenceLab/OCE-Materials) | +| L2 실전 적용 | 내 데이터에 어떻게 쓰나 | [책 예제 코드](https://github.com/CausalInferenceLab/causal-inference-in-python-code), [산업 사례 템플릿](https://github.com/CausalInferenceLab/causal-inference-practice), [특강·발표 자료](https://github.com/CausalInferenceLab/causal-inference-lecture) 외 | +| L3 도구 개발 | 분석을 어떻게 키우나 | [Lang2SQL](https://github.com/CausalInferenceLab/Lang2SQL), [정책 효과 분석 에이전트](https://github.com/CausalInferenceLab/policy-effect-analytics-agent) | +| L4 연구 | 무엇이 아직 안 풀렸나 | [ccwr](https://github.com/CausalInferenceLab/ccwr), RWE 가이드라인·적응형 설계 스터디, 외부 오픈소스 기여 | -자료는 주제에 따라 여섯 트랙(입문·기본, ML, 연구, 실험, 행동·마케팅, AI 에이전트)으로도 나뉩니다. 허브에서 "처음 시작해요", "A/B 테스트를 해요" 같은 목표를 고르면 볼 순서를 안내합니다. +자료는 주제에 따라 여섯 트랙(입문·기본, ML, 연구, 실험, 행동·마케팅, AI 에이전트)으로도 나뉩니다. 허브의 트랙 지도에서 노선을 고르면 그 주제만 따라갈 수 있고, 어디서 시작할지 모르겠다면 3문항으로 볼 순서를 추천받습니다. ## 한빛미디어와 함께 diff --git a/assets/css/hub.css b/assets/css/hub.css index 69d3a56..d57ff25 100644 --- a/assets/css/hub.css +++ b/assets/css/hub.css @@ -1634,3 +1634,18 @@ button.btn.sm { font-size: 14px; } /* pins keep white text readable in both themes */ .st-pin, .vl-pin { background: #4C4FD0; } #metro .pin rect, #metro .pin path { fill: #4C4FD0; } + +/* ---------- one record (the passport), action quests on WORLD 2, the start finder beside the tab note ---------- */ +.note-row { margin: -6px 0 14px; } +.note-row .tab-note { margin: 0; } +.note-row .route-chips { gap: 6px; } +.note-row .route-chips .chip { font-size: 13.5px; padding: 6px 12px; } +.qh { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 10px; padding: 7px 14px; border-radius: 999px; background: var(--surface); font-size: 13.5px; color: var(--muted); } +.qh:hover { box-shadow: 0 4px 14px rgba(20, 22, 43, .08); } +.qh:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.route-note:empty { display: none; } +.lv-sub { margin: 18px 0 -6px; font-size: 13px; font-weight: 800; color: var(--muted); letter-spacing: .02em; } +#shelf .ep.act { --c: var(--l-ai); } +.aq-body { display: flex; flex-direction: column; gap: 10px; padding: 16px 18px 12px; flex: 1; } +.aq-body .stamp { width: 44px; height: 44px; font-size: 16px; flex-shrink: 0; } +.ep-clear:disabled { cursor: default; } diff --git a/assets/js/hub.js b/assets/js/hub.js index 04e396c..22dc749 100644 --- a/assets/js/hub.js +++ b/assets/js/hub.js @@ -373,11 +373,11 @@ const tryUrl = r.preview || (r.previewLabel ? r.site : ""); const tryLink = tryUrl ? `${esc(r.previewLabel || "맛보기")}` : ""; const done = state.ridden.has(id); - const clearBtn = rideable(r) ? `` : ""; + const clearBtn = isMaterialQuest(r) ? `` : ""; const acts = tryLink || clearBtn ? `
    ${tryLink}${clearBtn}
    ` : ""; return `
  7. `; } + /* a WORLD 2 quest: something done outside the hub, ticked here, kept as a passport stamp */ + function actionQuestHTML(q, k, lv) { + const st = q.stamp, done = questDone(q); + return `
  8. +
    + 퀘스트 ${pad2(k + 1)}직접 해 보기 +

    ${esc(st.name)}

    ${esc(st.how)}
    +
    +
    ${st.go ? `${esc(st.goLabel || "하러 가기")}` : ""}
    +
  9. `; + } /* what clearing a stage gives: a finish card to save and share */ function stageGoalHTML(lv) { - const quests = questsOf(lv.id), done = quests.filter((r) => state.ridden.has(r.repo)).length; - if (levelDone(lv.id)) return `
    STAGE CLEAR${esc(lv.tier || lv.name)} 퀘스트 ${quests.length}개를 모두 마쳤습니다.
    `; - return `
    클리어 보상${esc(lv.tier || lv.name)} 완주 카드${done} / ${quests.length}
    `; + const quests = questsOf(lv.id), done = quests.filter(questDone).length; + if (levelDone(lv.id)) return `
    STAGE CLEAR여권에 ‘${esc(lv.tier || lv.name)} 클리어’ 스탬프가 찍혔습니다.
    `; + return `
    클리어 보상여권 스탬프 ‘${esc(lv.tier || lv.name)} 클리어’ · 완주 카드${done} / ${quests.length}
    `; } /* the Builders behind a level, each one a link to their page */ function levelCrewHTML(courses) { @@ -417,19 +428,23 @@ } /* quests are the free materials of a stage (and the outside library on L4); the book and lecture are not quests */ - const questsOf = (levelId) => levelItems(levelId).filter(rideable); + /* WORLD 1 quests are the stage's free materials; WORLD 2 quests are things to do, kept as passport stamps */ + function questsOf(levelId) { + const lv = levelOf(levelId); + if (lv && (lv.quests || []).length) { + return lv.quests.map((id) => passport().stamps.find((x) => x.id === id)).filter(Boolean).map((st) => ({ action: true, repo: st.id, title: st.name, stamp: st })); + } + return levelItems(levelId).filter(rideable); + } /* the stage to go to next: the first one that is not cleared */ const hereStage = () => (state.levels.find((lv) => !levelDone(lv.id)) || state.levels[state.levels.length - 1]).id; function renderHud() { const box = $("#quest-hud"); if (!box) return; - const all = state.levels.flatMap((lv) => questsOf(lv.id)); - const done = all.filter((r) => state.ridden.has(r.repo)).length; - const badges = state.levels.filter((lv) => levelDone(lv.id)).length; - const pct = all.length ? Math.round((done / all.length) * 100) : 0; - box.innerHTML = `내 로드맵 - - 퀘스트 ${done} / ${all.length}배지 ${badges} / ${state.levels.length}`; + const p = passport(), total = p.stamps.length; + const stages = state.levels.filter((lv) => levelDone(lv.id)).length; + box.innerHTML = ``; } function renderPath() { const box = $("#path"); @@ -440,7 +455,7 @@ box.innerHTML = state.levels.map((lv) => { const quests = questsOf(lv.id), books = levelItems(lv.id).filter((r) => r.kind === "paid").length; const makers = [...new Set(quests.flatMap((r) => r.builders || []))]; - const done = quests.filter((r) => state.ridden.has(r.repo)).length; + const done = quests.filter(questDone).length; const cleared = levelDone(lv.id); const on = route ? onRoute.has(lv.id) : state.level === lv.id; const pct = quests.length ? Math.round((done / quests.length) * 100) : 0; @@ -476,6 +491,7 @@

    STAGE ${i + 1} · ${esc(lv.name)}

    ${esc(lv.tier || lv.name)}${esc(lv.question)}

    ${esc(lv.goal)}

    ${lv.for ? `

    이런 분께${esc(lv.for)}

    ` : ""}
    ${levelCrewHTML(courses)}
+ ${(lv.quests || []).length ? `

퀘스트 · 직접 해 보기

    ${questsOf(lv.id).map((q, k) => actionQuestHTML(q, k, lv)).join("")}

이 스테이지의 레포

` : ""}
    ${courses.map((r, k) => episodeHTML(r, lv.id, k, top)).join("")}
${books.length ? textbookHTML(books, courses) : ""}
${stageGoalHTML(lv)}${next ? `` : ""}
@@ -518,16 +534,18 @@ if (source && id !== state.route) track("select_route", { route: id, source }); state.routeBegin = 0; state.route = id; - $("#route-chips").querySelectorAll(".chip").forEach((c) => c.setAttribute("aria-checked", String(c.dataset.route === id))); + renderRouteChips(); renderShelf(); applyRouteMap(); } function renderRouteChips() { const box = $("#route-chips"); - const all = [{ id: "all", label: "전체 보기" }, ...state.routes]; - box.innerHTML = all.map((r) => ``).join("") + - ``; + const route = currentRoute(); + box.innerHTML = (route ? `` : "") + + ``; + if (box.dataset.wired) return; + box.dataset.wired = "1"; box.addEventListener("click", (e) => { const b = e.target.closest("[data-route]"); if (!b) return; @@ -1224,9 +1242,19 @@ /* ---------- finish card (one level fully marked done) ---------- */ const rideable = (r) => !r.kind || r.kind === "external"; + const levelOf = (id) => state.levels.find((l) => l.id === id); + /* a material is a quest only on stages without action quests */ + const isMaterialQuest = (r) => rideable(r) && !!r.level && !((levelOf(r.level) || {}).quests || []).length; + const passport = () => window.hubPassport || { stamps: [], has: () => false, count: () => 0 }; + const questDone = (q) => (q.action ? passport().has(q.repo) : state.ridden.has(q.repo)); function levelDone(levelId) { - const items = levelItems(levelId).filter(rideable); - return items.length > 0 && items.every((r) => state.ridden.has(r.repo)); + const qs = questsOf(levelId); + return qs.length > 0 && qs.every(questDone); + } + /* a cleared stage puts its stamp in the passport (once; stamps stay) */ + function syncStageStamps() { + if (!window.hubStamp) return; + state.levels.forEach((lv) => { if (levelDone(lv.id)) window.hubStamp(`stage_${lv.id}`); }); } function drawCard(canvas, lv, items, name) { const W = 1200, H = 630, c = canvas.getContext("2d"); @@ -1319,7 +1347,7 @@ async function openCard(levelId) { const lv = state.levels.find((l) => l.id === levelId); if (!lv || !levelDone(levelId)) return; - const items = levelItems(levelId).filter(rideable); + const items = questsOf(levelId); const next = state.levels[state.levels.indexOf(lv) + 1]; track("finish_card_open", { level: levelId }); $("#station-body").innerHTML = `
@@ -1414,7 +1442,7 @@ const l = state.lines.get(lid); return `
${prev ? `` : ``}${next ? `` : ``}
`; }).join(""); - const canRide = !r.kind || r.kind === "external"; + const canRide = isMaterialQuest(r); const ridden = state.ridden.has(r.repo); $("#station-body").innerHTML = `
@@ -1469,6 +1497,21 @@ if (lv) dialog().addEventListener("close", () => gotoItem(id), { once: true }); } + function claimQuest(id) { + const lv = state.levels.find((l) => (l.quests || []).includes(id)); + const before = lv ? levelDone(lv.id) : false; + if (!window.hubStamp || !window.hubStamp(id)) return; + track("mark_done", { quest: id, level: lv ? lv.id : "", done: true, source: "card" }); + if (lv && !before && levelDone(lv.id)) { track("stage_clear", { level: lv.id }); syncStageStamps(); } + } + /* a stamp from anywhere (passport dialog, a quest card) redraws the roadmap and keeps focus where it was */ + function onStamp() { + const a = document.activeElement, key = a && (a.dataset.clear || a.dataset.questClaim); + renderShelf(); renderMap(); renderWeek(); + if (key) { const b = $(`#shelf [data-clear="${CSS.escape(key)}"], #shelf [data-quest-claim="${CSS.escape(key)}"]`); if (b) b.focus(); } + const goal = $("#shelf .stage-goal.clear"); + if (goal && key) goal.classList.add("pop"); + } function toggleClear(id) { const was = state.level, stageWas = state.repos.get(id) && state.repos.get(id).level; const before = stageWas ? levelDone(stageWas) : false; @@ -1481,6 +1524,7 @@ if (btn) btn.focus(); if (stageWas && !before && levelDone(stageWas)) { track("stage_clear", { level: stageWas }); + syncStageStamps(); const goal = $("#shelf .stage-goal.clear"); if (goal) { goal.classList.add("pop"); goal.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "nearest" }); } } @@ -1540,6 +1584,8 @@ if (dl) { saveCard(dl.dataset.cardSave); return; } const clr = e.target.closest("[data-clear]"); if (clr) { toggleClear(clr.dataset.clear); return; } + const qc = e.target.closest("[data-quest-claim]"); + if (qc) { claimQuest(qc.dataset.questClaim); return; } const ride = e.target.closest("[data-ride]"); if (ride) { toggleRide(ride.dataset.ride); return; } if (e.target.closest("[data-close]")) { closeStation(); return; } @@ -1605,6 +1651,8 @@ renderGrowth(); renderLadder(); renderContrib(); renderRouteChips(); renderShelf(); renderWeek(); + document.addEventListener("hub:stamp", onStamp); + syncStageStamps(); let spyQueued = false; window.addEventListener("scroll", () => { if (spyQueued) return; spyQueued = true; requestAnimationFrame(() => { spyQueued = false; shelfSpy(); }); }, { passive: true }); const header = $(".site-header"); diff --git a/assets/js/passport.js b/assets/js/passport.js index d5ca4f2..f07735e 100644 --- a/assets/js/passport.js +++ b/assets/js/passport.js @@ -7,8 +7,11 @@ const REPO = "https://github.com/CausalInferenceLab/CausalInferenceLab.github.io"; /* auto: the hub sees it happen. self: done outside the hub (GitHub, an event, a store), so the visitor ticks it after doing it. */ - const GROUPS = [["learn", "배우기"], ["together", "함께하기"], ["build", "기여하기"]]; + const GROUPS = [["roadmap", "로드맵"], ["learn", "배우기"], ["together", "함께하기"], ["build", "기여하기"]]; + /* one stamp per roadmap stage: the hub gives it when every quest in the stage is cleared */ + const STAGES = [["L0", "입", "입문"], ["L1", "개", "개념 완성"], ["L2", "실", "실전 적용"], ["L3", "도", "도구 개발"], ["L4", "연", "연구"]]; const STAMPS = [ + ...STAGES.map(([id, mark, name]) => ({ id: `stage_${id}`, group: "roadmap", kind: "auto", mark, name: `${name} 클리어`, how: `로드맵 ${id} ${name} 스테이지의 퀘스트를 모두 클리어하세요.`, href: "/#curriculum" })), { id: "first_open", group: "learn", kind: "auto", mark: "첫", name: "첫 걸음", how: "커리큘럼에서 자료 하나를 열어 보세요.", href: "/#curriculum" }, { id: "post", group: "learn", kind: "auto", mark: "글", name: "연재 독자", how: "연재 글을 하나 읽어 보세요.", href: "/#posts" }, { id: "webbook", group: "learn", kind: "auto", mark: "책", name: "웹북 독자", how: "웹북 『Python으로 하는 인과추론』에서 한 장을 끝까지 읽어 보세요.", href: "/Causal-Inference-with-Python/" }, @@ -63,8 +66,11 @@ const n = earnedCount(d); if (window.hubTrack) window.hubTrack("stamp_earned", { stamp: id, count: n }); paintCounts(); toast(stamp, n); + document.dispatchEvent(new CustomEvent("hub:stamp", { detail: { id } })); return true; }; + /* what the roadmap reads: the stamp list and whether one is earned */ + window.hubPassport = { stamps: STAMPS, has: (id) => !!(load().stamps || {})[id], count: () => earnedCount(load()) }; /* Distinct things seen (Builders), so reloading one page three times does not count. */ window.hubSeen = (key, value, need, stampId) => { const d = load(); @@ -102,7 +108,7 @@

스탬프 ${n} / ${STAMPS.length}

${n === STAMPS.length ? "모든 스탬프를 모았습니다. 다음 기수에 Builder로 만나요." : next ? `다음 스탬프: ${esc(next.name)}. ${esc(next.how)}` : ""}

${GROUPS.map(([g, label]) => `

${label}

`).join("")} -

배우기 스탬프는 허브가 알아서 찍고, 함께하기와 기여하기는 하고 나서 ‘했어요’를 누르면 찍힙니다. 기록은 이 브라우저에만 저장됩니다.

+

로드맵 스탬프는 스테이지를 클리어하면, 배우기 스탬프는 허브가 알아서 찍고, 함께하기와 기여하기는 하고 나서 ‘했어요’를 누르면 찍힙니다. 기록은 이 브라우저에만 저장됩니다.

`; if (!dlg.open) { if (typeof dlg.showModal === "function") dlg.showModal(); else dlg.setAttribute("open", ""); } dlg.querySelector(".station-close").focus(); diff --git a/data/levels.json b/data/levels.json index 42dcebe..3973b03 100644 --- a/data/levels.json +++ b/data/levels.json @@ -33,7 +33,11 @@ "question": "분석을 어떻게 키우나", "goal": "데이터 에이전트처럼 분석을 빠르게 만드는 도구를 함께 개발합니다.", "layer": "build", - "for": "분석을 돕는 도구를 직접 만들고 싶은 분" + "for": "분석을 돕는 도구를 직접 만들고 싶은 분", + "quests": [ + "comment", + "first_pr" + ] }, { "id": "L4", @@ -42,6 +46,10 @@ "question": "무엇이 아직 안 풀렸나", "goal": "연구 소프트웨어를 만들고 규제·임상 연구를 리뷰하며, 외부 오픈소스에 기여합니다.", "layer": "build", - "for": "헬스케어·규제 연구나 외부 오픈소스 기여를 하려는 분" + "for": "헬스케어·규제 연구나 외부 오픈소스 기여를 하려는 분", + "quests": [ + "runner", + "open_project" + ] } ] diff --git a/index.html b/index.html index bbcb89e..8e28cdd 100644 --- a/index.html +++ b/index.html @@ -74,19 +74,16 @@

Causal Inference Hub

커리큘럼

L0부터 L4까지, 팀이 만든 오픈소스를 순서대로 엮었습니다. L0–L2는 무료 자료만으로 끝까지 갈 수 있습니다.

-
- 무엇을 하고 싶나요? -
-
-

로드맵은 배우는 순서이고, 트랙은 주제입니다. 같은 주제를 L0부터 L4까지 이어 보려면 를 여세요.

-
+
+
+

`; const d = dialog(); if (!d.open) { if (typeof d.showModal === "function") d.showModal(); else d.setAttribute("open", ""); } diff --git a/index.html b/index.html index 8e28cdd..37b4aaf 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ Causal Inference Hub | 가짜연구소 인과추론팀 - + @@ -45,7 +45,7 @@