diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index aab51c4..65b1ae2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,10 +38,13 @@ jobs: run: | for f in data/*.json; do python3 -m json.tool "$f" > /dev/null || { echo "Invalid JSON: $f"; exit 1; }; done + - name: Build Builder pages and badges + run: python3 scripts/build_builders.py + - name: Collect site files run: | mkdir _site - cp -r index.html article.html 404.html .nojekyll assets data articles _site/ + cp -r index.html article.html game.html lab.html 404.html .nojekyll assets data articles builders badges _site/ - name: Publish to gh-pages uses: peaceiris/actions-gh-pages@v4 diff --git a/.gitignore b/.gitignore index d46aeab..880a853 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .DS_Store _site/ node_modules/ +/builders/ +/badges/ +__pycache__/ diff --git a/404.html b/404.html index 3c4a766..18deca6 100644 --- a/404.html +++ b/404.html @@ -2,13 +2,7 @@ - - + 페이지를 찾을 수 없습니다 | Causal Inference Hub @@ -20,6 +14,7 @@
@@ -29,5 +24,7 @@

페이지를 찾을 수 없습니다

허브로 돌아가기

+ + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 988c7db..49ddb49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,14 +14,16 @@ | 파일 | 고치는 경우 | | --- | --- | | `data/featured.json` | 첫 화면 배너의 대표 오픈소스 링크를 바꿀 때. `repo`를 적으면 스타 수가 함께 나옵니다. | -| `data/projects.json` | 기수별 프로젝트를 추가할 때. 러너는 인원수(`runners`)만 적습니다. | -| `data/builders.json` | Builder의 LinkedIn, GitHub 아이디(프로필 사진에 쓰임), 한 줄 소개, Builder가 되기 전 러너로 참여한 프로젝트(`runnerOf`)를 넣을 때 | +| `data/projects.json` | 기수별 프로젝트를 추가할 때. 러너는 인원수(`runners`)만 적습니다. Builder가 회고를 보내 주면 `"retro": {"why": "…", "learned": "…", "next": "…"}`로 넣습니다. 비어 있으면 화면에 나오지 않습니다. | +| `data/builders.json` | Builder의 LinkedIn, GitHub 아이디(프로필 사진에 쓰임), 한 줄 소개, Builder가 되기 전 러너로 참여한 프로젝트(`runnerOf`)를 넣을 때. `slug`는 Builder 페이지 주소(`/builders/.html`)가 되므로 한 번 정하면 바꾸지 않습니다. | | `data/growth.json` | 기수별 누적 러너 수. 프로젝트 시트를 xlsx로 내려받아 `python scripts/runners_from_sheet.py 파일.xlsx > data/growth.json`으로 만듭니다. 이름은 파일에 남지 않습니다. | | `data/events.json` | Data Forum 같은 행사를 추가할 때 | | `data/repos.json` | 레포를 추가하거나 설명·단계·트랙·상태를 바꿀 때 | | `data/map.json` | 트랙 지도에서 레포의 위치를 정할 때. 책과 강의(`paid`)의 표지와 소개도 여기서 고칩니다. | | `data/routes.json` | 경로 안내 항목을 바꿀 때 | | `data/articles.json`, `articles/*.md` | 읽을거리를 추가할 때 | +| `data/game.json` | ‘상관일까, 인과일까?’ 문항을 고칠 때. `answer`는 `corr` 또는 `cause`이고, 문항마다 웹북 장 링크(`chapter`)를 답니다. | +| `data/daily.json` | 오늘의 한 문제 문항을 더하거나 고칠 때. 형식은 `game.json`과 같고, 날짜마다 순서대로 돌아갑니다. 인과와 상관 문항을 비슷한 수로 맞춥니다. | | `data/stats.json` | 직접 고치지 않습니다. 매주 GitHub Actions가 스타 추이와 기여자 수를 갱신합니다. | 레포를 추가할 때의 예시입니다. @@ -43,6 +45,29 @@ `stars`는 기본값이고, 사이트는 열릴 때 GitHub에서 최신 스타 수와 마지막 커밋 날짜를 불러옵니다. +## Builder 페이지와 배지 + +`builders/.html`, `builders/index.html`, `badges/.svg`는 `scripts/build_builders.py`가 데이터 파일로 만듭니다. 만들어지는 파일이라 커밋하지 않고, 배포할 때 GitHub Actions가 실행합니다. 로컬에서 보려면 먼저 실행하세요. + +```bash +python3 scripts/build_builders.py +``` + +링크를 공유할 때 보이는 이미지(`assets/og/builders/.jpg`)는 따로 만들어 커밋합니다. Builder가 새로 생기거나 숫자가 바뀌면 다시 만들어 주세요. 이미지가 없는 페이지는 허브 기본 이미지를 씁니다. + +```bash +pip install playwright && python -m playwright install chromium +python3 scripts/builder_cards.py +``` + +## 학습 여권과 실험실 + +스탬프 정의는 `assets/js/passport.js`의 `STAMPS`에 있습니다. 다른 페이지에서는 `window.hubStamp("lab")`처럼 부르면 되고, 이미 받은 스탬프는 다시 주지 않습니다. 기록은 `localStorage`에만 남습니다. 교란 실험실(`lab.html`, `assets/js/lab.js`)의 데이터는 페이지에서 만드는 가상 사용자입니다. + +## 이벤트 측정 + +`assets/js/track.js`의 `hubTrack(이름, 파라미터)`가 GA4로 이벤트를 보냅니다. GA4가 설치되지 않았으면 아무 일도 하지 않습니다. 이벤트 이름과 파라미터는 PM 문서의 이벤트 사전을 따르고, 새 이벤트를 넣을 때는 사전에도 같이 적습니다. HTML에서는 `data-event="click_join" data-event-params='{"target":"template","source":"builder_page"}'`처럼 속성만으로도 보낼 수 있습니다. 로컬에서 확인하려면 콘솔에서 `window.HUB_DEBUG = true`를 켭니다. + ## 로컬에서 보기 ```bash diff --git a/README.md b/README.md index 5bdaa81..c8a067f 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,16 @@ 팀 레포 전체는 허브의 트랙 지도와 커리큘럼 탭에서 볼 수 있습니다. +## 허브에서 해 볼 것 + +- **오늘의 한 문제:** 첫 화면 아래에 매일 한 문제가 바뀝니다. 사흘 연속으로 풀면 스탬프를 받습니다. +- **교란 실험실:** 가상 사용자 800명으로 교란이 단순 비교를 얼마나 속이는지 슬라이더로 직접 움직여 봅니다. [실험실 열기](https://causalinferencelab.github.io/lab.html) +- **학습 여권:** 자료 열기, 진단, 게임, 실험실, 완주 같은 행동마다 스탬프가 쌓입니다. 기록은 내 브라우저에만 남습니다. +- **3문항으로 시작점 찾기:** 경험, 하는 일, 원하는 것을 고르면 볼 순서와 첫 자료를 추천합니다. [바로 해 보기](https://causalinferencelab.github.io/?quiz=1) +- **상관일까, 인과일까?:** 데이터 상황 10개를 보고 상관인지 인과인지 고르는 2분짜리 게임입니다. 답마다 관련 웹북 장으로 이어집니다. [게임 하기](https://causalinferencelab.github.io/game.html) +- **완주 카드:** 한 단계의 자료를 모두 학습 완료로 표시하면, 공유할 수 있는 완주 카드 이미지를 만들 수 있습니다. +- **Builder 페이지:** 프로젝트를 연 Builder마다 이력, 만든 레포, 배지를 모은 페이지가 있습니다. [Builder 전체 보기](https://causalinferencelab.github.io/builders/) + ## 커리큘럼: L0–L4 L0–L2는 **배우는 단계**로, 무료 자료만으로 끝까지 갈 수 있습니다. L3–L4는 **만드는 단계**입니다. diff --git a/article.html b/article.html index 4627f28..a88a677 100644 --- a/article.html +++ b/article.html @@ -2,13 +2,7 @@ - - + 읽을거리 | Causal Inference Hub @@ -33,6 +27,7 @@ 읽을거리 참여하기 + @@ -63,5 +58,7 @@

다른 글

+ + diff --git a/assets/css/hub.css b/assets/css/hub.css index 573679a..ff2ac4b 100644 --- a/assets/css/hub.css +++ b/assets/css/hub.css @@ -656,3 +656,263 @@ dialog.station::backdrop { background: rgba(10, 14, 20, .55); } .reader aside ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } .back { display: inline-flex; margin-bottom: 20px; font-weight: 600; } .notice { background: var(--surface); border: 1px solid var(--rule); border-radius: 12px; padding: 18px; margin-bottom: 16px; } + +/* Start-point quiz entry, quiz dialog, finish card, Builder share */ +.start-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: -2px 0 10px; } +.start-btn { font: inherit; font-size: 14px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 14px; border-radius: 999px; border: 1.5px dashed color-mix(in srgb, var(--accent) 55%, var(--rule)); background: var(--accent-soft); color: var(--ink); cursor: pointer; } +.start-btn b { color: var(--accent); font-weight: 800; } +.start-btn:hover { border-style: solid; border-color: var(--accent); } +.start-btn:focus-visible, .start-game:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.start-game { font-size: 14px; font-weight: 600; color: var(--muted); text-decoration: none; } +.start-game::after { content: " ›"; } +.start-game:hover { color: var(--ink); text-decoration: underline; } +.btn.sm { min-height: 36px; padding: 0 12px; font-size: 14px; margin-top: 0; } +button.btn { font: inherit; font-weight: 600; cursor: pointer; border: 0; } +button.btn.ghost { border: 1.5px solid var(--rule); } +button.btn.sm { font-size: 14px; } + +.quiz-step { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 13px; font-weight: 700; color: var(--accent); padding-right: 48px; } +.quiz-dots { display: inline-flex; gap: 4px; } +.quiz-dots i { width: 22px; height: 4px; border-radius: 2px; background: var(--rule); } +.quiz-dots i.on { background: var(--accent); } +.quiz h2 { font-size: 22px; } +.quiz-opts { display: grid; gap: 8px; } +.quiz-opts button { font: inherit; font-size: 15.5px; font-weight: 600; text-align: left; min-height: 52px; padding: 0 16px; border-radius: 12px; border: 1.5px solid var(--rule); background: var(--surface); color: var(--ink); cursor: pointer; } +.quiz-opts button:hover { border-color: var(--accent); background: var(--accent-soft); } +.quiz-opts button[aria-pressed="true"] { border-color: var(--accent); } +.quiz-opts button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.quiz-back { all: unset; cursor: pointer; justify-self: start; font-size: 14px; font-weight: 600; color: var(--muted); } +.quiz-back:hover { color: var(--ink); text-decoration: underline; } +.quiz-back:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; border-radius: 4px; } +.quiz-note { font-size: 13px; color: var(--muted); } +.quiz-stops { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } +.quiz-stops li { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 12px; background: color-mix(in srgb, var(--c) 8%, var(--surface)); border: 1px solid color-mix(in srgb, var(--c) 22%, var(--rule)); } +.quiz-stops .n { font-family: var(--f-display); font-weight: 800; font-size: 20px; color: var(--c); width: 30px; flex-shrink: 0; } +.quiz-stops b { display: block; font-size: 15px; } +.quiz-stops li span span { font-size: 12.5px; color: var(--muted); } + +.done-banner { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; border-radius: 12px; background: color-mix(in srgb, var(--live) 10%, var(--surface)); border: 1px solid color-mix(in srgb, var(--live) 35%, var(--rule)); font-size: 14px; } +.cont-chip.card { border-color: var(--live); color: var(--live); } +.card-canvas { width: 100%; height: auto; border-radius: 12px; box-shadow: var(--shadow); } +.card-note { font-size: 13.5px; color: var(--muted); } +.p-share { display: flex; flex-wrap: wrap; gap: 8px; } +.tl-retro { margin-top: 4px; font-size: 13px; } +.tl-retro summary { cursor: pointer; font-weight: 600; color: var(--accent); } +.tl-retro dl { margin: 6px 0 0; display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 4px 10px; } +.tl-retro dt { color: var(--muted); } +.tl-retro dd { margin: 0; } +.quiz-stops li.is-optional { opacity: .6; } + +/* Builder pages (builders/*.html, generated by scripts/build_builders.py) */ +.bp { padding-block: 28px 64px; } +.bp .back { font-size: 14px; color: var(--muted); text-decoration: none; } +.bp .back::before { content: "‹ "; } +.bp .back:hover { color: var(--ink); } +.bp-hero { display: flex; align-items: center; gap: 22px; margin: 18px 0 18px; padding: 26px 28px; border-radius: 20px; background: var(--banner); color: #fff; } +.bp-hero .avatar.lg { width: 96px; height: 96px; font-size: 40px; background: rgba(255, 255, 255, .16); color: #fff; box-shadow: 0 0 0 3px rgba(255, 255, 255, .3); } +.bp-id { min-width: 0; } +.bp-kicker { display: flex; align-items: center; gap: 8px; font-size: 13px; color: rgba(255, 255, 255, .8); } +.bp-hero h1 { font-family: var(--f-display); font-size: clamp(32px, 5vw, 46px); font-weight: 800; letter-spacing: -.03em; line-height: 1.1; margin-top: 4px; } +.bp-since { font-size: 15px; color: rgba(255, 255, 255, .86); margin-top: 6px; } +.bp-intro { font-size: 15px; margin-top: 8px; max-width: 40em; } +.bp-links { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 12px; } +.bp-links a { color: #fff; font-weight: 600; } +.bp-hero .btn.ghost { color: #fff; border-color: rgba(255, 255, 255, .45); background: transparent; } +.bp-stats { margin-bottom: 28px; } +.bp-grid { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 32px; align-items: start; } +.bp h2 { font-size: 18px; margin-bottom: 12px; } +.bp-grid aside { display: grid; gap: 28px; } +.bp-repos { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.bp-repos a { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--rule); background: var(--surface); color: var(--ink); text-decoration: none; } +.bp-repos a:hover { border-color: var(--c); } +.bp-repos .num { margin-top: 5px; } +.bp-repos b { display: block; font-size: 15px; } +.bp-repos a span span { font-size: 13px; color: var(--muted); } +.bp-badge { padding: 16px; border-radius: 14px; border: 1px solid var(--rule); background: var(--surface); display: grid; gap: 10px; justify-items: start; } +.bp-badge p { font-size: 13.5px; color: var(--muted); } +.bp-badge pre { margin: 0; width: 100%; box-sizing: border-box; overflow-x: auto; font-family: var(--f-code); font-size: 11.5px; padding: 10px; border-radius: 8px; background: var(--surface-2); } +.bp-cta { margin-top: 40px; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px 24px; padding: 22px 24px; border-radius: 16px; background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 25%, var(--rule)); } +.bp-cta h2 { margin-bottom: 4px; } +.bp-cta p { font-size: 14.5px; color: var(--muted); max-width: 36em; } +.bp-cta-links { display: flex; flex-wrap: wrap; gap: 8px; } +.bp-cta .btn { margin-top: 0; } +.bi-title { font-family: var(--f-display); font-size: clamp(30px, 4vw, 40px); font-weight: 800; letter-spacing: -.02em; margin-top: 16px; } +.bi-sub { color: var(--muted); margin: 6px 0 20px; } +.bi-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 10px; } +.bi-list a { position: relative; display: flex; gap: 14px; align-items: center; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--rule); background: var(--surface); color: var(--ink); text-decoration: none; } +.bi-list a:hover { border-color: var(--c); } +.bi-list a:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.bi-list .avatar.lg { width: 52px; height: 52px; font-size: 22px; } +.bi-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.bi-body b { font-size: 16px; } +.bi-body span { font-size: 13px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bi-list .rank-tag { position: absolute; top: 10px; right: 12px; font-size: 11px; padding: 1px 8px; } +@media (max-width: 860px) { .bp-grid { grid-template-columns: minmax(0, 1fr); } } +@media (max-width: 560px) { .bp-hero { flex-direction: column; align-items: flex-start; padding: 22px 20px; } .bp-hero .avatar.lg { width: 72px; height: 72px; font-size: 30px; } } + +/* Game page (game.html) */ +.gm { padding-block: 40px 72px; } +.gm-card { max-width: 720px; margin: 0 auto; padding: 36px 36px 32px; border-radius: 22px; background: var(--surface); border: 1px solid var(--rule); box-shadow: var(--shadow); display: grid; gap: 16px; justify-items: start; } +.gm-kicker { font-size: 13px; font-weight: 700; color: var(--accent); } +.gm-card h1 { font-family: var(--f-display); font-size: clamp(32px, 6vw, 48px); font-weight: 800; letter-spacing: -.03em; line-height: 1.1; } +.gm-card h2 { font-size: 22px; line-height: 1.4; } +.gm-lede { font-size: 16px; color: var(--muted); } +.gm-card .btn { margin-top: 0; } +.gm-progress { width: 100%; display: flex; align-items: center; gap: 12px; font-size: 13px; font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; } +.gm-bar { flex: 1; height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; } +.gm-bar i { display: block; height: 100%; background: var(--accent); transition: width .3s ease; } +.gm-q { font-size: clamp(19px, 3.2vw, 23px) !important; } +.gm-opts { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.gm-opts button { font: inherit; font-size: 16px; font-weight: 700; min-height: 64px; padding: 10px 14px; border-radius: 14px; border: 1.5px solid var(--rule); background: var(--surface); color: var(--ink); cursor: pointer; } +.gm-opts button:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-soft); } +.gm-opts button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.gm-opts button:disabled { cursor: default; opacity: .55; } +.gm-opts button.ok { opacity: 1; border-color: var(--live); background: color-mix(in srgb, var(--live) 12%, var(--surface)); } +.gm-opts button.no { opacity: 1; border-color: var(--l-research); background: color-mix(in srgb, var(--l-research) 10%, var(--surface)); } +.gm-feedback { width: 100%; display: grid; gap: 10px; justify-items: start; font-size: 15px; } +.gm-feedback:empty { display: none; } +.gm-verdict { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: 18px; font-weight: 800; } +.gm-verdict.ok { color: var(--live); } +.gm-verdict.no { color: var(--l-research); } +.gm-verdict span { font-size: 14px; font-weight: 600; color: var(--muted); } +.gm-read a { font-weight: 700; } +.gm-score { font-family: var(--f-display); font-size: 22px; color: var(--muted); } +.gm-score b { font-size: 72px; font-weight: 800; color: var(--ink); letter-spacing: -.03em; } +.gm-review { width: 100%; padding: 14px 16px; border-radius: 12px; background: var(--surface-2); } +.gm-review h3 { font-size: 14px; margin-bottom: 6px; } +.gm-review ul { margin: 0; padding-left: 18px; font-size: 14.5px; display: grid; gap: 4px; } +.gm-cta { display: flex; flex-wrap: wrap; gap: 8px; } +@media (max-width: 560px) { .gm-card { padding: 24px 20px; } .gm-opts { grid-template-columns: minmax(0, 1fr); } } +.bp-cta .btn.ghost { border-color: color-mix(in srgb, var(--accent) 40%, var(--rule)); } +.bp .back::before { content: "‹"; margin-right: 4px; } +.bp-self { font-size: 13px; color: rgba(255, 255, 255, .8); margin-top: 10px; } +.bp-self a { color: #fff; font-weight: 600; } +.bp-badge pre { white-space: pre-wrap; word-break: break-all; } +.card-name { display: grid; gap: 6px; font-size: 13.5px; font-weight: 600; color: var(--muted); } +.card-name input { font: inherit; font-size: 15px; font-weight: 500; color: var(--ink); background: var(--surface); border: 1.5px solid var(--rule); border-radius: 10px; padding: 9px 12px; max-width: 280px; } +.card-name input:focus-visible { outline: 3px solid var(--focus); outline-offset: 1px; } +.gm-q:focus { outline: none; } +.gm-claim { width: 100%; font-size: 17px; font-weight: 700; padding: 12px 14px; border-radius: 12px; background: var(--accent-soft); } +.gm-claim span { display: block; font-size: 12.5px; font-weight: 700; color: var(--accent); margin-bottom: 2px; } +.gm-split { font-size: 13.5px; color: var(--muted); } +.gm-score:focus { outline: none; } + +/* Learning passport: header button, stamps, toast, dialog */ +.pass-btn { font: inherit; font-size: 13.5px; font-weight: 700; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border-radius: 999px; border: 1.5px solid color-mix(in srgb, var(--accent) 45%, var(--rule)); background: var(--accent-soft); color: var(--accent); cursor: pointer; flex-shrink: 0; } +.pass-btn b { font-variant-numeric: tabular-nums; color: var(--ink); } +.pass-btn:hover { border-color: var(--accent); } +.pass-btn:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +@media (max-width: 820px) { .pass-btn { order: 1; margin-left: auto; min-height: 32px; font-size: 13px; } .site-nav { order: 2; } } +.stamp { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; border: 2px dashed var(--rule); color: var(--muted); font-family: var(--f-display); font-weight: 800; font-size: 18px; flex-shrink: 0; } +.stamp.on { border: 2px solid var(--accent); background: var(--accent); color: var(--surface); box-shadow: 0 0 0 3px var(--accent-soft); transform: rotate(-8deg); } +.stamp.sm { width: 36px; height: 36px; font-size: 15px; } +.toast { position: fixed; left: 50%; bottom: calc(16px + env(safe-area-inset-bottom, 0px)); z-index: 50; display: flex; align-items: center; gap: 12px; width: min(440px, calc(100vw - 32px)); box-sizing: border-box; padding: 12px 14px; border-radius: 14px; background: var(--ink); color: var(--paper); box-shadow: 0 12px 32px rgba(0, 0, 0, .25); transform: translate(-50%, 140%); transition: transform .3s ease; } +.toast.show { transform: translate(-50%, 0); } +.toast b { display: block; font-size: 14px; } +.toast span span { font-size: 12.5px; opacity: .75; } +.toast button { font: inherit; font-size: 13px; font-weight: 700; margin-left: 4px; padding: 6px 10px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, .35); background: transparent; color: inherit; cursor: pointer; white-space: nowrap; } +.toast .stamp.on { box-shadow: none; } +.pp-sub { font-size: 14.5px; color: var(--muted); } +.pp-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } +.pp-grid a { display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; padding: 14px 8px; border-radius: 12px; border: 1px solid var(--rule); color: var(--ink); text-decoration: none; height: 100%; box-sizing: border-box; } +.pp-grid a:hover { border-color: var(--accent); } +.pp-grid li.on a { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 30%, var(--rule)); } +.pp-grid b { font-size: 14px; } +.pp-grid a > span:last-child { font-size: 12px; color: var(--muted); line-height: 1.4; } +.pp-note { font-size: 12.5px; color: var(--muted); } +@media (max-width: 520px) { .pp-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (prefers-reduced-motion: reduce) { .toast { transition: none; } } + +/* Today's question band (between the hero and the curriculum) */ +.daily-band { padding-block: 8px 0; } +.daily { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 20px 28px; padding: 20px 24px; border-radius: 18px; background: var(--surface); border: 1.5px solid color-mix(in srgb, var(--accent) 30%, var(--rule)); } +.daily-head h2 { font-size: 18px; letter-spacing: -.01em; } +.daily-date { font-size: 13px; color: var(--muted); margin-top: 2px; } +.daily-streak { display: inline-block; margin-top: 10px; font-size: 13px; font-weight: 700; padding: 3px 10px; border-radius: 999px; background: #FFF1C2; color: #6B4B00; } +.daily-streak.quiet { background: var(--surface-2); color: var(--muted); font-weight: 600; } +.daily-body { display: grid; gap: 10px; min-width: 0; } +.daily-q { font-size: 16.5px; font-weight: 600; line-height: 1.5; } +.daily-claim { font-size: 15px; font-weight: 700; padding: 10px 12px; border-radius: 10px; background: var(--accent-soft); } +.daily-claim span { display: block; font-size: 12px; color: var(--accent); } +.daily-opts { display: flex; flex-wrap: wrap; gap: 8px; } +.daily-opts button { font: inherit; font-size: 15px; font-weight: 700; min-height: 46px; padding: 0 18px; border-radius: 12px; border: 1.5px solid var(--rule); background: var(--surface); color: var(--ink); cursor: pointer; } +.daily-opts button:hover { border-color: var(--accent); background: var(--accent-soft); } +.daily-opts button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.daily-verdict { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: 17px; font-weight: 800; } +.daily-verdict.ok { color: var(--live); } +.daily-verdict.no { color: var(--l-research); } +.daily-verdict span { font-size: 13.5px; font-weight: 600; color: var(--muted); } +.daily-why { font-size: 14.5px; } +.daily-more { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 14px; font-weight: 700; } +.daily-next { font-size: 12.5px; color: var(--muted); } +@media (max-width: 760px) { .daily { grid-template-columns: minmax(0, 1fr); padding: 18px; } .daily-opts button { flex: 1 1 100%; } } + +/* Confounding lab (lab.html) */ +.lab { padding-block: 32px 72px; } +.lab h1 { font-family: var(--f-display); font-size: clamp(28px, 5vw, 42px); font-weight: 800; letter-spacing: -.03em; line-height: 1.15; margin-top: 6px; } +.lab-lede { font-size: 16px; color: var(--muted); max-width: 46em; margin-top: 10px; } +.lab-grid { display: grid; grid-template-columns: minmax(0, 380px) minmax(0, 1fr); gap: 20px; margin-top: 24px; align-items: start; } +.lab-panel, .lab-chart { background: var(--surface); border: 1px solid var(--rule); border-radius: 18px; padding: 20px; } +.lab-panel { display: grid; gap: 18px; } +.lab-ctl { display: grid; gap: 4px; } +.lab-ctl > span { display: flex; justify-content: space-between; align-items: baseline; font-size: 15px; } +.lab-ctl output { font-family: var(--f-display); font-weight: 800; font-size: 20px; font-variant-numeric: tabular-nums; } +.lab-ctl small { font-size: 12.5px; color: var(--muted); } +.lab-ctl input[type=range] { width: 100%; accent-color: var(--accent); min-height: 32px; } +.lab-toggles { display: flex; flex-wrap: wrap; gap: 8px; } +.lab-rand { font: inherit; font-size: 14.5px; font-weight: 700; display: inline-flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 14px; border-radius: 12px; border: 1.5px solid var(--rule); background: var(--surface); color: var(--ink); cursor: pointer; } +.lab-rand .sw { width: 34px; height: 20px; border-radius: 999px; background: var(--rule); position: relative; transition: background .15s ease; } +.lab-rand .sw::after { content: ""; position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: var(--surface); transition: transform .15s ease; } +.lab-rand[aria-pressed="true"] { border-color: var(--live); } +.lab-rand[aria-pressed="true"] .sw { background: var(--live); } +.lab-rand[aria-pressed="true"] .sw::after { transform: translateX(14px); } +.lab-reseed { font: inherit; font-size: 13.5px; font-weight: 600; min-height: 44px; padding: 0 12px; border-radius: 12px; border: 1px dashed var(--rule); background: transparent; color: var(--muted); cursor: pointer; } +.lab-reseed:hover { color: var(--ink); border-color: var(--ink); } +.lab-rand:focus-visible, .lab-reseed:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.lab-results { display: grid; gap: 10px; padding: 14px; border-radius: 12px; background: var(--surface-2); } +.lab-row { display: grid; grid-template-columns: 92px minmax(0, 1fr) 56px; align-items: center; gap: 10px; font-size: 13.5px; } +.lab-row .bar { height: 14px; border-radius: 4px; background: var(--surface); overflow: hidden; } +.lab-row .bar i { display: block; height: 100%; border-radius: 0 4px 4px 0; transition: width .2s ease; } +.lab-row.truth .bar i { background: var(--ink); } +.lab-row.naive .bar i { background: #B7791F; } +.lab-row.adj .bar i { background: var(--live); } +.lab-row b { text-align: right; font-variant-numeric: tabular-nums; font-size: 15px; } +.lab-bias { font-size: 13.5px; } +.lab-challenge { padding: 14px; border-radius: 12px; border: 1.5px dashed color-mix(in srgb, var(--accent) 50%, var(--rule)); font-size: 14px; } +.lab-challenge.done { border-style: solid; border-color: var(--live); background: color-mix(in srgb, var(--live) 8%, var(--surface)); } +.lab-ch-k { font-size: 12px; font-weight: 800; color: var(--accent); margin-bottom: 4px; } +.lab-challenge.done .lab-ch-k { color: var(--live); } +.lab-chart h2 { font-size: 16px; } +.lab-chart-sub { font-size: 13px; color: var(--muted); margin: 2px 0 8px; } +#lab-svg { width: 100%; height: auto; display: block; } +#lab-svg .grid { stroke: var(--rule); } +#lab-svg .tick, #lab-svg .axis-t { fill: var(--muted); font-size: 11.5px; } +#lab-svg circle.on { fill: var(--l-main); fill-opacity: .55; } +#lab-svg circle.off { fill: var(--l-exp); fill-opacity: .45; } +#lab-svg .mean { stroke-width: 2; stroke-dasharray: 6 5; } +#lab-svg .mean.on { stroke: var(--l-main); } +#lab-svg .mean.off { stroke: var(--l-exp); } +#lab-svg .mean-t { font-size: 12px; font-weight: 700; paint-order: stroke; stroke: var(--surface); stroke-width: 4px; } +#lab-svg .mean-t.on { fill: var(--l-main); } +#lab-svg .mean-t.off { fill: var(--l-exp); } +.lab-legend { list-style: none; margin: 8px 0 0; padding: 0; display: flex; gap: 16px; font-size: 13px; color: var(--muted); } +.lab-legend li { display: flex; align-items: center; gap: 6px; } +.lab-legend .dot { width: 10px; height: 10px; border-radius: 50%; } +.lab-legend .dot.on { background: var(--l-main); } +.lab-legend .dot.off { background: var(--l-exp); } +.lab-learn { margin-top: 28px; max-width: 820px; } +.lab-learn h2 { font-size: 18px; margin-bottom: 10px; } +.lab-learn ol { margin: 0 0 18px; padding-left: 20px; display: grid; gap: 10px; font-size: 15px; } +.lab-learn a { font-weight: 700; margin-left: 4px; } +@media (max-width: 900px) { .lab-grid { grid-template-columns: minmax(0, 1fr); } } +.toast > span:nth-child(2) { flex: 1; min-width: 0; } +@media (max-width: 900px) { .toast { bottom: auto; top: 72px; transform: translate(-50%, -180%); } .toast.show { transform: translate(-50%, 0); } } +.lab-ctl.off input[type=range] { opacity: .45; } +.lab-ctl.off small { color: var(--live); font-weight: 600; } +.lab-note { font-size: 12.5px; color: var(--muted); } +#lab-svg .fit-l { stroke-width: 1.5; opacity: .9; } +#lab-svg .fit-l.on { stroke: var(--l-main); } +#lab-svg .fit-l.off { stroke: var(--l-exp); } +#lab-svg .fit-gap { stroke: var(--live); stroke-width: 3; } +#lab-svg .fit-t { fill: var(--live); font-size: 12px; font-weight: 700; paint-order: stroke; stroke: var(--surface); stroke-width: 4px; } +@media (max-width: 900px) { .lab-chart { order: -1; } } diff --git a/assets/img/og-game.jpg b/assets/img/og-game.jpg new file mode 100644 index 0000000..68f0229 Binary files /dev/null and b/assets/img/og-game.jpg differ diff --git a/assets/img/og-lab.jpg b/assets/img/og-lab.jpg new file mode 100644 index 0000000..8a51620 Binary files /dev/null and b/assets/img/og-lab.jpg differ diff --git a/assets/js/ga.js b/assets/js/ga.js new file mode 100644 index 0000000..4f010a0 --- /dev/null +++ b/assets/js/ga.js @@ -0,0 +1,15 @@ +/* Google Analytics 4 for every page of the hub. The first ID is the hub's property; + the second is the tag the web book already uses, kept so visits can be followed + across both sites until one property is chosen. Local previews are not counted. */ +(() => { + const IDS = ["G-C5V0X2SGD3", "G-BEBVKYH8HL"]; + if (location.hostname !== "causalinferencelab.github.io") return; + const s = document.createElement("script"); + s.async = true; + s.src = `https://www.googletagmanager.com/gtag/js?id=${IDS[0]}`; + document.head.appendChild(s); + window.dataLayer = window.dataLayer || []; + window.gtag = function gtag() { window.dataLayer.push(arguments); }; + window.gtag("js", new Date()); + IDS.forEach((id) => window.gtag("config", id)); +})(); diff --git a/assets/js/game.js b/assets/js/game.js new file mode 100644 index 0000000..434c541 --- /dev/null +++ b/assets/js/game.js @@ -0,0 +1,115 @@ +/* "상관일까, 인과일까?": ten situations, one choice each, a reason and a web book chapter per answer. */ +(() => { + "use strict"; + const $ = (s, r = document) => r.querySelector(s); + const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + const track = (n, p) => window.hubTrack && window.hubTrack(n, p); + const LABEL = { cause: "그렇게 말할 수 있다", corr: "상관까지만 말할 수 있다" }; + const state = { qs: [], i: 0, score: 0, wrong: [], answered: false, right: { corr: 0, cause: 0 }, runs: 0 }; + const root = $("#game"); + + const STUDIO = "https://causalinferencelab.github.io/causal-studio/"; + const CH1 = "https://causalinferencelab.github.io/Causal-Inference-with-Python/01-Introduction-To-Causality.html"; + /* A good score needs both kinds right: answering "상관" every time should not pass. */ + function tier(score, n, right, kinds) { + const both = right.corr >= kinds.corr - 1 && right.cause >= kinds.cause - 1; + if (score >= n - 1) return { head: "인과 감각이 탄탄합니다", body: "실험과 준실험을 본격적으로 다루는 커리큘럼 L1–L2로 바로 가 보세요." }; + if (score >= Math.ceil(n * 0.6) && both) return { head: "좋은 출발입니다", body: "틀린 문제의 장부터 읽으면 빈틈이 빠르게 채워집니다." }; + if (right.cause < kinds.cause - 1 && right.corr >= kinds.corr - 1) return { head: "의심은 충분합니다. 이제 근거를 볼 차례", body: "무작위 실험이나 잘 설계된 비교는 인과를 말할 수 있게 해 줍니다. 웹북 2장 무작위 실험부터 읽어 보세요." }; + return { head: "여기서부터 시작하면 됩니다", body: `처음이라면 영상으로 감을 잡고 웹북 1장부터 읽어 보세요.`, html: true }; + } + + function question() { + const q = state.qs.at(state.i), n = state.qs.length; + state.answered = false; + root.innerHTML = ` +

${state.i + 1} / ${n}맞힌 문제 ${state.score}

+

${esc(q.text)}

+

이렇게 말해도 될까요?“${esc(q.claim)}”

+
+ + +
+ `; + $(".gm-q", root).focus(); + } + + function answer(pick) { + if (state.answered) return; + state.answered = true; + const q = state.qs.at(state.i), right = pick === q.answer; + if (right) { state.score += 1; state.right[q.answer] += 1; } else state.wrong.push(q); + $(".gm-count", root).textContent = `맞힌 문제 ${state.score}`; + $(".gm-bar i", root).style.width = `${((state.i + 1) / state.qs.length) * 100}%`; + track("game_answer", { q: q.id, correct: right }); + root.querySelectorAll("[data-pick]").forEach((b) => { + b.disabled = true; + if (b.dataset.pick === q.answer) b.classList.add("ok"); + else if (b.dataset.pick === pick) b.classList.add("no"); + }); + const last = state.i === state.qs.length - 1; + $("#gm-feedback").innerHTML = ` +

${right ? "맞았습니다" : "아쉽습니다"}정답: ${LABEL[q.answer]}

+

${esc(q.why)}

+

${esc(q.chapter.title)} 읽기

+ `; + $("[data-next]", root).focus(); + } + + function result() { + const n = state.qs.length; + const kinds = { corr: state.qs.filter((q) => q.answer === "corr").length, cause: state.qs.filter((q) => q.answer === "cause").length }; + const t = tier(state.score, n, state.right, kinds); + track("game_complete", { score: state.score, total: n, cause_right: state.right.cause, corr_right: state.right.corr }); + if (window.hubStamp) { window.hubStamp("game"); if (state.score >= 8) window.hubStamp("sharp"); } + const chapters = [...new Map(state.wrong.map((q) => [q.chapter.url, q.chapter])).values()]; + const share = window.hubShareUrl ? window.hubShareUrl("/game.html", "game") : location.href; + root.innerHTML = ` +

결과

+

${state.score} / ${n}

+

인과라고 말할 수 있는 문제 ${state.right.cause} / ${kinds.cause} · 상관까지만인 문제 ${state.right.corr} / ${kinds.corr}

+

${esc(t.head)}

+

${t.html ? t.body : esc(t.body)}

+ ${chapters.length ? `

다시 볼 장

` : ""} +
+ 3문항으로 내 시작점 찾기 + + +
`; + state.shareText = `상관일까, 인과일까? 10문제 중 ${state.score}개를 맞혔어요. 당신은 몇 개?`; + state.shareUrl = share; + $(".gm-score", root).focus(); + } + + async function shareResult(btn) { + if (navigator.share) { + try { await navigator.share({ title: "상관일까, 인과일까?", text: state.shareText, url: state.shareUrl }); track("share", { content: "game", method: "share_sheet" }); } catch (e) { /* dismissed */ } + return; + } + const ok = window.hubCopy ? await window.hubCopy(`${state.shareText} ${state.shareUrl}`) : false; + const label = btn.textContent; + btn.textContent = ok ? "복사했습니다" : "복사하지 못했습니다"; + setTimeout(() => { btn.textContent = label; }, 1600); + if (ok) track("share", { content: "game", method: "copy" }); + } + + async function start() { + if (!state.qs.length) { + const res = await fetch("data/game.json", { cache: "no-cache" }); + state.qs = (await res.json()).questions; + } + state.i = 0; state.score = 0; state.wrong = []; state.right = { corr: 0, cause: 0 }; + track("game_start", { retry: state.runs > 0 }); + state.runs += 1; + question(); + } + + root.addEventListener("click", (e) => { + if (e.target.closest("[data-game-start], [data-restart]")) { start().catch(() => { root.innerHTML = "

문제를 불러오지 못했습니다. 잠시 뒤 다시 시도해 주세요.

"; }); return; } + const pick = e.target.closest("[data-pick]"); + if (pick) { answer(pick.dataset.pick); return; } + if (e.target.closest("[data-next]")) { state.i += 1; if (state.i < state.qs.length) question(); else result(); return; } + const sh = e.target.closest("[data-share-game]"); + if (sh) shareResult(sh); + }); +})(); diff --git a/assets/js/hub.js b/assets/js/hub.js index 88ab80c..5119f73 100644 --- a/assets/js/hub.js +++ b/assets/js/hub.js @@ -23,6 +23,9 @@ const fmt = (n) => Number(n).toLocaleString("ko-KR"); const $ = (sel, root = document) => root.querySelector(sel); const dot = (d) => d.replace(/-/g, "."); + const track = (name, params) => { if (window.hubTrack) window.hubTrack(name, params); }; + const stamp = (id) => { if (window.hubStamp) window.hubStamp(id); }; + const shareUrl = (path, medium) => (window.hubShareUrl ? window.hubShareUrl(path, medium) : path); const state = { repos: new Map(), lines: new Map(), lineList: [], levels: [], map: null, routes: [], @@ -43,7 +46,7 @@ /* ---------- data ---------- */ async function load() { - const names = ["repos", "lines", "levels", "map", "routes", "projects", "events", "articles", "builders", "growth", "stats", "featured"]; + const names = ["repos", "lines", "levels", "map", "routes", "projects", "events", "articles", "builders", "growth", "stats", "featured", "daily"]; const data = await Promise.all(names.map((n) => getJSON(`data/${n}.json`))); const d = Object.fromEntries(names.map((n, i) => [n, data[i]])); d.repos.forEach((r) => { @@ -55,7 +58,7 @@ (d.map.paid || []).forEach((p) => state.repos.set(p.id, { repo: p.id, kind: "paid", status: "paid", builders: [], cohorts: [], ...p })); state.lineList = d.lines; d.lines.forEach((l) => state.lines.set(l.id, l)); - Object.assign(state, { levels: d.levels, map: d.map, routes: d.routes, projects: d.projects, events: d.events, articles: d.articles, builders: d.builders, growth: d.growth, stats: d.stats, featured: d.featured }); + Object.assign(state, { levels: d.levels, map: d.map, routes: d.routes, projects: d.projects, events: d.events, articles: d.articles, builders: d.builders, growth: d.growth, stats: d.stats, featured: d.featured, daily: d.daily }); const pos = new Map(d.map.stations.map((s) => [s.id, s])); d.lines.forEach((l) => { @@ -313,7 +316,7 @@ const items = route.stops.map((id) => state.repos.get(id)).filter(Boolean); shelf.innerHTML = items.map((r, i) => episodeHTML(r, r.level || "", i, top, i + 1)).join(""); $("#chapters").innerHTML = items.map((r, i) => ``).join(""); - $("#route-note").innerHTML = `${esc(route.label)}: ${esc(route.description)} 01부터 순서대로 보세요.`; + renderRouteNote(); } else { const byLevel = new Map(state.levels.map((lv) => [lv.id, levelItems(lv.id)])); shelf.innerHTML = state.levels.map((lv) => { @@ -363,7 +366,15 @@ } /* ---------- goals (routes) ---------- */ - function setRoute(id) { + function renderRouteNote() { + const route = currentRoute(); + if (!route) return; + const b = state.routeBegin || 0; + $("#route-note").innerHTML = `${esc(route.label)}: ${esc(route.description)} ${b ? `${pad2(b + 1)}부터 보면 됩니다. 앞의 자료는 필요할 때 보세요.` : "01부터 순서대로 보세요."}`; + } + function setRoute(id, source) { + 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))); renderShelf(); @@ -376,7 +387,7 @@ box.innerHTML = all.map((r) => ``).join(""); box.addEventListener("click", (e) => { const b = e.target.closest("[data-route]"); - if (b) setRoute(b.dataset.route); + if (b) setRoute(b.dataset.route, "chip"); }); } @@ -424,7 +435,7 @@ state.projects.forEach((p) => { if (!p.builder) return; const r = p.repo && state.repos.get(p.repo); - get(p.builder).items.push({ cohort: p.cohort, term: p.term, name: p.name, runners: p.runners, repo: r ? r.repo : null, title: r ? r.title : null, link: p.link, line: r ? primaryLine(r) : "none" }); + get(p.builder).items.push({ cohort: p.cohort, term: p.term, name: p.name, runners: p.runners, repo: r ? r.repo : null, title: r ? r.title : null, link: p.link, line: r ? primaryLine(r) : "none", retro: p.retro || null }); }); state.repos.forEach((r) => { if (r.kind) return; @@ -507,10 +518,19 @@ $("#roster-legend").innerHTML = `
  • Builder로 연 기수, 숫자는 함께한 러너 수
  • 러너로 참여한 기수
  • 진행 중
  • 칸은 왼쪽부터 ${cohorts[0]}기–${cohorts[cohorts.length - 1]}기
  • `; } - function openPerson(name) { + /* Three lines a Builder writes after a project; nothing shows until they do. */ + function retroHTML(r) { + if (!r || !(r.why || r.learned || r.next)) return ""; + const row = (k, v) => (v ? `
    ${k}
    ${esc(v)}
    ` : ""); + return `
    회고
    ${row("왜 만들었나", r.why)}${row("무엇을 배웠나", r.learned)}${row("다음 사람에게", r.next)}
    `; + } + + function openPerson(name, source) { const p = (state.people || []).find((x) => x.name === name); if (!p) return; const info = builderInfo(name); + track("open_builder", { builder: info.slug || name, source: source || "hub" }); + if (window.hubSeen) window.hubSeen("builders", info.slug || name, 3, "builder"); const runnerItems = p.runnerOf.map((x) => { const proj = state.projects.find((q) => q.cohort === x.cohort && q.name === x.project); const r = proj && proj.repo && state.repos.get(proj.repo); @@ -522,7 +542,7 @@ const facts = e.role === "Builder" ? [e.runners ? `러너 ${e.runners}명` : "", e.live ? "진행 중" : ""] : [e.by ? `Builder ${esc(e.by)}` : ""]; const go = e.repo ? `` : e.link ? `기록 보기` : ""; const note = [...facts.filter(Boolean), go].filter(Boolean).join(''); - return `
  • ${e.cohort}기${esc(terms.get(e.cohort) || "")}${e.role}${esc(e.name)}${note ? `${note}` : ""}
  • `; + return `
  • ${e.cohort}기${esc(terms.get(e.cohort) || "")}${e.role}${esc(e.name)}${note ? `${note}` : ""}${retroHTML(e.retro)}
  • `; }; const repos = [...p.repos].map((id) => state.repos.get(id)).filter(Boolean); $("#station-body").innerHTML = ` @@ -531,6 +551,7 @@
    ${p.rank <= 3 ? `Top ${p.rank}` : ""}${p.first}기부터 Builder
    ${avatarHTML(p.name, "lg")}

    ${esc(p.name)}

    ${info.linkedin ? `LinkedIn` : ""}
    + ${info.slug ? `
    Builder 페이지
    ` : ""} ${info.intro ? `

    ${esc(info.intro)}

    ` : ""}
    • ${p.projects}프로젝트
    • ${p.cohorts.length}기수
    • ${fmt(p.runners)}함께한 러너
    • ${repos.length}레포

    이력

    @@ -556,8 +577,10 @@ const fresh = list.filter((r) => r.pushed).sort((a, b) => Date.parse(b.pushed) - Date.parse(a.pushed)).slice(0, 4); const recent = state.recent.map((x) => state.repos.get(x.id)).filter(Boolean).slice(0, 3); const done = list.filter((r) => state.ridden.has(r.repo)).length; + const finished = state.levels.filter((l) => levelDone(l.id)); + const cardChip = finished.length ? `` : ""; const cont = recent.length ? `
    이어서 보기${recent.map((r) => ``).join("")} - 학습 완료 ${done} / ${list.length}
    ` : ""; + ${cardChip}학습 완료 ${done} / ${list.length}
    ` : ""; $("#week").innerHTML = `${cont}

    이번 주의 레포월요일마다 바뀝니다

    @@ -659,7 +682,7 @@ $("#featured").innerHTML = (state.featured || []).map((f) => { const r = f.repo && state.repos.get(f.repo); const stars = r && r.stars >= 10 ? `${icons.star}${fmt(r.stars)}` : ""; - return `
  • + return `
  • ${esc(f.tag)} ${esc(f.title)}${f.desc ? `${esc(f.desc)}` : ""} ${stars} @@ -694,8 +717,8 @@ function stationGroup(parent, id, r, cls) { const g = svg("g", { class: "st" + cls, "data-line": primaryLine(r), "data-st": id, tabindex: 0, role: "button", "aria-label": `${r.title} 열기` }, parent); - g.addEventListener("click", () => openStation(id)); - g.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openStation(id); } }); + g.addEventListener("click", () => openStation(id, "map")); + g.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openStation(id, "map"); } }); return g; } @@ -776,13 +799,223 @@
  • ${esc(a.title)}

    ${esc(a.summary)}

  • `).join(""); } + /* ---------- today's question (one a day, streak kept in this browser) ---------- */ + const DAILY_KEY = "cil-hub-daily"; + const dayNum = (d = new Date()) => Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / DAY); + const loadDaily = () => { try { return JSON.parse(localStorage.getItem(DAILY_KEY) || "{}"); } catch (e) { return {}; } }; + const saveDaily = (d) => { try { localStorage.setItem(DAILY_KEY, JSON.stringify(d)); } catch (e) { /* storage unavailable */ } }; + const DAILY_LABEL = { cause: "그렇게 말할 수 있다", corr: "상관까지만 말할 수 있다" }; + function renderDaily() { + const pool = (state.daily && state.daily.questions) || []; + const box = $("#daily-card"); + if (!pool.length || !box) return; + const today = dayNum(), q = pool.at(today % pool.length), rec = loadDaily(); + const ans = rec.answers && rec.answers[today]; + const now = new Date(); + const streak = rec.last === today || rec.last === today - 1 ? rec.streak || 0 : 0; + const head = `

    오늘의 한 문제

    ${now.getMonth() + 1}월 ${now.getDate()}일 · 매일 바뀝니다

    + ${streak ? `

    ${streak}일 연속${streak < 3 ? ` · ${3 - streak}일 더 풀면 스탬프` : rec.best > streak ? ` · 최고 ${rec.best}일` : ""}

    ` : `

    사흘 연속 풀면 스탬프

    `}
    `; + if (!ans) { + box.innerHTML = `${head}
    +

    ${esc(q.text)}

    +

    이렇게 말해도 될까요?“${esc(q.claim)}”

    +
    `; + return; + } + box.innerHTML = `${head}
    +

    ${esc(q.text)}

    +

    이렇게 말해도 될까요?“${esc(q.claim)}”

    +

    ${ans.correct ? "맞았습니다" : "아쉽습니다"}내 답: ${DAILY_LABEL[ans.pick] || ""} · 정답: ${DAILY_LABEL[q.answer]}

    +

    ${esc(q.why)}

    +

    ${esc(q.chapter.title)} 읽기10문제 더 풀기실험실에서 직접 해 보기

    +

    자정에 새 문제가 열립니다.

    `; + } + function answerDaily(pick) { + const pool = (state.daily && state.daily.questions) || []; + const today = dayNum(), q = pool.at(today % pool.length), rec = loadDaily(); + rec.answers = rec.answers || {}; + if (rec.answers[today]) return; + const correct = pick === q.answer; + rec.answers[today] = { pick, correct }; + Object.keys(rec.answers).map(Number).filter((k) => k < today - 40).forEach((k) => delete rec.answers[k]); + rec.streak = rec.last === today - 1 ? (rec.streak || 0) + 1 : 1; + rec.best = Math.max(rec.best || 0, rec.streak); + rec.last = today; + saveDaily(rec); + track("daily_answer", { q: q.id, correct, streak: rec.streak }); + renderDaily(); + stamp("daily"); + if (rec.streak >= 3) stamp("streak3"); + } + + /* ---------- start-point quiz (three questions to one route) ---------- */ + const QUIZ = [ + { key: "experience", q: "인과추론은 어느 정도 해 보셨나요?", opts: [["new", "처음이에요"], ["some", "개념은 들어 봤어요"], ["used", "업무에 써 봤어요"]] }, + { key: "work", q: "지금 하는 일에 가장 가까운 것은?", opts: [["exp", "A/B 테스트와 실험"], ["mkt", "마케팅·정책 효과 분석"], ["ml", "ML과 모델링"], ["research", "연구·헬스케어"], ["tools", "데이터 도구·에이전트 개발"]] }, + { key: "want", q: "허브에서 가장 얻고 싶은 것은?", opts: [["concept", "개념을 제대로 이해하기"], ["apply", "바로 쓸 코드와 사례"], ["build", "직접 만들고 기여하기"]] } + ]; + const quiz = { step: 0, a: {} }; + const WHY = { exp: "A/B 테스트와 실험", mkt: "마케팅·정책 효과 분석", ml: "ML과 모델링", research: "연구·헬스케어", tools: "데이터 도구·에이전트 개발" }; + function quizReason(a, id) { + if (id === "build") return a.want === "build" ? "직접 만들고 기여하고 싶다고 하셔서 만드는 쪽 경로를 골랐습니다." : "데이터 도구를 만드신다고 하셔서 에이전트를 만드는 경로를 골랐습니다."; + if (id === "start" && a.experience === "new") return "처음이라고 하셔서 영상과 웹북으로 기초를 다지는 경로를 골랐습니다."; + return `${WHY[a.work] || "하시는 일"}을 고르셔서 이 경로를 추천합니다.`; + } + /* Where to start inside the route: skip the L0 primer if the basics are known, + and jump to the first L2 item when someone has used it at work and wants code. */ + function quizBegin(a, stops) { + if (a.experience === "new") return 0; + if (a.experience === "used" && a.want === "apply") { const k = stops.findIndex((r) => r.level === "L2"); if (k > 0) return k; } + return stops.length > 1 && stops[0].level === "L0" ? 1 : 0; + } + function quizRoute(a) { + if (a.want === "build" || a.work === "tools") return "build"; + if (a.work === "research") return "research"; + if (a.work === "exp") return "experiment"; + if (a.work === "ml") return a.experience === "new" ? "start" : "ml"; + if (a.work === "mkt" && a.experience === "used") return "experiment"; + return "start"; + } + function showDialog() { + const d = dialog(); + if (!d.open) { if (typeof d.showModal === "function") d.showModal(); else d.setAttribute("open", ""); } + } + function openQuiz(retry) { + quiz.step = 0; quiz.a = {}; + track("quiz_start", { retry: !!retry }); + renderQuiz(); showDialog(); + } + function renderQuiz() { + const shell = (inner) => `
    + +
    ${inner}
    `; + if (quiz.step < QUIZ.length) { + const item = QUIZ.at(quiz.step); + $("#station-body").innerHTML = shell(` +

    ${quiz.step + 1} / ${QUIZ.length}

    +

    ${esc(item.q)}

    +
    ${item.opts.map(([v, label]) => ``).join("")}
    + ${quiz.step ? `` : `

    답은 이름 없이 통계로만 집계합니다.

    `}`); + $("#station-body .quiz-opts button").focus(); + return; + } + const id = quizRoute(quiz.a); + const route = state.routes.find((r) => r.id === id); + const stops = route.stops.map((x) => state.repos.get(x)).filter(Boolean); + const begin = quizBegin(quiz.a, stops); + $("#station-body").innerHTML = shell(` +

    추천 경로

    +

    ${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("")}
    + `); + $("#station-body [data-quiz-go]").focus(); + } + function quizPick(v) { + quiz.a[QUIZ.at(quiz.step).key] = v; + quiz.step += 1; + if (quiz.step === QUIZ.length) { track("quiz_complete", { route: quizRoute(quiz.a), ...quiz.a }); stamp("quiz"); } + renderQuiz(); + } + function quizBack() { quiz.step = Math.max(0, quiz.step - 1); renderQuiz(); } + function quizGo(id, begin) { + closeStation(); + selectTab("tab-course"); + setRoute(id, "quiz"); + state.routeBegin = begin; + const stops = [...document.querySelectorAll("#shelf .ep")]; + const el = stops.at(Math.min(begin, stops.length - 1)); + renderRouteNote(); + $("#shelf-wrap").scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "center" }); + if (el) { + scrollShelfTo(el, false); + el.classList.add("picked"); + setTimeout(() => { el.classList.remove("flash"); void el.offsetWidth; el.classList.add("flash"); }, reduceMotion ? 0 : 550); + } + } + + /* ---------- finish card (one level fully marked done) ---------- */ + const rideable = (r) => !r.kind || r.kind === "external"; + function levelDone(levelId) { + const items = levelItems(levelId).filter(rideable); + return items.length > 0 && items.every((r) => state.ridden.has(r.repo)); + } + function drawCard(canvas, lv, items, name) { + const W = 1200, H = 630, c = canvas.getContext("2d"); + canvas.width = W; canvas.height = H; + const g = c.createLinearGradient(0, 0, W, H); + g.addColorStop(0, "#23276A"); g.addColorStop(0.5, "#333689"); g.addColorStop(1, "#6E68BC"); + c.fillStyle = g; c.fillRect(0, 0, W, H); + const glow = c.createRadialGradient(W * 0.86, H * 0.18, 0, W * 0.86, H * 0.18, 520); + glow.addColorStop(0, "rgba(255,255,255,.18)"); glow.addColorStop(1, "rgba(255,255,255,0)"); + c.fillStyle = glow; c.fillRect(0, 0, W, H); + const font = (w, px) => `${w} ${px}px "Pretendard Variable", Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif`; + 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.font = font(700, 44); c.fillText(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); + c.font = font(500, 26); c.fillStyle = "rgba(255,255,255,.78)"; + items.slice(0, 3).forEach((r, i) => c.fillText(`· ${r.title}`, 80, 420 + i * 38)); + if (items.length > 3) c.fillText(`외 ${items.length - 3}개`, 80, 420 + 3 * 38); + if (name) { c.fillStyle = "#fff"; c.font = font(700, 34); c.fillText(name, 80, H - 104); } + c.fillStyle = "rgba(255,255,255,.7)"; c.font = font(500, 24); + const d = new Date(), date = `${d.getFullYear()}.${pad2(d.getMonth() + 1)}.${pad2(d.getDate())}`; + c.fillText(`${date} · causalinferencelab.github.io`, 80, H - 60); + const mark = $(".banner-mark"); + if (mark && mark.complete && mark.naturalWidth) c.drawImage(mark, W - 300, 70, 210, 210); + } + 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 next = state.levels[state.levels.indexOf(lv) + 1]; + track("finish_card_open", { level: levelId }); + $("#station-body").innerHTML = `
    + +
    +

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

    + + +

    이미지를 저장해 링크드인이나 블로그에 올려 보세요. 이름과 진도는 이 브라우저 밖으로 보내지 않습니다.

    + +
    `; + showDialog(); + try { if (document.fonts) await document.fonts.load('800 92px "Pretendard Variable"'); } catch (e) { /* draw with fallback fonts */ } + const canvas = $("#station-body .card-canvas"), input = $("#station-body [data-card-name]"); + drawCard(canvas, lv, items, ""); + input.addEventListener("input", () => drawCard(canvas, lv, items, input.value.trim())); + } + function saveCard(levelId) { + const canvas = $("#station-body .card-canvas"); + if (!canvas) return; + const name = `causal-inference-hub-${levelId}.png`; + canvas.toBlob(async (blob) => { + if (!blob) return; + const file = typeof File === "function" ? new File([blob], name, { type: "image/png" }) : null; + /* phones: hand the image to the share sheet; elsewhere download it */ + if (file && navigator.canShare && navigator.canShare({ files: [file] })) { + try { await navigator.share({ files: [file], title: "Causal Inference Hub 완주 카드" }); track("share", { content: "finish_card", method: "share_sheet" }); return; } catch (e) { if (e && e.name === "AbortError") return; } + } + const a = document.createElement("a"); + a.download = name; a.href = URL.createObjectURL(blob); + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 4000); + track("share", { content: "finish_card", method: "download" }); + }, "image/png"); + } + /* ---------- station dialog ---------- */ const dialog = () => $("#station-dialog"); - function openStation(id) { + function openStation(id, source, silent) { const r = state.repos.get(id); if (!r) return; - if (r.kind !== "paid") { + state.openRepo = r; + if (!silent) { track("open_repo", { repo: r.repo, level: r.level || "", source: source || "hub" }); if (source !== "deeplink") stamp("first_open"); } + if (!silent && r.kind !== "paid") { state.recent = [{ id, t: Date.now() }, ...state.recent.filter((x) => x.id !== id)].slice(0, 6); saveRecent(); renderWeek(); } @@ -830,6 +1063,7 @@ ${adjacent} ${canRide ? `` : ""} + ${ridden && lv && levelDone(lv.id) ? `
    ${lv.id} ${esc(lv.name)} 자료를 모두 마쳤습니다.
    ` : ""}
    `; const d = dialog(); if (!d.open) { if (typeof d.showModal === "function") d.showModal(); else d.setAttribute("open", ""); } @@ -849,26 +1083,55 @@ const r = state.repos.get(id); const lv = r.level && r.kind !== "paid" ? r.level : null; $("#curriculum").scrollIntoView({ behavior: "auto" }); - openStation(id); + openStation(id, "deeplink"); if (lv) dialog().addEventListener("close", () => gotoItem(id), { once: true }); } function toggleRide(id) { if (state.ridden.has(id)) state.ridden.delete(id); else state.ridden.add(id); + const rr = state.repos.get(id); + track("mark_done", { repo: id, level: (rr && rr.level) || "", done: state.ridden.has(id) }); + if (state.ridden.has(id) && rr && rr.level && levelDone(rr.level)) stamp("finish"); saveRidden(); - renderMap(); renderShelf(true); - openStation(id); + renderMap(); renderShelf(true); renderWeek(); + openStation(id, "dialog", true); } + const sourceOf = (el) => (el.closest("#shelf") ? "shelf" : el.closest(".continue") ? "continue" : el.closest(".spot") ? "spotlight" : el.closest(".fresh") ? "fresh" : el.closest("#vlines") ? "map_list" : el.closest(".p-timeline, .p-repos") ? "builder_dialog" : el.closest("#station-body") ? "dialog" : el.closest("#roster, #top3") ? "contrib" : "hub"); + const joinTarget = (href) => (/discord/.test(href) ? "discord" : /Project-template/.test(href) ? "template" : /is%3Aissue|is:issue/.test(href) ? "issue" : /pseudo-lab\.com/.test(href) ? "runner" : "other"); + function wire() { document.addEventListener("click", (e) => { + const fa = e.target.closest("#featured a"); + 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']"); + 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" }); + const dpick = e.target.closest("[data-daily]"); + if (dpick) { answerDaily(dpick.dataset.daily); return; } + if (e.target.closest("[data-quiz-open]")) { openQuiz(); return; } + const pick = e.target.closest("[data-quiz-pick]"); + if (pick) { quizPick(pick.dataset.quizPick); return; } + if (e.target.closest("[data-quiz-back]")) { quizBack(); return; } + if (e.target.closest("[data-quiz-restart]")) { openQuiz(true); return; } + const qgo = e.target.closest("[data-quiz-go]"); + if (qgo) { quizGo(qgo.dataset.quizGo, Number(qgo.dataset.quizBegin) || 0); return; } + const card = e.target.closest("[data-card]"); + if (card) { openCard(card.dataset.card); return; } + const nl = e.target.closest("[data-next-level]"); + if (nl) { closeStation(); gotoLevel(nl.dataset.nextLevel); return; } + const dl = e.target.closest("[data-card-save]"); + if (dl) { saveCard(dl.dataset.cardSave); return; } const ride = e.target.closest("[data-ride]"); if (ride) { toggleRide(ride.dataset.ride); return; } if (e.target.closest("[data-close]")) { closeStation(); return; } const person = e.target.closest("[data-person]"); - if (person) { openPerson(person.dataset.person); return; } + if (person) { openPerson(person.dataset.person, person.closest("#top3") ? "top3" : person.closest("#people-rail") ? "rail" : person.closest("#roster") ? "roster" : "hub"); return; } const go = e.target.closest("[data-goto]"); - if (go) { gotoItem(go.dataset.goto); return; } + if (go) { if (go.closest("#partner-list")) track("click_partner", { item: go.dataset.goto, action: "goto" }); gotoItem(go.dataset.goto); return; } const lv = e.target.closest("[data-level]:not(.ep):not(.chap)"); if (lv && lv.tagName === "BUTTON") { gotoLevel(lv.dataset.level); return; } const ch = e.target.closest("[data-chapter]"); @@ -882,7 +1145,7 @@ const tabLink = e.target.closest('a[href="#contrib"]'); if (tabLink) { e.preventDefault(); selectTab("tab-contrib"); $("#curriculum").scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth" }); return; } const open = e.target.closest("[data-open]"); - if (open) { e.preventDefault(); openStation(open.dataset.open); } + if (open) { e.preventDefault(); openStation(open.dataset.open, sourceOf(open)); } }); dialog().addEventListener("click", (e) => { if (e.target === dialog()) closeStation(); }); dialog().addEventListener("close", () => { @@ -935,9 +1198,10 @@ $("#shelf").addEventListener("scroll", shelfSpy, { passive: true }); renderContrib(); renderLegend(); renderMap(); wireTabs(); - renderFeatured(); renderPartner(); renderEvents(); renderArticles(); - if (location.hash === "#contrib") selectTab("tab-contrib"); + renderFeatured(); renderPartner(); renderEvents(); renderArticles(); renderDaily(); + if (location.hash === "#contrib") { selectTab("tab-contrib"); requestAnimationFrame(() => $("#curriculum").scrollIntoView()); } openFromHash(); + if (location.hash === "#start" || new URLSearchParams(location.search).has("quiz")) openQuiz(); navSpy(); refreshStars(); } diff --git a/assets/js/lab.js b/assets/js/lab.js new file mode 100644 index 0000000..c0e21df --- /dev/null +++ b/assets/js/lab.js @@ -0,0 +1,131 @@ +/* Confounding lab: 800 simulated users. Loyalty z pushes both "notifications on" (T) and + retention (Y); the slider sets how hard z pushes T. Y = 50 + 6z + effect*T + noise. + Shows the true effect, the naive difference in means, and an OLS estimate adjusted for z. */ +(() => { + "use strict"; + const $ = (s) => document.querySelector(s); + const track = (n, p) => window.hubTrack && window.hubTrack(n, p); + const N = 800, SVG_NS = "http://www.w3.org/2000/svg"; + let seed = 9, base = null, started = false, solved = false; + + function rng(a) { return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } + function draw(s) { + const r = rng(s), normal = () => { let u = 0, v = 0; while (u === 0) u = r(); while (v === 0) v = r(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); }; + return Array.from({ length: N }, () => ({ z: normal(), u: r(), e: normal() * 4 })); + } + /* OLS of Y on [1, T, z]: solve the 3x3 normal equations. */ + function ols(rows) { + const X = rows.map((d) => [1, d.t, d.z]), y = rows.map((d) => d.y); + const A = [[0, 0, 0], [0, 0, 0], [0, 0, 0]], b = [0, 0, 0]; + X.forEach((x, i) => { for (let j = 0; j < 3; j++) { b[j] += x[j] * y[i]; for (let k = 0; k < 3; k++) A[j][k] += x[j] * x[k]; } }); + for (let c = 0; c < 3; c++) { + let p = c; for (let r2 = c + 1; r2 < 3; r2++) if (Math.abs(A[r2][c]) > Math.abs(A[p][c])) p = r2; + [A[c], A[p]] = [A[p], A[c]]; [b[c], b[p]] = [b[p], b[c]]; + if (Math.abs(A[c][c]) < 1e-9) return NaN; + for (let r2 = 0; r2 < 3; r2++) if (r2 !== c) { const f = A[r2][c] / A[c][c]; for (let k = c; k < 3; k++) A[r2][k] -= f * A[c][k]; b[r2] -= f * b[c]; } + } + return [b[0] / A[0][0], b[1] / A[1][1], b[2] / A[2][2]]; + } + const fmt = (v) => (Number.isFinite(v) ? `${v >= 0 ? "+" : "−"}${Math.abs(v).toFixed(1)}` : "계산 불가"); + + function compute() { + const conf = Number($("#in-conf").value), eff = Number($("#in-eff").value), rand = $("#in-rand").getAttribute("aria-pressed") === "true"; + const k = conf * 0.12; + const rows = base.map((d) => { const p = rand ? 0.5 : 1 / (1 + Math.exp(-k * d.z)); const t = d.u < p ? 1 : 0; return { z: d.z, t, y: 50 + 6 * d.z + eff * t + d.e }; }); + const on = rows.filter((d) => d.t), off = rows.filter((d) => !d.t); + const mean = (a) => a.reduce((s, d) => s + d.y, 0) / (a.length || 1); + const mOn = mean(on), mOff = mean(off); + const fit = ols(rows); + return { conf, eff, rand, rows, mOn, mOff, naive: mOn - mOff, adj: Array.isArray(fit) ? fit[1] : NaN, fit, nOn: on.length }; + } + + function bars(r) { + const hi = Math.max(10, r.eff, r.naive, Number.isFinite(r.adj) ? r.adj : 0) * 1.08; + const w = (v) => `${Math.max(0, Math.min(100, (Math.max(0, v) / hi) * 100))}%`; + $("#bar-true").style.width = w(r.eff); $("#bar-naive").style.width = w(r.naive); $("#bar-adj").style.width = w(r.adj); + $("#v-true").textContent = fmt(r.eff); $("#v-naive").textContent = fmt(r.naive); $("#v-adj").textContent = fmt(r.adj); + const bias = r.naive - r.eff; + $("#lab-bias").innerHTML = r.rand + ? `단순 비교와 진짜 효과의 차이 ${fmt(bias)}: 800명 표본의 우연 오차 범위(±1 안팎)입니다.` + : Math.abs(bias) <= 1 + ? `단순 비교의 편향 ${fmt(bias)}: 진짜 효과에 거의 붙었습니다.` + : `단순 비교의 편향 ${fmt(bias)}: 진짜 효과보다 ${Math.abs(bias).toFixed(1)}점 ${bias > 0 ? "부풀려졌습니다" : "작게 나왔습니다"}.`; + $("#out-conf").textContent = r.conf; $("#out-eff").textContent = `+${r.eff}`; + /* while randomized, loyalty no longer decides who turns notifications on */ + const ctl = $("#in-conf").closest(".lab-ctl"); + ctl.classList.toggle("off", r.rand); + $("#in-conf").setAttribute("aria-disabled", String(r.rand)); + ctl.querySelector("small").textContent = r.rand ? "무작위 배정 중: 충성도가 알림을 정하지 못합니다" : "충성도가 알림 켜기에 얼마나 영향을 주나"; + } + + function chart(r) { + const svg = $("#lab-svg"); while (svg.lastChild && svg.lastChild.nodeName !== "desc") svg.removeChild(svg.lastChild); + /* draw in the chart's own pixel size so labels stay readable on phones */ + const W = Math.max(300, Math.round(svg.parentElement.clientWidth - 40)), H = W < 480 ? 220 : Math.round(Math.min(360, Math.max(260, W * 0.62))); + svg.setAttribute("viewBox", `0 0 ${W} ${H}`); + const small = W < 480, dot = small ? 2 : 2.6; + const L = 40, R = W - 16, T = 14, B = H - 40, X = (z) => L + ((Math.max(-3, Math.min(3, z)) + 3) / 6) * (R - L), Y = (y) => B - ((Math.max(15, Math.min(85, y)) - 15) / 70) * (B - T); + const el = (tag, a, parent = svg) => { const n = document.createElementNS(SVG_NS, tag); Object.entries(a).forEach(([k2, v]) => n.setAttribute(k2, v)); parent.appendChild(n); return n; }; + const g = el("g", { class: "axes" }); + [20, 35, 50, 65, 80].forEach((v) => { el("line", { x1: L, x2: R, y1: Y(v), y2: Y(v), class: "grid" }, g); el("text", { x: L - 8, y: Y(v) + 4, class: "tick", "text-anchor": "end" }, g).textContent = v; }); + [-2, 0, 2].forEach((v) => { el("text", { x: X(v), y: B + 18, class: "tick", "text-anchor": "middle" }, g).textContent = v === 0 ? "평균" : v < 0 ? "낮음" : "높음"; }); + el("text", { x: R, y: B + 34, class: "axis-t", "text-anchor": "end" }, g).textContent = "충성도 →"; + const dots = el("g", { class: "dots" }); + r.rows.forEach((d) => el("circle", { cx: X(d.z).toFixed(1), cy: Y(d.y).toFixed(1), r: dot, class: d.t ? "on" : "off" }, dots)); + /* the adjusted estimate: two parallel fitted lines, one per group, at the same loyalty */ + if (Array.isArray(r.fit)) { + const [a, tau, bz] = r.fit, fitG = el("g", { class: "fit" }); + [[0, "off"], [1, "on"]].forEach(([tt, cls]) => el("line", { x1: X(-3), x2: X(3), y1: Y(a + bz * -3 + tau * tt), y2: Y(a + bz * 3 + tau * tt), class: `fit-l ${cls}` }, fitG)); + const zl = -2.2, yOn = Y(a + bz * zl + tau), yOff = Y(a + bz * zl); + el("line", { x1: X(zl), x2: X(zl), y1: yOn, y2: yOff, class: "fit-gap" }, fitG); + el("text", { x: X(zl) + 6, y: (yOn + yOff) / 2 + 4, class: "fit-t" }, fitG).textContent = `같은 충성도에서의 간격 = 보정 ${fmt(tau)}`; + } + const means = el("g", { class: "means" }); + [["on", r.mOn, "켬 평균"], ["off", r.mOff, "끔 평균"]].forEach(([cls, m, label]) => { + el("line", { x1: L, x2: R, y1: Y(m), y2: Y(m), class: `mean ${cls}` }, means); + const t = el("text", { x: R - 4, y: Y(m) + (cls === "on" ? -6 : 14), class: `mean-t ${cls}`, "text-anchor": "end" }, means); t.textContent = `${label} ${m.toFixed(1)}`; + }); + } + + let moves = 0; + function challenge(r) { + if (solved) return; + const close = Math.abs(r.naive - r.eff) <= 1; + if (r.conf >= 5 && r.rand && close) { + solved = true; + $("#lab-challenge").classList.add("done"); + $("#lab-ch-text").textContent = "해냈습니다. 무작위로 켜면 충성도와 알림이 따로 움직여서, 단순 비교만으로도 진짜 효과에 가까워집니다. 이것이 무작위 실험이 하는 일입니다."; + track("lab_challenge_complete", { randomized: true, conf: r.conf }); + if (window.hubStamp) window.hubStamp("lab"); + return; + } + if (r.rand && r.conf >= 5) { + $("#lab-ch-text").textContent = "무작위 배정이 맞는 답입니다. 지금 남은 차이는 800명 표본의 우연 오차입니다. ‘새 사용자 800명 뽑기’를 눌러 보세요."; + } else if (moves >= 8) { + $("#lab-ch-text").textContent = "힌트: 교란 강도는 5 이상으로 그대로 두고, 슬라이더가 아닌 다른 버튼을 찾아보세요."; + } + } + + function update(source) { + if (source === "confounding" || source === "effect") moves += 1; + const r = compute(); + bars(r); chart(r); challenge(r); + if (source && !started) { started = true; track("lab_start", { control: source }); } + } + + document.addEventListener("DOMContentLoaded", () => { + base = draw(seed); + $("#in-conf").addEventListener("input", () => update("confounding")); + $("#in-eff").addEventListener("input", () => update("effect")); + $("#in-rand").addEventListener("click", (e) => { const b = e.currentTarget; b.setAttribute("aria-pressed", String(b.getAttribute("aria-pressed") !== "true")); track("lab_randomize", { on: b.getAttribute("aria-pressed") === "true" }); update("randomize"); }); + $("#in-seed").addEventListener("click", () => { seed += 1; base = draw(seed); update("reseed"); }); + $("#lab-share").addEventListener("click", async (e) => { + const url = window.hubShareUrl ? window.hubShareUrl("/lab.html", "lab") : location.href; + const ok = window.hubCopy ? await window.hubCopy(url) : false; + const b = e.currentTarget, label = b.textContent; b.textContent = ok ? "복사했습니다" : "복사하지 못했습니다"; setTimeout(() => { b.textContent = label; }, 1600); + if (ok) track("share", { content: "lab", method: "copy" }); + }); + update(); + let rt; window.addEventListener("resize", () => { clearTimeout(rt); rt = setTimeout(() => chart(compute()), 150); }); + }); +})(); diff --git a/assets/js/passport.js b/assets/js/passport.js new file mode 100644 index 0000000..a0973a6 --- /dev/null +++ b/assets/js/passport.js @@ -0,0 +1,107 @@ +/* Learning passport: stamps for small actions across the hub, the game, the lab and the + Builder pages. Everything stays in this browser (localStorage); GA only gets stamp_earned. */ +(() => { + "use strict"; + const KEY = "cil-hub-stamps"; + const STAMPS = [ + { id: "first_open", mark: "첫", name: "첫 걸음", how: "커리큘럼에서 레포 하나를 열어 보세요.", href: "/#curriculum" }, + { id: "quiz", mark: "길", name: "길잡이", how: "3문항 시작점 진단을 끝내 보세요.", href: "/?quiz=1" }, + { id: "daily", mark: "오", name: "오늘의 한 문제", how: "허브의 오늘의 한 문제에 답해 보세요.", href: "/#daily" }, + { id: "streak3", mark: "3", name: "사흘 연속", how: "오늘의 한 문제를 3일 연속 풀어 보세요.", href: "/#daily" }, + { id: "game", mark: "몸", name: "몸풀기 완주", how: "‘상관일까, 인과일까?’ 10문제를 끝까지 풀어 보세요.", href: "/game.html" }, + { id: "sharp", mark: "감", name: "인과 감별사", how: "게임에서 8문제 이상 맞혀 보세요.", href: "/game.html" }, + { id: "lab", mark: "실", name: "실험가", how: "교란 실험실에서 편향을 0 가까이 줄여 보세요.", href: "/lab.html" }, + { id: "finish", mark: "완", name: "한 단계 완주", how: "한 단계의 자료를 모두 학습 완료로 표시해 보세요.", href: "/#curriculum" }, + { id: "builder", mark: "B", name: "Builder 탐방", how: "Builder 이력이나 페이지를 세 번 열어 보세요.", href: "/builders/" } + ]; + const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || "{}"); } catch (e) { return {}; } }; + const save = (d) => { try { localStorage.setItem(KEY, JSON.stringify(d)); } catch (e) { /* storage unavailable */ } }; + const today = () => { const d = new Date(); return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`; }; + const earnedCount = (d) => STAMPS.filter((s) => d.stamps && d.stamps[s.id]).length; + + function paintCounts() { + const d = load(), n = earnedCount(d); + document.querySelectorAll("[data-passport-count]").forEach((el) => { el.textContent = `${n}/${STAMPS.length}`; }); + } + + let toastTimer; + function toast(stamp, n) { + let el = document.getElementById("hub-toast"); + if (!el) { + el = document.createElement("div"); + el.id = "hub-toast"; el.className = "toast"; el.setAttribute("role", "status"); el.setAttribute("aria-live", "polite"); + document.body.appendChild(el); + } + el.innerHTML = `스탬프 획득 · ${esc(stamp.name)}${n}/${STAMPS.length}`; + el.classList.add("show"); + const hide = () => { toastTimer = setTimeout(() => el.classList.remove("show"), 4200); }; + clearTimeout(toastTimer); hide(); + /* stay while the pointer or keyboard is on it */ + el.onmouseenter = el.onfocusin = () => clearTimeout(toastTimer); + el.onmouseleave = el.onfocusout = () => { clearTimeout(toastTimer); hide(); }; + } + + window.hubStamp = (id) => { + const stamp = STAMPS.find((s) => s.id === id); + if (!stamp) return false; + const d = load(); + d.stamps = d.stamps || {}; + if (d.stamps[id]) return false; + d.stamps[id] = today(); + save(d); + const n = earnedCount(d); + if (window.hubTrack) window.hubTrack("stamp_earned", { stamp: id, count: n }); + paintCounts(); toast(stamp, n); + return true; + }; + /* Distinct things seen (Builders), so reloading one page three times does not count. */ + window.hubSeen = (key, value, need, stampId) => { + const d = load(); + d.seen = d.seen || {}; + const list = new Set(d.seen[key] || []); + list.add(value); + d.seen[key] = [...list]; + save(d); + if (list.size >= need) window.hubStamp(stampId); + }; + + function passportDialog(source) { + let dlg = document.getElementById("passport-dialog"); + if (!dlg) { + dlg = document.createElement("dialog"); + dlg.id = "passport-dialog"; dlg.className = "station passport"; dlg.setAttribute("aria-labelledby", "passport-title"); + document.body.appendChild(dlg); + dlg.addEventListener("click", (e) => { if (e.target === dlg || e.target.closest("[data-passport-close]")) dlg.close(); }); + } + const d = load(), n = earnedCount(d), got = d.stamps || {}; + const next = STAMPS.find((s) => !got[s.id]); + dlg.innerHTML = `
    + +
    +

    학습 여권

    +

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

    +

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

    + +

    기록은 이 브라우저에만 저장됩니다. 다른 기기나 시크릿 창에서는 처음부터 시작합니다.

    +
    `; + if (!dlg.open) { if (typeof dlg.showModal === "function") dlg.showModal(); else dlg.setAttribute("open", ""); } + dlg.querySelector(".station-close").focus(); + if (window.hubTrack) window.hubTrack("passport_open", { count: n, source: source || "header" }); + } + window.openPassport = passportDialog; + + document.addEventListener("click", (e) => { + const b = e.target.closest("[data-passport]"); + if (b) { e.preventDefault(); passportDialog(b.dataset.passport || "header"); } + }); + document.addEventListener("DOMContentLoaded", () => { + paintCounts(); + /* a Builder page view counts toward the Builder stamp */ + const m = location.pathname.match(/^\/builders\/([^/]+)\.html$/); + if (m) window.hubSeen("builders", m[1], 3, "builder"); + }); +})(); diff --git a/assets/js/track.js b/assets/js/track.js new file mode 100644 index 0000000..e0cf985 --- /dev/null +++ b/assets/js/track.js @@ -0,0 +1,43 @@ +/* Event layer for every page. Events go to GA4 when it is installed (gtag or dataLayer) and are + dropped otherwise, so the code can ship before the measurement ID. Names follow the event + dictionary in the PM doc; keep them in snake_case and the params flat. */ +(() => { + "use strict"; + const SITE = "https://causalinferencelab.github.io"; + window.hubTrack = (name, params = {}) => { + try { + if (typeof window.gtag === "function") window.gtag("event", name, params); + else if (Array.isArray(window.dataLayer)) window.dataLayer.push({ event: name, ...params }); + if (window.HUB_DEBUG) console.debug("[hub]", name, params); + } catch (e) { /* never let measurement break the page */ } + }; + /* Links people share carry utm tags so shared visits can be counted. */ + window.hubShareUrl = (path, medium) => `${SITE}${path}${path.includes("?") ? "&" : "?"}utm_source=share&utm_medium=${encodeURIComponent(medium)}`; + window.hubCopy = async (text) => { + try { await navigator.clipboard.writeText(text); return true; } catch (e) { + const t = document.createElement("textarea"); t.value = text; t.setAttribute("readonly", ""); t.style.position = "fixed"; t.style.opacity = "0"; + document.body.appendChild(t); t.select(); + let ok = false; try { ok = document.execCommand("copy"); } catch (err) { ok = false; } + t.remove(); return ok; + } + }; + /* Any element with data-event sends that event on click; data-event-params holds flat JSON. */ + document.addEventListener("click", (e) => { + const el = e.target.closest("[data-event]"); + if (!el) return; + let params = {}; + try { params = JSON.parse(el.dataset.eventParams || "{}"); } catch (err) { params = {}; } + window.hubTrack(el.dataset.event, params); + }); + /* Buttons with data-copy copy their value and confirm in place. */ + document.addEventListener("click", async (e) => { + const b = e.target.closest("[data-copy]"); + if (!b) return; + const ok = await window.hubCopy(b.dataset.copy); + const label = b.dataset.label || b.textContent; + b.dataset.label = label; + b.textContent = ok ? "복사했습니다" : "복사하지 못했습니다"; + setTimeout(() => { b.textContent = label; }, 1600); + if (ok && b.dataset.share) window.hubTrack("share", { content: b.dataset.share, method: "copy" }); + }); +})(); diff --git a/assets/og/builders/bang-taemo.jpg b/assets/og/builders/bang-taemo.jpg new file mode 100644 index 0000000..f9a0b53 Binary files /dev/null and b/assets/og/builders/bang-taemo.jpg differ diff --git a/assets/og/builders/cho-haechang.jpg b/assets/og/builders/cho-haechang.jpg new file mode 100644 index 0000000..cc57a76 Binary files /dev/null and b/assets/og/builders/cho-haechang.jpg differ diff --git a/assets/og/builders/choi-seyoung.jpg b/assets/og/builders/choi-seyoung.jpg new file mode 100644 index 0000000..ee69b1c Binary files /dev/null and b/assets/og/builders/choi-seyoung.jpg differ diff --git a/assets/og/builders/kim-sangwon.jpg b/assets/og/builders/kim-sangwon.jpg new file mode 100644 index 0000000..a45c67d Binary files /dev/null and b/assets/og/builders/kim-sangwon.jpg differ diff --git a/assets/og/builders/kim-seongsu.jpg b/assets/og/builders/kim-seongsu.jpg new file mode 100644 index 0000000..48d296b Binary files /dev/null and b/assets/og/builders/kim-seongsu.jpg differ diff --git a/assets/og/builders/kwon-myeongseok.jpg b/assets/og/builders/kwon-myeongseok.jpg new file mode 100644 index 0000000..e7a79a0 Binary files /dev/null and b/assets/og/builders/kwon-myeongseok.jpg differ diff --git a/assets/og/builders/kwon-namtaek.jpg b/assets/og/builders/kwon-namtaek.jpg new file mode 100644 index 0000000..a043fe8 Binary files /dev/null and b/assets/og/builders/kwon-namtaek.jpg differ diff --git a/assets/og/builders/lee-dongwook.jpg b/assets/og/builders/lee-dongwook.jpg new file mode 100644 index 0000000..768c1b3 Binary files /dev/null and b/assets/og/builders/lee-dongwook.jpg differ diff --git a/assets/og/builders/park-isak.jpg b/assets/og/builders/park-isak.jpg new file mode 100644 index 0000000..a183f91 Binary files /dev/null and b/assets/og/builders/park-isak.jpg differ diff --git a/assets/og/builders/park-sangho.jpg b/assets/og/builders/park-sangho.jpg new file mode 100644 index 0000000..2d8aa3b Binary files /dev/null and b/assets/og/builders/park-sangho.jpg differ diff --git a/assets/og/builders/shin-jinsoo.jpg b/assets/og/builders/shin-jinsoo.jpg new file mode 100644 index 0000000..1cb1a2c Binary files /dev/null and b/assets/og/builders/shin-jinsoo.jpg differ diff --git a/data/builders.json b/data/builders.json index 169532c..72004b0 100644 --- a/data/builders.json +++ b/data/builders.json @@ -1,13 +1,13 @@ [ - {"name": "신진수", "linkedin": "https://www.linkedin.com/in/jinsoo-shin-436060162/", "github": "jsshin2022", "intro": "", "runnerOf": [{"cohort": 8, "project": "Kafka - Zero to Hero 실시간 성장하기"}, {"cohort": 10, "project": "Marketing Science"}]}, - {"name": "김성수", "linkedin": "https://www.linkedin.com/in/%EC%84%B1%EC%88%98-%EA%B9%80-50825717b/", "github": "", "intro": "", "runnerOf": [{"cohort": 4, "project": "Casual하게 Causality 이해하기"}, {"cohort": 5, "project": "Casual하지 않게 Causality 이해하기"}, {"cohort": 6, "project": "Case로 Causality 이해하기"}, {"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]}, - {"name": "방태모", "linkedin": "https://www.linkedin.com/in/taemo/", "github": "", "intro": "", "runnerOf": [{"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]}, - {"name": "박상호", "linkedin": "https://www.linkedin.com/in/shstat1729/", "github": "", "intro": "", "runnerOf": [{"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]}, - {"name": "이동욱", "linkedin": "https://www.linkedin.com/in/dwlee-1995-lkdin/", "github": "", "intro": "", "runnerOf": []}, - {"name": "박이삭", "linkedin": "https://www.linkedin.com/in/%EC%9D%B4%EC%82%AD-%EB%B0%95-75a7a916a/", "github": "", "intro": "", "runnerOf": [{"cohort": 7, "project": "Gamifying Causal Inference with Game"}, {"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]}, - {"name": "권남택", "linkedin": "https://www.linkedin.com/in/namtaek-kwon/", "github": "", "intro": "", "runnerOf": []}, - {"name": "권명석", "linkedin": "https://www.linkedin.com/in/myeongseok-gwon-b41414312/", "github": "", "intro": "", "runnerOf": [{"cohort": 12, "project": "Causal Studio: Causal Inference Handbook & Visualization"}]}, - {"name": "김상원", "linkedin": "https://www.linkedin.com/in/sangwon-kim-1438ba221/", "github": "", "intro": "", "runnerOf": []}, - {"name": "최세영", "linkedin": "https://www.linkedin.com/in/seyoung4503/", "github": "", "intro": "", "runnerOf": [{"cohort": 11, "project": "Lang2SQL"}]}, - {"name": "조해창", "linkedin": "https://www.linkedin.com/in/hae-chang-cho/", "github": "", "intro": "", "runnerOf": [{"cohort": 11, "project": "Awesome Causal Inference with Python"}]} + {"name": "신진수", "slug": "shin-jinsoo", "linkedin": "https://www.linkedin.com/in/jinsoo-shin-436060162/", "github": "jsshin2022", "intro": "", "runnerOf": [{"cohort": 8, "project": "Kafka - Zero to Hero 실시간 성장하기"}, {"cohort": 10, "project": "Marketing Science"}]}, + {"name": "김성수", "slug": "kim-seongsu", "linkedin": "https://www.linkedin.com/in/%EC%84%B1%EC%88%98-%EA%B9%80-50825717b/", "github": "", "intro": "", "runnerOf": [{"cohort": 4, "project": "Casual하게 Causality 이해하기"}, {"cohort": 5, "project": "Casual하지 않게 Causality 이해하기"}, {"cohort": 6, "project": "Case로 Causality 이해하기"}, {"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]}, + {"name": "방태모", "slug": "bang-taemo", "linkedin": "https://www.linkedin.com/in/taemo/", "github": "", "intro": "", "runnerOf": [{"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]}, + {"name": "박상호", "slug": "park-sangho", "linkedin": "https://www.linkedin.com/in/shstat1729/", "github": "", "intro": "", "runnerOf": [{"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]}, + {"name": "이동욱", "slug": "lee-dongwook", "linkedin": "https://www.linkedin.com/in/dwlee-1995-lkdin/", "github": "", "intro": "", "runnerOf": []}, + {"name": "박이삭", "slug": "park-isak", "linkedin": "https://www.linkedin.com/in/%EC%9D%B4%EC%82%AD-%EB%B0%95-75a7a916a/", "github": "", "intro": "", "runnerOf": [{"cohort": 7, "project": "Gamifying Causal Inference with Game"}, {"cohort": 8, "project": "실무로 통하는 인과추론 with 파이썬 특강"}]}, + {"name": "권남택", "slug": "kwon-namtaek", "linkedin": "https://www.linkedin.com/in/namtaek-kwon/", "github": "", "intro": "", "runnerOf": []}, + {"name": "권명석", "slug": "kwon-myeongseok", "linkedin": "https://www.linkedin.com/in/myeongseok-gwon-b41414312/", "github": "", "intro": "", "runnerOf": [{"cohort": 12, "project": "Causal Studio: Causal Inference Handbook & Visualization"}]}, + {"name": "김상원", "slug": "kim-sangwon", "linkedin": "https://www.linkedin.com/in/sangwon-kim-1438ba221/", "github": "", "intro": "", "runnerOf": []}, + {"name": "최세영", "slug": "choi-seyoung", "linkedin": "https://www.linkedin.com/in/seyoung4503/", "github": "", "intro": "", "runnerOf": [{"cohort": 11, "project": "Lang2SQL"}]}, + {"name": "조해창", "slug": "cho-haechang", "linkedin": "https://www.linkedin.com/in/hae-chang-cho/", "github": "", "intro": "", "runnerOf": [{"cohort": 11, "project": "Awesome Causal Inference with Python"}]} ] diff --git a/data/daily.json b/data/daily.json new file mode 100644 index 0000000..88ce7dd --- /dev/null +++ b/data/daily.json @@ -0,0 +1,192 @@ +{ + "title": "오늘의 한 문제", + "questions": [ + { + "id": "reviews", + "text": "리뷰가 많은 상품일수록 판매량이 많았다.", + "claim": "리뷰를 늘리면 판매가 는다", + "answer": "corr", + "why": "많이 팔린 상품에 리뷰가 많이 쌓이는 방향도 있습니다. 원인과 결과가 서로를 움직이면 관측만으로는 방향을 가를 수 없습니다.", + "chapter": { + "title": "1장 인과추론 소개", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/01-Introduction-To-Causality.html" + } + }, + { + "id": "onboarding", + "text": "새 온보딩을 도입한 국가와 비교 국가의 가입 전환율은 도입 전 2년 동안 나란히 움직였다. 도입 뒤에는 도입 국가만 올랐고, 같은 시기 그 나라에만 있던 다른 캠페인은 없었다. 차이는 매달 같은 방향으로 이어졌다.", + "claim": "새 온보딩이 가입 전환율을 올렸다", + "answer": "cause", + "why": "도입이 없었다면 두 나라가 계속 나란히 움직였을 것이라는 평행 추세 가정이 받아들일 만합니다. 그러면 벌어진 차이를 온보딩의 효과로 읽을 수 있습니다.", + "chapter": { + "title": "13장 이중차분법", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/13-Difference-in-Differences.html" + } + }, + { + "id": "delivery_geo", + "text": "전국 250개 시군구 중 무작위로 고른 125곳에서만 한 달 동안 배달비를 낮췄더니, 그 지역의 주문이 다른 지역보다 12% 늘었고 통계적으로 유의했다.", + "claim": "배달비 인하가 주문을 늘렸다", + "answer": "cause", + "why": "배달비를 낮출 지역을 무작위로 골랐기 때문에 지역 간 다른 차이는 평균적으로 고르게 나뉩니다. 지역 단위 무작위 실험입니다.", + "chapter": { + "title": "2장 무작위 실험", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/02-Randomised-Experiments.html" + } + }, + { + "id": "firefighters", + "text": "소방관이 많이 출동한 화재일수록 피해액이 컸다.", + "claim": "소방관이 많이 출동하면 피해가 커진다", + "answer": "corr", + "why": "큰 불일수록 소방관이 많이 출동하고 피해도 큽니다. 화재 규모가 둘을 함께 움직이는 교란 요인입니다.", + "chapter": { + "title": "4장 그래프 인과 모델", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/04-Graphical-Causal-Models.html" + } + }, + { + "id": "tutoring_show", + "text": "추첨으로 절반의 학생에게 무료 과외를 배정했다. 그중 실제로 과외에 꾸준히 나온 학생만 골라, 배정받지 못한 학생과 비교했더니 점수 차이가 컸다.", + "claim": "과외가 그만큼 점수를 올렸다", + "answer": "corr", + "why": "배정은 추첨이었지만, 꾸준히 나온 학생은 스스로 골라졌습니다. 성실한 학생이 더 잘 나옵니다. 배정 기준으로 그대로 비교하거나, 배정을 도구변수로 써서 ‘배정되면 나오는 학생’에 대한 효과를 따로 추정해야 합니다.", + "chapter": { + "title": "9장 불응과 LATE", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/09-Non-Compliance-and-LATE.html" + } + }, + { + "id": "tutoring", + "text": "학생 1,000명 중 추첨으로 절반에게만 무료 과외를 제공했다. 과외를 받은 쪽의 시험 점수가 평균 6점 높았고, 통계적으로 유의했다.", + "claim": "무료 과외가 점수를 올렸다", + "answer": "cause", + "why": "추첨으로 나눴기 때문에 두 집단은 과외 말고는 평균적으로 같습니다. 남은 차이는 과외의 효과로 볼 수 있습니다.", + "chapter": { + "title": "2장 무작위 실험", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/02-Randomised-Experiments.html" + } + }, + { + "id": "gym", + "text": "헬스장 회원권을 산 사람은 사지 않은 사람보다 1년 동안 체중이 덜 늘었다.", + "claim": "회원권을 사면 체중 증가가 줄어든다", + "answer": "corr", + "why": "원래 건강에 관심이 많은 사람이 회원권을 삽니다. 누가 샀는지를 스스로 정했기 때문에 선택 편향이 섞여 있습니다.", + "chapter": { + "title": "1장 인과추론 소개", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/01-Introduction-To-Causality.html" + } + }, + { + "id": "coffee", + "text": "커피를 많이 마시는 사람일수록 폐암 발병률이 높았다. 흡연자는 커피도 많이 마시는 경향이 있었다.", + "claim": "커피가 폐암 위험을 높인다", + "answer": "corr", + "why": "흡연이 커피 소비와 폐암을 함께 늘립니다. 흡연이라는 교란 요인을 빼고 보면 커피의 효과는 사라질 수 있습니다.", + "chapter": { + "title": "4장 그래프 인과 모델", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/04-Graphical-Causal-Models.html" + } + }, + { + "id": "loan_rdd", + "text": "대출 심사 점수 600점을 기준으로 금리가 갈렸다. 600점에서 달라지는 것은 금리뿐이고, 양쪽 모두 대출이 승인됐다. 600점 바로 위와 바로 아래 고객을 비교했더니 낮은 금리를 받은 쪽의 연체율이 낮았다. 고객이 점수를 조정할 방법은 없었다.", + "claim": "기준선 근처 고객에게 낮은 금리가 연체율을 낮췄다", + "answer": "cause", + "why": "기준선 바로 근처 고객은 신용이 거의 같고 금리만 갈립니다. 그래서 그 근처의 차이는 금리의 효과로 볼 수 있습니다. 기준선 근처 고객에 대한 효과입니다.", + "chapter": { + "title": "16장 회귀 불연속 설계", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/16-Regression-Discontinuity-Design.html" + } + }, + { + "id": "did_diverge", + "text": "새 요금제를 출시한 국가와 비교 국가의 매출을 이중차분법으로 비교했다. 그런데 두 나라 매출은 출시 1년 전부터 이미 벌어지고 있었다.", + "claim": "새 요금제가 매출 격차를 만들었다", + "answer": "corr", + "why": "이중차분법은 ‘처치가 없었다면 두 집단이 나란히 갔을 것’이라는 가정 위에 서 있습니다. 출시 전부터 벌어지고 있었다면 그 가정이 깨져, 출시 뒤의 격차를 요금제 효과로 읽을 수 없습니다.", + "chapter": { + "title": "13장 이중차분법", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/13-Difference-in-Differences.html" + } + }, + { + "id": "housing_lottery", + "text": "공공임대 입주자를 추첨으로 뽑았다. 신청한 가구 4,000곳을 당첨 여부로 나눠 비교했더니, 당첨 가구 아이들의 결석률이 낮았고 통계적으로 유의했다.", + "claim": "공공임대 당첨이 아이들의 결석률을 낮췄다", + "answer": "cause", + "why": "추첨은 무작위 배정과 같습니다. 당첨 여부로 그대로 비교했으니 차이는 당첨의 효과입니다. 실제로 입주한 가구만 고르면 다시 선택 편향이 생깁니다.", + "chapter": { + "title": "9장 불응과 LATE", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/09-Non-Compliance-and-LATE.html" + } + }, + { + "id": "trial", + "text": "컴퓨터 추첨으로 신약과 위약을 배정하고, 환자도 의사도 무엇을 받았는지 모르게 한 임상시험에서 신약 쪽 회복률이 높았고 통계적으로 유의했다.", + "claim": "신약이 회복률을 높였다", + "answer": "cause", + "why": "무작위 배정이 두 집단을 같게 만들고, 이중맹검이 기대 효과를 막습니다. 남은 차이는 약의 효과로 볼 수 있습니다.", + "chapter": { + "title": "2장 무작위 실험", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/02-Randomised-Experiments.html" + } + }, + { + "id": "premium", + "text": "프리미엄 요금제로 바꾼 사용자는 바꾸기 전보다 사용 시간이 늘었다.", + "claim": "프리미엄 전환이 사용 시간을 늘렸다", + "answer": "corr", + "why": "원래 사용이 늘던 사람이 프리미엄으로 바꾸기 쉽습니다. 전후 비교는 그 추세와 전환 효과를 가를 수 없습니다. 바꾸지 않은 사용자와 비교하더라도 전환 전 추세까지 같아야 하고, 그래도 바꿀 시점을 스스로 골랐다는 문제가 남습니다.", + "chapter": { + "title": "13장 이중차분법", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/13-Difference-in-Differences.html" + } + }, + { + "id": "training", + "text": "사내 교육을 들은 직원의 다음 해 성과 평가가 더 높았다. 교육은 팀장이 추천한 사람만 들었다.", + "claim": "교육이 성과를 올렸다", + "answer": "corr", + "why": "팀장은 성과가 좋을 사람을 추천했을 가능성이 큽니다. 추천 기준이 기록에 남아 있다면 비슷한 사람끼리 짝지어 비교(매칭)할 수 있지만, 팀장의 감처럼 기록되지 않은 기준이 있다면 매칭으로도 가를 수 없습니다.", + "chapter": { + "title": "10장 매칭", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/10-Matching.html" + } + }, + { + "id": "tobacco_sc", + "text": "한 나라가 담배세를 올렸다. 담배세를 올리지 않은 여러 나라를 섞어 그 나라의 과거 흡연율 추세를 거의 똑같이 따라가는 ‘합성 비교군’을 만들었더니, 인상 뒤 그 나라의 흡연율이 합성 비교군보다 크게 떨어졌다. 같은 시기 다른 금연 정책은 없었고, 다른 나라에 같은 방법을 적용했을 때는 이만큼 큰 차이가 나온 적이 없었다.", + "claim": "담배세 인상이 흡연율을 낮췄다", + "answer": "cause", + "why": "과거를 잘 따라가는 합성 비교군은 ‘인상하지 않았다면’의 모습을 대신합니다. 정책이 없던 나라들에 같은 방법을 써 봐도 이만큼 차이가 나지 않았다면 우연으로 보기 어렵습니다. 다른 충격이 없었다는 전제에서 정책 효과로 읽을 수 있습니다. 통제집단합성법입니다.", + "chapter": { + "title": "15장 통제집단합성법", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/15-Synthetic-Control.html" + } + }, + { + "id": "rdd_manip", + "text": "장학금 기준 점수 바로 위와 바로 아래 학생을 비교했더니 장학금을 받은 쪽의 졸업률이 높았다. 다만 기준에 조금 못 미친 학생은 재시험으로 점수를 올릴 수 있었고, 의욕 있는 학생이 많이 응시했다.", + "claim": "기준선 근처 학생에게 장학금이 졸업률을 올렸다", + "answer": "corr", + "why": "재시험으로 기준선을 넘은 학생은 의욕이 높은 쪽으로 골라졌습니다. 기준선 위와 아래가 더는 비슷한 학생이 아니어서, 회귀 불연속 설계의 전제(기준선 근처는 운으로 갈린다)가 깨집니다.", + "chapter": { + "title": "16장 회귀 불연속 설계", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/16-Regression-Discontinuity-Design.html" + } + }, + { + "id": "books", + "text": "책을 많이 읽는 아이일수록 성적이 높았다.", + "claim": "아이에게 책을 더 읽히면 성적이 오른다", + "answer": "corr", + "why": "부모의 관심이나 가정 환경이 독서량과 성적을 함께 올릴 수 있습니다. 이런 교란 요인을 다 재지 않으면 독서만의 효과는 알 수 없습니다.", + "chapter": { + "title": "4장 그래프 인과 모델", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/04-Graphical-Causal-Models.html" + } + } + ] +} diff --git a/data/game.json b/data/game.json new file mode 100644 index 0000000..9c38bbc --- /dev/null +++ b/data/game.json @@ -0,0 +1,115 @@ +{ + "title": "상관일까, 인과일까?", + "questions": [ + { + "id": "icecream", + "text": "아이스크림이 많이 팔린 날일수록 물놀이 사고도 많았다.", + "claim": "아이스크림 판매가 물놀이 사고를 늘린다", + "answer": "corr", + "why": "더운 날씨가 아이스크림 판매와 물놀이를 함께 늘립니다. 둘을 같이 움직이는 교란 요인이 있으면, 관계가 있어 보여도 한쪽이 다른 쪽의 원인이라고 할 수 없습니다.", + "chapter": { + "title": "4장 그래프 인과 모델", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/04-Graphical-Causal-Models.html" + } + }, + { + "id": "random_ui", + "text": "사용자 10만 명을 무작위로 반씩 나눠 한쪽에만 새 추천 화면을 보여 줬다. 두 집단의 크기는 계획대로 나뉘었고, 새 화면 쪽 구매율이 2%p 높았으며 통계적으로 유의했다.", + "claim": "새 추천 화면이 구매율을 올렸다", + "answer": "cause", + "why": "무작위 배정은 두 집단을 평균적으로 같게 만듭니다. 배정이 제대로 됐고 차이가 우연으로 보기 어렵다면, 남은 차이는 새 화면 때문이라고 볼 수 있습니다.", + "chapter": { + "title": "2장 무작위 실험", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/02-Randomised-Experiments.html" + } + }, + { + "id": "push", + "text": "앱 알림을 켜 둔 사용자는 끈 사용자보다 30일 뒤 남아 있는 비율이 두 배 높았다.", + "claim": "알림을 켜게 하면 사용자가 더 남는다", + "answer": "corr", + "why": "원래 앱을 좋아하는 사람이 알림을 켜기 쉽습니다. 누가 알림을 켰는지를 사용자가 스스로 정했기 때문에 선택 편향이 섞여 있습니다.", + "chapter": { + "title": "1장 인과추론 소개", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/01-Introduction-To-Causality.html" + } + }, + { + "id": "ai_launch", + "text": "AI 요약 기능을 출시한 달에 매출이 10% 늘었다. 마침 연말 성수기였다.", + "claim": "AI 요약 기능이 매출을 10% 올렸다", + "answer": "corr", + "why": "출시 전후만 비교하면 같은 시기의 다른 변화(계절, 프로모션)와 효과가 섞입니다. 기능을 받지 않은 비교 대상을 함께 놓아야 효과를 떼어 낼 수 있습니다.", + "chapter": { + "title": "13장 이중차분법", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/13-Difference-in-Differences.html" + } + }, + { + "id": "geo_holdout", + "text": "전국 200개 지역 중 무작위로 고른 100곳에서만 한 달 동안 TV 광고를 껐다. 광고를 끈 지역의 매출이 다른 지역보다 4% 낮았다.", + "claim": "TV 광고가 매출을 올린다", + "answer": "cause", + "why": "광고를 끌 지역을 무작위로 골랐기 때문에, 지역 간 다른 차이는 평균적으로 비슷하게 나뉩니다. 사람 대신 지역을 무작위로 나누는 지역 단위 실험입니다.", + "chapter": { + "title": "2장 무작위 실험", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/02-Randomised-Experiments.html" + } + }, + { + "id": "did", + "text": "시범 지역과 비교 지역의 매출은 정책 전 3년 동안 나란히 움직였다. 정책 뒤에는 시범 지역만 올랐고, 같은 시기 시범 지역에만 생긴 다른 변화는 없었다.", + "claim": "정책이 시범 지역의 매출을 올렸다", + "answer": "cause", + "why": "정책이 없었다면 두 지역이 계속 나란히 움직였을 것이라는 평행 추세 가정이 받아들일 만하면, 벌어진 차이를 정책 효과로 읽을 수 있습니다. 과거에 나란했다는 것은 이 가정의 근거일 뿐 증명은 아닙니다.", + "chapter": { + "title": "13장 이중차분법", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/13-Difference-in-Differences.html" + } + }, + { + "id": "ads", + "text": "광고를 본 사람의 구매율이 안 본 사람보다 높았다. 광고는 구매 가능성이 높은 사람에게 보여 주도록 설정되어 있었다.", + "claim": "광고가 구매율을 올렸다", + "answer": "corr", + "why": "타기팅 자체가 구매할 사람을 골라냈습니다. 타기팅에 쓴 정보를 모두 알고 있다면 성향점수로 비슷한 사람끼리 비교할 수 있고, 가장 깔끔한 방법은 일부에게 광고를 무작위로 보여 주지 않는 실험입니다.", + "chapter": { + "title": "11장 성향점수", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/11-Propensity-Score.html" + } + }, + { + "id": "rdd", + "text": "장학금 기준 점수 바로 위와 바로 아래 학생을 비교했더니, 장학금을 받은 쪽의 졸업률이 높았다. 학생이 점수를 기준선에 맞춰 조정할 방법은 없었다.", + "claim": "기준선 근처 학생에게 장학금은 졸업률을 올렸다", + "answer": "cause", + "why": "기준선 바로 근처의 학생들은 실력이 거의 같고, 받았는지 여부만 운처럼 갈립니다. 그래서 그 근처의 차이는 장학금 효과로 볼 수 있습니다. 다만 기준선 근처 학생에 대한 효과입니다.", + "chapter": { + "title": "16장 회귀 불연속 설계", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/16-Regression-Discontinuity-Design.html" + } + }, + { + "id": "coupon_itt", + "text": "쿠폰을 무작위로 절반에게 보냈다. 쿠폰을 받은 사람 전체(쓰지 않은 사람 포함)의 평균 구매액이 받지 않은 사람보다 컸다.", + "claim": "쿠폰을 보내는 것이 구매액을 늘렸다", + "answer": "cause", + "why": "무작위로 나눈 집단을 그대로 비교했기 때문에, 차이는 쿠폰을 보낸 효과입니다. 쓴 사람만 고르지 않고 배정대로 비교하는 것이 핵심입니다.", + "chapter": { + "title": "9장 불응과 LATE", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/09-Non-Compliance-and-LATE.html" + } + }, + { + "id": "coupon_used", + "text": "같은 실험에서 이번에는 쿠폰을 실제로 쓴 사람만 골라 받지 않은 사람과 비교했더니, 차이가 훨씬 더 컸다.", + "claim": "쿠폰을 쓰면 구매액이 그만큼 늘어난다", + "answer": "corr", + "why": "보낸 것은 무작위지만, 쓴 사람은 스스로 골라졌습니다. 원래 살 사람이 쿠폰을 쓰기 쉽습니다. 배정을 도구변수로 쓰면 ‘받으면 쓰는 사람’에 대한 효과(LATE)를 따로 추정할 수 있습니다.", + "chapter": { + "title": "9장 불응과 LATE", + "url": "https://causalinferencelab.github.io/Causal-Inference-with-Python/09-Non-Compliance-and-LATE.html" + } + } + ] +} diff --git a/game.html b/game.html new file mode 100644 index 0000000..7c87a99 --- /dev/null +++ b/game.html @@ -0,0 +1,49 @@ + + + + + + +상관일까, 인과일까? | Causal Inference Hub + + + + + + + + + + + + + + +
    +
    +
    +

    2분 몸풀기 · 10문항

    +

    상관일까, 인과일까?

    +

    데이터에서 본 관계를 원인이라고 말해도 될까요? 상황 10개마다 주장 하나가 붙어 있습니다. 그렇게 말해도 되는지 골라 보세요. 답마다 이유와 함께 읽을 웹북 장을 알려 드립니다.

    + + +
    +
    +
    + + + + + + diff --git a/index.html b/index.html index 87c031c..e64c1e9 100644 --- a/index.html +++ b/index.html @@ -2,13 +2,7 @@ - - + Causal Inference Hub | 가짜연구소 인과추론팀 @@ -39,6 +33,7 @@ 읽을거리 참여하기 + @@ -81,6 +76,10 @@

    인과추론 커리큘럼

    +
    +

    오늘의 한 문제

    +
    +
    @@ -91,6 +90,11 @@

    커리큘럼과 오픈소스

    무엇을 하고 싶나요?
    +

    @@ -192,12 +196,15 @@

    Builder로 프로젝트 열기

    @@ -207,6 +214,8 @@

    Builder로 프로젝트 열기

    + + diff --git a/lab.html b/lab.html new file mode 100644 index 0000000..81ca980 --- /dev/null +++ b/lab.html @@ -0,0 +1,91 @@ + + + + + + +교란 실험실 | Causal Inference Hub + + + + + + + + + + + + + + +
    +
    +

    교란 실험실 · 2분

    +

    알림을 켜면 사용자가 더 남을까?

    +

    가상 사용자 800명의 데이터입니다. 충성도가 높은 사용자는 알림을 더 잘 켜고, 원래 더 오래 남습니다. 슬라이더를 움직여 ‘알림 켠 사람과 끈 사람의 단순 비교’가 진짜 효과에서 얼마나 멀어지는지 보세요.

    + +
    +
    +

    실험 조건

    + + +
    + + +
    + +
    +
    진짜 효과+3.0
    +
    단순 비교+0.0
    +
    충성도로 보정+0.0
    +

    +

    보정 값도 표본에 따라 ±0.5쯤 흔들립니다. ‘새 사용자 800명 뽑기’로 확인해 보세요.

    +
    + +
    +

    도전

    +

    교란 강도를 5 이상으로 둔 채, 단순 비교와 진짜 효과의 차이를 1 이하로 줄여 보세요.

    +
    +
    + +
    +

    사용자 800명

    +

    가로는 충성도, 세로는 30일 잔존 점수. 점선은 두 집단의 평균(간격이 단순 비교), 실선은 충성도가 같을 때의 두 집단(간격이 보정 값)입니다.

    + 알림을 켠 사용자와 끈 사용자의 충성도와 잔존 점수 산점도 +
    • 알림 켬
    • 알림 끔
    +
    +
    + +
    +

    여기서 본 것

    +
      +
    1. 교란이 단순 비교를 부풀립니다. 충성도가 알림과 잔존을 함께 움직이면, 알림 효과가 0이어도 단순 비교는 양수로 나옵니다. 4장 그래프 인과 모델
    2. +
    3. 무작위로 켜면 편향이 사라집니다. 충성도와 알림이 따로 움직이니 두 집단이 평균적으로 같아집니다. 2장 무작위 실험
    4. +
    5. 교란 요인을 안다면 보정할 수 있습니다. 여기서는 충성도를 알기에 회귀로 보정한 값이 정답에 가깝습니다. 현실에서는 충성도처럼 다 재기 어려운 요인이 남는다는 것이 문제입니다. 5장 선형회귀
    6. +
    + +
    +
    +
    + + + + + + diff --git a/scripts/build_builders.py b/scripts/build_builders.py new file mode 100644 index 0000000..df4439f --- /dev/null +++ b/scripts/build_builders.py @@ -0,0 +1,295 @@ +"""Build one page per Builder, an index page, and a badge for each Builder. + +Reads data/builders.json, data/projects.json and data/repos.json and writes +builders/.html, builders/index.html and badges/.svg. The output is +generated, so it is not committed; the deploy workflow runs this script before +publishing. Run it locally to preview: + + python3 scripts/build_builders.py && python3 -m http.server 8000 + +Share images live in assets/og/builders/.jpg and are made separately by +scripts/builder_cards.py. A page whose image is missing falls back to the hub's. +""" +import html +import json +import os +from collections import OrderedDict + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SITE = "https://causalinferencelab.github.io" +ORG = "CausalInferenceLab" + + +def load(name): + with open(os.path.join(ROOT, "data", name), encoding="utf-8") as f: + return json.load(f) + + +def esc(s): + return html.escape(str(s if s is not None else ""), quote=True) + + +def builder_stats(): + """Mirror builderStats() in assets/js/hub.js so the pages and the hub agree.""" + builders = load("builders.json") + projects = load("projects.json") + repos = {r["repo"]: r for r in load("repos.json")} + current = max(p["cohort"] for p in projects) + people = OrderedDict() + + def get(name): + if name not in people: + people[name] = {"name": name, "items": [], "repos": [], "runnerOf": []} + return people[name] + + for p in projects: + if not p.get("builder"): + continue + r = repos.get(p.get("repo") or "") + get(p["builder"])["items"].append({ + "cohort": p["cohort"], "term": p.get("term", ""), "name": p["name"], "runners": p.get("runners"), + "repo": r["repo"] if r else None, "title": r["title"] if r else None, "link": p.get("link"), + "line": (r.get("lines") or ["none"])[0] if r else "none", "retro": p.get("retro"), "live": False, + }) + for r in repos.values(): + for b in r.get("builders") or []: + if r["repo"] not in get(b)["repos"]: + get(b)["repos"].append(r["repo"]) + if r.get("status") != "running" or any(p.get("repo") == r["repo"] for p in projects): + continue + for b in r.get("builders") or []: + get(b)["items"].append({ + "cohort": current, "term": "", "name": r["title"], "runners": None, "repo": r["repo"], "title": r["title"], + "link": None, "line": (r.get("lines") or ["none"])[0], "retro": None, "live": True, + }) + info = {b["name"]: b for b in builders} + for name, p in people.items(): + p["runnerOf"] = info.get(name, {}).get("runnerOf", []) + p["info"] = info.get(name, {"name": name}) + p["items"].sort(key=lambda i: i["cohort"]) + p["cohorts"] = sorted({i["cohort"] for i in p["items"]}) + p["projects"] = len(p["items"]) + p["runners"] = sum(i["runners"] or 0 for i in p["items"]) + p["first"] = p["cohorts"][0] + p["line"] = next((i["line"] for i in p["items"] if i["line"] != "none"), "main") + ranked = sorted(people.values(), key=lambda p: (-p["projects"], -p["runners"], p["first"], p["name"])) + for i, p in enumerate(ranked): + p["rank"] = i + 1 + terms = {p["cohort"]: p.get("term", "") for p in projects} + return ranked, repos, projects, terms + + +def span(cohorts): + if not cohorts: + return "" + return f"{cohorts[0]}기" if cohorts[0] == cohorts[-1] else f"{cohorts[0]}–{cohorts[-1]}기" + + +def head(title, desc, url, image): + return f""" + + + + + +{esc(title)} + + + + + + + + + + + + + + + +""" + + +FOOT = """ + + + + +""" + + +def avatar(p, size="lg"): + gh = p["info"].get("github") + img = f'' if gh else "" + return f'' + + +def retro_html(r): + if not r or not any(r.get(k) for k in ("why", "learned", "next")): + return "" + rows = "".join(f"
    {k}
    {esc(r[f])}
    " for k, f in (("왜 만들었나", "why"), ("무엇을 배웠나", "learned"), ("다음 사람에게", "next")) if r.get(f)) + return f'
    회고
    {rows}
    ' + + +def note_html(note): + return f'{note}' if note else "" + + +def timeline(p, repos, projects, terms): + events = [dict(i, role="Builder") for i in p["items"]] + for x in p["runnerOf"]: + proj = next((q for q in projects if q["cohort"] == x["cohort"] and q["name"] == x["project"]), None) + r = repos.get((proj or {}).get("repo") or "") + events.append({"cohort": x["cohort"], "role": "러너", "name": x["project"], "repo": r["repo"] if r else None, + "title": r["title"] if r else None, "link": (proj or {}).get("link"), "by": (proj or {}).get("builder"), + "line": (r.get("lines") or ["none"])[0] if r else "none", "runners": None, "live": False, "retro": None}) + events.sort(key=lambda e: (-e["cohort"], 0 if e["role"] == "Builder" else 1)) + out = [] + for e in events: + if e["role"] == "Builder": + facts = [f"러너 {e['runners']}명" if e.get("runners") else "", "진행 중" if e.get("live") else ""] + else: + facts = [f"Builder {esc(e['by'])}" if e.get("by") else ""] + link = (f'레포 보기' if e.get("repo") + else f'기록 보기' if e.get("link") else "") + note = ''.join(x for x in facts + [link] if x) + cls = "b" if e["role"] == "Builder" else "r" + out.append(f'
  • {e["cohort"]}기{esc(terms.get(e["cohort"], ""))}' + f'{e["role"]}{esc(e["name"])}' + f'{note_html(note)}{retro_html(e.get("retro"))}
  • ') + return "".join(out) + + +def badge_svg(label, message): + def width(text): + return sum(11 if "가" <= ch <= "힣" else 6.6 for ch in text) + lw, mw = round(width(label) + 14), round(width(message) + 14) + w = lw + mw + return f""" +{esc(label)}: {esc(message)} + + + + +{esc(label)} +{esc(message)} + + +""" + + +def badge_message(p): + n = len(p["cohorts"]) + return f"Builder · {p['first']}기" if n == 1 else f"Builder · {p['first']}기부터 · {n}개 기수" + + +def page(p, repos, projects, terms): + slug = p["info"]["slug"] + url = f"{SITE}/builders/{slug}.html" + s = span(p["cohorts"]) + desc = f"{s} Builder · 프로젝트 {p['projects']}개 · 함께한 러너 {p['runners']}명. 가짜연구소 인과추론팀" + card = os.path.join(ROOT, "assets", "og", "builders", f"{slug}.jpg") + image = f"{SITE}/assets/og/builders/{slug}.jpg" if os.path.exists(card) else f"{SITE}/assets/img/og-hub.png" + linkedin = p["info"].get("linkedin") + intro = p["info"].get("intro") + own = [repos[r] for r in p["repos"] if r in repos] + badge_url = f"{SITE}/badges/{slug}.svg" + badge_md = f"[![CausalInferenceLab Builder]({badge_url})]({SITE}/builders/{slug}.html?utm_source=share&utm_medium=badge)" + share = f"{url}?utm_source=share&utm_medium=builder_page" + repo_cards = "".join( + f'
  • ' + f'{esc(r["title"])}{esc(r.get("summary", ""))}
  • ' + for r in own) + top = rank_tag(p) + body = f"""
    +
    + 허브의 Builder 전체 보기 +
    + {avatar(p)} +
    +

    {top}Causal Inference Hub Builder

    +

    {esc(p['name'])}

    +

    {p['first']}기부터 Builder · {s} 중 {len(p['cohorts'])}개 기수

    + {f'

    {esc(intro)}

    ' if intro else ''} + + {'' if (intro and p["info"].get("github")) else f'

    본인이신가요? 소개와 사진 추가하기

    '} +
    +
    +
    • {p['projects']}프로젝트
    • {len(p['cohorts'])}기수
    • {p['runners']}함께한 러너
    • {len(own)}레포
    +
    +

    이력

      {timeline(p, repos, projects, terms)}
    + +
    +
    +

    다음 기수에 프로젝트를 열어 보세요

    러너로 시작한 Builder도 많습니다. 템플릿으로 레포를 만들고, 기수 모집 때 프로젝트를 등록하면 됩니다.

    + +
    +
    +
    +""" + return head(f"{p['name']} | Causal Inference Hub Builder", desc, url, image) + body + FOOT + + +def rank_tag(p): + return f'Top {p["rank"]}' if p["rank"] <= 3 else "" + + +def index_page(ranked): + cards = "".join( + f'
  • {avatar(p)}' + f'{esc(p["name"])}{span(p["cohorts"])} · 프로젝트 {p["projects"]}개 · 러너 {p["runners"]}명' + f'최근 · {esc(p["items"][-1]["name"])}{rank_tag(p)}
  • ' + for p in ranked) + total_projects = len(load("projects.json")) + body = f"""
    +
    + 허브로 돌아가기 +

    Builder

    +

    인과추론팀에서 프로젝트를 연 사람들입니다. {len(ranked)}명이 프로젝트 {total_projects}개를 열었습니다. 이름을 누르면 이력과 배지가 있습니다.

    +
      {cards}
    +
    +
    +""" + return head("Builder | Causal Inference Hub", f"가짜연구소 인과추론팀에서 프로젝트를 연 Builder {len(ranked)}명", f"{SITE}/builders/", f"{SITE}/assets/img/og-hub.png") + body + FOOT + + +def main(): + ranked, repos, projects, terms = builder_stats() + os.makedirs(os.path.join(ROOT, "builders"), exist_ok=True) + os.makedirs(os.path.join(ROOT, "badges"), exist_ok=True) + for p in ranked: + slug = p["info"].get("slug") + if not slug: + raise SystemExit(f"builders.json: {p['name']} has no slug") + with open(os.path.join(ROOT, "builders", f"{slug}.html"), "w", encoding="utf-8") as f: + f.write(page(p, repos, projects, terms)) + with open(os.path.join(ROOT, "badges", f"{slug}.svg"), "w", encoding="utf-8") as f: + f.write(badge_svg("CausalInferenceLab", badge_message(p))) + with open(os.path.join(ROOT, "builders", "index.html"), "w", encoding="utf-8") as f: + f.write(index_page(ranked)) + print(f"wrote {len(ranked)} Builder pages and badges") + + +if __name__ == "__main__": + main() diff --git a/scripts/builder_cards.py b/scripts/builder_cards.py new file mode 100644 index 0000000..6a05234 --- /dev/null +++ b/scripts/builder_cards.py @@ -0,0 +1,77 @@ +"""Make the 1200x630 share image for each Builder page. + +Writes assets/og/builders/.jpg. Needs Playwright with Chromium: + + pip install playwright && python -m playwright install chromium + python3 scripts/builder_cards.py + +Run it again when a Builder is added or their numbers change, and commit the +images. Pages without an image fall back to the hub's share image. +""" +import asyncio +import html +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from build_builders import ROOT, builder_stats, span # noqa: E402 + +TEMPLATE = """ + +
    +

    Causal Inference Hub Builder · 가짜연구소 인과추론팀

    +

    {name}{top}

    +

    {since}

    +
    {projects}프로젝트
    {runners}함께한 러너
    {cohorts}기수
    +

    causalinferencelab.github.io/builders/{slug}

    +
    """ + + +def short(text, limit=30): + return text if len(text) <= limit else text[: limit - 1].rstrip(" :·-") + "…" + + +async def main(): + from playwright.async_api import async_playwright + ranked = builder_stats()[0] + out = os.path.join(ROOT, "assets", "og", "builders") + os.makedirs(out, exist_ok=True) + mark = os.path.join(ROOT, "assets", "img", "team-mark.png") + async with async_playwright() as p: + browser = await p.chromium.launch() + page = await browser.new_page(viewport={"width": 1200, "height": 630}) + for b in ranked: + slug = b["info"]["slug"] + doc = TEMPLATE.format( + mark=mark, name=html.escape(b["name"]), slug=slug, + top=f'Top {b["rank"]}' if b["rank"] <= 3 else "", + since=f'{span(b["cohorts"])} Builder · 최근 {html.escape(short(b["items"][-1]["name"]))}', + projects=b["projects"], runners=b["runners"], cohorts=len(b["cohorts"])) + path = os.path.join(out, f"{slug}.html") + with open(path, "w", encoding="utf-8") as f: + f.write(doc) + await page.goto("file://" + path) + await page.wait_for_timeout(600) + await page.screenshot(path=os.path.join(out, f"{slug}.jpg"), type="jpeg", quality=88) + os.remove(path) + await browser.close() + print(f"wrote {len(ranked)} share images to {out}") + + +if __name__ == "__main__": + asyncio.run(main())