Created
February 22, 2026 14:47
-
-
Save jbdamask/d3724a0208005cb985295da121ecef9c 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: #f7971e; | |
| --text: #e8e8f0; | |
| --text2: #9999b0; | |
| --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; | |
| } | |
| /* Scrollbar */ | |
| ::-webkit-scrollbar { width: 6px; } | |
| ::-webkit-scrollbar-track { background: var(--bg); } | |
| ::-webkit-scrollbar-thumb { background: var(--accent); border-radius: 3px; } | |
| /* Canvas background */ | |
| #bg-canvas { | |
| position: fixed; | |
| top: 0; left: 0; | |
| width: 100%; height: 100%; | |
| z-index: 0; | |
| pointer-events: none; | |
| } | |
| /* Navigation */ | |
| nav { | |
| position: fixed; | |
| top: 0; left: 0; right: 0; | |
| z-index: 100; | |
| padding: 1rem 2rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| backdrop-filter: blur(20px); | |
| background: rgba(10,10,15,0.7); | |
| border-bottom: 1px solid var(--glass-border); | |
| transition: transform 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%; } | |
| /* Sections */ | |
| section { | |
| position: relative; | |
| z-index: 1; | |
| padding: 6rem 2rem; | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| } | |
| /* Hero */ | |
| .hero { | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| text-align: center; | |
| padding-top: 5rem; | |
| } | |
| .hero .badge { | |
| display: inline-block; | |
| padding: 0.4rem 1.2rem; | |
| border-radius: 50px; | |
| background: rgba(108,99,255,0.15); | |
| border: 1px solid rgba(108,99,255,0.3); | |
| color: var(--accent); | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| margin-bottom: 2rem; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| } | |
| .hero h1 { | |
| font-size: clamp(2.5rem, 6vw, 4.5rem); | |
| font-weight: 900; | |
| line-height: 1.1; | |
| margin-bottom: 1.5rem; | |
| background: linear-gradient(135deg, #fff 0%, #6c63ff 50%, #ff6584 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| max-width: 900px; | |
| } | |
| .hero p { | |
| font-size: 1.15rem; | |
| color: var(--text2); | |
| max-width: 650px; | |
| margin-bottom: 3rem; | |
| line-height: 1.8; | |
| } | |
| .hero .scroll-indicator { | |
| margin-top: 2rem; | |
| animation: bounce 2s infinite; | |
| color: var(--accent); | |
| font-size: 1.5rem; | |
| } | |
| @keyframes bounce { | |
| 0%, 100% { transform: translateY(0); } | |
| 50% { transform: translateY(10px); } | |
| } | |
| /* Section headers */ | |
| .section-label { | |
| display: inline-block; | |
| padding: 0.3rem 1rem; | |
| border-radius: 50px; | |
| font-size: 0.75rem; | |
| font-weight: 700; | |
| letter-spacing: 2px; | |
| text-transform: uppercase; | |
| margin-bottom: 1rem; | |
| } | |
| .section-title { | |
| font-size: clamp(2rem, 4vw, 3rem); | |
| font-weight: 800; | |
| margin-bottom: 1rem; | |
| line-height: 1.2; | |
| } | |
| .section-desc { | |
| font-size: 1.05rem; | |
| color: var(--text2); | |
| max-width: 700px; | |
| margin-bottom: 3rem; | |
| } | |
| /* Cards */ | |
| .card-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | |
| gap: 1.5rem; | |
| margin-top: 2rem; | |
| } | |
| .card { | |
| background: var(--glass); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 16px; | |
| padding: 2rem; | |
| transition: transform 0.3s, border-color 0.3s, box-shadow 0.3s; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .card:hover { | |
| transform: translateY(-4px); | |
| border-color: rgba(108,99,255,0.3); | |
| box-shadow: 0 20px 60px rgba(108,99,255,0.1); | |
| } | |
| .card .card-icon { | |
| width: 48px; height: 48px; | |
| border-radius: 12px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 1.5rem; | |
| margin-bottom: 1.2rem; | |
| } | |
| .card h3 { | |
| font-size: 1.15rem; | |
| font-weight: 700; | |
| margin-bottom: 0.7rem; | |
| } | |
| .card p { | |
| font-size: 0.9rem; | |
| color: var(--text2); | |
| line-height: 1.7; | |
| } | |
| /* Interactive Demo */ | |
| .demo-container { | |
| background: var(--glass); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 20px; | |
| 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 50%); | |
| pointer-events: none; | |
| } | |
| .demo-controls { | |
| display: flex; | |
| gap: 1rem; | |
| margin-bottom: 2rem; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .demo-btn { | |
| padding: 0.6rem 1.4rem; | |
| border-radius: 10px; | |
| border: 1px solid var(--glass-border); | |
| background: var(--glass); | |
| color: var(--text); | |
| font-family: inherit; | |
| font-size: 0.85rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| } | |
| .demo-btn:hover, .demo-btn.active { | |
| background: var(--accent); | |
| border-color: var(--accent); | |
| color: #fff; | |
| box-shadow: 0 4px 20px rgba(108,99,255,0.4); | |
| } | |
| .demo-btn.reset { | |
| background: rgba(255,101,132,0.1); | |
| border-color: rgba(255,101,132,0.3); | |
| color: var(--accent2); | |
| } | |
| .demo-btn.reset:hover { | |
| background: var(--accent2); | |
| color: #fff; | |
| } | |
| .demo-canvas-wrap { | |
| position: relative; | |
| width: 100%; | |
| height: 400px; | |
| border-radius: 12px; | |
| background: rgba(0,0,0,0.3); | |
| border: 1px solid var(--glass-border); | |
| overflow: hidden; | |
| } | |
| #demo-canvas { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| } | |
| .demo-legend { | |
| display: flex; | |
| gap: 1.5rem; | |
| margin-top: 1.5rem; | |
| flex-wrap: wrap; | |
| } | |
| .legend-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| font-size: 0.8rem; | |
| color: var(--text2); | |
| } | |
| .legend-dot { | |
| width: 12px; height: 12px; | |
| border-radius: 50%; | |
| } | |
| /* Timeline */ | |
| .timeline { | |
| position: relative; | |
| padding-left: 3rem; | |
| margin-top: 2rem; | |
| } | |
| .timeline::before { | |
| content: ''; | |
| position: absolute; | |
| left: 0; top: 0; bottom: 0; | |
| width: 2px; | |
| background: linear-gradient(180deg, var(--accent), var(--accent2), var(--accent3)); | |
| } | |
| .timeline-item { | |
| position: relative; | |
| margin-bottom: 3rem; | |
| padding: 1.5rem 2rem; | |
| background: var(--glass); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 12px; | |
| transition: border-color 0.3s; | |
| } | |
| .timeline-item:hover { | |
| border-color: rgba(108,99,255,0.3); | |
| } | |
| .timeline-item::before { | |
| content: ''; | |
| position: absolute; | |
| left: -3rem; | |
| top: 1.8rem; | |
| width: 14px; height: 14px; | |
| border-radius: 50%; | |
| border: 3px solid var(--accent); | |
| background: var(--bg); | |
| transform: translateX(-6px); | |
| } | |
| .timeline-item .step-number { | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 0.75rem; | |
| color: var(--accent); | |
| font-weight: 600; | |
| margin-bottom: 0.5rem; | |
| } | |
| .timeline-item h4 { | |
| font-size: 1.1rem; | |
| font-weight: 700; | |
| margin-bottom: 0.5rem; | |
| } | |
| .timeline-item p { | |
| font-size: 0.9rem; | |
| color: var(--text2); | |
| } | |
| /* Comparison Table */ | |
| .comparison-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| margin-top: 2rem; | |
| font-size: 0.9rem; | |
| } | |
| .comparison-table th { | |
| text-align: left; | |
| padding: 1rem 1.2rem; | |
| font-weight: 700; | |
| font-size: 0.8rem; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| color: var(--text2); | |
| border-bottom: 2px solid var(--glass-border); | |
| } | |
| .comparison-table td { | |
| padding: 1rem 1.2rem; | |
| border-bottom: 1px solid var(--glass-border); | |
| color: var(--text2); | |
| } | |
| .comparison-table tr:hover td { | |
| background: var(--glass); | |
| } | |
| .comparison-table .check { | |
| color: var(--accent3); | |
| font-weight: 700; | |
| font-size: 1.1rem; | |
| } | |
| .comparison-table .cross { | |
| color: var(--accent2); | |
| font-weight: 700; | |
| font-size: 1rem; | |
| } | |
| .comparison-table .approx { | |
| font-family: 'JetBrains Mono', monospace; | |
| color: var(--accent4); | |
| font-weight: 600; | |
| font-size: 0.85rem; | |
| } | |
| /* Toy example interactive */ | |
| .toy-example { | |
| display: flex; | |
| gap: 2rem; | |
| margin-top: 2rem; | |
| flex-wrap: wrap; | |
| } | |
| .toy-population, .toy-panels { | |
| flex: 1; | |
| min-width: 300px; | |
| } | |
| .toy-population h4, .toy-panels h4 { | |
| font-size: 0.9rem; | |
| font-weight: 700; | |
| margin-bottom: 1rem; | |
| color: var(--text2); | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| .group-bar { | |
| display: flex; | |
| align-items: center; | |
| margin-bottom: 0.8rem; | |
| gap: 0.8rem; | |
| } | |
| .group-bar .label { | |
| width: 80px; | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| color: var(--text2); | |
| } | |
| .group-bar .bar-wrap { | |
| flex: 1; | |
| height: 28px; | |
| background: rgba(255,255,255,0.05); | |
| border-radius: 6px; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| .group-bar .bar { | |
| height: 100%; | |
| border-radius: 6px; | |
| transition: width 0.8s ease; | |
| } | |
| .group-bar .bar-text { | |
| position: absolute; | |
| right: 8px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| font-size: 0.7rem; | |
| font-weight: 600; | |
| color: rgba(255,255,255,0.7); | |
| } | |
| .panel-visual { | |
| display: flex; | |
| gap: 0.5rem; | |
| margin-bottom: 1.5rem; | |
| align-items: center; | |
| } | |
| .panel-visual .panel-label { | |
| font-size: 0.8rem; | |
| font-weight: 700; | |
| color: var(--text2); | |
| width: 70px; | |
| } | |
| .seat { | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 8px; | |
| border: 2px solid var(--glass-border); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.7rem; | |
| font-weight: 700; | |
| transition: all 0.5s; | |
| position: relative; | |
| } | |
| .seat.filled { | |
| border-color: transparent; | |
| color: #fff; | |
| } | |
| /* Stats row */ | |
| .stats-row { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 1.5rem; | |
| margin-top: 2rem; | |
| } | |
| .stat-card { | |
| background: var(--glass); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 16px; | |
| padding: 1.5rem; | |
| text-align: center; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .stat-card .stat-value { | |
| font-size: 2.5rem; | |
| font-weight: 900; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| line-height: 1; | |
| margin-bottom: 0.5rem; | |
| } | |
| .stat-card .stat-label { | |
| font-size: 0.8rem; | |
| color: var(--text2); | |
| font-weight: 500; | |
| } | |
| /* Approach sections */ | |
| .approach-card { | |
| background: var(--glass); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 20px; | |
| padding: 2.5rem; | |
| margin-bottom: 2rem; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .approach-card .approach-number { | |
| position: absolute; | |
| top: -20px; right: 20px; | |
| font-size: 8rem; | |
| font-weight: 900; | |
| color: rgba(108,99,255,0.05); | |
| line-height: 1; | |
| } | |
| .approach-card .approach-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.3rem; | |
| font-weight: 700; | |
| margin-bottom: 0.8rem; | |
| } | |
| .approach-card p { | |
| color: var(--text2); | |
| font-size: 0.95rem; | |
| margin-bottom: 1.2rem; | |
| } | |
| .guarantee-pills { | |
| display: flex; | |
| gap: 0.6rem; | |
| flex-wrap: wrap; | |
| } | |
| .pill { | |
| padding: 0.4rem 0.9rem; | |
| border-radius: 50px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| border: 1px solid; | |
| } | |
| /* Footer */ | |
| footer { | |
| position: relative; | |
| z-index: 1; | |
| text-align: center; | |
| padding: 4rem 2rem; | |
| border-top: 1px solid var(--glass-border); | |
| color: var(--text2); | |
| font-size: 0.85rem; | |
| } | |
| footer a { | |
| color: var(--accent); | |
| text-decoration: none; | |
| } | |
| /* Animations */ | |
| .fade-in { | |
| opacity: 0; | |
| transform: translateY(30px); | |
| transition: opacity 0.8s ease, transform 0.8s ease; | |
| } | |
| .fade-in.visible { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| /* Responsive */ | |
| @media (max-width: 768px) { | |
| nav .nav-links { display: none; } | |
| section { padding: 4rem 1.2rem; } | |
| .demo-canvas-wrap { height: 300px; } | |
| .toy-example { flex-direction: column; } | |
| } | |
| /* Metric space visualization */ | |
| .metric-viz { | |
| width: 100%; | |
| height: 300px; | |
| border-radius: 12px; | |
| background: rgba(0,0,0,0.3); | |
| border: 1px solid var(--glass-border); | |
| position: relative; | |
| overflow: hidden; | |
| margin-top: 1.5rem; | |
| } | |
| #metric-canvas { | |
| width: 100%; | |
| height: 100%; | |
| } | |
| /* Glow effects */ | |
| .glow-accent { | |
| position: absolute; | |
| width: 400px; | |
| height: 400px; | |
| border-radius: 50%; | |
| filter: blur(120px); | |
| opacity: 0.15; | |
| pointer-events: none; | |
| } | |
| /* Code block style */ | |
| .formula-block { | |
| background: rgba(0,0,0,0.4); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 10px; | |
| padding: 1.2rem 1.5rem; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 0.85rem; | |
| color: var(--accent); | |
| margin: 1rem 0; | |
| overflow-x: auto; | |
| } | |
| .highlight { | |
| color: var(--accent3); | |
| font-weight: 600; | |
| } | |
| .highlight2 { | |
| color: var(--accent2); | |
| font-weight: 600; | |
| } | |
| .highlight3 { | |
| color: var(--accent4); | |
| font-weight: 600; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="bg-canvas"></canvas> | |
| <nav> | |
| <div class="logo">Temporal Sortition</div> | |
| <ul class="nav-links"> | |
| <li><a href="#problem">The Problem</a></li> | |
| <li><a href="#how">How It Works</a></li> | |
| <li><a href="#demo">Demo</a></li> | |
| <li><a href="#approaches">Approaches</a></li> | |
| <li><a href="#results">Results</a></li> | |
| </ul> | |
| </nav> | |
| <!-- HERO --> | |
| <section class="hero"> | |
| <div class="badge">📄 Research Paper Explained</div> | |
| <h1>Making Democracy Fair Over Time</h1> | |
| <p>How can rotating citizen panels represent <em>everyone</em>—even small groups—when no single panel is big enough? A new framework for temporal sortition makes it mathematically possible.</p> | |
| <div style="display: flex; gap: 1rem; flex-wrap: wrap; justify-content: center;"> | |
| <a href="#problem" class="demo-btn" style="text-decoration:none;">Learn More ↓</a> | |
| <a href="#demo" class="demo-btn active" style="text-decoration:none;">Try the Demo ⚡</a> | |
| </div> | |
| <div class="scroll-indicator">↓</div> | |
| </section> | |
| <!-- PROBLEM --> | |
| <section id="problem" class="fade-in"> | |
| <div class="section-label" style="background: rgba(108,99,255,0.15); color: var(--accent);">The Challenge</div> | |
| <h2 class="section-title">What Are Citizens' Assemblies?</h2> | |
| <p class="section-desc">Citizens' assemblies randomly select everyday people to deliberate on policy issues. Think of it as jury duty for democracy. But there's a fundamental tension between panel size and representation.</p> | |
| <div class="card-grid"> | |
| <div class="card"> | |
| <div class="card-icon" style="background: rgba(108,99,255,0.15); color: var(--accent);">🎲</div> | |
| <h3>Sortition</h3> | |
| <p>People are randomly selected to form a panel, like ancient Athenian democracy. This avoids the biases of elections and ensures diverse perspectives.</p> | |
| </div> | |
| <div class="card"> | |
| <div class="card-icon" style="background: rgba(255,101,132,0.15); color: var(--accent2);">⚖️</div> | |
| <h3>Representation</h3> | |
| <p>Every sufficiently large group in the population should have someone "like them" on the panel. Groups are defined by similarity in a mathematical space.</p> | |
| </div> | |
| <div class="card"> | |
| <div class="card-icon" style="background: rgba(67,233,123,0.15); color: var(--accent3);">🔄</div> | |
| <h3>Fairness Over Time</h3> | |
| <p>Permanent assemblies rotate members periodically. This creates an opportunity: groups too small for one panel can be represented across multiple panels.</p> | |
| </div> | |
| </div> | |
| <div class="demo-container" style="margin-top: 3rem;"> | |
| <h3 style="margin-bottom: 0.5rem;">The Representation Problem</h3> | |
| <p style="color: var(--text2); font-size: 0.9rem; margin-bottom: 1.5rem;">Imagine a population with 4 groups of different sizes, and a panel with only 4 seats. The smallest groups can't both fit!</p> | |
| <div class="toy-example" id="toy-example"> | |
| <div class="toy-population"> | |
| <h4>Population Distribution</h4> | |
| <div class="group-bar"> | |
| <div class="label" style="color: #6c63ff;">Group A</div> | |
| <div class="bar-wrap"> | |
| <div class="bar" style="width: 50%; background: linear-gradient(90deg, #6c63ff, #8b83ff);"></div> | |
| <div class="bar-text">50%</div> | |
| </div> | |
| </div> | |
| <div class="group-bar"> | |
| <div class="label" style="color: #ff6584;">Group B</div> | |
| <div class="bar-wrap"> | |
| <div class="bar" style="width: 25%; background: linear-gradient(90deg, #ff6584, #ff8da3);"></div> | |
| <div class="bar-text">25%</div> | |
| </div> | |
| </div> | |
| <div class="group-bar"> | |
| <div class="label" style="color: #43e97b;">Group C</div> | |
| <div class="bar-wrap"> | |
| <div class="bar" style="width: 12.5%; background: linear-gradient(90deg, #43e97b, #66f09b);"></div> | |
| <div class="bar-text">12.5%</div> | |
| </div> | |
| </div> | |
| <div class="group-bar"> | |
| <div class="label" style="color: #f7971e;">Group D</div> | |
| <div class="bar-wrap"> | |
| <div class="bar" style="width: 12.5%; background: linear-gradient(90deg, #f7971e, #ffc247);"></div> | |
| <div class="bar-text">12.5%</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="toy-panels" id="toy-panels-container"> | |
| <h4>Panel Assignments (4 seats each)</h4> | |
| <div id="toy-panels-content"></div> | |
| <div style="margin-top: 1rem;"> | |
| <button class="demo-btn" id="toy-single-btn">Single Panel</button> | |
| <button class="demo-btn" id="toy-temporal-btn">Temporal (2 Panels)</button> | |
| </div> | |
| <p id="toy-explanation" style="font-size: 0.8rem; color: var(--text2); margin-top: 1rem;"></p> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- HOW IT WORKS --> | |
| <section id="how" class="fade-in"> | |
| <div class="section-label" style="background: rgba(67,233,123,0.15); color: var(--accent3);">Framework</div> | |
| <h2 class="section-title">How Temporal Sortition Works</h2> | |
| <p class="section-desc">People are placed in a "metric space" where distance measures how differently two people think or live. The closer two people are, the better one can represent the other.</p> | |
| <div class="metric-viz"> | |
| <canvas id="metric-canvas"></canvas> | |
| </div> | |
| <p style="font-size: 0.8rem; color: var(--text2); margin-top: 0.5rem; text-align: center;"> | |
| <em>Interactive: hover to see representation distances. Each cluster is a group of similar citizens.</em> | |
| </p> | |
| <div style="margin-top: 3rem;"> | |
| <h3 style="margin-bottom: 1.5rem;">Three Key Properties</h3> | |
| <div class="timeline"> | |
| <div class="timeline-item"> | |
| <div class="step-number">PROPERTY 01</div> | |
| <h4>Representation Per Panel</h4> | |
| <p>Every sufficiently large group of citizens gets at least one representative in each individual panel. A group of size ≥ <span class="highlight">n/k</span> (where n = population, k = panel size) deserves at least 1 seat.</p> | |
| </div> | |
| <div class="timeline-item"> | |
| <div class="step-number">PROPERTY 02</div> | |
| <h4>Representation Over Time (Prefix)</h4> | |
| <p>After <em>t</em> panels, the combined set of all selected members represents every group of size ≥ <span class="highlight2">n / Σk<sub>i</sub></span>. Smaller groups accumulate representation across rounds.</p> | |
| </div> | |
| <div class="timeline-item"> | |
| <div class="step-number">PROPERTY 03</div> | |
| <h4>Individual Fairness</h4> | |
| <p>Every citizen has an <span class="highlight3">equal probability</span> of being selected across the entire process. No one is systematically favored or excluded from participation.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="formula-block" style="margin-top: 2rem;"> | |
| <span style="color: var(--text2);">// Proportional Fairness Condition</span><br> | |
| For any group S with |S| ≥ n/k:<br> | |
| ∃ v ∈ S, p ∈ Panel : <span style="color: var(--accent3);">d(v, p)</span> ≤ α · <span style="color: var(--accent2);">min<sub>y</sub> max<sub>u∈S</sub> d(u, y)</span><br><br> | |
| <span style="color: var(--text2);">// Translation: someone on the panel is "close" to someone in the group,</span><br> | |
| <span style="color: var(--text2);">// relative to how spread out the group itself is.</span> | |
| </div> | |
| </section> | |
| <!-- INTERACTIVE DEMO --> | |
| <section id="demo" class="fade-in"> | |
| <div class="section-label" style="background: rgba(255,101,132,0.15); color: var(--accent2);">Interactive</div> | |
| <h2 class="section-title">See Temporal Selection in Action</h2> | |
| <p class="section-desc">Watch how the Greedy Capture algorithm selects panels over time. Citizens are dots in a 2D space; selected panel members are highlighted. Watch smaller groups get represented across rounds.</p> | |
| <div class="demo-container"> | |
| <div class="demo-controls"> | |
| <button class="demo-btn active" id="btn-step">Step Forward ▶</button> | |
| <button class="demo-btn" id="btn-auto">Auto Play ⏵⏵</button> | |
| <button class="demo-btn reset" id="btn-reset">Reset ↺</button> | |
| <span style="font-size: 0.8rem; color: var(--text2); margin-left: auto;" id="demo-status">Panel 1 / 3 — Seats: 0 / 4</span> | |
| </div> | |
| <div class="demo-canvas-wrap"> | |
| <canvas id="demo-canvas"></canvas> | |
| </div> | |
| <div class="demo-legend"> | |
| <div class="legend-item"><div class="legend-dot" style="background: #555;"></div> Unselected Citizen</div> | |
| <div class="legend-item"><div class="legend-dot" style="background: #6c63ff;"></div> Panel 1</div> | |
| <div class="legend-item"><div class="legend-dot" style="background: #ff6584;"></div> Panel 2</div> | |
| <div class="legend-item"><div class="legend-dot" style="background: #43e97b;"></div> Panel 3</div> | |
| <div class="legend-item"><div class="legend-dot" style="border: 2px solid #fff; box-sizing: border-box;"></div> Capture Radius</div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- THREE APPROACHES --> | |
| <section id="approaches" class="fade-in"> | |
| <div class="section-label" style="background: rgba(247,151,30,0.15); color: var(--accent4);">Algorithms</div> | |
| <h2 class="section-title">Three Approaches to Temporal Selection</h2> | |
| <p class="section-desc">The paper proposes three algorithms with different trade-offs between the strength of guarantees and computational tractability.</p> | |
| <div class="approach-card"> | |
| <div class="approach-number">1</div> | |
| <div class="approach-tag" style="background: rgba(108,99,255,0.15); color: var(--accent);">Section 3 — Warm-up</div> | |
| <h3>Panel + Global Representation</h3> | |
| <p>The simplest approach: first select a large pool of ℓ·k people with good representation, then partition them into ℓ panels. Each panel and the overall pool are well-represented.</p> | |
| <p><strong>Key insight:</strong> When a large panel's size is divisible by the smaller panel's size, the sub-panel inherits representation guarantees. But if it's <em>not</em> divisible—surprisingly—no finite guarantee is possible!</p> | |
| <div class="guarantee-pills"> | |
| <div class="pill" style="color: var(--accent3); border-color: rgba(67,233,123,0.3); background: rgba(67,233,123,0.1);">✓ Per-Panel O(1)-PRF</div> | |
| <div class="pill" style="color: var(--accent3); border-color: rgba(67,233,123,0.3); background: rgba(67,233,123,0.1);">✓ Global O(1)-PRF</div> | |
| <div class="pill" style="color: var(--accent2); border-color: rgba(255,101,132,0.3); background: rgba(255,101,132,0.1);">✗ Prefix Representation</div> | |
| <div class="pill" style="color: var(--accent3); border-color: rgba(67,233,123,0.3); background: rgba(67,233,123,0.1);">✓ Individual Fairness</div> | |
| </div> | |
| </div> | |
| <div class="approach-card"> | |
| <div class="approach-number">2</div> | |
| <div class="approach-tag" style="background: rgba(255,101,132,0.15); color: var(--accent2);">Section 4 — Nested Groups</div> | |
| <h3>Panel + Prefix Representation (Nested)</h3> | |
| <p>A hierarchical approach: build a tree of nested groups at different scales. Larger groups (needing representation in fewer panels) nest inside smaller ones. Walk the tree to assign representatives to panels in order.</p> | |
| <p><strong>Trade-off:</strong> Guarantees for both individual panels and all prefixes, but the approximation factor grows <span class="highlight2">exponentially</span> with the number of panels (O(4<sup>ℓ</sup>)).</p> | |
| <div class="guarantee-pills"> | |
| <div class="pill" style="color: var(--accent4); border-color: rgba(247,151,30,0.3); background: rgba(247,151,30,0.1);">~ Per-Panel O(4<sup>ℓ</sup>)-PFC</div> | |
| <div class="pill" style="color: var(--accent4); border-color: rgba(247,151,30,0.3); background: rgba(247,151,30,0.1);">~ Prefix O(4<sup>ℓ-t</sup>)-PFC</div> | |
| <div class="pill" style="color: var(--accent3); border-color: rgba(67,233,123,0.3); background: rgba(67,233,123,0.1);">✓ Individual Fairness</div> | |
| </div> | |
| </div> | |
| <div class="approach-card"> | |
| <div class="approach-number">3</div> | |
| <div class="approach-tag" style="background: rgba(67,233,123,0.15); color: var(--accent3);">Section 5 — Chain-Based</div> | |
| <h3>Prefix Representation (Chains) ⭐</h3> | |
| <p>The most elegant approach: instead of nesting groups, <strong>link</strong> them into chains across time levels. Groups at different scales are connected so that representing the endpoint of a chain automatically covers all groups along it.</p> | |
| <p><strong>Key innovation:</strong> Anchors prevent error accumulation. When a chain's group shrinks to half the radius, a new anchor is set, bounding total error to a <span class="highlight">geometric series</span> that converges to a constant!</p> | |
| <div class="guarantee-pills"> | |
| <div class="pill" style="color: var(--accent2); border-color: rgba(255,101,132,0.3); background: rgba(255,101,132,0.1);">✗ Per-Panel Guarantee</div> | |
| <div class="pill" style="color: var(--accent3); border-color: rgba(67,233,123,0.3); background: rgba(67,233,123,0.1);">✓ Prefix O(1)-PFC ⭐</div> | |
| <div class="pill" style="color: var(--accent3); border-color: rgba(67,233,123,0.3); background: rgba(67,233,123,0.1);">✓ Individual Fairness</div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- RESULTS COMPARISON --> | |
| <section id="results" class="fade-in"> | |
| <div class="section-label" style="background: rgba(108,99,255,0.15); color: var(--accent);">Results</div> | |
| <h2 class="section-title">Comparing the Approaches</h2> | |
| <p class="section-desc">Each algorithm makes different trade-offs. Here's a side-by-side summary.</p> | |
| <div style="overflow-x: auto;"> | |
| <table class="comparison-table"> | |
| <thead> | |
| <tr> | |
| <th>Property</th> | |
| <th>Approach 1<br><small>Panel + Global</small></th> | |
| <th>Approach 2<br><small>Nested Groups</small></th> | |
| <th>Approach 3<br><small>Chain-Based</small></th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr> | |
| <td><strong>Per-Panel Representation</strong></td> | |
| <td><span class="check">✓</span> <span class="approx">O(1)-PRF</span></td> | |
| <td><span class="check">✓</span> <span class="approx">O(4<sup>ℓ</sup>)-PFC</span></td> | |
| <td><span class="cross">✗</span></td> | |
| </tr> | |
| <tr> | |
| <td><strong>Global Representation</strong></td> | |
| <td><span class="check">✓</span> <span class="approx">O(1)-PRF</span></td> | |
| <td><span class="check">✓</span> <span class="approx">O(1)-PFC</span></td> | |
| <td><span class="check">✓</span> <span class="approx">O(1)-PFC</span></td> | |
| </tr> | |
| <tr> | |
| <td><strong>Prefix Representation</strong></td> | |
| <td><span class="cross">✗</span></td> | |
| <td><span class="check">✓</span> <span class="approx">O(4<sup>ℓ-t</sup>)-PFC</span></td> | |
| <td><span class="check">✓</span> <span class="approx">O(1)-PFC ⭐</span></td> | |
| </tr> | |
| <tr> | |
| <td><strong>Individual Fairness</strong></td> | |
| <td><span class="check">✓</span></td> | |
| <td><span class="check">✓</span></td> | |
| <td><span class="check">✓</span></td> | |
| </tr> | |
| <tr> | |
| <td><strong>Approximation Quality</strong></td> | |
| <td><span class="approx">Constant</span></td> | |
| <td><span class="approx">Exponential in ℓ</span></td> | |
| <td><span class="approx">Constant ⭐</span></td> | |
| </tr> | |
| <tr> | |
| <td><strong>Panel Sizes</strong></td> | |
| <td>Equal only</td> | |
| <td>Equal only</td> | |
| <td>Variable ✓</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| <div class="stats-row" style="margin-top: 3rem;"> | |
| <div class="stat-card"> | |
| <div class="stat-value">3</div> | |
| <div class="stat-label">Algorithms Proposed</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value">O(1)</div> | |
| <div class="stat-label">Best Prefix Approximation</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value">19</div> | |
| <div class="stat-label">PFC Factor (Chain-Based)</div> | |
| </div> | |
| <div class="stat-card"> | |
| <div class="stat-value">3</div> | |
| <div class="stat-label">Open Questions Remain</div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- REAL WORLD --> | |
| <section class="fade-in"> | |
| <div class="section-label" style="background: rgba(67,233,123,0.15); color: var(--accent3);">Impact</div> | |
| <h2 class="section-title">Real-World Applications</h2> | |
| <p class="section-desc">Permanent citizens' assemblies already exist. This research provides the mathematical foundation to make them truly fair over time.</p> | |
| <div class="card-grid"> | |
| <div class="card"> | |
| <div class="card-icon" style="background: rgba(108,99,255,0.15); color: var(--accent);">🇧🇪</div> | |
| <h3>Ostbelgien, Belgium</h3> | |
| <p>Since 2019, the German-speaking community has a permanent Citizens' Council with rotating panels. This is the first institutionalized permanent assembly.</p> | |
| </div> | |
| <div class="card"> | |
| <div class="card-icon" style="background: rgba(255,101,132,0.15); color: var(--accent2);">🇪🇺</div> | |
| <h3>European Union</h3> | |
| <p>The EU has experimented with European Citizens' Panels, bringing randomly selected citizens from across the continent to deliberate on policy.</p> | |
| </div> | |
| <div class="card"> | |
| <div class="card-icon" style="background: rgba(67,233,123,0.15); color: var(--accent3);">🇮🇪</div> | |
| <h3>Ireland</h3> | |
| <p>Ireland's Citizens' Assembly led to historic referendums on marriage equality and abortion. One-off panels showed the power of sortition.</p> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- OPEN QUESTIONS --> | |
| <section class="fade-in"> | |
| <div class="section-label" style="background: rgba(247,151,30,0.15); color: var(--accent4);">Open Questions</div> | |
| <h2 class="section-title">What's Still Unsolved?</h2> | |
| <p class="section-desc">The paper leaves three tantalizing open questions for future research.</p> | |
| <div class="card-grid"> | |
| <div class="card" style="border-left: 3px solid var(--accent);"> | |
| <h3 style="font-family: 'JetBrains Mono'; font-size: 0.9rem; color: var(--accent); margin-bottom: 0.8rem;">Open Question #1</h3> | |
| <p>Can we achieve O(1)-PRF for both individual panels <em>and</em> the global panel when panels have <strong>different sizes</strong>?</p> | |
| </div> | |
| <div class="card" style="border-left: 3px solid var(--accent2);"> | |
| <h3 style="font-family: 'JetBrains Mono'; font-size: 0.9rem; color: var(--accent2); margin-bottom: 0.8rem;">Open Question #2</h3> | |
| <p>Can we get O(1)-PFC or O(1)-PRF for <strong>both</strong> individual panels and all prefix panels simultaneously?</p> | |
| </div> | |
| <div class="card" style="border-left: 3px solid var(--accent3);"> | |
| <h3 style="font-family: 'JetBrains Mono'; font-size: 0.9rem; color: var(--accent3); margin-bottom: 0.8rem;">Open Question #3</h3> | |
| <p>Can we achieve O(1)-PRF (not just PFC) for every prefix? PRF gives <strong>proportionally many</strong> representatives, not just one.</p> | |
| </div> | |
| </div> | |
| </section> | |
| <footer> | |
| <p style="margin-bottom: 0.5rem;">Based on <strong>"Temporal Panel Selection in Ongoing Citizens' Assemblies"</strong></p> | |
| <p>Yusuf Hakan Kalaycı & Evi Micha — University of Southern California — <a href="https://arxiv.org/abs/2602.16194" target="_blank">arXiv:2602.16194</a></p> | |
| </footer> | |
| <script> | |
| // ============================================= | |
| // BACKGROUND CANVAS - Particle network | |
| // ============================================= | |
| (function() { | |
| const canvas = document.getElementById('bg-canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| let particles = []; | |
| let w, h; | |
| function resize() { | |
| w = canvas.width = window.innerWidth; | |
| h = canvas.height = window.innerHeight; | |
| } | |
| resize(); | |
| window.addEventListener('resize', resize); | |
| class Particle { | |
| constructor() { | |
| this.x = Math.random() * w; | |
| this.y = Math.random() * h; | |
| this.vx = (Math.random() - 0.5) * 0.3; | |
| this.vy = (Math.random() - 0.5) * 0.3; | |
| this.r = Math.random() * 1.5 + 0.5; | |
| } | |
| update() { | |
| this.x += this.vx; | |
| this.y += this.vy; | |
| if (this.x < 0 || this.x > w) this.vx *= -1; | |
| if (this.y < 0 || this.y > h) this.vy *= -1; | |
| } | |
| } | |
| for (let i = 0; i < 60; i++) particles.push(new Particle()); | |
| function drawBg() { | |
| ctx.clearRect(0, 0, w, h); | |
| particles.forEach(p => p.update()); | |
| for (let i = 0; i < particles.length; i++) { | |
| const p = particles[i]; | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); | |
| ctx.fillStyle = 'rgba(108,99,255,0.15)'; | |
| ctx.fill(); | |
| for (let j = i + 1; j < particles.length; j++) { | |
| const q = particles[j]; | |
| const dist = Math.hypot(p.x - q.x, p.y - q.y); | |
| if (dist < 150) { | |
| ctx.beginPath(); | |
| ctx.moveTo(p.x, p.y); | |
| ctx.lineTo(q.x, q.y); | |
| ctx.strokeStyle = `rgba(108,99,255,${0.06 * (1 - dist / 150)})`; | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| requestAnimationFrame(drawBg); | |
| } | |
| drawBg(); | |
| })(); | |
| // ============================================= | |
| // SCROLL ANIMATIONS | |
| // ============================================= | |
| const fadeEls = document.querySelectorAll('.fade-in'); | |
| const observer = new IntersectionObserver((entries) => { | |
| entries.forEach(e => { | |
| if (e.isIntersecting) e.target.classList.add('visible'); | |
| }); | |
| }, { threshold: 0.1 }); | |
| fadeEls.forEach(el => observer.observe(el)); | |
| // ============================================= | |
| // TOY EXAMPLE | |
| // ============================================= | |
| (function() { | |
| const container = document.getElementById('toy-panels-content'); | |
| const singleBtn = document.getElementById('toy-single-btn'); | |
| const temporalBtn = document.getElementById('toy-temporal-btn'); | |
| const explanation = document.getElementById('toy-explanation'); | |
| const colors = { A: '#6c63ff', B: '#ff6584', C: '#43e97b', D: '#f7971e' }; | |
| function renderPanels(panels, mode) { | |
| container.innerHTML = ''; | |
| panels.forEach((panel, i) => { | |
| const row = document.createElement('div'); | |
| row.className = 'panel-visual'; | |
| row.innerHTML = `<div class="panel-label">Panel ${i + 1}</div>`; | |
| panel.forEach(group => { | |
| const seat = document.createElement('div'); | |
| seat.className = 'seat filled'; | |
| seat.style.background = colors[group]; | |
| seat.textContent = group; | |
| row.appendChild(seat); | |
| }); | |
| container.appendChild(row); | |
| }); | |
| if (mode === 'single') { | |
| explanation.innerHTML = '⚠️ With a single panel of 4 seats, one of the small groups (<span style="color:#43e97b">C</span> or <span style="color:#f7971e">D</span>) must be left out. There simply aren\'t enough seats.'; | |
| } else { | |
| explanation.innerHTML = '✅ With <strong>two panels over time</strong>, we alternate the last seat: <span style="color:#43e97b">C</span> gets represented in Panel 1, and <span style="color:#f7971e">D</span> in Panel 2. <strong>Everyone is represented across the sequence!</strong>'; | |
| } | |
| } | |
| singleBtn.addEventListener('click', () => { | |
| singleBtn.classList.add('active'); | |
| temporalBtn.classList.remove('active'); | |
| renderPanels([['A', 'A', 'B', 'C']], 'single'); | |
| }); | |
| temporalBtn.addEventListener('click', () => { | |
| temporalBtn.classList.add('active'); | |
| singleBtn.classList.remove('active'); | |
| renderPanels([['A', 'A', 'B', 'C'], ['A', 'A', 'B', 'D']], 'temporal'); | |
| }); | |
| renderPanels([['A', 'A', 'B', 'C']], 'single'); | |
| singleBtn.classList.add('active'); | |
| })(); | |
| // ============================================= | |
| // METRIC SPACE VIZ | |
| // ============================================= | |
| (function() { | |
| const canvas = document.getElementById('metric-canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| let w, h; | |
| let mouseX = -1000, mouseY = -1000; | |
| function resize() { | |
| const rect = canvas.parentElement.getBoundingClientRect(); | |
| w = canvas.width = rect.width; | |
| h = canvas.height = rect.height; | |
| } | |
| resize(); | |
| window.addEventListener('resize', resize); | |
| // Generate clusters | |
| const clusters = [ | |
| { cx: 0.15, cy: 0.3, r: 0.06, color: '#6c63ff', label: 'Urban Youth', n: 15 }, | |
| { cx: 0.35, cy: 0.6, r: 0.08, color: '#ff6584', label: 'Suburban Families', n: 20 }, | |
| { cx: 0.6, cy: 0.25, r: 0.05, color: '#43e97b', label: 'Rural Workers', n: 8 }, | |
| { cx: 0.75, cy: 0.65, r: 0.07, color: '#f7971e', label: 'Seniors', n: 14 }, | |
| { cx: 0.9, cy: 0.35, r: 0.04, color: '#e84393', label: 'Students', n: 6 }, | |
| ]; | |
| const points = []; | |
| clusters.forEach(c => { | |
| for (let i = 0; i < c.n; i++) { | |
| const angle = Math.random() * Math.PI * 2; | |
| const dist = Math.random() * c.r; | |
| points.push({ | |
| x: c.cx + Math.cos(angle) * dist, | |
| y: c.cy + Math.sin(angle) * dist, | |
| color: c.color, | |
| label: c.label, | |
| baseX: c.cx + Math.cos(angle) * dist, | |
| baseY: c.cy + Math.sin(angle) * dist, | |
| }); | |
| } | |
| }); | |
| canvas.addEventListener('mousemove', (e) => { | |
| const rect = canvas.getBoundingClientRect(); | |
| mouseX = (e.clientX - rect.left) / w; | |
| mouseY = (e.clientY - rect.top) / h; | |
| }); | |
| canvas.addEventListener('mouseleave', () => { | |
| mouseX = -1000; | |
| mouseY = -1000; | |
| }); | |
| function draw() { | |
| ctx.clearRect(0, 0, w, h); | |
| // Draw connections to nearest hovered | |
| let hovered = null; | |
| let minDist = Infinity; | |
| points.forEach(p => { | |
| const dx = p.x - mouseX; | |
| const dy = p.y - mouseY; | |
| const d = Math.sqrt(dx * dx + dy * dy); | |
| if (d < minDist) { | |
| minDist = d; | |
| hovered = p; | |
| } | |
| }); | |
| if (hovered && minDist < 0.1) { | |
| // Draw radius circle | |
| ctx.beginPath(); | |
| ctx.arc(hovered.x * w, hovered.y * h, 0.1 * w, 0, Math.PI * 2); | |
| ctx.strokeStyle = 'rgba(255,255,255,0.1)'; | |
| ctx.lineWidth = 1; | |
| ctx.setLineDash([4, 4]); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| // Draw connections | |
| points.forEach(p => { | |
| const dx = p.x - hovered.x; | |
| const dy = p.y - hovered.y; | |
| const d = Math.sqrt(dx * dx + dy * dy); | |
| if (d < 0.15 && p !== hovered) { | |
| ctx.beginPath(); | |
| ctx.moveTo(hovered.x * w, hovered.y * h); | |
| ctx.lineTo(p.x * w, p.y * h); | |
| const alpha = 0.5 * (1 - d / 0.15); | |
| ctx.strokeStyle = `rgba(255,255,255,${alpha})`; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| } | |
| }); | |
| // Label | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = '12px Inter'; | |
| ctx.fillText(hovered.label, hovered.x * w + 10, hovered.y * h - 10); | |
| ctx.fillStyle = 'rgba(255,255,255,0.5)'; | |
| ctx.font = '10px JetBrains Mono'; | |
| ctx.fillText(`d = distance to panel rep`, hovered.x * w + 10, hovered.y * h + 5); | |
| } | |
| // Draw cluster labels | |
| clusters.forEach(c => { | |
| ctx.fillStyle = c.color + '40'; | |
| ctx.beginPath(); | |
| ctx.arc(c.cx * w, c.cy * h, c.r * w * 1.3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| }); | |
| // Draw points | |
| points.forEach(p => { | |
| const px = p.x * w; | |
| const py = p.y * h; | |
| ctx.beginPath(); | |
| ctx.arc(px, py, 3, 0, Math.PI * 2); | |
| ctx.fillStyle = p.color; | |
| if (hovered && p === hovered) { | |
| ctx.fillStyle = '#fff'; | |
| ctx.arc(px, py, 6, 0, Math.PI * 2); | |
| } | |
| ctx.fill(); | |
| }); | |
| // Labels | |
| clusters.forEach(c => { | |
| ctx.fillStyle = 'rgba(255,255,255,0.6)'; | |
| ctx.font = '11px Inter'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(c.label, c.cx * w, (c.cy + c.r + 0.04) * h); | |
| ctx.textAlign = 'left'; | |
| }); | |
| requestAnimationFrame(draw); | |
| } | |
| draw(); | |
| })(); | |
| // ============================================= | |
| // MAIN INTERACTIVE DEMO | |
| // ============================================= | |
| (function() { | |
| const canvas = document.getElementById('demo-canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const statusEl = document.getElementById('demo-status'); | |
| let w, h; | |
| function resize() { | |
| const rect = canvas.parentElement.getBoundingClientRect(); | |
| w = canvas.width = rect.width; | |
| h = canvas.height = rect.height; | |
| } | |
| resize(); | |
| window.addEventListener('resize', () => { resize(); draw(); }); | |
| const NUM_PANELS = 3; | |
| const PANEL_SIZE = 4; | |
| const panelColors = ['#6c63ff', '#ff6584', '#43e97b']; | |
| // Generate population with clusters | |
| const pop = []; | |
| const clusterDefs = [ | |
| { cx: 0.2, cy: 0.3, r: 0.08, n: 12 }, | |
| { cx: 0.5, cy: 0.7, r: 0.1, n: 18 }, | |
| { cx: 0.8, cy: 0.25, r: 0.06, n: 8 }, | |
| { cx: 0.35, cy: 0.5, r: 0.05, n: 5 }, | |
| { cx: 0.7, cy: 0.6, r: 0.07, n: 10 }, | |
| { cx: 0.15, cy: 0.75, r: 0.04, n: 4 }, | |
| ]; | |
| clusterDefs.forEach(c => { | |
| for (let i = 0; i < c.n; i++) { | |
| const angle = Math.random() * Math.PI * 2; | |
| const dist = Math.random() * c.r; | |
| pop.push({ | |
| x: c.cx + Math.cos(angle) * dist, | |
| y: c.cy + Math.sin(angle) * dist, | |
| panel: -1, | |
| selected: false, | |
| captureRadius: 0, | |
| animProgress: 0, | |
| }); | |
| } | |
| }); | |
| let currentPanel = 0; | |
| let seatsFilledInPanel = 0; | |
| let selectedOrder = []; | |
| let captureAnimations = []; | |
| let autoInterval = null; | |
| function dist(a, b) { | |
| return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); | |
| } | |
| function greedyCapture() { | |
| // Find unselected citizen that is farthest from all selected | |
| // Simplified: find the unselected point that maximizes minimum distance to any selected | |
| const unselected = pop.filter(p => !p.selected); | |
| if (unselected.length === 0) return null; | |
| const selected = pop.filter(p => p.selected); | |
| if (selected.length === 0) { | |
| // Pick the one closest to the densest cluster center | |
| let best = unselected[0]; | |
| let bestScore = -Infinity; | |
| unselected.forEach(p => { | |
| let nearbyCount = 0; | |
| unselected.forEach(q => { | |
| if (dist(p, q) < 0.15) nearbyCount++; | |
| }); | |
| if (nearbyCount > bestScore) { | |
| bestScore = nearbyCount; | |
| best = p; | |
| } | |
| }); | |
| return best; | |
| } | |
| // Greedy capture: find unselected point that covers the most unrepresented people | |
| // Simplified version: pick the unselected with the most unselected neighbors within some radius | |
| // that are also far from existing selected members | |
| let best = null; | |
| let bestScore = -Infinity; | |
| unselected.forEach(candidate => { | |
| let score = 0; | |
| unselected.forEach(other => { | |
| const dOther = dist(candidate, other); | |
| if (dOther < 0.15) { | |
| // How far is 'other' from nearest selected? | |
| let minSelected = Infinity; | |
| selected.forEach(s => { | |
| minSelected = Math.min(minSelected, dist(other, s)); | |
| }); | |
| score += minSelected; | |
| } | |
| }); | |
| if (score > bestScore) { | |
| bestScore = score; | |
| best = candidate; | |
| } | |
| }); | |
| return best; | |
| } | |
| function step() { | |
| if (currentPanel >= NUM_PANELS) return; | |
| const chosen = greedyCapture(); | |
| if (!chosen) return; | |
| chosen.selected = true; | |
| chosen.panel = currentPanel; | |
| chosen.animProgress = 0; | |
| selectedOrder.push(chosen); | |
| // Compute capture radius for visualization | |
| const unselected = pop.filter(p => !p.selected); | |
| let maxDist = 0; | |
| unselected.forEach(p => { | |
| const d = dist(chosen, p); | |
| if (d < 0.15) maxDist = Math.max(maxDist, d); | |
| }); | |
| chosen.captureRadius = Math.max(maxDist, 0.05); | |
| captureAnimations.push({ | |
| x: chosen.x, y: chosen.y, | |
| maxR: chosen.captureRadius, | |
| progress: 0, | |
| color: panelColors[currentPanel], | |
| }); | |
| seatsFilledInPanel++; | |
| if (seatsFilledInPanel >= PANEL_SIZE) { | |
| currentPanel++; | |
| seatsFilledInPanel = 0; | |
| } | |
| updateStatus(); | |
| } | |
| function updateStatus() { | |
| const panel = Math.min(currentPanel + 1, NUM_PANELS); | |
| const seats = currentPanel >= NUM_PANELS ? PANEL_SIZE : seatsFilledInPanel; | |
| statusEl.textContent = currentPanel >= NUM_PANELS | |
| ? `All ${NUM_PANELS} panels filled! (${NUM_PANELS * PANEL_SIZE} representatives selected)` | |
| : `Panel ${panel} / ${NUM_PANELS} — Seats: ${seats} / ${PANEL_SIZE}`; | |
| } | |
| function reset() { | |
| pop.forEach(p => { | |
| p.selected = false; | |
| p.panel = -1; | |
| p.animProgress = 0; | |
| p.captureRadius = 0; | |
| }); | |
| currentPanel = 0; | |
| seatsFilledInPanel = 0; | |
| selectedOrder = []; | |
| captureAnimations = []; | |
| if (autoInterval) { | |
| clearInterval(autoInterval); | |
| autoInterval = null; | |
| document.getElementById('btn-auto').classList.remove('active'); | |
| } | |
| updateStatus(); | |
| } | |
| document.getElementById('btn-step').addEventListener('click', step); | |
| document.getElementById('btn-reset').addEventListener('click', reset); | |
| document.getElementById('btn-auto').addEventListener('click', function() { | |
| if (autoInterval) { | |
| clearInterval(autoInterval); | |
| autoInterval = null; | |
| this.classList.remove('active'); | |
| } else { | |
| this.classList.add('active'); | |
| autoInterval = setInterval(() => { | |
| if (currentPanel >= NUM_PANELS) { | |
| clearInterval(autoInterval); | |
| autoInterval = null; | |
| document.getElementById('btn-auto').classList.remove('active'); | |
| return; | |
| } | |
| step(); | |
| }, 600); | |
| } | |
| }); | |
| function draw() { | |
| ctx.clearRect(0, 0, w, h); | |
| // Draw capture animations | |
| captureAnimations.forEach(a => { | |
| a.progress = Math.min(a.progress + 0.03, 1); | |
| const r = a.maxR * a.progress * w * 0.8; | |
| ctx.beginPath(); | |
| ctx.arc(a.x * w, a.y * h, r, 0, Math.PI * 2); | |
| ctx.strokeStyle = a.color + Math.floor(40 * (1 - a.progress)).toString(16).padStart(2, '0'); | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| }); | |
| captureAnimations = captureAnimations.filter(a => a.progress < 1); | |
| // Draw connections between selected and nearby unselected | |
| pop.filter(p => p.selected).forEach(s => { | |
| pop.filter(p => !p.selected).forEach(u => { | |
| const d = dist(s, u); | |
| if (d < 0.1) { | |
| ctx.beginPath(); | |
| ctx.moveTo(s.x * w, s.y * h); | |
| ctx.lineTo(u.x * w, u.y * h); | |
| ctx.strokeStyle = `rgba(255,255,255,${0.06 * (1 - d / 0.1)})`; | |
| ctx.lineWidth = 0.5; | |
| ctx.stroke(); | |
| } | |
| }); | |
| }); | |
| // Draw unselected | |
| pop.filter(p => !p.selected).forEach(p => { | |
| ctx.beginPath(); | |
| ctx.arc(p.x * w, p.y * h, 3, 0, Math.PI * 2); | |
| ctx.fillStyle = 'rgba(100,100,120,0.6)'; | |
| ctx.fill(); | |
| }); | |
| // Draw selected with animation | |
| pop.filter(p => p.selected).forEach(p => { | |
| p.animProgress = Math.min(p.animProgress + 0.05, 1); | |
| const size = 3 + 5 * p.animProgress; | |
| const color = panelColors[p.panel]; | |
| // Glow | |
| ctx.beginPath(); | |
| ctx.arc(p.x * w, p.y * h, size + 4, 0, Math.PI * 2); | |
| ctx.fillStyle = color + '30'; | |
| ctx.fill(); | |
| // Point | |
| ctx.beginPath(); | |
| ctx.arc(p.x * w, p.y * h, size, 0, Math.PI * 2); | |
| ctx.fillStyle = color; | |
| ctx.fill(); | |
| // Border | |
| ctx.beginPath(); | |
| ctx.arc(p.x * w, p.y * h, size, 0, Math.PI * 2); | |
| ctx.strokeStyle = '#fff'; | |
| ctx.lineWidth = 1.5; | |
| ctx.stroke(); | |
| }); | |
| requestAnimationFrame(draw); | |
| } | |
| draw(); | |
| updateStatus(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment