diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 82a1090..fe9609c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,7 +4,9 @@ on: push: branches: [main] schedule: + # stats once a week (Monday), the activity feed every day - cron: "17 0 * * 1" + - cron: "17 21 * * *" workflow_dispatch: permissions: @@ -24,7 +26,7 @@ jobs: persist-credentials: false - name: Refresh GitHub stats - if: github.event_name != 'push' + if: github.event_name == 'workflow_dispatch' || github.event.schedule == '17 0 * * 1' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -37,6 +39,11 @@ jobs: git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:main fi + - name: Build the activity feed + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 scripts/build_activity.py data/activity.json || echo "activity feed kept as is" + - name: Check data files run: python3 scripts/check_data.py 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 e63c23c..e42df06 100644 --- a/assets/css/hub.css +++ b/assets/css/hub.css @@ -533,9 +533,15 @@ body::before { content: ""; position: fixed; inset: 0 0 auto; height: env(safe-a /* Vertical lines replace the map on phones and small tablets */ .vlines { display: none; padding: 0 16px; } +/* phones get the real map, panned sideways, and the line-by-line list folded under it */ +.vl-fold { display: none; } @media (max-width: 900px) { - .map-scroll, .map-hint { display: none !important; } - .vlines { display: grid; gap: 22px; } + .vl-fold { display: block; margin: 10px 16px 0; border-radius: 12px; background: var(--surface-2); } + .vl-fold > summary { cursor: pointer; padding: 12px 14px; font-size: 14px; font-weight: 700; list-style: none; } + .vl-fold > summary::-webkit-details-marker { display: none; } + .vl-fold > summary::after { content: " ▾"; color: var(--muted); } + .vl-fold[open] > summary::after { content: " ▴"; } + .vl-fold .vlines { display: grid; gap: 22px; padding: 4px 14px 16px; } } .vline h3 { display: flex; align-items: center; gap: 8px; font-size: 16px; margin-bottom: 8px; } .vline ol { list-style: none; margin: 0; padding: 0; } @@ -1599,3 +1605,130 @@ 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: 12px; 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; } + +/* ---------- 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; } + +/* ---------- Level labels ---------- */ +.st-lv { margin-top: 8px; font-size: 12px; font-weight: 700; letter-spacing: .02em; color: var(--muted); } +.st-lv + .st-name { margin-top: 0; } +.lv-id { display: flex; flex-direction: column; align-items: flex-start; line-height: .9; } +.lv-id small { font-family: var(--f-body); font-size: 13px; font-weight: 800; letter-spacing: .04em; color: var(--muted); } +#metro .zone-l { font-size: 18px; } +@media (max-width: 900px) { + .line-legend { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 4px; } + .line-legend::-webkit-scrollbar { display: none; } + .line-legend li { flex-shrink: 0; } +} + +/* ---------- alive: the activity line under the hero, the feed with names, the loop ---------- */ +.avatar.xs { width: 22px; height: 22px; font-size: 12px; position: relative; } +.live { display: flex; align-items: center; gap: 10px 14px; margin-top: 10px; padding: 10px 16px; border-radius: 14px; background: var(--surface); font-size: 14px; min-height: 46px; } +.live[hidden] { display: none; } +.lv-k { display: inline-flex; align-items: center; gap: 7px; font-weight: 800; white-space: nowrap; flex-shrink: 0; } +.lv-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--live); box-shadow: 0 0 0 0 color-mix(in srgb, var(--live) 50%, transparent); } +@media (prefers-reduced-motion: no-preference) { + .lv-dot { animation: lv-pulse 2s ease-out infinite; } + @keyframes lv-pulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--live) 55%, transparent); } 100% { box-shadow: 0 0 0 9px transparent; } } +} +.lv-line { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; white-space: nowrap; overflow: hidden; } +.lv-what { display: inline-flex; align-items: baseline; min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.lv-line time, .ac-list time { color: var(--muted); font-size: 13px; flex-shrink: 0; margin-left: 4px; } +.lv-more { flex-shrink: 0; font-size: 13.5px; font-weight: 700; color: var(--accent); text-decoration: none; } +.lv-more:hover { text-decoration: underline; } +.ac-who { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; color: var(--ink); text-decoration: none; flex-shrink: 0; } +.ac-who:hover { text-decoration: underline; } +.ac-repo { all: unset; cursor: pointer; font-weight: 700; color: var(--ink); margin-left: 4px; } +span.ac-repo { cursor: default; } +button.ac-repo:hover { text-decoration: underline; } +.ac-repo:focus-visible, .ac-who:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; border-radius: 4px; } +.ac-verb { color: var(--ink); } +.ac-title { margin-left: 6px; color: var(--muted); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +a.ac-title:hover { text-decoration: underline; } +.lv-what .ac-title { display: none; } +@media (max-width: 760px) { + .live { flex-wrap: wrap; } + .lv-line { flex-basis: 100%; order: 3; } + .lv-more { margin-left: auto; } +} +.activity { margin-top: 12px; padding: 18px 20px; border-radius: 20px; background: var(--surface); } +.activity[hidden] { display: none; } +.ac-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; } +.ac-head h3 { font-size: 17px; } +.ac-head p { font-size: 14px; color: var(--muted); } +.ac-people { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-top: 12px; } +.ac-n { font-size: 14px; color: var(--muted); } +.ac-n b { font-size: 20px; color: var(--ink); margin-right: 2px; } +.ac-people ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; } +.ac-people li .ac-who { padding: 3px 10px 3px 3px; border-radius: 999px; background: var(--surface-2); font-size: 13.5px; } +.ac-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 0; } +.ac-list li { display: flex; align-items: baseline; gap: 6px; padding: 9px 0; border-top: 1px solid var(--rule); font-size: 14px; min-width: 0; } +.ac-list li .ac-who { align-self: center; } +.ac-what { display: flex; align-items: baseline; min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; } +.ac-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 14px; margin-top: 12px; font-size: 12.5px; color: var(--muted); } +@media (max-width: 760px) { + .ac-list li { flex-wrap: wrap; } + .ac-what { flex-basis: 100%; white-space: normal; flex-wrap: wrap; } + .ac-title { white-space: normal; } +} +.loop { list-style: none; margin: 0 0 14px; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; counter-reset: none; position: relative; } +.loop > li > a { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 14px 16px; border-radius: 16px; background: var(--surface); color: var(--ink); text-decoration: none; position: relative; } +.loop > li > a:hover { box-shadow: 0 6px 20px rgba(20, 22, 43, .08); } +.loop > li > a:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.loop > li:not(.lp-back):not(:nth-child(4)) > a::after { content: "›"; position: absolute; right: -10px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 13px; font-weight: 800; display: grid; place-items: center; z-index: 1; line-height: 1; } +.lp-n { font-family: var(--f-display); font-size: 22px; font-weight: 800; color: var(--accent); line-height: 1; } +.loop b { font-size: 15.5px; margin-top: 4px; } +.loop a span:last-child { font-size: 13.5px; color: var(--muted); } +.lp-back { grid-column: 1 / -1; padding: 8px 14px; border-radius: 12px; border: 1.5px dashed color-mix(in srgb, var(--accent) 45%, var(--rule)); color: var(--muted); font-size: 13.5px; text-align: center; } +@media (max-width: 760px) { + .loop { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .loop > li:nth-child(2) > a::after { display: none; } +} +#join .steps li::before { display: none; } diff --git a/assets/js/hub.js b/assets/js/hub.js index fff6abb..4545343 100644 --- a/assets/js/hub.js +++ b/assets/js/hub.js @@ -77,6 +77,8 @@ async function load() { const names = ["repos", "lines", "levels", "map", "routes", "projects", "events", "articles", "builders", "growth", "stats", "featured", "daily", "stages"]; const data = await Promise.all(names.map((n) => getJSON(`data/${n}.json`))); + /* the activity snapshot is optional: the feed asks GitHub live and falls back to it */ + state.activitySnap = await getJSON("data/activity.json").catch(() => ({ updated: null, items: [] })); const d = cleanData(Object.fromEntries(names.map((n, i) => [n, data[i]]))); d.repos.forEach((r) => { const live = d.stats.repos && d.stats.repos[r.repo]; @@ -334,6 +336,8 @@ } const pad2 = (n) => String(n).padStart(2, "0"); + /* levels read as "Level 0".."Level 4"; the ids stay L0..L4 in data and links */ + const LV = (id) => (id ? `Level ${String(id).replace(/^L/, "")}` : ""); const DAY = 864e5; const ageDays = (iso) => (iso ? Math.max(0, Math.floor((Date.now() - Date.parse(iso)) / DAY)) : null); function ago(iso) { @@ -367,18 +371,17 @@ const tracks = (r.lines || []).map((l) => esc(state.lines.get(l).name)).join(" · "); const lv = state.levels.find((x) => x.id === level); /* what a course says about itself: three tags, what it is made of, the book it follows, and a first look */ - const tags = (r.tags || []).length ? `${r.tags.map((t) => `#${esc(t)}`).join("")}` : ""; const book = r.textbook ? state.repos.get(r.textbook) : null; const spec = r.format || book ? `${r.format ? `구성${esc(r.format)}` : ""}${book ? `교재${esc(book.shortTitle || book.title)}` : ""}` : ""; 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 `
  • - @@ -386,11 +389,22 @@
  • `; } + /* 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 `
  • +
    + 퀘스트 ${pad2(k + 1)}직접 해 보기 +

    ${esc(st.name)}

    ${esc(st.how)}
    +
    +
    ${st.go ? `${esc(st.goLabel || "하러 가기")}` : ""}
    +
  • `; + } /* 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 `
    클리어여권에 스탬프가 찍혔습니다.
    `; + return `
    다 하면 여권 스탬프${done} / ${quests.length}
    `; } /* the Builders behind a level, each one a link to their page */ function levelCrewHTML(courses) { @@ -417,19 +431,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,15 +458,15 @@ 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; const cls = ["step", lv.layer === "build" ? "build" : "", cleared ? "cleared" : "", lv.id === here ? "here" : ""].filter(Boolean).join(" "); - return `
  • `; }).join(""); @@ -472,13 +490,14 @@ const i = state.levels.indexOf(lv), next = state.levels[i + 1]; shelf.innerHTML = `
  • - ${lv.id} -

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

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

    ${esc(lv.goal)}

    ${lv.for ? `

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

    ` : ""}
    + Level${lv.id.replace(/^L/, "")} +

    ${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 ? `` : ""}
    +
    ${stageGoalHTML(lv)}${next ? `` : ""}
  • `; $("#route-note").textContent = ""; } @@ -487,7 +506,7 @@ const repos = [...state.repos.values()].filter((r) => !r.kind && r.status !== "depot"); const depot = [...state.repos.values()].filter((r) => r.status === "depot"); - $("#curriculum-sub").textContent = `인과추론을 어디서 시작해 어디까지 가면 되는지, 팀이 만든 레포 ${repos.length}개를 다섯 스테이지의 로드맵으로 엮었습니다. L0–L2는 무료 자료만으로도 끝까지 갈 수 있습니다.`; + $("#curriculum-sub").textContent = `팀이 만든 오픈소스 ${repos.length}개를 Level 0부터 4까지 엮었습니다. Level 0–2는 무료 자료만으로 끝까지 갈 수 있습니다.`; $("#n-course").textContent = repos.length; $("#n-course").title = `레포 ${repos.length}개`; $("#shelf-foot").innerHTML = `카드를 누르면 자세한 정보와 링크가 열립니다. 클리어 기록은 이 브라우저에만 남습니다. 운영·보관 레포: ${depot.map((r) => `${esc(r.title)}`).join(", ")}`; } @@ -518,16 +537,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; @@ -756,24 +777,32 @@ const list = learnables(); const recent = state.recent.map((x) => state.repos.get(x.id)).filter(Boolean).slice(0, 3); const finished = state.levels.filter((l) => levelDone(l.id)); - const cardChip = finished.length ? `` : ""; + const cardChip = finished.length ? `` : ""; box.innerHTML = recent.length ? `
    이어서 보기${recent.map((r) => ``).join("")} ${cardChip}
    ` : ""; } /* ---------- 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 ? `${LV(r.level)}` : ""}${transfer}${state.ridden.has(id) ? `클리어` : id === pin ? `지금 여기` : ""}
      4. `; }).join("")}
      `; }).join(""); } @@ -781,8 +810,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) { @@ -858,7 +886,7 @@ ${esc(p.shortTitle || p.title)}${esc(p.type)}${esc(who)} - + `; }).join(""); } @@ -902,6 +930,132 @@ el.hidden = false; el.innerHTML = `다음 행사${d === 0 ? "오늘" : `D-${d}`}${dot(next.date)} · ${esc(next.name)}${next.link ? "참가 신청을 받고 있습니다" : "신청 링크는 곧 열립니다"} · 지난 ${state.events.filter((e) => !e.upcoming).length}회에 ${fmt(state.events.reduce((s, e) => s + (e.participants || 0), 0))}명이 함께했습니다`; } + /* ---------- activity: what the team did on GitHub lately, credited by name ---------- */ + const BOT = /\[bot\]$|^github-actions$|^dependabot/; + /* the same reading of a GitHub event as scripts/build_activity.py */ + function activityItem(e) { + const login = (e.actor || {}).login || "", full = (e.repo || {}).name || ""; + if (!login || BOT.test(login) || !full.includes("/")) return null; + const repo = full.split("/")[1], p = e.payload || {}, a = p.action; + const base = { id: e.id, login, repo, at: e.created_at }; + const prUrl = (n) => (n ? `https://github.com/${full}/pull/${n}` : null); + switch (e.type) { + case "PushEvent": return /^refs\/heads\/(main|master)$/.test(p.ref || "") ? { ...base, kind: "push" } : null; + case "PullRequestEvent": { + const pr = p.pull_request || {}, n = p.number || pr.number, merged = a === "merged" || (a === "closed" && pr.merged); + if (a !== "opened" && !merged) return null; + return { ...base, kind: merged ? "pr_merge" : "pr_open", number: n, title: pr.title || null, url: prUrl(n) }; + } + case "IssuesEvent": return a === "opened" ? { ...base, kind: "issue", number: (p.issue || {}).number, title: (p.issue || {}).title, url: (p.issue || {}).html_url } : null; + case "IssueCommentEvent": return { ...base, kind: "comment", number: (p.issue || {}).number, title: (p.issue || {}).title, url: (p.comment || {}).html_url || (p.issue || {}).html_url }; + case "PullRequestReviewEvent": { const n = p.number || (p.pull_request || {}).number; return { ...base, kind: "review", number: n, title: (p.pull_request || {}).title || null, url: prUrl(n) }; } + case "WatchEvent": return { ...base, kind: "star" }; + case "ForkEvent": return { ...base, kind: "fork" }; + case "ReleaseEvent": return { ...base, kind: "release", title: (p.release || {}).tag_name, url: (p.release || {}).html_url }; + case "CreateEvent": return p.ref_type === "repository" ? { ...base, kind: "repo" } : null; + default: return null; + } + } + const CONTRIB_KINDS = new Set(["push", "pr_open", "pr_merge", "issue", "comment", "review", "release", "repo"]); + const VERB = { push: "에 코드를 올렸어요", pr_open: "에 PR을 열었어요", pr_merge: "의 PR을 머지했어요", issue: "에 이슈를 남겼어요", comment: "에 댓글을 남겼어요", review: "의 PR을 리뷰했어요", star: "에 ★를 눌렀어요", fork: "를 포크했어요", release: "의 새 버전을 냈어요", repo: " 레포를 새로 열었어요" }; + const builderByLogin = (login) => state.builders.find((b) => (b.github || "").toLowerCase() === String(login).toLowerCase()); + /* newest first; repeated pushes by the same person to the same repo count as one line */ + function tidyActivity(items) { + const titles = new Map((state.activitySnap.items || []).filter((x) => x.title).map((x) => [String(x.id), x.title])); + const sorted = items.filter(Boolean).map((x) => ({ ...x, title: x.title || titles.get(String(x.id)) || null })).sort((a, b) => (a.at < b.at ? 1 : -1)); + const out = []; + sorted.forEach((x) => { + const last = out[out.length - 1]; + if (last && last.login === x.login && last.repo === x.repo && last.kind === x.kind && (x.kind === "push" || x.kind === "star")) { last.n = (last.n || 1) + 1; return; } + out.push({ ...x }); + }); + return out; + } + async function loadActivity() { + let items = null, live = false; + try { + const res = await fetch(`https://api.github.com/orgs/${ORG}/events?per_page=100`, { headers: { Accept: "application/vnd.github+json" } }); + if (res.ok) { items = (await res.json()).map(activityItem); live = true; } + } catch (e) { /* offline or blocked: use the snapshot */ } + if (!items || !items.filter(Boolean).length) { items = state.activitySnap.items || []; live = false; } + state.activity = tidyActivity(items); + state.activityAt = live ? new Date().toISOString() : state.activitySnap.updated; + renderLive(); renderActivity(); renderLoop(); + } + function whoHTML(login, placement) { + const b = builderByLogin(login); + const img = ``; + const name = b ? `${esc(b.name)}님` : `@${esc(login)}`; + const href = b && b.slug ? `builders/${esc(b.slug)}.html` : `https://github.com/${esc(login)}`; + return `${name}`; + } + function whatHTML(x, placement) { + const r = state.repos.get(x.repo), label = r && !r.kind ? r.title : x.repo; + const repoHTML = r && !r.kind ? `` : `${esc(label)}`; + const verb = x.kind === "push" && x.n > 1 ? `에 코드를 ${x.n}번 올렸어요` : x.kind === "star" && x.n > 1 ? `에 ★ ${x.n}개` : VERB[x.kind]; + const title = x.title ? (x.url ? `${esc(x.title)}` : `${esc(x.title)}`) : ""; + return `${repoHTML}${esc(verb)}${title}`; + } + const recentContrib = (days) => (state.activity || []).filter((x) => CONTRIB_KINDS.has(x.kind) && ageDays(x.at) != null && ageDays(x.at) <= days); + /* one line under the hero that changes every few seconds */ + let liveTimer = null; + function renderLive() { + const box = $("#live"); + if (!box) return; + const list = (state.activity || []).slice(0, 6); + if (!list.length) { box.hidden = true; return; } + box.hidden = false; + let i = 0; + const paint = () => { + const x = list[i % list.length]; + box.querySelector(".lv-line").innerHTML = `${whoHTML(x.login, "ticker")}${whatHTML(x, "ticker")}`; + wireAvatars(box); + }; + box.innerHTML = `지금 팀에서더 보기`; + paint(); + clearInterval(liveTimer); + if (reduceMotion || list.length < 2) return; + let paused = false; + box.addEventListener("mouseenter", () => { paused = true; }); + box.addEventListener("mouseleave", () => { paused = false; }); + box.addEventListener("focusin", () => { paused = true; }); + box.addEventListener("focusout", () => { paused = false; }); + liveTimer = setInterval(() => { if (paused || document.hidden) return; i += 1; paint(); }, 5000); + } + /* the feed in the contributors section: everyone who did something lately, then what they did */ + function renderActivity() { + const box = $("#activity"); + if (!box) return; + const contrib = recentContrib(30); + const people = [...new Map(contrib.map((x) => [x.login, x])).keys()]; + const interest = (state.activity || []).filter((x) => (x.kind === "star" || x.kind === "fork") && ageDays(x.at) <= 30).reduce((s, x) => s + (x.n || 1), 0); + const list = (state.activity || []).filter((x) => CONTRIB_KINDS.has(x.kind)).slice(0, 8); + if (!list.length) { box.hidden = true; return; } + box.hidden = false; + const at = state.activityAt ? `GitHub 공개 활동 기준 · ${ago(state.activityAt)} 갱신` : "GitHub 공개 활동 기준"; + box.innerHTML = `

      최근 30일, 이렇게 기여했어요

      기여하면 여기와 Builder 페이지에 이름이 올라갑니다.

      +
      ${people.length}명이 기여${interest ? ` · ★·포크 ${interest}` : ""}
        ${people.slice(0, 16).map((l) => `
      • ${whoHTML(l, "feed")}
      • `).join("")}
      +
        ${list.map((x) => `
      1. ${whoHTML(x.login, "feed")}${whatHTML(x, "feed")}
      2. `).join("")}
      + `; + wireAvatars(box); + } + /* the loop the hub runs on, with today's numbers: learn, contribute, get named, share */ + function renderLoop() { + const box = $("#loop"); + if (!box) return; + const materials = [...state.repos.values()].filter((r) => !r.kind && r.status !== "depot").length; + const people = new Set(recentContrib(30).map((x) => x.login)).size; + const runners = (state.growth && state.growth.uniqueRunners || []).slice(-1)[0]; + const steps = [ + ["배우기", `자료 ${materials}개, 무료`, "#curriculum"], + ["기여하기", people ? `최근 30일 ${people}명` : "이슈 하나, PR 하나부터", "#activity"], + ["이름이 올라가기", `Builder ${state.builders.length}명${runners ? ` · 러너 ${fmt(runners.cumulative)}명` : ""}`, "#contrib"], + ["공유하기", "Builder 페이지·완주 카드", "#contrib"] + ]; + box.innerHTML = steps.map(([t, n, href], i) => `
    7. ${i + 1}${t}${esc(n)}
    8. `).join("") + + ``; + } + /* the next Data Forum gets the space; past ones are one line each */ function renderEvents() { const list = [...state.events].sort((a, b) => b.order - a.order); @@ -913,14 +1067,14 @@ const nextHTML = next ? `` : ""; const pastHTML = past.map((e) => `
    9. ${e.order}회 - ${e.link ? `${esc(e.name)}` : esc(e.name)}${when(e)} + ${e.link ? `${esc(e.name)}` : esc(e.name)}${when(e)} ${e.participants ? `${fmt(e.participants)}명` : ""}
    10. `).join(""); $("#event-list").innerHTML = nextHTML + (past.length ? `
    11. 지난 Data Forum ${past.length}회 · 참가 ${fmt(total)}명

        ${pastHTML}
    12. ` : ""); @@ -956,8 +1110,8 @@ const lv = levelById.get(z.level); 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 + 14, y: 37, class: "zone-l" }, g).textContent = LV(z.level); + svg("text", { x: z.x + 92, 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 +1122,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 +1150,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; @@ -1176,7 +1342,7 @@

      추천 경로

      ${esc(route.label)}

      ${esc(quizReason(quiz.a, id))} ${esc(route.description)}

      -
        ${stops.map((r, i) => `
      1. ${pad2(i + 1)}${esc(r.title)}${esc(r.level || "")}${i === begin ? " · 여기서 시작" : i < begin ? " · 필요하면" : ""}
      2. `).join("")}
      +
        ${stops.map((r, i) => `
      1. ${pad2(i + 1)}${esc(r.title)}${esc(LV(r.level))}${i === begin ? " · 여기서 시작" : i < begin ? " · 필요하면" : ""}
      2. `).join("")}
      `); $("#station-body [data-quiz-go]").focus(); } @@ -1205,9 +1371,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"); @@ -1222,7 +1398,7 @@ c.fillStyle = "rgba(255,255,255,.82)"; c.font = font(600, 28); c.fillText("Causal Inference Hub · 가짜연구소 인과추론팀", 80, 110); c.fillStyle = "#fff"; c.font = font(800, 92); - c.fillText(`${lv.id} 완주`, 80, 236); + c.fillText(`${LV(lv.id)} 완주`, 80, 236); c.font = font(700, 44); c.fillText(lv.tier ? `${lv.tier} · ${lv.name}` : lv.name, 80, 300); c.fillStyle = "rgba(255,255,255,.86)"; c.font = font(500, 30); c.fillText(items.length === 1 ? `“${lv.question}”에 답하는 자료를 마쳤습니다.` : `“${lv.question}”에 답하는 자료 ${items.length}개를 모두 마쳤습니다.`, 80, 362); @@ -1300,17 +1476,17 @@ 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 = `
      -

      ${lv.id} ${esc(lv.name)} 완주 카드

      - +

      ${LV(lv.id)} ${esc(lv.tier || lv.name)} 완주 카드

      +

      이미지를 저장해 링크드인이나 블로그에 올려 보세요. 이름은 이 브라우저 밖으로 보내지 않고, 학습 진도는 이름 없이 방문 통계로만 집계합니다.

      - +
      `; showDialog(); try { if (document.fonts) await document.fonts.load('800 92px "Pretendard Variable"'); } catch (e) { /* draw with fallback fonts */ } @@ -1369,7 +1545,7 @@ const extraTag = r.kind === "paid" ? `유료` : statusLabel[r.status] ? `${statusLabel[r.status]}` : ""; const builders = (r.builders || []).map(nameLink).join(", "); const rows = [ - lv ? `
      단계
      ${lv.id} ${esc(lv.name)}: ${esc(lv.question)}
      ` : "", + lv ? `
      단계
      ${LV(lv.id)} ${esc(lv.tier || lv.name)}: ${esc(lv.question)}
      ` : "", r.kind === "paid" && r.type ? `
      형태
      ${esc(r.type)} · ${esc(r.meta || "")} · 한빛미디어
      ` : "", r.kind === "paid" && r.credit ? `
      ${r.type === "강의" ? "강사" : "만든 사람"}
      ${esc(r.credit)}
      ` : "", builders ? `
      Builder
      ${builders}
      ` : "", @@ -1395,7 +1571,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 = `
      @@ -1411,7 +1587,7 @@ ${adjacent} ${canRide ? `` : ""} - ${ridden && lv && levelDone(lv.id) ? `
      STAGE CLEAR · ${lv.id} ${esc(lv.tier || lv.name)} 퀘스트를 모두 마쳤습니다.
      ` : ""} + ${ridden && lv && levelDone(lv.id) ? `
      클리어 · ${LV(lv.id)} ${esc(lv.tier || lv.name)} 퀘스트를 모두 마쳤습니다.
      ` : ""}
    `; const d = dialog(); if (!d.open) { if (typeof d.showModal === "function") d.showModal(); else d.setAttribute("open", ""); } @@ -1450,6 +1626,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; @@ -1462,6 +1653,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" }); } } @@ -1484,7 +1676,7 @@ if (fa) track("click_featured", { repo: fa.dataset.repo || "" }); const out = e.target.closest(".pt-out"); if (out) track("click_partner", { item: out.closest(".pt-card").querySelector("[data-goto]").dataset.goto, action: "out" }); - const ja = e.target.closest("#join a, .footer-links a[href*='discord']"); + const ja = e.target.closest("#join .steps a, #join .join-foot a, .footer-links a[href*='discord']"); if (ja) track("click_join", { target: joinTarget(ja.getAttribute("href") || ""), source: ja.closest("#join") ? "join_section" : "footer" }); const sa = e.target.closest("#station-body .links a"); if (sa && state.openRepo) track("click_outbound", { repo: state.openRepo.repo, kind: state.openRepo.kind === "paid" ? "paid" : /github\.com/.test(sa.href) ? "github" : "site" }); @@ -1521,6 +1713,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; } @@ -1579,6 +1773,18 @@ $("#curriculum .wrap").insertAdjacentHTML("afterbegin", `

    데이터를 불러오지 못했습니다. 로컬에서 볼 때는 python3 -m http.server로 띄운 뒤 접속해 주세요.

    `); } + /* section_view: each section once per page view, when half of it (or most of the screen) is shown */ + function watchSections() { + if (!("IntersectionObserver" in window)) return; + const seen = new Set(); + const io = new IntersectionObserver((entries) => entries.forEach((en) => { + const id = en.target.id; + if (!en.isIntersecting || seen.has(id)) return; + seen.add(id); io.unobserve(en.target); + track("section_view", { section: id }); + }), { threshold: [0.35] }); + ["curriculum", "contrib", "activity", "events", "posts", "join"].forEach((id) => { const el = document.getElementById(id); if (el) io.observe(el); }); + } async function init() { loadRidden(); loadRecent(); wire(); @@ -1586,6 +1792,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"); @@ -1593,6 +1801,7 @@ setHeader(); window.addEventListener("resize", setHeader); renderLegend(); renderMap(); wireTabs(); renderFeatured(); renderPartner(); renderEvents(); renderNextEvent(); renderStages(); renderArticles(); renderDaily(); watchMapScroll(); + renderLoop(); loadActivity(); watchSections(); /* sections fill in after the data loads, so jump again once they have their height */ if (/^#(contrib|events|reading|join)$/.test(location.hash)) requestAnimationFrame(() => { const el = $(location.hash); if (el) el.scrollIntoView(); }); openFromHash(); 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/activity.json b/data/activity.json new file mode 100644 index 0000000..a5a0b61 --- /dev/null +++ b/data/activity.json @@ -0,0 +1,4 @@ +{ + "updated": null, + "items": [] +} 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 88884ad..ab774bb 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ Causal Inference Hub | 가짜연구소 인과추론팀 - + @@ -45,7 +45,7 @@