Last active
December 30, 2025 04:42
-
-
Save sbamin/6f11a4ffd036412992394383b67f110a to your computer and use it in GitHub Desktop.
Life in Weeks chart by Tim Urban - customizable, preview: https://gistpreview.github.io/?6f11a4ffd036412992394383b67f110a/life_in_weeks.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Life in Weeks Chart</title> | |
| <style> | |
| :root { | |
| --bg: #ffffff; | |
| --text: #111827; | |
| --muted: #6b7280; | |
| --stroke: #c7cdd6; | |
| --fill-on: #4682b4; /* steelblue */ | |
| --fill-off: #ffffff; | |
| } | |
| body { | |
| margin: 0; | |
| background: var(--bg); | |
| color: var(--text); | |
| font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; | |
| } | |
| header { | |
| padding: 16px 18px 8px; | |
| } | |
| h1 { | |
| font-size: 16px; | |
| margin: 0 0 4px; | |
| font-weight: 600; | |
| } | |
| #subtitle { | |
| color: var(--muted); | |
| margin: 0; | |
| } | |
| .controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-top: 10px; | |
| color: var(--muted); | |
| user-select: none; | |
| } | |
| .config { | |
| display: grid; | |
| grid-template-columns: 120px 1fr; | |
| gap: 8px 12px; | |
| margin-top: 12px; | |
| max-width: 860px; | |
| color: var(--muted); | |
| } | |
| .config label { | |
| font-size: 12px; | |
| font-weight: 600; | |
| align-self: center; | |
| } | |
| .config input[type="number"], | |
| .config textarea { | |
| font: 12px/1.3 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; | |
| color: var(--text); | |
| border: 1px solid #d1d5db; | |
| border-radius: 8px; | |
| padding: 8px 10px; | |
| background: #ffffff; | |
| } | |
| .config textarea { | |
| min-height: 92px; | |
| resize: vertical; | |
| } | |
| .config .row { | |
| grid-column: 2; | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| } | |
| button { | |
| appearance: none; | |
| border: 1px solid #d1d5db; | |
| background: #ffffff; | |
| color: var(--text); | |
| padding: 7px 10px; | |
| border-radius: 8px; | |
| font: 12px/1.2 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; | |
| cursor: pointer; | |
| } | |
| button:hover { | |
| background: #f9fafb; | |
| } | |
| .hint { | |
| font-size: 12px; | |
| color: var(--muted); | |
| } | |
| .switch { | |
| position: relative; | |
| display: inline-block; | |
| width: 44px; | |
| height: 24px; | |
| } | |
| .switch input { | |
| opacity: 0; | |
| width: 0; | |
| height: 0; | |
| } | |
| .slider { | |
| position: absolute; | |
| cursor: pointer; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background-color: #e5e7eb; | |
| border: 1px solid #d1d5db; | |
| transition: 120ms; | |
| border-radius: 999px; | |
| } | |
| .slider:before { | |
| position: absolute; | |
| content: ""; | |
| height: 18px; | |
| width: 18px; | |
| left: 3px; | |
| bottom: 2px; | |
| background-color: white; | |
| transition: 120ms; | |
| border-radius: 999px; | |
| border: 1px solid #d1d5db; | |
| } | |
| input:checked + .slider:before { | |
| transform: translateX(18px); | |
| } | |
| .control-label { | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--muted); | |
| } | |
| main { | |
| padding: 8px 18px 18px; | |
| } | |
| .chart-wrap { | |
| overflow-x: auto; | |
| border-top: 1px solid #eef2f7; | |
| padding-top: 12px; | |
| } | |
| svg { | |
| display: block; | |
| max-width: 100%; | |
| height: auto; | |
| } | |
| .tile { | |
| stroke: var(--stroke); | |
| stroke-width: 0.6; | |
| shape-rendering: crispEdges; | |
| } | |
| /* | |
| Tile fill is set inline in JS so we can: | |
| - color by life-phase (year ranges) | |
| - optionally dim future weeks vs. weeks up to today's week | |
| */ | |
| .axis { | |
| fill: var(--muted); | |
| font-size: 10px; | |
| } | |
| .axis-title { | |
| fill: var(--muted); | |
| font-size: 12px; | |
| font-weight: 600; | |
| } | |
| .phase-label { | |
| font-size: 14px; | |
| font-weight: 600; | |
| } | |
| .note { | |
| color: var(--muted); | |
| font-size: 12px; | |
| margin-top: 10px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>The life in weeks</h1> | |
| <p id="subtitle">Loading…</p> | |
| <div class="controls"> | |
| <span class="control-label">X-axis</span> | |
| <span>Year</span> | |
| <label class="switch" title="Toggle x-axis labels between year and age"> | |
| <input id="xAxisToggle" type="checkbox" aria-label="Toggle x-axis labels between year and age" /> | |
| <span class="slider"></span> | |
| </label> | |
| <span>Age</span> | |
| </div> | |
| <div class="config" aria-label="Chart options"> | |
| <label for="startYearInput">Start year</label> | |
| <input id="startYearInput" type="number" inputmode="numeric" /> | |
| <label for="endYearInput">End year</label> | |
| <input id="endYearInput" type="number" inputmode="numeric" /> | |
| <label for="phasesInput">Life phases</label> | |
| <textarea id="phasesInput" spellcheck="false" placeholder="One per line: 1985-1999|childhood|#9ecae1"></textarea> | |
| <div class="row"> | |
| <button id="applyConfig" type="button">Apply</button> | |
| <button id="resetConfig" type="button">Reset</button> | |
| <span class="hint">Format: <code>start-end|name|#rrggbb</code> (color optional)</span> | |
| </div> | |
| </div> | |
| </header> | |
| <main> | |
| <div class="chart-wrap"> | |
| <svg id="chart" role="img" aria-label="Year by week grid"></svg> | |
| </div> | |
| <p class="note"> | |
| Each square represents one week in a 100-year lifespan, colored by life phase. | |
| Weeks after today are dimmed. | |
| <br /> | |
| Credit: Arjun Raj <a href="https://x.com/arjunrajlab/status/2005738901118636158">@arjunrajlab</a>, Tim Urban <a href="https://waitbutwhy.com/2014/05/life-weeks.html">Your Life in Weeks - Wait But Why</a> | |
| <br /> | |
| Vibe coded with GitHub Copilot in VSCode and ChatGPT 5.2. | |
| </p> | |
| </main> | |
| <script> | |
| (function () { | |
| const defaultConfig = { | |
| startYear: 1985, | |
| endYear: 2085, | |
| weeksPerYear: 52, | |
| phases: [ | |
| { name: "👶 🍼 🛝 🏏", start: 1985, end: 1999, color: "#9ecae1" }, | |
| { name: "🏫 📚", start: 2000, end: 2004, color: "#a1d99b" }, | |
| { name: "🧑🏫", start: 2005, end: 2015, color: "#fdae6b" }, | |
| { name: "🧑🔬 🎯 ⏱️", start: 2016, end: 2050, color: "#bcbddc" }, | |
| { name: "🧘 🧑🍳 🏖️", start: 2051, end: 2070, color: "#fee08b" }, | |
| { name: "🧑🦯 ⚰️", start: 2071, end: 2085, color: "#d9d9d9" } | |
| ], | |
| otherPhase: { name: "other", color: "#e5e7eb" } | |
| }; | |
| function toInt(value) { | |
| const n = Number.parseInt(String(value), 10); | |
| return Number.isFinite(n) ? n : null; | |
| } | |
| function clampInt(n, min, max) { | |
| return Math.max(min, Math.min(max, n)); | |
| } | |
| function parsePhases(phasesParam) { | |
| if (!phasesParam || !String(phasesParam).trim()) return null; | |
| const items = String(phasesParam) | |
| .split(/\s*(?:;|\n)\s*/) | |
| .map(s => s.trim()) | |
| .filter(Boolean); | |
| const parsed = []; | |
| for (const item of items) { | |
| const parts = item.split("|"); | |
| if (parts.length < 2) continue; | |
| const range = parts[0].trim(); | |
| const name = parts[1].trim(); | |
| const color = (parts[2] || "").trim(); | |
| const m = range.match(/^(\d{4})\s*-\s*(\d{4})$/); | |
| if (!m) continue; | |
| const start = toInt(m[1]); | |
| const end = toInt(m[2]); | |
| if (start == null || end == null) continue; | |
| if (!name) continue; | |
| parsed.push({ | |
| name, | |
| start, | |
| end, | |
| color: color || "#d1d5db" | |
| }); | |
| } | |
| return parsed.length ? parsed : null; | |
| } | |
| function phasesToText(phases) { | |
| return (phases || []) | |
| .map(p => { | |
| const name = String(p.name ?? "").trim(); | |
| const start = toInt(p.start); | |
| const end = toInt(p.end); | |
| const color = String(p.color ?? "").trim(); | |
| if (!name || start == null || end == null) return null; | |
| return `${start}-${end}|${name}${color ? `|${color}` : ""}`; | |
| }) | |
| .filter(Boolean) | |
| .join("\n"); | |
| } | |
| function buildConfig() { | |
| const url = new URL(window.location.href); | |
| const startFromUrl = toInt(url.searchParams.get("start")); | |
| const endFromUrl = toInt(url.searchParams.get("end")); | |
| const phasesFromUrl = parsePhases(url.searchParams.get("phases")); | |
| const userCfg = (typeof window.LIFE_WEEKS_CONFIG === "object" && window.LIFE_WEEKS_CONFIG) ? window.LIFE_WEEKS_CONFIG : {}; | |
| const cfg = { | |
| ...defaultConfig, | |
| ...userCfg | |
| }; | |
| if (startFromUrl != null) cfg.startYear = startFromUrl; | |
| if (endFromUrl != null) cfg.endYear = endFromUrl; | |
| if (phasesFromUrl) cfg.phases = phasesFromUrl; | |
| // Basic validation | |
| cfg.weeksPerYear = cfg.weeksPerYear || 52; | |
| cfg.startYear = toInt(cfg.startYear) ?? defaultConfig.startYear; | |
| cfg.endYear = toInt(cfg.endYear) ?? defaultConfig.endYear; | |
| if (cfg.endYear < cfg.startYear) { | |
| const tmp = cfg.startYear; | |
| cfg.startYear = cfg.endYear; | |
| cfg.endYear = tmp; | |
| } | |
| cfg.weeksPerYear = clampInt(toInt(cfg.weeksPerYear) ?? 52, 1, 52); | |
| cfg.otherPhase = cfg.otherPhase || defaultConfig.otherPhase; | |
| cfg.phases = Array.isArray(cfg.phases) ? cfg.phases : defaultConfig.phases; | |
| // Normalize phases | |
| cfg.phases = cfg.phases | |
| .map(p => ({ | |
| name: String(p.name ?? "").trim(), | |
| start: toInt(p.start), | |
| end: toInt(p.end), | |
| color: String(p.color ?? "#d1d5db").trim() | |
| })) | |
| .filter(p => p.name && p.start != null && p.end != null); | |
| return cfg; | |
| } | |
| let stateConfig = buildConfig(); | |
| const svg = document.getElementById("chart"); | |
| const xAxisToggle = document.getElementById("xAxisToggle"); | |
| const startYearInput = document.getElementById("startYearInput"); | |
| const endYearInput = document.getElementById("endYearInput"); | |
| const phasesInput = document.getElementById("phasesInput"); | |
| const applyConfigBtn = document.getElementById("applyConfig"); | |
| const resetConfigBtn = document.getElementById("resetConfig"); | |
| const tile = 10; | |
| const gap = 1; | |
| // Extra bottom margin prevents clipping of rotated tick labels + axis title. | |
| const margin = { top: 34, right: 10, bottom: 80, left: 44 }; | |
| function pad2(n) { return String(n).padStart(2, "0"); } | |
| function formatYMD(d) { | |
| return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; | |
| } | |
| // ISO week number (1..53). We cap to 52 for this visualization. | |
| function getISOWeek(dateLocal) { | |
| const d = new Date(Date.UTC(dateLocal.getFullYear(), dateLocal.getMonth(), dateLocal.getDate())); | |
| const day = d.getUTCDay() || 7; // 1..7 (Mon..Sun) | |
| d.setUTCDate(d.getUTCDate() + 4 - day); // shift to Thursday | |
| const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); | |
| const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7); | |
| return weekNo; | |
| } | |
| function isFilled(year, week, currentYear, currentWeek) { | |
| if (Number.isNaN(currentYear) || Number.isNaN(currentWeek)) return false; | |
| if (currentYear < startYear) return false; | |
| if (currentYear > endYear) return true; | |
| if (year < currentYear) return true; | |
| if (year > currentYear) return false; | |
| return week <= currentWeek; | |
| } | |
| function getPhaseForYear(year) { | |
| for (const p of phases) { | |
| if (year >= p.start && year <= p.end) return p; | |
| } | |
| return otherPhase; | |
| } | |
| const today = new Date(); | |
| const currentYear = today.getFullYear(); | |
| const ns = "http://www.w3.org/2000/svg"; | |
| let setXAxisModeFn = null; | |
| function syncFormFromConfig(cfg) { | |
| if (startYearInput) startYearInput.value = String(cfg.startYear); | |
| if (endYearInput) endYearInput.value = String(cfg.endYear); | |
| if (phasesInput) phasesInput.value = phasesToText(cfg.phases); | |
| } | |
| function configFromForm() { | |
| const start = toInt(startYearInput?.value); | |
| const end = toInt(endYearInput?.value); | |
| const phasesText = phasesInput?.value ?? ""; | |
| const parsedPhases = parsePhases(phasesText); | |
| const cfg = { | |
| ...stateConfig, | |
| startYear: start ?? stateConfig.startYear, | |
| endYear: end ?? stateConfig.endYear, | |
| phases: parsedPhases ?? stateConfig.phases | |
| }; | |
| cfg.startYear = toInt(cfg.startYear) ?? defaultConfig.startYear; | |
| cfg.endYear = toInt(cfg.endYear) ?? defaultConfig.endYear; | |
| if (cfg.endYear < cfg.startYear) { | |
| const tmp = cfg.startYear; | |
| cfg.startYear = cfg.endYear; | |
| cfg.endYear = tmp; | |
| } | |
| cfg.weeksPerYear = clampInt(toInt(cfg.weeksPerYear) ?? defaultConfig.weeksPerYear, 1, 52); | |
| cfg.otherPhase = cfg.otherPhase || defaultConfig.otherPhase; | |
| cfg.phases = Array.isArray(cfg.phases) ? cfg.phases : defaultConfig.phases; | |
| return cfg; | |
| } | |
| function render(cfg) { | |
| const startYear = cfg.startYear; | |
| const endYear = cfg.endYear; | |
| const weeksPerYear = cfg.weeksPerYear; | |
| const phases = cfg.phases; | |
| const otherPhase = cfg.otherPhase; | |
| const nYears = endYear - startYear + 1; | |
| function isFilled(year, week, currentYear, currentWeek) { | |
| if (Number.isNaN(currentYear) || Number.isNaN(currentWeek)) return false; | |
| if (currentYear < startYear) return false; | |
| if (currentYear > endYear) return true; | |
| if (year < currentYear) return true; | |
| if (year > currentYear) return false; | |
| return week <= currentWeek; | |
| } | |
| function getPhaseForYear(year) { | |
| for (const p of phases) { | |
| if (year >= p.start && year <= p.end) return p; | |
| } | |
| return otherPhase; | |
| } | |
| const today = new Date(); | |
| const currentYear = today.getFullYear(); | |
| const currentWeek = Math.min(getISOWeek(today), weeksPerYear); | |
| document.getElementById("subtitle").textContent = `Today: ${formatYMD(today)} | Week: ${currentWeek}/52`; | |
| // Clear SVG then rebuild | |
| svg.replaceChildren(); | |
| const plotW = nYears * tile + (nYears - 1) * gap; | |
| const plotH = weeksPerYear * tile + (weeksPerYear - 1) * gap; | |
| const width = margin.left + plotW + margin.right; | |
| const height = margin.top + plotH + margin.bottom; | |
| svg.setAttribute("viewBox", `0 0 ${width} ${height}`); | |
| svg.setAttribute("width", String(width)); | |
| svg.setAttribute("height", String(height)); | |
| // X axis title | |
| const xTitle = document.createElementNS(ns, "text"); | |
| xTitle.setAttribute("class", "axis-title"); | |
| xTitle.setAttribute("x", String(margin.left + plotW / 2)); | |
| xTitle.setAttribute("y", String(margin.top + plotH + 62)); | |
| xTitle.setAttribute("text-anchor", "middle"); | |
| xTitle.textContent = "Year"; | |
| svg.appendChild(xTitle); | |
| // Week axis label | |
| const yTitle = document.createElementNS(ns, "text"); | |
| yTitle.setAttribute("class", "axis-title"); | |
| yTitle.setAttribute("x", "12"); | |
| yTitle.setAttribute("y", String(margin.top + plotH / 2)); | |
| yTitle.setAttribute("text-anchor", "middle"); | |
| yTitle.setAttribute("transform", `rotate(-90 12 ${margin.top + plotH / 2})`); | |
| yTitle.textContent = "Week"; | |
| svg.appendChild(yTitle); | |
| // Week ticks | |
| const weekTicks = [1, 13, 26, 39, 52].filter(w => w <= weeksPerYear); | |
| for (const w of weekTicks) { | |
| const t = document.createElementNS(ns, "text"); | |
| t.setAttribute("class", "axis"); | |
| t.setAttribute("x", String(margin.left - 8)); | |
| t.setAttribute("y", String(margin.top + (w - 1) * (tile + gap) + tile * 0.75)); | |
| t.setAttribute("text-anchor", "end"); | |
| t.textContent = String(w); | |
| svg.appendChild(t); | |
| } | |
| // X tick labels | |
| const xTickEls = []; | |
| for (let y = startYear; y <= endYear; y += 5) { | |
| const col = y - startYear; | |
| const x = margin.left + col * (tile + gap) + tile / 2; | |
| const yPos = margin.top + plotH + 14; | |
| const t = document.createElementNS(ns, "text"); | |
| t.setAttribute("class", "axis"); | |
| t.setAttribute("x", String(x)); | |
| t.setAttribute("y", String(yPos)); | |
| t.setAttribute("text-anchor", "middle"); | |
| t.setAttribute("transform", `rotate(90 ${x} ${yPos})`); | |
| t.setAttribute("data-year", String(y)); | |
| t.textContent = String(y); | |
| svg.appendChild(t); | |
| xTickEls.push(t); | |
| } | |
| function setXAxisMode(mode) { | |
| const isAge = mode === "age"; | |
| xTitle.textContent = isAge ? "Age (years)" : "Year"; | |
| for (const el of xTickEls) { | |
| const y = Number(el.getAttribute("data-year")); | |
| const age = y - startYear; | |
| el.textContent = isAge ? String(age) : String(y); | |
| } | |
| } | |
| setXAxisModeFn = setXAxisMode; | |
| setXAxisMode(xAxisToggle && xAxisToggle.checked ? "age" : "year"); | |
| // Phase labels | |
| for (const phase of phases) { | |
| const a = Math.max(phase.start, startYear); | |
| const b = Math.min(phase.end, endYear); | |
| if (a > b) continue; | |
| const leftX = margin.left + (a - startYear) * (tile + gap); | |
| const rightX = margin.left + (b - startYear) * (tile + gap) + tile; | |
| const cx = (leftX + rightX) / 2; | |
| const cy = 22; | |
| const label = document.createElementNS(ns, "text"); | |
| label.setAttribute("class", "phase-label"); | |
| label.setAttribute("x", String(cx)); | |
| label.setAttribute("y", String(cy)); | |
| label.setAttribute("text-anchor", "middle"); | |
| label.setAttribute("fill", phase.color); | |
| label.textContent = phase.name; | |
| svg.appendChild(label); | |
| } | |
| // Tiles | |
| const tilesGroup = document.createElementNS(ns, "g"); | |
| svg.appendChild(tilesGroup); | |
| for (let year = startYear; year <= endYear; year++) { | |
| const col = year - startYear; | |
| const x = margin.left + col * (tile + gap); | |
| const phase = getPhaseForYear(year); | |
| for (let week = 1; week <= weeksPerYear; week++) { | |
| const row = week - 1; | |
| const y = margin.top + row * (tile + gap); | |
| const filled = isFilled(year, week, currentYear, currentWeek); | |
| const rect = document.createElementNS(ns, "rect"); | |
| rect.setAttribute("class", "tile"); | |
| rect.setAttribute("x", String(x)); | |
| rect.setAttribute("y", String(y)); | |
| rect.setAttribute("width", String(tile)); | |
| rect.setAttribute("height", String(tile)); | |
| rect.setAttribute("fill", phase.color); | |
| rect.setAttribute("fill-opacity", filled ? "1" : "0.18"); | |
| rect.setAttribute("data-phase", phase.name); | |
| tilesGroup.appendChild(rect); | |
| } | |
| } | |
| // Border | |
| const border = document.createElementNS(ns, "rect"); | |
| border.setAttribute("x", String(margin.left - 0.5)); | |
| border.setAttribute("y", String(margin.top - 0.5)); | |
| border.setAttribute("width", String(plotW + 1)); | |
| border.setAttribute("height", String(plotH + 1)); | |
| border.setAttribute("fill", "none"); | |
| border.setAttribute("stroke", "#e5e7eb"); | |
| svg.appendChild(border); | |
| } | |
| // Hook up toggle once (no duplicate listeners on re-render) | |
| if (xAxisToggle && !xAxisToggle.dataset.bound) { | |
| xAxisToggle.dataset.bound = "1"; | |
| xAxisToggle.addEventListener("change", () => { | |
| if (setXAxisModeFn) setXAxisModeFn(xAxisToggle.checked ? "age" : "year"); | |
| }); | |
| } | |
| // Hook up Apply/Reset once | |
| if (applyConfigBtn && !applyConfigBtn.dataset.bound) { | |
| applyConfigBtn.dataset.bound = "1"; | |
| applyConfigBtn.addEventListener("click", () => { | |
| stateConfig = configFromForm(); | |
| syncFormFromConfig(stateConfig); | |
| render(stateConfig); | |
| }); | |
| } | |
| if (resetConfigBtn && !resetConfigBtn.dataset.bound) { | |
| resetConfigBtn.dataset.bound = "1"; | |
| resetConfigBtn.addEventListener("click", () => { | |
| stateConfig = { ...defaultConfig }; | |
| syncFormFromConfig(stateConfig); | |
| render(stateConfig); | |
| }); | |
| } | |
| // Initial render | |
| syncFormFromConfig(stateConfig); | |
| render(stateConfig); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment