Created
February 22, 2026 14:43
-
-
Save jbdamask/674dd60ec8ed2603c5d7f94da519e0fa to your computer and use it in GitHub Desktop.
NowIGetIt: temporarl-patterns.pdf
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.0"> | |
| <title>Temporal Panel Selection in Citizens' Assemblies</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap'); | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| :root { | |
| --bg: #0a0a0f; | |
| --bg2: #12121a; | |
| --bg3: #1a1a2e; | |
| --accent: #6c63ff; | |
| --accent2: #ff6584; | |
| --accent3: #43e97b; | |
| --accent4: #f9d423; | |
| --text: #e8e8f0; | |
| --text2: #a0a0b8; | |
| --glass: rgba(255,255,255,0.04); | |
| --glass-border: rgba(255,255,255,0.08); | |
| } | |
| html { scroll-behavior: smooth; } | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| overflow-x: hidden; | |
| line-height: 1.7; | |
| } | |
| /* Animated gradient background */ | |
| .bg-gradient { | |
| position: fixed; | |
| top: 0; left: 0; width: 100%; height: 100%; | |
| z-index: -1; | |
| background: radial-gradient(ellipse at 20% 50%, rgba(108,99,255,0.08) 0%, transparent 50%), | |
| radial-gradient(ellipse at 80% 20%, rgba(255,101,132,0.06) 0%, transparent 50%), | |
| radial-gradient(ellipse at 50% 80%, rgba(67,233,123,0.05) 0%, transparent 50%); | |
| } | |
| /* Navigation */ | |
| nav { | |
| position: fixed; top: 0; width: 100%; z-index: 1000; | |
| backdrop-filter: blur(20px); | |
| background: rgba(10,10,15,0.8); | |
| border-bottom: 1px solid var(--glass-border); | |
| padding: 0.8rem 2rem; | |
| display: flex; justify-content: space-between; align-items: center; | |
| transition: all 0.3s; | |
| } | |
| nav .logo { | |
| font-weight: 800; font-size: 1.1rem; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; | |
| } | |
| nav .nav-links { display: flex; gap: 1.5rem; list-style: none; } | |
| nav .nav-links a { | |
| color: var(--text2); text-decoration: none; font-size: 0.85rem; | |
| font-weight: 500; transition: color 0.3s; position: relative; | |
| } | |
| nav .nav-links a:hover { color: var(--accent); } | |
| nav .nav-links a::after { | |
| content: ''; position: absolute; bottom: -4px; left: 0; width: 0; | |
| height: 2px; background: var(--accent); transition: width 0.3s; | |
| } | |
| nav .nav-links a:hover::after { width: 100%; } | |
| /* Hero */ | |
| .hero { | |
| min-height: 100vh; | |
| display: flex; flex-direction: column; justify-content: center; align-items: center; | |
| text-align: center; padding: 6rem 2rem 4rem; | |
| position: relative; | |
| } | |
| .hero-badge { | |
| display: inline-flex; align-items: center; gap: 0.5rem; | |
| background: var(--glass); border: 1px solid var(--glass-border); | |
| border-radius: 999px; padding: 0.5rem 1.2rem; | |
| font-size: 0.8rem; color: var(--accent); margin-bottom: 2rem; | |
| backdrop-filter: blur(10px); | |
| } | |
| .hero-badge .dot { | |
| width: 6px; height: 6px; border-radius: 50%; | |
| background: var(--accent3); animation: pulse 2s infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; transform: scale(1); } | |
| 50% { opacity: 0.5; transform: scale(1.5); } | |
| } | |
| .hero h1 { | |
| font-size: clamp(2.5rem, 6vw, 4.5rem); | |
| font-weight: 900; line-height: 1.1; | |
| max-width: 900px; margin-bottom: 1.5rem; | |
| } | |
| .hero h1 .gradient-text { | |
| background: linear-gradient(135deg, var(--accent), var(--accent2), var(--accent4)); | |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent; | |
| } | |
| .hero p { | |
| font-size: 1.15rem; color: var(--text2); max-width: 650px; | |
| margin-bottom: 2.5rem; | |
| } | |
| .hero-cta { | |
| display: flex; gap: 1rem; flex-wrap: wrap; justify-content: center; | |
| } | |
| .btn { | |
| padding: 0.8rem 2rem; border-radius: 12px; font-weight: 600; | |
| font-size: 0.95rem; cursor: pointer; transition: all 0.3s; | |
| text-decoration: none; display: inline-flex; align-items: center; gap: 0.5rem; | |
| border: none; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, var(--accent), #8b5cf6); | |
| color: #fff; box-shadow: 0 4px 20px rgba(108,99,255,0.3); | |
| } | |
| .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(108,99,255,0.4); } | |
| .btn-ghost { | |
| background: var(--glass); color: var(--text); border: 1px solid var(--glass-border); | |
| } | |
| .btn-ghost:hover { background: rgba(255,255,255,0.08); } | |
| /* Scroll indicator */ | |
| .scroll-indicator { | |
| position: absolute; bottom: 2rem; animation: bob 2s ease-in-out infinite; | |
| } | |
| .scroll-indicator svg { width: 24px; height: 24px; stroke: var(--text2); } | |
| @keyframes bob { | |
| 0%, 100% { transform: translateY(0); } | |
| 50% { transform: translateY(8px); } | |
| } | |
| /* Sections */ | |
| section { padding: 5rem 2rem; max-width: 1200px; margin: 0 auto; } | |
| .section-label { | |
| display: inline-flex; align-items: center; gap: 0.5rem; | |
| font-size: 0.75rem; font-weight: 700; text-transform: uppercase; | |
| letter-spacing: 2px; color: var(--accent); margin-bottom: 1rem; | |
| } | |
| .section-label::before { | |
| content: ''; width: 20px; height: 2px; background: var(--accent); | |
| } | |
| h2 { | |
| font-size: clamp(1.8rem, 4vw, 2.8rem); | |
| font-weight: 800; margin-bottom: 1rem; | |
| } | |
| .section-desc { | |
| font-size: 1.05rem; color: var(--text2); max-width: 700px; | |
| margin-bottom: 3rem; | |
| } | |
| /* Cards grid */ | |
| .cards-grid { | |
| display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | |
| gap: 1.5rem; | |
| } | |
| .card { | |
| background: var(--glass); border: 1px solid var(--glass-border); | |
| border-radius: 20px; padding: 2rem; position: relative; overflow: hidden; | |
| transition: all 0.4s; | |
| } | |
| .card:hover { | |
| border-color: rgba(108,99,255,0.3); | |
| transform: translateY(-4px); | |
| box-shadow: 0 20px 60px rgba(0,0,0,0.3); | |
| } | |
| .card-icon { | |
| width: 48px; height: 48px; border-radius: 14px; | |
| display: flex; align-items: center; justify-content: center; | |
| margin-bottom: 1.2rem; font-size: 1.4rem; | |
| } | |
| .card h3 { font-size: 1.2rem; font-weight: 700; margin-bottom: 0.6rem; } | |
| .card p { font-size: 0.92rem; color: var(--text2); line-height: 1.6; } | |
| /* Interactive Demo */ | |
| .demo-container { | |
| background: var(--bg2); border: 1px solid var(--glass-border); | |
| border-radius: 24px; padding: 2.5rem; margin-top: 2rem; | |
| position: relative; overflow: hidden; | |
| } | |
| .demo-container::before { | |
| content: ''; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; | |
| background: radial-gradient(circle at 30% 30%, rgba(108,99,255,0.05) 0%, transparent 60%); | |
| pointer-events: none; | |
| } | |
| .demo-header { | |
| display: flex; justify-content: space-between; align-items: center; | |
| flex-wrap: wrap; gap: 1rem; margin-bottom: 2rem; | |
| } | |
| .demo-header h3 { font-size: 1.3rem; font-weight: 700; } | |
| .demo-controls { display: flex; gap: 0.8rem; flex-wrap: wrap; } | |
| .demo-btn { | |
| padding: 0.5rem 1.2rem; border-radius: 10px; border: 1px solid var(--glass-border); | |
| background: var(--glass); color: var(--text); font-size: 0.85rem; | |
| cursor: pointer; transition: all 0.3s; font-family: inherit; font-weight: 500; | |
| } | |
| .demo-btn:hover, .demo-btn.active { | |
| background: var(--accent); border-color: var(--accent); color: #fff; | |
| } | |
| /* Population Visualization */ | |
| .pop-viz { | |
| display: flex; flex-direction: column; gap: 2rem; | |
| } | |
| .pop-canvas-wrapper { | |
| position: relative; background: rgba(0,0,0,0.3); border-radius: 16px; | |
| overflow: hidden; border: 1px solid var(--glass-border); | |
| } | |
| #popCanvas { display: block; width: 100%; } | |
| .pop-legend { | |
| display: flex; gap: 1.5rem; flex-wrap: wrap; padding: 0 0.5rem; | |
| } | |
| .pop-legend-item { | |
| display: flex; align-items: center; gap: 0.5rem; font-size: 0.82rem; color: var(--text2); | |
| } | |
| .pop-legend-dot { | |
| width: 10px; height: 10px; border-radius: 50%; | |
| } | |
| /* Timeline viz */ | |
| .timeline-viz { | |
| display: flex; flex-direction: column; gap: 1.5rem; margin-top: 1.5rem; | |
| } | |
| .timeline-panel { | |
| display: flex; align-items: stretch; gap: 1rem; | |
| } | |
| .timeline-label { | |
| min-width: 100px; display: flex; flex-direction: column; justify-content: center; | |
| align-items: flex-end; padding-right: 1rem; border-right: 2px solid var(--glass-border); | |
| } | |
| .timeline-label .panel-name { | |
| font-weight: 700; font-size: 0.95rem; | |
| } | |
| .timeline-label .panel-time { | |
| font-size: 0.75rem; color: var(--text2); | |
| } | |
| .timeline-members { | |
| display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; | |
| padding: 0.8rem 0; | |
| } | |
| .member-dot { | |
| width: 36px; height: 36px; border-radius: 10px; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 0.7rem; font-weight: 700; color: #fff; | |
| transition: all 0.4s; cursor: default; | |
| position: relative; | |
| } | |
| .member-dot:hover { transform: scale(1.2); z-index: 2; } | |
| .member-dot .tooltip { | |
| display: none; position: absolute; bottom: 110%; left: 50%; transform: translateX(-50%); | |
| background: var(--bg); border: 1px solid var(--glass-border); border-radius: 8px; | |
| padding: 0.4rem 0.7rem; font-size: 0.7rem; white-space: nowrap; color: var(--text); | |
| pointer-events: none; | |
| } | |
| .member-dot:hover .tooltip { display: block; } | |
| /* Cumulative bar */ | |
| .cumulative-section { margin-top: 1.5rem; } | |
| .cumulative-bar-track { | |
| height: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; | |
| overflow: hidden; display: flex; margin-top: 0.5rem; | |
| } | |
| .cumulative-bar-segment { | |
| height: 100%; transition: width 0.5s ease; | |
| } | |
| .cumulative-stats { | |
| display: flex; gap: 2rem; margin-top: 1rem; flex-wrap: wrap; | |
| } | |
| .stat-item { text-align: center; } | |
| .stat-value { font-size: 1.8rem; font-weight: 800; } | |
| .stat-label { font-size: 0.75rem; color: var(--text2); } | |
| /* Approach cards */ | |
| .approach-grid { | |
| display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); | |
| gap: 1.5rem; | |
| } | |
| .approach-card { | |
| background: var(--glass); border: 1px solid var(--glass-border); | |
| border-radius: 20px; padding: 2rem; position: relative; overflow: hidden; | |
| transition: all 0.4s; | |
| } | |
| .approach-card:hover { | |
| transform: translateY(-3px); border-color: rgba(108,99,255,0.3); | |
| } | |
| .approach-card .approach-num { | |
| position: absolute; top: 1rem; right: 1.5rem; | |
| font-size: 3.5rem; font-weight: 900; opacity: 0.06; | |
| line-height: 1; | |
| } | |
| .approach-card .tag { | |
| display: inline-block; padding: 0.3rem 0.8rem; border-radius: 6px; | |
| font-size: 0.7rem; font-weight: 700; margin-bottom: 1rem; | |
| text-transform: uppercase; letter-spacing: 1px; | |
| } | |
| .approach-card h3 { font-size: 1.15rem; font-weight: 700; margin-bottom: 0.5rem; } | |
| .approach-card p { font-size: 0.9rem; color: var(--text2); margin-bottom: 1rem; } | |
| .approach-card .guarantee { | |
| display: flex; gap: 0.5rem; flex-wrap: wrap; | |
| } | |
| .guarantee-badge { | |
| padding: 0.3rem 0.7rem; border-radius: 6px; | |
| background: rgba(255,255,255,0.05); font-size: 0.72rem; | |
| font-family: 'JetBrains Mono', monospace; color: var(--text2); | |
| border: 1px solid var(--glass-border); | |
| } | |
| /* Metric space viz */ | |
| #metricCanvas { display: block; width: 100%; border-radius: 12px; cursor: crosshair; } | |
| /* Footer */ | |
| footer { | |
| text-align: center; padding: 3rem 2rem; color: var(--text2); | |
| font-size: 0.85rem; border-top: 1px solid var(--glass-border); | |
| margin-top: 4rem; | |
| } | |
| footer a { color: var(--accent); text-decoration: none; } | |
| footer a:hover { text-decoration: underline; } | |
| /* Divider */ | |
| .divider { | |
| height: 1px; background: linear-gradient(90deg, transparent, var(--glass-border), transparent); | |
| margin: 2rem auto; max-width: 600px; | |
| } | |
| /* Mobile responsive */ | |
| @media (max-width: 768px) { | |
| nav .nav-links { display: none; } | |
| section { padding: 3rem 1.2rem; } | |
| .demo-container { padding: 1.5rem; } | |
| .timeline-label { min-width: 70px; } | |
| .timeline-label .panel-name { font-size: 0.8rem; } | |
| .approach-grid { grid-template-columns: 1fr; } | |
| .member-dot { width: 28px; height: 28px; font-size: 0.6rem; } | |
| } | |
| /* Animations */ | |
| .fade-up { | |
| opacity: 0; transform: translateY(30px); | |
| transition: all 0.6s ease; | |
| } | |
| .fade-up.visible { | |
| opacity: 1; transform: translateY(0); | |
| } | |
| /* Fancy metric space explainer */ | |
| .metric-explainer { | |
| display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; | |
| align-items: center; margin-top: 2rem; | |
| } | |
| @media (max-width: 768px) { .metric-explainer { grid-template-columns: 1fr; } } | |
| .metric-text h3 { font-size: 1.3rem; font-weight: 700; margin-bottom: 0.8rem; } | |
| .metric-text p { font-size: 0.95rem; color: var(--text2); margin-bottom: 1rem; } | |
| .metric-canvas-wrap { | |
| background: rgba(0,0,0,0.3); border-radius: 16px; border: 1px solid var(--glass-border); | |
| overflow: hidden; position: relative; | |
| } | |
| /* Key insight highlight */ | |
| .insight-box { | |
| background: linear-gradient(135deg, rgba(108,99,255,0.1), rgba(255,101,132,0.05)); | |
| border: 1px solid rgba(108,99,255,0.2); | |
| border-radius: 16px; padding: 2rem; margin: 2rem 0; | |
| position: relative; | |
| } | |
| .insight-box::before { | |
| content: '💡'; position: absolute; top: -12px; left: 20px; | |
| font-size: 1.5rem; | |
| } | |
| .insight-box p { font-size: 1rem; color: var(--text); } | |
| .insight-box strong { color: var(--accent); } | |
| /* Sortition stepper */ | |
| .stepper { display: flex; gap: 0; margin: 2rem 0; } | |
| .step { | |
| flex: 1; padding: 1.2rem; background: var(--glass); border: 1px solid var(--glass-border); | |
| text-align: center; position: relative; cursor: pointer; transition: all 0.3s; | |
| } | |
| .step:first-child { border-radius: 14px 0 0 14px; } | |
| .step:last-child { border-radius: 0 14px 14px 0; } | |
| .step.active { background: rgba(108,99,255,0.15); border-color: var(--accent); } | |
| .step .step-num { | |
| width: 28px; height: 28px; border-radius: 50%; display: inline-flex; | |
| align-items: center; justify-content: center; font-size: 0.8rem; | |
| font-weight: 700; margin-bottom: 0.5rem; | |
| background: var(--glass-border); color: var(--text2); | |
| } | |
| .step.active .step-num { background: var(--accent); color: #fff; } | |
| .step .step-title { font-size: 0.78rem; font-weight: 600; } | |
| .step-content-area { | |
| background: var(--glass); border: 1px solid var(--glass-border); | |
| border-radius: 16px; padding: 2rem; margin-top: -1px; | |
| min-height: 180px; | |
| } | |
| .step-content-area p { font-size: 0.92rem; color: var(--text2); } | |
| /* Floating particles */ | |
| .particles { | |
| position: fixed; top: 0; left: 0; width: 100%; height: 100%; | |
| pointer-events: none; z-index: -1; overflow: hidden; | |
| } | |
| .particle { | |
| position: absolute; border-radius: 50%; opacity: 0.15; | |
| animation: float linear infinite; | |
| } | |
| @keyframes float { | |
| 0% { transform: translateY(100vh) rotate(0deg); opacity: 0; } | |
| 10% { opacity: 0.15; } | |
| 90% { opacity: 0.15; } | |
| 100% { transform: translateY(-10vh) rotate(720deg); opacity: 0; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="bg-gradient"></div> | |
| <div class="particles" id="particles"></div> | |
| <nav> | |
| <div class="logo">⚖️ Temporal Sortition</div> | |
| <ul class="nav-links"> | |
| <li><a href="#problem">The Problem</a></li> | |
| <li><a href="#metric">Metric Space</a></li> | |
| <li><a href="#demo">Interactive Demo</a></li> | |
| <li><a href="#approaches">Approaches</a></li> | |
| <li><a href="#results">Key Results</a></li> | |
| </ul> | |
| </nav> | |
| <!-- HERO --> | |
| <section class="hero"> | |
| <div class="hero-badge"> | |
| <span class="dot"></span> | |
| Research Paper Explainer • Kalaycı & Micha, USC 2026 | |
| </div> | |
| <h1> | |
| Fair Representation<br> | |
| <span class="gradient-text">Over Time</span> | |
| </h1> | |
| <p> | |
| How do you pick citizens' assemblies that are fair not just once, but across many panels over months and years? This research cracks the code on <strong>temporal sortition</strong>. | |
| </p> | |
| <div class="hero-cta"> | |
| <a href="#demo" class="btn btn-primary">▶ See It In Action</a> | |
| <a href="#problem" class="btn btn-ghost">Learn More ↓</a> | |
| </div> | |
| <div class="scroll-indicator"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg> | |
| </div> | |
| </section> | |
| <!-- PROBLEM --> | |
| <section id="problem" class="fade-up"> | |
| <div class="section-label">The Challenge</div> | |
| <h2>What is a Citizens' Assembly?</h2> | |
| <p class="section-desc"> | |
| A citizens' assembly randomly selects everyday people to deliberate on policy — like a jury for democracy. The goal: a panel that <em>looks like</em> the population it represents. | |
| </p> | |
| <div class="cards-grid"> | |
| <div class="card"> | |
| <div class="card-icon" style="background: rgba(108,99,255,0.15);">🎲</div> | |
| <h3>Sortition</h3> | |
| <p>Citizens are chosen by lottery — not elected. This avoids campaign money, popularity contests, and political polarization. Pure random chance.</p> | |
| </div> | |
| <div class="card"> | |
| <div class="card-icon" style="background: rgba(255,101,132,0.15);">🏛️</div> | |
| <h3>Permanent Assemblies</h3> | |
| <p>Some assemblies are ongoing (like Belgium's Ostbelgien Model). Panels rotate every 6–12 months, creating a continuous institution of citizen power.</p> | |
| </div> | |
| <div class="card"> | |
| <div class="card-icon" style="background: rgba(67,233,123,0.15);">⚖️</div> | |
| <h3>The Fairness Problem</h3> | |
| <p>A single panel can't represent everyone — small groups get left out. But across <em>multiple</em> panels over time, can we guarantee every voice is eventually heard?</p> | |
| </div> | |
| </div> | |
| <div class="insight-box" style="margin-top: 2.5rem;"> | |
| <p> | |
| <strong>The Key Insight:</strong> If your community has 4 groups — one being 50%, one 25%, and two at 12.5% each — a single 4-person panel can represent at most 3 groups. But across <strong>two panels over time</strong>, you can alternate the 4th seat, giving <em>every</em> group a voice. | |
| </p> | |
| </div> | |
| </section> | |
| <div class="divider"></div> | |
| <!-- METRIC SPACE --> | |
| <section id="metric" class="fade-up"> | |
| <div class="section-label">Core Concept</div> | |
| <h2>The Representation Metric Space</h2> | |
| <p class="section-desc"> | |
| People aren't just labels — they exist in a space of features. The closer two people are in this space, the better one can represent the other. | |
| </p> | |
| <div class="metric-explainer"> | |
| <div class="metric-text"> | |
| <h3>How "distance" captures representation</h3> | |
| <p>Imagine mapping every citizen based on their features: age, geography, occupation, education. Two people who share many features are "close" — one can represent the other well.</p> | |
| <p>The paper uses this <strong>metric space</strong> to rigorously define when a panel is proportionally representative. Groups of similar citizens deserve at least one person nearby on the panel.</p> | |
| <p style="color: var(--accent); font-weight: 600; font-size: 0.9rem;">👈 Click anywhere on the canvas to add a citizen and see representation distances.</p> | |
| </div> | |
| <div class="metric-canvas-wrap"> | |
| <canvas id="metricCanvas" width="500" height="400"></canvas> | |
| </div> | |
| </div> | |
| </section> | |
| <div class="divider"></div> | |
| <!-- INTERACTIVE DEMO --> | |
| <section id="demo" class="fade-up"> | |
| <div class="section-label">Interactive Demo</div> | |
| <h2>Watch Temporal Sortition in Action</h2> | |
| <p class="section-desc"> | |
| See how panels are selected over time and how cumulative representation improves with each round. | |
| </p> | |
| <div class="demo-container"> | |
| <div class="demo-header"> | |
| <h3>🎲 Panel Selection Simulator</h3> | |
| <div class="demo-controls"> | |
| <button class="demo-btn active" onclick="resetDemo()">Reset</button> | |
| <button class="demo-btn" onclick="advancePanel()" id="advanceBtn">Next Panel →</button> | |
| <button class="demo-btn" onclick="autoPlay()" id="autoBtn">▶ Auto Play</button> | |
| </div> | |
| </div> | |
| <!-- Population viz --> | |
| <div class="pop-viz"> | |
| <div class="pop-canvas-wrapper"> | |
| <canvas id="popCanvas" width="900" height="300"></canvas> | |
| </div> | |
| <div class="pop-legend" id="popLegend"></div> | |
| </div> | |
| <!-- Timeline --> | |
| <div class="timeline-viz" id="timelineViz"></div> | |
| <!-- Cumulative stats --> | |
| <div class="cumulative-section"> | |
| <div style="font-weight: 700; font-size: 0.95rem; margin-bottom: 0.3rem;">Cumulative Representation</div> | |
| <div style="font-size: 0.82rem; color: var(--text2); margin-bottom: 0.5rem;"> | |
| Groups represented across all panels so far: | |
| </div> | |
| <div class="cumulative-bar-track" id="cumulBar"></div> | |
| <div class="cumulative-stats" id="cumulStats"></div> | |
| </div> | |
| </div> | |
| </section> | |
| <div class="divider"></div> | |
| <!-- THREE GOALS / AXIOMS --> | |
| <section class="fade-up"> | |
| <div class="section-label">Fairness Goals</div> | |
| <h2>The Three Pillars of Temporal Sortition</h2> | |
| <p class="section-desc"> | |
| The paper formalizes three requirements that a good temporal selection algorithm must satisfy simultaneously. | |
| </p> | |
| <div class="cards-grid" style="grid-template-columns: repeat(3, 1fr);"> | |
| <div class="card" style="border-top: 3px solid var(--accent);"> | |
| <div class="card-icon" style="background: rgba(108,99,255,0.15);">👤</div> | |
| <h3>Individual Fairness</h3> | |
| <p>Every person has an <strong>equal probability</strong> of being selected across all panels. No one gets preferential treatment.</p> | |
| </div> | |
| <div class="card" style="border-top: 3px solid var(--accent2);"> | |
| <div class="card-icon" style="background: rgba(255,101,132,0.15);">📋</div> | |
| <h3>Per-Panel Representation</h3> | |
| <p>Each individual panel must proportionally represent every sufficiently large group. Big groups always get a voice.</p> | |
| </div> | |
| <div class="card" style="border-top: 3px solid var(--accent3);"> | |
| <div class="card-icon" style="background: rgba(67,233,123,0.15);">📈</div> | |
| <h3>Prefix Representation</h3> | |
| <p>At any point in time, the <em>cumulative</em> set of all panels so far must be representative — not just the final total.</p> | |
| </div> | |
| </div> | |
| </section> | |
| <div class="divider"></div> | |
| <!-- HOW IT WORKS STEPPER --> | |
| <section class="fade-up"> | |
| <div class="section-label">Process</div> | |
| <h2>How the Algorithms Work</h2> | |
| <p class="section-desc"> | |
| The paper proposes clever algorithmic strategies. Here's the intuition behind the chain-based approach (Section 5). | |
| </p> | |
| <div class="stepper" id="stepper"> | |
| <div class="step active" onclick="setStep(0)"> | |
| <div class="step-num">1</div> | |
| <div class="step-title">Build Groups</div> | |
| </div> | |
| <div class="step" onclick="setStep(1)"> | |
| <div class="step-num">2</div> | |
| <div class="step-title">Link Chains</div> | |
| </div> | |
| <div class="step" onclick="setStep(2)"> | |
| <div class="step-num">3</div> | |
| <div class="step-title">Assign Priorities</div> | |
| </div> | |
| <div class="step" onclick="setStep(3)"> | |
| <div class="step-num">4</div> | |
| <div class="step-title">Fill Panels</div> | |
| </div> | |
| </div> | |
| <div class="step-content-area" id="stepContent"> | |
| <p id="stepText"></p> | |
| </div> | |
| </section> | |
| <div class="divider"></div> | |
| <!-- APPROACHES --> | |
| <section id="approaches" class="fade-up"> | |
| <div class="section-label">Technical Contributions</div> | |
| <h2>Three Approaches, Three Tradeoffs</h2> | |
| <p class="section-desc"> | |
| The paper presents three algorithms, each striking a different balance between strength of guarantees and computational tractability. | |
| </p> | |
| <div class="approach-grid"> | |
| <div class="approach-card"> | |
| <div class="approach-num">01</div> | |
| <div class="tag" style="background: rgba(108,99,255,0.2); color: var(--accent);">Section 3 · Warm-up</div> | |
| <h3>Panel + Global Representation</h3> | |
| <p>Select a big global panel, then partition it into sub-panels. Each sub-panel and the union of all panels are guaranteed proportional representation.</p> | |
| <div class="guarantee"> | |
| <span class="guarantee-badge">✅ Per-Panel: O(1)-PRF</span> | |
| <span class="guarantee-badge">✅ Global: O(1)-PRF</span> | |
| <span class="guarantee-badge">✅ Individual Fairness</span> | |
| <span class="guarantee-badge">⚠️ No Prefix Guarantee</span> | |
| </div> | |
| </div> | |
| <div class="approach-card"> | |
| <div class="approach-num">02</div> | |
| <div class="tag" style="background: rgba(255,101,132,0.2); color: var(--accent2);">Section 4 · Nested</div> | |
| <h3>Panel + Prefix Representation</h3> | |
| <p>Build a hierarchical tree of nested groups and carefully assign representatives so every prefix of panels is representative — but approximation grows exponentially.</p> | |
| <div class="guarantee"> | |
| <span class="guarantee-badge">✅ Per-Panel: O(4<sup>ℓ</sup>)-PFC</span> | |
| <span class="guarantee-badge">✅ Prefix: O(4<sup>ℓ-t</sup>)-PFC</span> | |
| <span class="guarantee-badge">✅ Individual Fairness</span> | |
| <span class="guarantee-badge">⚠️ Exponential Blowup</span> | |
| </div> | |
| </div> | |
| <div class="approach-card"> | |
| <div class="approach-num">03</div> | |
| <div class="tag" style="background: rgba(67,233,123,0.2); color: var(--accent3);">Section 5 · Chains</div> | |
| <h3>Prefix Representation Only</h3> | |
| <p>Link groups across panel sizes into chains. Representing the last group in each chain covers all earlier ones. Achieves constant-factor approximation for every prefix!</p> | |
| <div class="guarantee"> | |
| <span class="guarantee-badge">✅ Prefix: O(1)-PFC</span> | |
| <span class="guarantee-badge">✅ Individual Fairness</span> | |
| <span class="guarantee-badge">❌ No Per-Panel Guarantee</span> | |
| <span class="guarantee-badge">⭐ Constant Factor!</span> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <div class="divider"></div> | |
| <!-- KEY RESULTS --> | |
| <section id="results" class="fade-up"> | |
| <div class="section-label">Takeaways</div> | |
| <h2>Key Results & Open Questions</h2> | |
| <p class="section-desc"> | |
| This work lays the theoretical foundation for fair ongoing citizens' assemblies, but several fascinating questions remain. | |
| </p> | |
| <div class="cards-grid" style="grid-template-columns: 1fr 1fr;"> | |
| <div class="card" style="border-left: 3px solid var(--accent3);"> | |
| <h3>🎯 What They Proved</h3> | |
| <p style="margin-top: 0.5rem;"> | |
| <strong>Constant-factor prefix representation</strong> is achievable: at every point in time, the cumulative panel fairly represents the whole population within a fixed approximation ratio.<br><br> | |
| <strong>Surprising impossibility:</strong> When extracting a smaller panel from a larger one, if the sizes aren't divisible, no finite approximation is possible!<br><br> | |
| <strong>Individual fairness</strong> (everyone has equal probability of selection) can be maintained simultaneously with representation. | |
| </p> | |
| </div> | |
| <div class="card" style="border-left: 3px solid var(--accent4);"> | |
| <h3>❓ Open Questions</h3> | |
| <p style="margin-top: 0.5rem;"> | |
| <strong>Q1:</strong> Can we get O(1)-PRF for both individual panels and the global panel when panels have different sizes?<br><br> | |
| <strong>Q2:</strong> Can we get O(1)-PFC (or PRF) for both individual panels <em>and</em> every prefix simultaneously?<br><br> | |
| <strong>Q3:</strong> Can we guarantee representation for <em>any consecutive subsequence</em> of panels, not just prefixes?<br><br> | |
| <strong>Q4:</strong> Can we do all this <em>without</em> knowing in advance how many panels there will be? | |
| </p> | |
| </div> | |
| </div> | |
| <div class="insight-box" style="margin-top: 2rem;"> | |
| <p> | |
| <strong>Why This Matters:</strong> As permanent citizens' assemblies spread (Belgium, the EU, and beyond), these algorithms ensure that even small communities—too small for any single panel—will eventually have their voices heard. It's mathematics in service of democracy. | |
| </p> | |
| </div> | |
| </section> | |
| <footer> | |
| <p> | |
| Based on <a href="https://arxiv.org/abs/2602.16194" target="_blank">"Temporal Panel Selection in Ongoing Citizens' Assemblies"</a> | |
| by Yusuf Hakan Kalaycı & Evi Micha (USC), arXiv 2026. | |
| </p> | |
| <p style="margin-top: 0.5rem; font-size: 0.75rem; color: #555;"> | |
| Interactive explainer — not affiliated with the authors. | |
| </p> | |
| </footer> | |
| <script> | |
| // ===================== PARTICLES ===================== | |
| (function() { | |
| const container = document.getElementById('particles'); | |
| const colors = ['#6c63ff','#ff6584','#43e97b','#f9d423']; | |
| for (let i = 0; i < 20; i++) { | |
| const p = document.createElement('div'); | |
| p.className = 'particle'; | |
| const size = Math.random() * 4 + 2; | |
| p.style.width = size + 'px'; | |
| p.style.height = size + 'px'; | |
| p.style.left = Math.random() * 100 + '%'; | |
| p.style.background = colors[Math.floor(Math.random() * colors.length)]; | |
| p.style.animationDuration = (Math.random() * 20 + 15) + 's'; | |
| p.style.animationDelay = (Math.random() * 20) + 's'; | |
| container.appendChild(p); | |
| } | |
| })(); | |
| // ===================== SCROLL ANIMATIONS ===================== | |
| const observer = new IntersectionObserver((entries) => { | |
| entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible'); }); | |
| }, { threshold: 0.1 }); | |
| document.querySelectorAll('.fade-up').forEach(el => observer.observe(el)); | |
| // ===================== STEPPER ===================== | |
| const stepData = [ | |
| "For each possible prefix size (panels 1 through ℓ), run a greedy grouping algorithm on the entire population. This creates families of groups G¹, G², ... Gˡ — where Gᵗ contains the groups that must be represented within the first t panels. Bigger prefixes have more seats, so their groups can be smaller.", | |
| "For each group in an early family (say G¹), find a 'partner' group in G², then in G³, and so on, forming a chain: G¹→G²→...→Gˡ. The key: each successive group overlaps with its predecessor and has a radius no larger. When the radius shrinks by half, update the 'anchor' — this prevents error from piling up.", | |
| "The last group in each chain (in Gˡ) gets a priority label equal to the index of the chain's head. So if a chain starts from G², the endpoint in Gˡ gets priority 2, meaning its representative must appear within the first 2 panels.", | |
| "Sample one person from each group in Gˡ (plus extras to fill seats), then sort all sampled people by priority and fill panels from earliest to latest. People with low priority numbers get placed in early panels, ensuring every prefix gets the representation it needs. Each person in the population has equal probability of selection!" | |
| ]; | |
| function setStep(i) { | |
| document.querySelectorAll('.step').forEach((s,j) => s.classList.toggle('active', j===i)); | |
| document.getElementById('stepText').textContent = stepData[i]; | |
| } | |
| setStep(0); | |
| // ===================== METRIC CANVAS ===================== | |
| (function() { | |
| const canvas = document.getElementById('metricCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| let points = []; | |
| const colors = ['#6c63ff','#ff6584','#43e97b','#f9d423','#38bdf8','#fb923c','#a78bfa','#f472b6']; | |
| function drawMetric() { | |
| const dpr = window.devicePixelRatio || 1; | |
| const rect = canvas.getBoundingClientRect(); | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| ctx.scale(dpr, dpr); | |
| const w = rect.width, h = rect.height; | |
| ctx.clearRect(0,0,w,h); | |
| ctx.fillStyle = 'rgba(0,0,0,0.4)'; | |
| ctx.fillRect(0,0,w,h); | |
| // Draw connections | |
| for (let i = 0; i < points.length; i++) { | |
| for (let j = i+1; j < points.length; j++) { | |
| const dx = points[i].x - points[j].x; | |
| const dy = points[i].y - points[j].y; | |
| const dist = Math.sqrt(dx*dx + dy*dy); | |
| const maxDist = Math.sqrt(w*w + h*h); | |
| const alpha = Math.max(0, 0.3 - dist/maxDist * 0.5); | |
| ctx.beginPath(); | |
| ctx.moveTo(points[i].x, points[i].y); | |
| ctx.lineTo(points[j].x, points[j].y); | |
| ctx.strokeStyle = `rgba(108,99,255,${alpha})`; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| // Distance label | |
| if (points.length <= 8) { | |
| const mx = (points[i].x + points[j].x)/2; | |
| const my = (points[i].y + points[j].y)/2; | |
| ctx.fillStyle = `rgba(160,160,184,${alpha*2})`; | |
| ctx.font = '10px Inter'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(Math.round(dist)+'', mx, my - 4); | |
| } | |
| } | |
| } | |
| // Draw points | |
| points.forEach((p,i) => { | |
| // Glow | |
| const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 25); | |
| grad.addColorStop(0, colors[i % colors.length] + '44'); | |
| grad.addColorStop(1, 'transparent'); | |
| ctx.fillStyle = grad; | |
| ctx.fillRect(p.x-25, p.y-25, 50, 50); | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, 6, 0, Math.PI*2); | |
| ctx.fillStyle = colors[i % colors.length]; | |
| ctx.fill(); | |
| ctx.strokeStyle = '#fff'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = '11px Inter'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('P'+(i+1), p.x, p.y - 12); | |
| }); | |
| if (points.length === 0) { | |
| ctx.fillStyle = 'rgba(160,160,184,0.4)'; | |
| ctx.font = '14px Inter'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Click to add citizens to the metric space', w/2, h/2); | |
| } | |
| } | |
| canvas.addEventListener('click', (e) => { | |
| const rect = canvas.getBoundingClientRect(); | |
| points.push({ x: e.clientX - rect.left, y: e.clientY - rect.top }); | |
| if (points.length > 12) points.shift(); | |
| drawMetric(); | |
| }); | |
| window.addEventListener('resize', drawMetric); | |
| drawMetric(); | |
| })(); | |
| // ===================== MAIN DEMO ===================== | |
| const GROUPS = [ | |
| { name: 'Urban Professionals', color: '#6c63ff', size: 8, x: 150 }, | |
| { name: 'Rural Workers', color: '#ff6584', size: 4, x: 350 }, | |
| { name: 'Young Students', color: '#43e97b', size: 2, x: 550 }, | |
| { name: 'Elderly Retirees', color: '#f9d423', size: 2, x: 700 }, | |
| { name: 'Immigrant Community', color: '#38bdf8', size: 2, x: 820 }, | |
| ]; | |
| const TOTAL_POP = GROUPS.reduce((s,g) => s + g.size, 0); | |
| const PANEL_SIZE = 4; | |
| const NUM_PANELS = 4; | |
| let population = []; | |
| let panels = []; | |
| let currentPanel = 0; | |
| let autoPlaying = false; | |
| let autoTimer = null; | |
| function initPop() { | |
| population = []; | |
| let id = 0; | |
| GROUPS.forEach((g, gi) => { | |
| for (let i = 0; i < g.size; i++) { | |
| population.push({ | |
| id: id++, | |
| group: gi, | |
| label: g.name.split(' ').map(w => w[0]).join(''), | |
| x: g.x + (Math.random() - 0.5) * 60, | |
| y: 80 + Math.random() * 140, | |
| selected: -1, | |
| }); | |
| } | |
| }); | |
| } | |
| function drawPopCanvas() { | |
| const canvas = document.getElementById('popCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const dpr = window.devicePixelRatio || 1; | |
| const rect = canvas.getBoundingClientRect(); | |
| canvas.width = rect.width * dpr; | |
| canvas.height = 300 * dpr; | |
| ctx.scale(dpr, dpr); | |
| const w = rect.width, h = 300; | |
| const scaleX = w / 900; | |
| ctx.clearRect(0,0,w,h); | |
| // Group labels | |
| GROUPS.forEach(g => { | |
| ctx.fillStyle = g.color + '22'; | |
| ctx.beginPath(); | |
| ctx.ellipse(g.x * scaleX, 150, 50 * scaleX, 80, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| ctx.fillStyle = g.color + '88'; | |
| ctx.font = `600 ${11}px Inter`; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(g.name, g.x * scaleX, 270); | |
| ctx.fillText(`(${Math.round(g.size/TOTAL_POP*100)}%)`, g.x * scaleX, 284); | |
| }); | |
| // People | |
| population.forEach(p => { | |
| const px = p.x * scaleX; | |
| const py = p.y; | |
| const isSelected = p.selected >= 0; | |
| const radius = isSelected ? 10 : 7; | |
| if (isSelected) { | |
| const grad = ctx.createRadialGradient(px, py, 0, px, py, 20); | |
| grad.addColorStop(0, GROUPS[p.group].color + '66'); | |
| grad.addColorStop(1, 'transparent'); | |
| ctx.fillStyle = grad; | |
| ctx.fillRect(px-20, py-20, 40, 40); | |
| } | |
| ctx.beginPath(); | |
| ctx.arc(px, py, radius, 0, Math.PI*2); | |
| ctx.fillStyle = isSelected ? GROUPS[p.group].color : GROUPS[p.group].color + '55'; | |
| ctx.fill(); | |
| if (isSelected) { | |
| ctx.strokeStyle = '#fff'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = 'bold 8px Inter'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('P'+(p.selected+1), px, py + 3); | |
| } | |
| }); | |
| } | |
| // Build legend | |
| function buildLegend() { | |
| const el = document.getElementById('popLegend'); | |
| el.innerHTML = GROUPS.map(g => | |
| `<div class="pop-legend-item"><div class="pop-legend-dot" style="background:${g.color}"></div>${g.name} (${Math.round(g.size/TOTAL_POP*100)}%)</div>` | |
| ).join(''); | |
| } | |
| function buildTimeline() { | |
| const el = document.getElementById('timelineViz'); | |
| let html = ''; | |
| for (let i = 0; i < panels.length; i++) { | |
| const members = panels[i]; | |
| html += `<div class="timeline-panel"> | |
| <div class="timeline-label"> | |
| <span class="panel-name">Panel ${i+1}</span> | |
| <span class="panel-time">Round ${i+1}</span> | |
| </div> | |
| <div class="timeline-members">`; | |
| members.forEach(pid => { | |
| const p = population[pid]; | |
| const g = GROUPS[p.group]; | |
| html += `<div class="member-dot" style="background:${g.color};"> | |
| ${p.label} | |
| <div class="tooltip">${g.name}</div> | |
| </div>`; | |
| }); | |
| // Fill empty slots | |
| for (let j = members.length; j < PANEL_SIZE; j++) { | |
| html += `<div class="member-dot" style="background:rgba(255,255,255,0.05); border: 1px dashed rgba(255,255,255,0.15);">?</div>`; | |
| } | |
| html += `</div></div>`; | |
| } | |
| // Upcoming panels | |
| for (let i = panels.length; i < NUM_PANELS; i++) { | |
| html += `<div class="timeline-panel" style="opacity:0.3;"> | |
| <div class="timeline-label"> | |
| <span class="panel-name">Panel ${i+1}</span> | |
| <span class="panel-time">Round ${i+1}</span> | |
| </div> | |
| <div class="timeline-members">`; | |
| for (let j = 0; j < PANEL_SIZE; j++) { | |
| html += `<div class="member-dot" style="background:rgba(255,255,255,0.05); border: 1px dashed rgba(255,255,255,0.1);">?</div>`; | |
| } | |
| html += `</div></div>`; | |
| } | |
| el.innerHTML = html; | |
| } | |
| function updateCumulative() { | |
| const represented = new Set(); | |
| panels.forEach(panel => { | |
| panel.forEach(pid => represented.add(population[pid].group)); | |
| }); | |
| const bar = document.getElementById('cumulBar'); | |
| let barHTML = ''; | |
| GROUPS.forEach((g, i) => { | |
| const w = (g.size / TOTAL_POP * 100); | |
| const active = represented.has(i); | |
| barHTML += `<div class="cumulative-bar-segment" style="width:${w}%; background:${active ? g.color : 'rgba(255,255,255,0.05)'};"></div>`; | |
| }); | |
| bar.innerHTML = barHTML; | |
| const stats = document.getElementById('cumulStats'); | |
| const totalSeats = panels.reduce((s,p) => s + p.length, 0); | |
| stats.innerHTML = ` | |
| <div class="stat-item"> | |
| <div class="stat-value" style="color: var(--accent);">${panels.length}/${NUM_PANELS}</div> | |
| <div class="stat-label">Panels Selected</div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-value" style="color: var(--accent2);">${totalSeats}</div> | |
| <div class="stat-label">Total Seats Filled</div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-value" style="color: var(--accent3);">${represented.size}/${GROUPS.length}</div> | |
| <div class="stat-label">Groups Represented</div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-value" style="color: var(--accent4);">${Math.round(represented.size / GROUPS.length * 100)}%</div> | |
| <div class="stat-label">Coverage</div> | |
| </div> | |
| `; | |
| } | |
| // Temporal sortition-inspired selection | |
| function selectPanel() { | |
| const available = population.filter(p => p.selected < 0); | |
| if (available.length < PANEL_SIZE) return []; | |
| // Greedy: prioritize unrepresented groups, then largest groups | |
| const representedSoFar = new Set(); | |
| panels.forEach(panel => panel.forEach(pid => representedSoFar.add(population[pid].group))); | |
| const selected = []; | |
| const usedGroups = new Set(); | |
| // First, try to represent unrepresented groups | |
| for (let gi = GROUPS.length - 1; gi >= 0; gi--) { | |
| if (selected.length >= PANEL_SIZE) break; | |
| if (!representedSoFar.has(gi) && !usedGroups.has(gi)) { | |
| const candidates = available.filter(p => p.group === gi && !selected.includes(p.id)); | |
| if (candidates.length > 0) { | |
| const pick = candidates[Math.floor(Math.random() * candidates.length)]; | |
| selected.push(pick.id); | |
| usedGroups.add(gi); | |
| } | |
| } | |
| } | |
| // Then fill proportionally from largest groups | |
| const groupOrder = GROUPS.map((g,i) => ({ i, size: g.size })).sort((a,b) => b.size - a.size); | |
| for (const go of groupOrder) { | |
| if (selected.length >= PANEL_SIZE) break; | |
| if (!usedGroups.has(go.i)) { | |
| const candidates = available.filter(p => p.group === go.i && !selected.includes(p.id)); | |
| if (candidates.length > 0) { | |
| const pick = candidates[Math.floor(Math.random() * candidates.length)]; | |
| selected.push(pick.id); | |
| usedGroups.add(go.i); | |
| } | |
| } | |
| } | |
| // Fill any remaining with random | |
| while (selected.length < PANEL_SIZE) { | |
| const remaining = available.filter(p => !selected.includes(p.id)); | |
| if (remaining.length === 0) break; | |
| const pick = remaining[Math.floor(Math.random() * remaining.length)]; | |
| selected.push(pick.id); | |
| } | |
| return selected; | |
| } | |
| function advancePanel() { | |
| if (currentPanel >= NUM_PANELS) return; | |
| const sel = selectPanel(); | |
| sel.forEach(pid => { population[pid].selected = currentPanel; }); | |
| panels.push(sel); | |
| currentPanel++; | |
| render(); | |
| if (currentPanel >= NUM_PANELS) { | |
| document.getElementById('advanceBtn').style.opacity = 0.4; | |
| stopAuto(); | |
| } | |
| } | |
| function autoPlay() { | |
| if (autoPlaying) { stopAuto(); return; } | |
| autoPlaying = true; | |
| document.getElementById('autoBtn').textContent = '⏸ Pause'; | |
| document.getElementById('autoBtn').classList.add('active'); | |
| autoTimer = setInterval(() => { | |
| if (currentPanel >= NUM_PANELS) { stopAuto(); return; } | |
| advancePanel(); | |
| }, 1200); | |
| } | |
| function stopAuto() { | |
| autoPlaying = false; | |
| document.getElementById('autoBtn').textContent = '▶ Auto Play'; | |
| document.getElementById('autoBtn').classList.remove('active'); | |
| if (autoTimer) clearInterval(autoTimer); | |
| } | |
| function resetDemo() { | |
| stopAuto(); | |
| currentPanel = 0; | |
| panels = []; | |
| initPop(); | |
| document.getElementById('advanceBtn').style.opacity = 1; | |
| render(); | |
| } | |
| function render() { | |
| drawPopCanvas(); | |
| buildTimeline(); | |
| updateCumulative(); | |
| } | |
| // Init | |
| initPop(); | |
| buildLegend(); | |
| render(); | |
| window.addEventListener('resize', render); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment