Skip to content

Commit 37d8dc7

Browse files
committed
Lead with the track map, add a weekly pick and a sliding Builder rail
- Goal picker moves above the tabs and drives both the map and the list; tabs now run track map, curriculum, contribution - Curriculum tab opens with a pick that rotates every Monday, the four most recently updated repos, and a continue row for repos opened before - Repo cards show how long ago they were updated - Contribution tab leads with Builder cards that slide sideways and pause on hover or focus; the cohort table follows - Widen the one-line intro beyond causal inference
1 parent db4bf10 commit 37d8dc7

6 files changed

Lines changed: 163 additions & 30 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
가짜연구소 인과추론팀(CausalInferenceLab)의 허브입니다. 2022년부터 기수마다 Builder와 러너가 함께 만든 인과추론 오픈소스를 L0부터 L4까지 하나의 커리큘럼으로 엮고, [Brave & True 인과추론 번역서](https://causalinferencelab.github.io/Causal-Inference-with-Python/), [Causal Studio](https://causalinferencelab.github.io/causal-studio/), [ccwr](https://github.com/CausalInferenceLab/ccwr) 같은 팀의 사이트로 이어 줍니다.
44

5+
다루는 범위는 인과추론에서 시작해 실험, 데이터 에이전트까지, AI 시대에 데이터로 판단하는 힘 전반입니다.
6+
57
**사이트: https://causalinferencelab.github.io**
68

79
![Causal Inference Hub](assets/img/hub-preview.png)
@@ -13,9 +15,10 @@
1315
| 위치 | 내용 |
1416
| --- | --- |
1517
| 첫 화면 | 위쪽 배너에서 대표 사이트(웹북, 영상, R 패키지)로 바로 갑니다. 아래 왼쪽은 함께한 러너(중복 제외), 프로젝트, GitHub 스타, 행사 참가자의 추이, 오른쪽은 L0–L4 커리큘럼 한눈에 보기입니다. |
16-
| 커리큘럼 탭 | 팀 레포 전체를 L0부터 L4까지 챕터로 나눈 가로 목록입니다. 목표를 고르면 그 경로의 레포만 순서대로 보여 줍니다. |
17-
| 기여 탭 | Builder × 기수 표입니다. 칸 하나가 프로젝트 하나이고, 색은 트랙, 숫자와 진하기는 러너 수입니다. |
18-
| 트랙 지도 탭 | 같은 레포를 여섯 개 트랙의 지도로 그린 보기입니다. |
18+
| 목표 고르기 | "처음 시작해요", "A/B 테스트를 해요" 같은 목표를 고르면 트랙 지도와 커리큘럼에 볼 순서가 표시됩니다. |
19+
| 트랙 지도 탭 | 팀 레포를 여섯 개 트랙과 L0–L4 단계 위에 그린 전체 그림입니다. |
20+
| 커리큘럼 탭 | 매주 월요일 바뀌는 '이번 주의 레포', 최근 업데이트, 최근에 본 레포(이어서 보기)와 L0–L4 챕터별 가로 목록입니다. |
21+
| 기여 탭 | 옆으로 흐르는 Builder 카드와, 칸 하나가 프로젝트 하나인 Builder × 기수 표입니다. |
1922
| 행사 | Data Forum 회차별 참가자 수와 다음 행사입니다. |
2023

2124
러너는 이름 없이 숫자로만 보여 줍니다. 이름은 Builder만 표시합니다.

‎assets/css/hub.css‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,63 @@ body::before { content: ""; position: fixed; inset: 0 0 auto; height: env(safe-a
284284
.goal-row .route-chips .chip { flex-shrink: 0; }
285285
}
286286

287+
/* This week (curriculum tab): spotlight, recent updates, continue */
288+
.week { display: grid; gap: 12px; margin: 4px 0 20px; }
289+
.continue { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 14px; border: 1px solid var(--rule); border-radius: 12px; background: var(--surface); }
290+
.cont-label { font-weight: 700; font-size: 14px; margin-right: 4px; }
291+
.cont-chip { font: inherit; font-size: 13.5px; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 999px; border: 1.5px solid var(--rule); background: var(--surface); color: var(--ink); cursor: pointer; max-width: 260px; }
292+
.cont-chip:hover { border-color: var(--ink); }
293+
.cont-chip i { width: 8px; height: 8px; border-radius: 50%; background: var(--c); flex-shrink: 0; }
294+
.cont-progress { margin-left: auto; display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); }
295+
.cont-progress b { color: var(--ink); }
296+
.cont-progress .bar { width: 96px; height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; display: inline-block; }
297+
.cont-progress .bar i { display: block; height: 100%; background: var(--live); }
298+
.week-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 12px; }
299+
@media (max-width: 760px) { .week-grid { grid-template-columns: minmax(0, 1fr); } .cont-progress { margin-left: 0; } }
300+
.spot { position: relative; overflow: hidden; border-radius: 16px; padding: 20px 22px 20px 26px; background: color-mix(in srgb, var(--c) 10%, var(--surface)); border: 1px solid color-mix(in srgb, var(--c) 28%, var(--rule)); }
301+
.spot::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--c); }
302+
.spot-label { font-size: 12.5px; font-weight: 700; color: var(--c); }
303+
.spot-label span { color: var(--muted); font-weight: 500; margin-left: 8px; }
304+
.spot h3 { font-size: 22px; letter-spacing: -.02em; margin-top: 6px; }
305+
.spot-meta { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--muted); margin-top: 4px; }
306+
.spot-sum { font-size: 14.5px; margin-top: 10px; max-width: 46em; }
307+
.spot-cta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
308+
.spot-cta .btn { margin-top: 0; }
309+
.fresh { border: 1px solid var(--rule); border-radius: 16px; padding: 16px 18px; background: var(--surface); }
310+
.fresh .spot-label { color: var(--ink); }
311+
.fresh ol { list-style: none; margin: 6px 0 0; padding: 0; }
312+
.fresh li button { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 0; border-bottom: 1px solid var(--rule); font-size: 14px; }
313+
.fresh li:last-child button { border-bottom: 0; }
314+
.fresh li button:hover .t { text-decoration: underline; }
315+
.fresh li button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; border-radius: 4px; }
316+
.fresh li i { width: 8px; height: 8px; border-radius: 50%; background: var(--c); flex-shrink: 0; }
317+
.fresh .t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
318+
.fresh .d { color: var(--muted); font-size: 12.5px; white-space: nowrap; }
319+
.ep-badges .b.new { color: var(--accent); }
320+
.ep-track .age { color: var(--muted); }
321+
322+
/* Builder rail (contribution tab): slides sideways, pauses on hover or focus */
323+
.people-rail { overflow: hidden; margin: 0 0 28px; -webkit-mask-image: linear-gradient(90deg, transparent, #000 40px, #000 calc(100% - 40px), transparent); mask-image: linear-gradient(90deg, transparent, #000 40px, #000 calc(100% - 40px), transparent); }
324+
.people-move { display: flex; gap: 12px; width: max-content; animation: people-slide var(--dur, 60s) linear infinite; }
325+
.people-rail:hover .people-move, .people-rail:focus-within .people-move { animation-play-state: paused; }
326+
@keyframes people-slide { from { transform: translateX(0); } to { transform: translateX(calc(-50% - 6px)); } }
327+
.people-track { list-style: none; margin: 0; padding: 4px 0; display: flex; gap: 12px; }
328+
.pcard { flex: 0 0 auto; width: 272px; display: flex; gap: 12px; align-items: flex-start; background: var(--surface); border: 1px solid var(--rule); border-radius: 16px; padding: 14px; }
329+
.pc-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
330+
.pc-name { font-size: 16px; }
331+
.pc-name a { color: var(--ink); text-decoration: none; }
332+
.pc-name a:hover { text-decoration: underline; }
333+
.pc-since { font-size: 12.5px; color: var(--muted); }
334+
.pc-intro { font-size: 13px; margin-top: 4px; }
335+
.pc-stats { font-size: 13px; color: var(--muted); margin-top: 6px; }
336+
.pc-stats b { color: var(--ink); }
337+
.pc-latest { font-size: 12.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; }
338+
.contrib-head { font-size: 16px; font-weight: 700; margin: 0 0 4px; }
339+
@media (prefers-reduced-motion: reduce) {
340+
.people-rail { overflow-x: auto; -webkit-mask-image: none; mask-image: none; }
341+
.people-track.dup { display: none; }
342+
}
343+
287344
/* Contribution table */
288345
.contrib-sub { color: var(--muted); font-size: 15px; margin-bottom: 14px; }
289346
.matrix-scroll { overflow-x: auto; background: var(--surface); border: 1px solid var(--rule); border-radius: 16px; }

‎assets/img/hub-preview.png‎

2.65 KB
Loading

‎assets/img/og-hub.png‎

1.16 KB
Loading

‎assets/js/hub.js‎

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
const ORG = "CausalInferenceLab";
55
const RIDE_KEY = "cil-hub-ridden";
6+
const RECENT_KEY = "cil-hub-recent";
67
const SVG_NS = "http://www.w3.org/2000/svg";
78
const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
89

@@ -26,10 +27,12 @@
2627
const state = {
2728
repos: new Map(), lines: new Map(), lineList: [], levels: [], map: null, routes: [],
2829
projects: [], events: [], articles: [], builders: [], growth: null, stats: null,
29-
ridden: new Set(), order: new Map(), route: "all"
30+
ridden: new Set(), recent: [], order: new Map(), route: "all"
3031
};
3132

3233
function loadRidden() { try { state.ridden = new Set(JSON.parse(localStorage.getItem(RIDE_KEY) || "[]")); } catch (e) { state.ridden = new Set(); } }
34+
function loadRecent() { try { state.recent = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]").filter((x) => x && x.id); } catch (e) { state.recent = []; } }
35+
function saveRecent() { try { localStorage.setItem(RECENT_KEY, JSON.stringify(state.recent.slice(0, 6))); } catch (e) { /* storage unavailable */ } }
3336
function saveRidden() { try { localStorage.setItem(RIDE_KEY, JSON.stringify([...state.ridden])); } catch (e) { /* storage unavailable */ } }
3437

3538
async function getJSON(path) {
@@ -73,7 +76,7 @@
7376
if (typeof g.stargazers_count === "number") r.stars = g.stargazers_count;
7477
if (g.pushed_at) r.pushed = g.pushed_at;
7578
});
76-
renderGrowth(false); renderLadder(); renderShelf(true);
79+
renderGrowth(false); renderLadder(); renderShelf(true); renderWeek();
7780
} catch (e) { /* offline or rate limited: keep the snapshot */ }
7881
}
7982

@@ -251,6 +254,18 @@
251254
}
252255

253256
const pad2 = (n) => String(n).padStart(2, "0");
257+
const DAY = 864e5;
258+
const ageDays = (iso) => (iso ? Math.max(0, Math.floor((Date.now() - Date.parse(iso)) / DAY)) : null);
259+
function ago(iso) {
260+
const d = ageDays(iso);
261+
if (d == null) return "";
262+
if (d < 1) return "오늘";
263+
if (d < 7) return `${d}일 전`;
264+
if (d < 31) return `${Math.floor(d / 7)}주 전`;
265+
if (d < 365) return `${Math.floor(d / 30)}개월 전`;
266+
return `${Math.floor(d / 365)}년 전`;
267+
}
268+
const learnables = () => [...state.repos.values()].filter((r) => !r.kind && (r.lines || []).length && r.status !== "depot");
254269
const currentRoute = () => state.routes.find((r) => r.id === state.route);
255270

256271
function episodeHTML(r, level, k, top, step) {
@@ -260,6 +275,7 @@
260275
const badges = [
261276
rank >= 0 ? `<span class="b top">TOP ${rank + 1}</span>` : "",
262277
r.status === "running" ? `<span class="b live">진행 중</span>` : "",
278+
r.status !== "running" && ageDays(r.pushed) != null && ageDays(r.pushed) <= 14 ? `<span class="b new">새 업데이트</span>` : "",
263279
r.kind === "paid" ? `<span class="b paid">유료</span>` : "",
264280
r.kind === "external" ? `<span class="b ext">팀 밖 오픈소스</span>` : ""
265281
].join("");
@@ -277,7 +293,7 @@
277293
${state.ridden.has(id) ? `<span class="ep-done" aria-label="학습 완료">✓</span>` : ""}
278294
</span>
279295
<span class="ep-body">
280-
<span class="ep-track">${tracks}</span>
296+
<span class="ep-track">${tracks}${r.pushed && !r.kind ? `<span class="age"> · ${ago(r.pushed)} 업데이트</span>` : ""}</span>
281297
<h3>${esc(r.title)}</h3>
282298
<span class="sum">${esc(r.summary)}</span>
283299
<span class="ep-foot">${people}${r.kind ? "" : `<span class="star">${icons.star}${fmt(r.stars || 0)}</span>`}</span>
@@ -307,15 +323,15 @@
307323
return chap + items.map((r, k) => episodeHTML(r, lv.id, k, top)).join("");
308324
}).join("");
309325
$("#chapters").innerHTML = state.levels.map((lv) => `<button type="button" data-chapter="${lv.id}" style="flex-grow:${byLevel.get(lv.id).length + 1}" aria-current="false"><span class="bar" aria-hidden="true"></span><b>${lv.id}</b> <span class="nm">${esc(lv.name)}</span></button>`).join("");
310-
$("#route-note").textContent = "L0부터 L4까지 모든 레포입니다. 목표를 고르면 볼 순서대로 추려 드립니다.";
326+
$("#route-note").textContent = "목표를 고르면 트랙 지도와 커리큘럼에 볼 순서가 표시됩니다.";
311327
}
312328
wireAvatars(shelf);
313329

314330
const repos = [...state.repos.values()].filter((r) => !r.kind && r.status !== "depot");
315331
const depot = [...state.repos.values()].filter((r) => r.status === "depot");
316332
const learnable = repos.filter((r) => (r.lines || []).length);
317333
const done = learnable.filter((r) => state.ridden.has(r.repo)).length;
318-
$("#curriculum-sub").textContent = `팀 레포 ${repos.length}개를 단계 순서로 놓았습니다.`;
334+
$("#curriculum-sub").textContent = `팀 레포 ${repos.length}개. 트랙 지도로 전체를 보고, 커리큘럼에서 하나씩 열어 보세요.`;
319335
$("#n-course").textContent = repos.length;
320336
$("#shelf-foot").innerHTML = `${done ? `<span class="done-n">학습 완료 ${done} / ${learnable.length}</span> ` : ""}카드를 누르면 자세한 정보와 링크가 열립니다. 운영·보관 레포: ${depot.map((r) => `<a href="${esc(repoUrl(r))}" target="_blank" rel="noopener">${esc(r.title)}</a>`).join(", ")}`;
321337
shelf.scrollLeft = keepScroll ? prevLeft : 0;
@@ -404,10 +420,11 @@
404420
const people = new Map();
405421
const put = (name, cohort, item) => {
406422
if (!name) return;
407-
if (!people.has(name)) people.set(name, { name, projects: 0, runners: 0, first: 99, line: null });
423+
if (!people.has(name)) people.set(name, { name, projects: 0, runners: 0, first: 99, line: null, latest: null, latestCohort: -1 });
408424
const p = people.get(name);
409425
p.projects += 1; p.runners += item.runners || 0; p.first = Math.min(p.first, cohort);
410426
if (!p.line && item.line !== "none") p.line = item.line;
427+
if (cohort >= p.latestCohort) { p.latestCohort = cohort; p.latest = item; }
411428
const key = name + "|" + cohort;
412429
if (!cells.has(key)) cells.set(key, []);
413430
cells.get(key).push(item);
@@ -424,6 +441,7 @@
424441
const totals = new Map(cohorts.map((c) => [c, state.projects.filter((p) => p.cohort === c).reduce((s, p) => s + (p.runners || 0), 0)]));
425442
const maxT = Math.max(...totals.values()) || 1;
426443
const rows = [...people.values()].sort((a, b) => a.first - b.first || b.projects - a.projects || a.name.localeCompare(b.name, "ko"));
444+
renderPeople([...rows].sort((a, b) => b.latestCohort - a.latestCohort || b.projects - a.projects));
427445

428446
const head = `<thead><tr><th scope="col" class="corner">Builder</th>${cohorts.map((c) => `<th scope="col" class="${c === cur ? "cur" : ""}"><b>${c}기</b><span>${esc(shortTerm(terms.get(c)))}</span></th>`).join("")}</tr>
429447
<tr class="totals"><th scope="row">기수별 러너</th>${cohorts.map((c) => { const t = totals.get(c); return `<td class="${c === cur ? "cur" : ""}"><span class="tb" aria-hidden="true"><i style="height:${Math.max(6, Math.round((t / maxT) * 100))}%"></i></span><span class="tn">${t ? t : "집계 중"}</span></td>`; }).join("")}</tr></thead>`;
@@ -450,6 +468,53 @@
450468
}
451469
function matrixToEnd() { requestAnimationFrame(() => { const sc = $(".matrix-scroll"); sc.scrollLeft = sc.scrollWidth; }); }
452470

471+
472+
/* ---------- Builder rail (contribution tab) ---------- */
473+
function renderPeople(list) {
474+
const card = (p, copy) => {
475+
const info = builderInfo(p.name);
476+
const name = info.linkedin ? `<a href="${esc(info.linkedin)}" target="_blank" rel="noopener"${copy ? ' tabindex="-1"' : ""}>${esc(p.name)}</a>` : esc(p.name);
477+
const latest = p.latest ? (p.latest.repo && state.repos.get(p.latest.repo) ? state.repos.get(p.latest.repo).title : p.latest.name) : "";
478+
return `<li class="pcard" data-line="${p.line || "main"}">${avatarHTML(p.name, "lg")}
479+
<div class="pc-body"><b class="pc-name">${name}</b><span class="pc-since">${p.first}기부터 Builder</span>
480+
${info.intro ? `<p class="pc-intro">${esc(info.intro)}</p>` : ""}
481+
<span class="pc-stats">프로젝트 <b>${p.projects}</b> · 러너 <b>${fmt(p.runners)}</b></span>
482+
${latest ? `<span class="pc-latest">최근 · ${esc(latest)}</span>` : ""}</div></li>`;
483+
};
484+
$("#people-rail").innerHTML = `<div class="people-move" style="--dur:${list.length * 6}s"><ul class="people-track">${list.map((p) => card(p, false)).join("")}</ul><ul class="people-track dup" aria-hidden="true">${list.map((p) => card(p, true)).join("")}</ul></div>`;
485+
wireAvatars($("#people-rail"));
486+
}
487+
488+
/* ---------- this week (curriculum tab) ---------- */
489+
const weekIndex = () => Math.floor((Date.now() - Date.UTC(2026, 0, 5)) / (7 * DAY));
490+
function renderWeek() {
491+
const list = learnables();
492+
if (!list.length) return;
493+
const ordered = state.levels.flatMap((lv) => list.filter((r) => r.level === lv.id).sort((a, b) => (b.stars || 0) - (a.stars || 0)));
494+
const n = ordered.length;
495+
const pick = ordered.at(((weekIndex() % n) + n) % n);
496+
const lv = state.levels.find((l) => l.id === pick.level);
497+
const who = (pick.builders || []).join(", ");
498+
const fresh = list.filter((r) => r.pushed).sort((a, b) => Date.parse(b.pushed) - Date.parse(a.pushed)).slice(0, 4);
499+
const recent = state.recent.map((x) => state.repos.get(x.id)).filter(Boolean).slice(0, 3);
500+
const done = list.filter((r) => state.ridden.has(r.repo)).length;
501+
const cont = recent.length ? `<div class="continue"><span class="cont-label">이어서 보기</span>${recent.map((r) => `<button type="button" class="cont-chip" data-line="${primaryLine(r)}" data-open="${esc(r.repo)}"><i aria-hidden="true"></i>${esc(r.title)}</button>`).join("")}
502+
<span class="cont-progress">학습 완료 <b>${done}</b> / ${list.length}<span class="bar" aria-hidden="true"><i style="width:${Math.round((done / list.length) * 100)}%"></i></span></span></div>` : "";
503+
$("#week").innerHTML = `${cont}<div class="week-grid">
504+
<article class="spot" data-line="${primaryLine(pick)}">
505+
<p class="spot-label">이번 주의 레포<span>월요일마다 바뀝니다</span></p>
506+
<h3>${esc(pick.title)}</h3>
507+
<p class="spot-meta">${lv ? `<span>${lv.id} ${esc(lv.name)}</span>` : ""}${who ? `<span>${esc(who)}</span>` : ""}${pick.stars ? `<span class="star">${icons.star}${fmt(pick.stars)}</span>` : ""}</p>
508+
<p class="spot-sum">${esc(pick.summary)}</p>
509+
<div class="spot-cta"><button type="button" class="btn" data-open="${esc(pick.repo)}">자세히 보기</button>${pick.site ? `<a class="btn ghost" href="${esc(pick.site)}" target="_blank" rel="noopener">${esc(pick.siteLabel || "사이트 열기")}</a>` : ""}</div>
510+
</article>
511+
<div class="fresh">
512+
<p class="spot-label">최근 업데이트<span>매주 월요일 집계</span></p>
513+
<ol>${fresh.map((r) => `<li data-line="${primaryLine(r)}"><button type="button" data-open="${esc(r.repo)}"><i aria-hidden="true"></i><span class="t">${esc(r.title)}</span><span class="d">${ago(r.pushed)}</span></button></li>`).join("")}</ol>
514+
</div>
515+
</div>`;
516+
}
517+
453518
/* ---------- map legend ---------- */
454519
function renderVLines() {
455520
$("#vlines").innerHTML = state.lineList.map((l) => {
@@ -622,6 +687,10 @@
622687
function openStation(id) {
623688
const r = state.repos.get(id);
624689
if (!r) return;
690+
if (r.kind !== "paid") {
691+
state.recent = [{ id, t: Date.now() }, ...state.recent.filter((x) => x.id !== id)].slice(0, 6);
692+
saveRecent(); renderWeek();
693+
}
625694
const line = primaryLine(r);
626695
const lv = state.levels.find((l) => l.id === r.level);
627696
const lineTags = (r.lines || []).map((lid) => { const l = state.lines.get(lid); return `<span class="tag-line" data-line="${lid}"><span class="num" aria-hidden="true"></span>${esc(l.name)}</span>`; }).join("");
@@ -737,11 +806,11 @@
737806
}
738807

739808
async function init() {
740-
loadRidden();
809+
loadRidden(); loadRecent();
741810
wire();
742811
try { await load(); } catch (err) { fail(err); return; }
743812
renderGrowth(); renderLadder();
744-
renderRouteChips(); renderShelf();
813+
renderRouteChips(); renderShelf(); renderWeek();
745814
$("#shelf").addEventListener("scroll", shelfSpy, { passive: true });
746815
renderMatrix();
747816
renderLegend(); renderMap(); wireTabs();

0 commit comments

Comments
 (0)