Created
February 22, 2026 14:47
-
-
Save jbdamask/efed80f990c5ee3241df94231384a8be 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=Space+Grotesk:wght@400;500;600;700&display=swap'); | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| :root { | |
| --bg: #0a0a0f; | |
| --bg2: #12121a; | |
| --bg3: #1a1a2e; | |
| --accent: #6c63ff; | |
| --accent2: #00d2ff; | |
| --accent3: #ff6b9d; | |
| --accent4: #ffd93d; | |
| --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 background */ | |
| .bg-grid { | |
| position: fixed; | |
| top: 0; left: 0; | |
| width: 100%; height: 100%; | |
| z-index: 0; | |
| pointer-events: none; | |
| background-image: | |
| radial-gradient(ellipse 600px 600px at 20% 50%, rgba(108,99,255,0.07) 0%, transparent 70%), | |
| radial-gradient(ellipse 500px 500px at 80% 20%, rgba(0,210,255,0.05) 0%, transparent 70%), | |
| radial-gradient(ellipse 400px 400px at 60% 80%, rgba(255,107,157,0.04) 0%, transparent 70%); | |
| } | |
| .floating-orbs { | |
| position: fixed; | |
| top: 0; left: 0; | |
| width: 100%; height: 100%; | |
| z-index: 0; | |
| pointer-events: none; | |
| overflow: hidden; | |
| } | |
| .orb { | |
| position: absolute; | |
| border-radius: 50%; | |
| filter: blur(80px); | |
| animation: float 20s infinite ease-in-out; | |
| } | |
| .orb:nth-child(1) { width: 300px; height: 300px; background: rgba(108,99,255,0.15); top: 10%; left: 10%; animation-delay: 0s; } | |
| .orb:nth-child(2) { width: 250px; height: 250px; background: rgba(0,210,255,0.1); top: 60%; left: 70%; animation-delay: -7s; } | |
| .orb:nth-child(3) { width: 200px; height: 200px; background: rgba(255,107,157,0.08); top: 80%; left: 20%; animation-delay: -14s; } | |
| @keyframes float { | |
| 0%, 100% { transform: translate(0, 0) scale(1); } | |
| 25% { transform: translate(50px, -30px) scale(1.1); } | |
| 50% { transform: translate(-20px, 40px) scale(0.95); } | |
| 75% { transform: translate(30px, 20px) scale(1.05); } | |
| } | |
| /* Navigation */ | |
| nav { | |
| position: fixed; | |
| top: 0; left: 0; | |
| width: 100%; | |
| z-index: 1000; | |
| padding: 16px 40px; | |
| backdrop-filter: blur(20px); | |
| background: rgba(10,10,15,0.8); | |
| border-bottom: 1px solid var(--glass-border); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| transition: all 0.3s; | |
| } | |
| nav .logo { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-weight: 700; | |
| 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: 28px; } | |
| 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(--accent2); } | |
| nav .nav-links a::after { | |
| content: ''; | |
| position: absolute; | |
| bottom: -4px; left: 0; | |
| width: 0; height: 2px; | |
| background: var(--accent2); | |
| transition: width 0.3s; | |
| } | |
| nav .nav-links a:hover::after { width: 100%; } | |
| /* Sections */ | |
| section { | |
| position: relative; | |
| z-index: 1; | |
| max-width: 1100px; | |
| margin: 0 auto; | |
| padding: 100px 24px; | |
| } | |
| /* Hero */ | |
| .hero { | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| text-align: center; | |
| padding-top: 80px; | |
| } | |
| .hero-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 6px 16px; | |
| border-radius: 30px; | |
| background: var(--glass); | |
| border: 1px solid var(--glass-border); | |
| font-size: 0.8rem; | |
| color: var(--accent2); | |
| font-weight: 500; | |
| margin-bottom: 24px; | |
| animation: fadeInUp 0.8s ease-out; | |
| } | |
| .hero-badge .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent2); animation: pulse 2s infinite; } | |
| @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } | |
| .hero h1 { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-size: clamp(2.4rem, 5.5vw, 4.2rem); | |
| font-weight: 800; | |
| line-height: 1.15; | |
| margin-bottom: 24px; | |
| animation: fadeInUp 0.8s ease-out 0.1s both; | |
| } | |
| .hero h1 .gradient { | |
| background: linear-gradient(135deg, var(--accent), var(--accent2), var(--accent3)); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .hero p { | |
| font-size: 1.15rem; | |
| color: var(--text2); | |
| max-width: 680px; | |
| margin-bottom: 40px; | |
| animation: fadeInUp 0.8s ease-out 0.2s both; | |
| } | |
| .hero-cta { | |
| display: flex; | |
| gap: 16px; | |
| animation: fadeInUp 0.8s ease-out 0.3s both; | |
| } | |
| .btn { | |
| padding: 12px 28px; | |
| border-radius: 12px; | |
| font-weight: 600; | |
| font-size: 0.9rem; | |
| cursor: pointer; | |
| border: none; | |
| text-decoration: none; | |
| transition: all 0.3s; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, var(--accent), #8b5cf6); | |
| color: white; | |
| box-shadow: 0 4px 20px rgba(108,99,255,0.3); | |
| } | |
| .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 6px 30px rgba(108,99,255,0.5); } | |
| .btn-secondary { | |
| background: var(--glass); | |
| color: var(--text); | |
| border: 1px solid var(--glass-border); | |
| } | |
| .btn-secondary:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.15); } | |
| @keyframes fadeInUp { | |
| from { opacity: 0; transform: translateY(30px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| /* Section headers */ | |
| .section-label { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| color: var(--accent); | |
| margin-bottom: 16px; | |
| } | |
| .section-label .line { width: 32px; height: 2px; background: var(--accent); border-radius: 2px; } | |
| h2 { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-size: clamp(1.8rem, 3.5vw, 2.6rem); | |
| font-weight: 700; | |
| margin-bottom: 20px; | |
| line-height: 1.2; | |
| } | |
| .section-desc { | |
| font-size: 1.05rem; | |
| color: var(--text2); | |
| max-width: 700px; | |
| margin-bottom: 48px; | |
| } | |
| /* Cards */ | |
| .card { | |
| background: var(--glass); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 20px; | |
| padding: 32px; | |
| backdrop-filter: blur(10px); | |
| transition: all 0.4s; | |
| } | |
| .card:hover { border-color: rgba(108,99,255,0.3); transform: translateY(-4px); box-shadow: 0 12px 40px rgba(0,0,0,0.3); } | |
| .card-icon { | |
| width: 48px; height: 48px; | |
| border-radius: 14px; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 1.4rem; | |
| margin-bottom: 16px; | |
| } | |
| .card h3 { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-size: 1.15rem; | |
| font-weight: 600; | |
| margin-bottom: 10px; | |
| } | |
| .card p { color: var(--text2); font-size: 0.92rem; } | |
| /* Grid layouts */ | |
| .grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; } | |
| .grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 24px; } | |
| /* Problem section */ | |
| .problem-visual { | |
| background: var(--bg2); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 24px; | |
| padding: 40px; | |
| margin-top: 40px; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .problem-visual::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; left: 0; | |
| width: 100%; height: 100%; | |
| background: radial-gradient(ellipse at 50% 0%, rgba(108,99,255,0.08), transparent 60%); | |
| } | |
| /* Interactive Simulation */ | |
| .sim-container { | |
| background: var(--bg2); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 24px; | |
| padding: 32px; | |
| margin-top: 32px; | |
| } | |
| .sim-controls { | |
| display: flex; | |
| gap: 16px; | |
| flex-wrap: wrap; | |
| margin-bottom: 24px; | |
| align-items: center; | |
| } | |
| .sim-controls label { | |
| font-size: 0.85rem; | |
| color: var(--text2); | |
| font-weight: 500; | |
| } | |
| .sim-controls input[type="range"] { | |
| -webkit-appearance: none; | |
| width: 140px; | |
| height: 6px; | |
| border-radius: 3px; | |
| background: var(--bg3); | |
| outline: none; | |
| } | |
| .sim-controls input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 18px; height: 18px; | |
| border-radius: 50%; | |
| background: var(--accent); | |
| cursor: pointer; | |
| box-shadow: 0 0 10px rgba(108,99,255,0.5); | |
| } | |
| .sim-canvas-wrap { | |
| position: relative; | |
| width: 100%; | |
| height: 420px; | |
| border-radius: 16px; | |
| overflow: hidden; | |
| background: var(--bg); | |
| border: 1px solid var(--glass-border); | |
| } | |
| canvas { display: block; width: 100%; height: 100%; } | |
| .sim-stats { | |
| display: flex; | |
| gap: 20px; | |
| margin-top: 20px; | |
| flex-wrap: wrap; | |
| } | |
| .stat-box { | |
| background: var(--glass); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 14px; | |
| padding: 16px 24px; | |
| flex: 1; | |
| min-width: 150px; | |
| text-align: center; | |
| } | |
| .stat-box .stat-value { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-size: 1.6rem; | |
| font-weight: 700; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .stat-box .stat-label { | |
| font-size: 0.75rem; | |
| color: var(--text2); | |
| font-weight: 500; | |
| margin-top: 4px; | |
| } | |
| /* Timeline */ | |
| .timeline { | |
| position: relative; | |
| margin-top: 40px; | |
| } | |
| .timeline::before { | |
| content: ''; | |
| position: absolute; | |
| left: 24px; | |
| top: 0; | |
| bottom: 0; | |
| width: 2px; | |
| background: linear-gradient(180deg, var(--accent), var(--accent2), var(--accent3)); | |
| } | |
| .timeline-item { | |
| position: relative; | |
| padding-left: 64px; | |
| margin-bottom: 40px; | |
| opacity: 0; | |
| transform: translateX(-20px); | |
| transition: all 0.6s ease-out; | |
| } | |
| .timeline-item.visible { opacity: 1; transform: translateX(0); } | |
| .timeline-dot { | |
| position: absolute; | |
| left: 15px; | |
| top: 4px; | |
| width: 20px; height: 20px; | |
| border-radius: 50%; | |
| border: 3px solid var(--accent); | |
| background: var(--bg); | |
| z-index: 2; | |
| } | |
| .timeline-item:nth-child(2) .timeline-dot { border-color: var(--accent2); } | |
| .timeline-item:nth-child(3) .timeline-dot { border-color: var(--accent3); } | |
| .timeline-item h3 { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-size: 1.1rem; | |
| font-weight: 600; | |
| margin-bottom: 8px; | |
| } | |
| .timeline-item p { color: var(--text2); font-size: 0.92rem; } | |
| /* Toy Example - Interactive */ | |
| .toy-example { | |
| background: var(--bg2); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 24px; | |
| padding: 40px; | |
| margin-top: 40px; | |
| text-align: center; | |
| } | |
| .groups-row { | |
| display: flex; | |
| justify-content: center; | |
| gap: 20px; | |
| flex-wrap: wrap; | |
| margin: 32px 0; | |
| } | |
| .group-box { | |
| border-radius: 16px; | |
| padding: 20px; | |
| min-width: 120px; | |
| text-align: center; | |
| transition: all 0.5s; | |
| cursor: default; | |
| } | |
| .group-box .group-people { font-size: 2rem; margin-bottom: 8px; } | |
| .group-box .group-label { font-weight: 600; font-size: 0.85rem; } | |
| .group-box .group-pct { font-size: 0.75rem; color: var(--text2); margin-top: 4px; } | |
| .panel-display { | |
| display: flex; | |
| justify-content: center; | |
| gap: 24px; | |
| flex-wrap: wrap; | |
| margin-top: 24px; | |
| } | |
| .panel-box { | |
| background: var(--glass); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 16px; | |
| padding: 20px 24px; | |
| min-width: 200px; | |
| transition: all 0.5s; | |
| } | |
| .panel-box h4 { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-size: 0.9rem; | |
| margin-bottom: 12px; | |
| color: var(--accent2); | |
| } | |
| .panel-seats { | |
| display: flex; | |
| gap: 8px; | |
| justify-content: center; | |
| } | |
| .seat { | |
| width: 36px; height: 36px; | |
| border-radius: 10px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.8rem; | |
| font-weight: 700; | |
| transition: all 0.5s; | |
| border: 2px solid transparent; | |
| } | |
| .toy-btn { | |
| margin-top: 24px; | |
| padding: 10px 28px; | |
| border-radius: 12px; | |
| border: none; | |
| background: linear-gradient(135deg, var(--accent), #8b5cf6); | |
| color: white; | |
| font-weight: 600; | |
| font-size: 0.9rem; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| } | |
| .toy-btn:hover { transform: scale(1.05); box-shadow: 0 4px 20px rgba(108,99,255,0.4); } | |
| /* Algorithm comparison table */ | |
| .algo-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| margin-top: 32px; | |
| font-size: 0.88rem; | |
| } | |
| .algo-table th, .algo-table td { | |
| padding: 16px 20px; | |
| text-align: left; | |
| border-bottom: 1px solid var(--glass-border); | |
| } | |
| .algo-table th { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-weight: 600; | |
| color: var(--accent2); | |
| font-size: 0.78rem; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| .algo-table td { color: var(--text2); } | |
| .algo-table tr:hover td { color: var(--text); background: var(--glass); } | |
| .tag { | |
| display: inline-block; | |
| padding: 3px 10px; | |
| border-radius: 8px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| } | |
| .tag-green { background: rgba(0,200,100,0.15); color: #00c864; } | |
| .tag-yellow { background: rgba(255,217,61,0.15); color: #ffd93d; } | |
| .tag-red { background: rgba(255,107,157,0.15); color: #ff6b9d; } | |
| /* Metric Space Visualization */ | |
| .metric-viz { | |
| position: relative; | |
| width: 100%; | |
| height: 300px; | |
| border-radius: 16px; | |
| background: var(--bg); | |
| border: 1px solid var(--glass-border); | |
| overflow: hidden; | |
| margin-top: 24px; | |
| } | |
| .metric-point { | |
| position: absolute; | |
| width: 12px; height: 12px; | |
| border-radius: 50%; | |
| transition: all 0.6s ease-out; | |
| cursor: pointer; | |
| } | |
| .metric-point:hover { transform: scale(1.8); z-index: 10; } | |
| .metric-point .tooltip { | |
| position: absolute; | |
| bottom: 20px; left: 50%; | |
| transform: translateX(-50%); | |
| background: var(--bg3); | |
| border: 1px solid var(--glass-border); | |
| padding: 4px 10px; | |
| border-radius: 8px; | |
| font-size: 0.7rem; | |
| white-space: nowrap; | |
| opacity: 0; | |
| pointer-events: none; | |
| transition: opacity 0.3s; | |
| } | |
| .metric-point:hover .tooltip { opacity: 1; } | |
| /* Prefix diagram */ | |
| .prefix-diagram { | |
| display: flex; | |
| gap: 4px; | |
| align-items: flex-end; | |
| justify-content: center; | |
| height: 200px; | |
| margin: 32px 0; | |
| } | |
| .prefix-bar { | |
| width: 60px; | |
| border-radius: 8px 8px 0 0; | |
| transition: all 0.6s ease-out; | |
| position: relative; | |
| cursor: pointer; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: flex-end; | |
| overflow: hidden; | |
| } | |
| .prefix-bar:hover { filter: brightness(1.2); } | |
| .prefix-bar .bar-label { | |
| text-align: center; | |
| font-size: 0.7rem; | |
| font-weight: 600; | |
| padding: 8px 0; | |
| color: white; | |
| } | |
| .prefix-bar .bar-sublabel { | |
| position: absolute; | |
| bottom: -24px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| font-size: 0.7rem; | |
| color: var(--text2); | |
| white-space: nowrap; | |
| } | |
| /* Footer */ | |
| footer { | |
| position: relative; | |
| z-index: 1; | |
| text-align: center; | |
| padding: 60px 24px; | |
| border-top: 1px solid var(--glass-border); | |
| color: var(--text2); | |
| font-size: 0.85rem; | |
| } | |
| footer a { color: var(--accent); text-decoration: none; } | |
| footer a:hover { text-decoration: underline; } | |
| /* Scroll reveal */ | |
| .reveal { | |
| opacity: 0; | |
| transform: translateY(40px); | |
| transition: all 0.8s ease-out; | |
| } | |
| .reveal.visible { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| /* Responsive */ | |
| @media (max-width: 768px) { | |
| nav .nav-links { display: none; } | |
| section { padding: 60px 16px; } | |
| .sim-canvas-wrap { height: 300px; } | |
| .groups-row { gap: 10px; } | |
| .group-box { min-width: 70px; padding: 12px; } | |
| .prefix-bar { width: 40px; } | |
| } | |
| /* Highlight boxes */ | |
| .highlight-box { | |
| background: linear-gradient(135deg, rgba(108,99,255,0.1), rgba(0,210,255,0.05)); | |
| border: 1px solid rgba(108,99,255,0.2); | |
| border-radius: 16px; | |
| padding: 24px 28px; | |
| margin: 24px 0; | |
| } | |
| .highlight-box p { color: var(--text); font-size: 0.95rem; } | |
| .highlight-box .hb-title { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-weight: 600; | |
| font-size: 1rem; | |
| color: var(--accent2); | |
| margin-bottom: 8px; | |
| } | |
| /* Animated number counter */ | |
| .counter { | |
| font-family: 'Space Grotesk', sans-serif; | |
| font-weight: 800; | |
| font-size: 3rem; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| /* Chain visualization */ | |
| #chainViz { | |
| width: 100%; | |
| height: 160px; | |
| margin-top: 20px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="bg-grid"></div> | |
| <div class="floating-orbs"> | |
| <div class="orb"></div> | |
| <div class="orb"></div> | |
| <div class="orb"></div> | |
| </div> | |
| <nav> | |
| <div class="logo">⬡ Temporal Sortition</div> | |
| <div class="nav-links"> | |
| <a href="#problem">Problem</a> | |
| <a href="#how">How It Works</a> | |
| <a href="#simulation">Simulation</a> | |
| <a href="#algorithms">Algorithms</a> | |
| <a href="#results">Results</a> | |
| </div> | |
| </nav> | |
| <!-- HERO --> | |
| <section class="hero"> | |
| <div class="hero-badge"><span class="dot"></span> Kalaycı & Micha — USC — 2026</div> | |
| <h1>Fair Representation<br><span class="gradient">Across Time</span></h1> | |
| <p>How do you pick citizen panels that represent <em>everyone</em> — not just today, but over months and years? This research solves the puzzle of <strong>temporal sortition</strong> for permanent citizens' assemblies.</p> | |
| <div class="hero-cta"> | |
| <a href="#problem" class="btn btn-primary">Explore the Ideas ↓</a> | |
| <a href="https://arxiv.org/abs/2602.16194" target="_blank" class="btn btn-secondary">Read the Paper →</a> | |
| </div> | |
| </section> | |
| <!-- WHAT'S THE PROBLEM --> | |
| <section id="problem"> | |
| <div class="reveal"> | |
| <div class="section-label"><span class="line"></span> The Challenge</div> | |
| <h2>One Panel Can't Represent Everyone</h2> | |
| <p class="section-desc">Citizens' assemblies randomly select people to deliberate on policy. But a single panel of ~100 people can't capture every community. Small groups get left out.</p> | |
| </div> | |
| <div class="grid-3 reveal"> | |
| <div class="card"> | |
| <div class="card-icon" style="background:rgba(108,99,255,0.15);color:var(--accent);">🏛️</div> | |
| <h3>Citizens' Assemblies</h3> | |
| <p>Randomly selected groups of citizens who deliberate on policy issues — used in Ireland, Belgium, the EU, and many cities worldwide.</p> | |
| </div> | |
| <div class="card"> | |
| <div class="card-icon" style="background:rgba(0,210,255,0.15);color:var(--accent2);">🎲</div> | |
| <h3>Sortition</h3> | |
| <p>The process of random selection. The goal: every citizen has an equal chance, and the panel mirrors the population's diversity.</p> | |
| </div> | |
| <div class="card"> | |
| <div class="card-icon" style="background:rgba(255,107,157,0.15);color:var(--accent3);">⏱️</div> | |
| <h3>Permanent Assemblies</h3> | |
| <p>Unlike one-off panels, permanent assemblies rotate members every 6-12 months — like the Ostbelgien Model in Belgium, running since 2019.</p> | |
| </div> | |
| </div> | |
| <!-- Toy Example --> | |
| <div class="toy-example reveal" id="toyExample"> | |
| <h3 style="font-family:'Space Grotesk';font-size:1.2rem;margin-bottom:8px;">The Key Insight: Representation Over Time</h3> | |
| <p style="color:var(--text2);font-size:0.9rem;max-width:600px;margin:0 auto 20px;">Imagine 4 groups in a city. With only 4 panel seats, the two smallest groups can't <em>both</em> fit on one panel. But across two panels, they can take turns!</p> | |
| <div class="groups-row" id="groupsRow"> | |
| <div class="group-box" style="background:rgba(108,99,255,0.2);border:2px solid rgba(108,99,255,0.4);"> | |
| <div class="group-people">👤👤👤👤</div> | |
| <div class="group-label" style="color:var(--accent);">Group A</div> | |
| <div class="group-pct">50% of pop.</div> | |
| </div> | |
| <div class="group-box" style="background:rgba(0,210,255,0.15);border:2px solid rgba(0,210,255,0.3);"> | |
| <div class="group-people">👤👤</div> | |
| <div class="group-label" style="color:var(--accent2);">Group B</div> | |
| <div class="group-pct">25% of pop.</div> | |
| </div> | |
| <div class="group-box" style="background:rgba(255,217,61,0.15);border:2px solid rgba(255,217,61,0.3);"> | |
| <div class="group-people">👤</div> | |
| <div class="group-label" style="color:var(--accent4);">Group C</div> | |
| <div class="group-pct">12.5% of pop.</div> | |
| </div> | |
| <div class="group-box" style="background:rgba(255,107,157,0.15);border:2px solid rgba(255,107,157,0.3);"> | |
| <div class="group-people">👤</div> | |
| <div class="group-label" style="color:var(--accent3);">Group D</div> | |
| <div class="group-pct">12.5% of pop.</div> | |
| </div> | |
| </div> | |
| <div style="font-size:0.85rem;color:var(--text2);margin-bottom:16px;" id="toyStatus">Panel size = 4 seats. Click below to see how rotation helps!</div> | |
| <div class="panel-display" id="panelDisplay"> | |
| <div class="panel-box" id="panel1Box"> | |
| <h4>Panel 1</h4> | |
| <div class="panel-seats" id="panel1Seats"> | |
| <div class="seat" style="background:rgba(108,99,255,0.3);border-color:var(--accent);">A</div> | |
| <div class="seat" style="background:rgba(108,99,255,0.3);border-color:var(--accent);">A</div> | |
| <div class="seat" style="background:rgba(0,210,255,0.25);border-color:var(--accent2);">B</div> | |
| <div class="seat" style="background:rgba(80,80,100,0.3);border-color:rgba(100,100,120,0.5);">?</div> | |
| </div> | |
| </div> | |
| <div class="panel-box" id="panel2Box"> | |
| <h4>Panel 2</h4> | |
| <div class="panel-seats" id="panel2Seats"> | |
| <div class="seat" style="background:rgba(108,99,255,0.3);border-color:var(--accent);">A</div> | |
| <div class="seat" style="background:rgba(108,99,255,0.3);border-color:var(--accent);">A</div> | |
| <div class="seat" style="background:rgba(0,210,255,0.25);border-color:var(--accent2);">B</div> | |
| <div class="seat" style="background:rgba(80,80,100,0.3);border-color:rgba(100,100,120,0.5);">?</div> | |
| </div> | |
| </div> | |
| </div> | |
| <button class="toy-btn" id="toyBtn" onclick="animateToy()">▶ See Temporal Rotation</button> | |
| </div> | |
| </section> | |
| <!-- HOW IT WORKS --> | |
| <section id="how"> | |
| <div class="reveal"> | |
| <div class="section-label"><span class="line"></span> Framework</div> | |
| <h2>The Temporal Sortition Framework</h2> | |
| <p class="section-desc">The paper introduces a rigorous mathematical framework. Citizens live in a "metric space" — a map of how similar they are. Three goals must be balanced simultaneously:</p> | |
| </div> | |
| <div class="timeline reveal"> | |
| <div class="timeline-item visible"> | |
| <div class="timeline-dot"></div> | |
| <h3>1. Representation Per Panel</h3> | |
| <p>Every sufficiently large group deserves a representative on <em>each</em> panel. A group of n/k citizens (where k = panel size) should have at least one voice.</p> | |
| </div> | |
| <div class="timeline-item visible"> | |
| <div class="timeline-dot"></div> | |
| <h3>2. Representation Over Time (Prefix)</h3> | |
| <p>Looking at the first t panels cumulatively, every group whose size justifies it should have representatives. Smaller groups accumulate representation across panels.</p> | |
| </div> | |
| <div class="timeline-item visible"> | |
| <div class="timeline-dot"></div> | |
| <h3>3. Individual Fairness</h3> | |
| <p>Every single citizen must have an <em>equal probability</em> of being selected. No one is systematically favored or excluded from the process.</p> | |
| </div> | |
| </div> | |
| <div class="highlight-box reveal"> | |
| <div class="hb-title">💡 What's a Metric Space?</div> | |
| <p>Think of it as a map where citizens are dots, and the distance between two dots measures how <strong>different</strong> they are — based on geography, age, occupation, education, etc. Someone close to you on this map shares many of your characteristics and can represent you well.</p> | |
| </div> | |
| <!-- Prefix diagram --> | |
| <div class="reveal" style="margin-top:48px;"> | |
| <h3 style="font-family:'Space Grotesk';font-size:1.1rem;text-align:center;margin-bottom:8px;">Cumulative Representation Over Time</h3> | |
| <p style="color:var(--text2);font-size:0.85rem;text-align:center;margin-bottom:4px;">Each bar shows the total seats across panels 1…t. As panels accumulate, smaller groups qualify for representation.</p> | |
| <div class="prefix-diagram" id="prefixDiagram"></div> | |
| <div style="display:flex;justify-content:center;gap:24px;flex-wrap:wrap;font-size:0.75rem;color:var(--text2);margin-top:32px;"> | |
| <span>⬤ <span style="color:var(--accent);">Large groups: always represented</span></span> | |
| <span>⬤ <span style="color:var(--accent4);">Medium groups: represented after 2-3 panels</span></span> | |
| <span>⬤ <span style="color:var(--accent3);">Small groups: represented after many panels</span></span> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- SIMULATION --> | |
| <section id="simulation"> | |
| <div class="reveal"> | |
| <div class="section-label"><span class="line"></span> Interactive Demo</div> | |
| <h2>Explore Sortition in Action</h2> | |
| <p class="section-desc">This simulation places citizens in a 2D metric space. Watch how the algorithm selects panels that represent clusters of citizens, accumulating coverage over time.</p> | |
| </div> | |
| <div class="sim-container reveal"> | |
| <div class="sim-controls"> | |
| <div> | |
| <label>Population: <strong id="popLabel">80</strong></label><br> | |
| <input type="range" id="popSlider" min="40" max="200" value="80" step="10"> | |
| </div> | |
| <div> | |
| <label>Panel size: <strong id="panelSizeLabel">6</strong></label><br> | |
| <input type="range" id="panelSizeSlider" min="3" max="12" value="6"> | |
| </div> | |
| <div> | |
| <label>Panels: <strong id="numPanelsLabel">4</strong></label><br> | |
| <input type="range" id="numPanelsSlider" min="2" max="8" value="4"> | |
| </div> | |
| <div> | |
| <button class="toy-btn" id="runSimBtn" onclick="runSimulation()" style="margin-top:0;">▶ Run Sortition</button> | |
| <button class="toy-btn" id="resetSimBtn" onclick="resetSim()" style="margin-top:0;background:var(--glass);border:1px solid var(--glass-border);">↺ Reset</button> | |
| </div> | |
| </div> | |
| <div class="sim-canvas-wrap"> | |
| <canvas id="simCanvas"></canvas> | |
| </div> | |
| <div class="sim-stats"> | |
| <div class="stat-box"> | |
| <div class="stat-value" id="statCoverage">0%</div> | |
| <div class="stat-label">Population Coverage</div> | |
| </div> | |
| <div class="stat-box"> | |
| <div class="stat-value" id="statPanelNum">0/0</div> | |
| <div class="stat-label">Panels Formed</div> | |
| </div> | |
| <div class="stat-box"> | |
| <div class="stat-value" id="statFairness">—</div> | |
| <div class="stat-label">Selection Probability</div> | |
| </div> | |
| <div class="stat-box"> | |
| <div class="stat-value" id="statGroups">0</div> | |
| <div class="stat-label">Groups Represented</div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- ALGORITHMS --> | |
| <section id="algorithms"> | |
| <div class="reveal"> | |
| <div class="section-label"><span class="line"></span> Algorithms</div> | |
| <h2>Three Approaches, Three Trade-offs</h2> | |
| <p class="section-desc">The paper presents three algorithms, each balancing per-panel quality, cumulative quality, and computational feasibility differently.</p> | |
| </div> | |
| <div class="grid-2 reveal"> | |
| <div class="card" style="border-left: 3px solid var(--accent);"> | |
| <h3 style="color:var(--accent);">🔹 Warm-Up: Panel + Global</h3> | |
| <p>Select a large pool of citizens fairly, then partition them into panels. Each panel and the overall union satisfy <strong>constant-factor</strong> proportional representation.</p> | |
| <div style="margin-top:12px;"> | |
| <span class="tag tag-green">Per-panel: O(1)-PRF</span> | |
| <span class="tag tag-green">Global: O(1)-PRF</span> | |
| <span class="tag tag-red">Prefix: ✗</span> | |
| </div> | |
| </div> | |
| <div class="card" style="border-left: 3px solid var(--accent4);"> | |
| <h3 style="color:var(--accent4);">🔸 Nested-Based: Panel + Prefix</h3> | |
| <p>Build a hierarchy of groups at each time scale. Guarantees representation for each panel <em>and</em> every prefix — but the quality degrades exponentially with the number of panels.</p> | |
| <div style="margin-top:12px;"> | |
| <span class="tag tag-yellow">Per-panel: O(4<sup>ℓ</sup>)-PFC</span> | |
| <span class="tag tag-yellow">Prefix: O(4<sup>ℓ−t</sup>)-PFC</span> | |
| <span class="tag tag-green">Fairness: ✓</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="reveal" style="margin-top:24px;"> | |
| <div class="card" style="border-left: 3px solid var(--accent3);max-width:550px;"> | |
| <h3 style="color:var(--accent3);">🔺 Chain-Based: Best Prefix Guarantee</h3> | |
| <p>Links groups across time scales into chains. Achieves <strong>constant-factor</strong> representation for every prefix — the strongest cumulative guarantee — but drops per-panel promises.</p> | |
| <div style="margin-top:12px;"> | |
| <span class="tag tag-red">Per-panel: ✗</span> | |
| <span class="tag tag-green">Prefix: O(1)-PFC</span> | |
| <span class="tag tag-green">Fairness: ✓</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Chain visualization --> | |
| <div class="reveal" style="margin-top:48px;"> | |
| <h3 style="font-family:'Space Grotesk';font-size:1.1rem;margin-bottom:8px;">How Chains Work</h3> | |
| <p style="color:var(--text2);font-size:0.88rem;">Groups from different time scales are linked: representing the last group in a chain automatically represents all earlier ones — preventing error from accumulating.</p> | |
| <svg id="chainViz" viewBox="0 0 800 160"></svg> | |
| </div> | |
| <!-- Comparison table --> | |
| <div class="reveal" style="margin-top:48px;overflow-x:auto;"> | |
| <h3 style="font-family:'Space Grotesk';font-size:1.1rem;margin-bottom:16px;">Algorithm Comparison</h3> | |
| <table class="algo-table"> | |
| <thead> | |
| <tr> | |
| <th>Algorithm</th> | |
| <th>Per-Panel</th> | |
| <th>Prefix (Cumulative)</th> | |
| <th>Fairness</th> | |
| <th>Approx. Quality</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr> | |
| <td style="font-weight:600;color:var(--accent);">Warm-Up</td> | |
| <td><span class="tag tag-green">✓ O(1)-PRF</span></td> | |
| <td><span class="tag tag-red">✗</span></td> | |
| <td><span class="tag tag-green">✓</span></td> | |
| <td>Best per-panel</td> | |
| </tr> | |
| <tr> | |
| <td style="font-weight:600;color:var(--accent4);">Nested-Based</td> | |
| <td><span class="tag tag-yellow">O(4<sup>ℓ</sup>)</span></td> | |
| <td><span class="tag tag-yellow">O(4<sup>ℓ−t</sup>)</span></td> | |
| <td><span class="tag tag-green">✓</span></td> | |
| <td>Exponential cost</td> | |
| </tr> | |
| <tr> | |
| <td style="font-weight:600;color:var(--accent3);">Chain-Based</td> | |
| <td><span class="tag tag-red">✗</span></td> | |
| <td><span class="tag tag-green">✓ O(1)-PFC</span></td> | |
| <td><span class="tag tag-green">✓</span></td> | |
| <td>Best cumulative</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </section> | |
| <!-- RESULTS --> | |
| <section id="results"> | |
| <div class="reveal"> | |
| <div class="section-label"><span class="line"></span> Key Findings</div> | |
| <h2>What Did They Discover?</h2> | |
| </div> | |
| <div class="grid-3 reveal"> | |
| <div class="card"> | |
| <div style="font-size:2.5rem;margin-bottom:12px;">⚡</div> | |
| <h3>Surprise: Divisibility Matters</h3> | |
| <p>When a large panel's size isn't evenly divisible by a smaller one, the smaller panel can <strong>completely fail</strong> to represent the population — no matter how hard you try.</p> | |
| </div> | |
| <div class="card"> | |
| <div style="font-size:2.5rem;margin-bottom:12px;">🔗</div> | |
| <h3>Chains Beat Nesting</h3> | |
| <p>The chain-based approach avoids exponential error growth by cleverly linking groups across time scales, keeping the approximation factor <strong>constant</strong>.</p> | |
| </div> | |
| <div class="card"> | |
| <div style="font-size:2.5rem;margin-bottom:12px;">🔓</div> | |
| <h3>Open Questions Remain</h3> | |
| <p>Can we achieve constant-factor guarantees for <em>both</em> per-panel and prefix simultaneously? This remains an exciting open problem.</p> | |
| </div> | |
| </div> | |
| <div class="highlight-box reveal" style="margin-top:40px;"> | |
| <div class="hb-title">🌍 Real-World Impact</div> | |
| <p>This work lays the mathematical foundation for designing permanent citizens' assemblies — like Belgium's Ostbelgien Model — that are provably fair across time. As more governments adopt ongoing deliberative bodies, these algorithms can ensure no community is systematically excluded.</p> | |
| </div> | |
| <div class="grid-2 reveal" style="margin-top:32px;"> | |
| <div class="card" style="text-align:center;"> | |
| <div class="counter" id="counter1">0</div> | |
| <div style="color:var(--text2);font-size:0.85rem;margin-top:4px;">Open questions posed</div> | |
| </div> | |
| <div class="card" style="text-align:center;"> | |
| <div class="counter" id="counter2">0</div> | |
| <div style="color:var(--text2);font-size:0.85rem;margin-top:4px;">Algorithms proposed</div> | |
| </div> | |
| </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, 2026)</p> | |
| <p style="margin-top:8px;font-size:0.75rem;color:rgba(160,160,184,0.6);">Interactive explainer — not affiliated with the authors.</p> | |
| </footer> | |
| <script> | |
| // ============ TOY EXAMPLE ============ | |
| let toyState = 0; | |
| function animateToy() { | |
| const btn = document.getElementById('toyBtn'); | |
| const status = document.getElementById('toyStatus'); | |
| const p1 = document.getElementById('panel1Seats'); | |
| const p2 = document.getElementById('panel2Seats'); | |
| if (toyState === 0) { | |
| p1.innerHTML = ` | |
| <div class="seat" style="background:rgba(108,99,255,0.3);border-color:var(--accent);transform:scale(1.1);">A</div> | |
| <div class="seat" style="background:rgba(108,99,255,0.3);border-color:var(--accent);transform:scale(1.1);">A</div> | |
| <div class="seat" style="background:rgba(0,210,255,0.25);border-color:var(--accent2);transform:scale(1.1);">B</div> | |
| <div class="seat" style="background:rgba(255,217,61,0.25);border-color:var(--accent4);transform:scale(1.1);box-shadow:0 0 12px rgba(255,217,61,0.4);">C</div>`; | |
| p2.innerHTML = ` | |
| <div class="seat" style="background:rgba(108,99,255,0.3);border-color:var(--accent);transform:scale(1.1);">A</div> | |
| <div class="seat" style="background:rgba(108,99,255,0.3);border-color:var(--accent);transform:scale(1.1);">A</div> | |
| <div class="seat" style="background:rgba(0,210,255,0.25);border-color:var(--accent2);transform:scale(1.1);">B</div> | |
| <div class="seat" style="background:rgba(255,107,157,0.25);border-color:var(--accent3);transform:scale(1.1);box-shadow:0 0 12px rgba(255,107,157,0.4);">D</div>`; | |
| status.innerHTML = '✅ <strong>Both C and D are represented</strong> across the two panels! Temporal rotation solves the problem.'; | |
| btn.textContent = '↺ Reset'; | |
| toyState = 1; | |
| } else { | |
| p1.innerHTML = ` | |
| <div class="seat" style="background:rgba(108,99,255,0.3);border-color:var(--accent);">A</div> | |
| <div class="seat" style="background:rgba(108,99,255,0.3);border-color:var(--accent);">A</div> | |
| <div class="seat" style="background:rgba(0,210,255,0.25);border-color:var(--accent2);">B</div> | |
| <div class="seat" style="background:rgba(80,80,100,0.3);border-color:rgba(100,100,120,0.5);">?</div>`; | |
| p2.innerHTML = ` | |
| <div class="seat" style="background:rgba(108,99,255,0.3);border-color:var(--accent);">A</div> | |
| <div class="seat" style="background:rgba(108,99,255,0.3);border-color:var(--accent);">A</div> | |
| <div class="seat" style="background:rgba(0,210,255,0.25);border-color:var(--accent2);">B</div> | |
| <div class="seat" style="background:rgba(80,80,100,0.3);border-color:rgba(100,100,120,0.5);">?</div>`; | |
| status.innerHTML = 'Panel size = 4 seats. Click below to see how rotation helps!'; | |
| btn.textContent = '▶ See Temporal Rotation'; | |
| toyState = 0; | |
| } | |
| } | |
| // ============ PREFIX DIAGRAM ============ | |
| function buildPrefixDiagram() { | |
| const container = document.getElementById('prefixDiagram'); | |
| container.innerHTML = ''; | |
| const panels = 6; | |
| const k = 5; | |
| const colors = ['var(--accent)', 'var(--accent)', 'var(--accent2)', 'var(--accent4)', 'var(--accent4)', 'var(--accent3)']; | |
| for (let t = 1; t <= panels; t++) { | |
| const h = (t * k / (panels * k)) * 180 + 20; | |
| const bar = document.createElement('div'); | |
| bar.className = 'prefix-bar'; | |
| bar.style.height = h + 'px'; | |
| bar.style.background = `linear-gradient(180deg, ${colors[t-1]}, rgba(0,0,0,0.3))`; | |
| bar.innerHTML = `<div class="bar-label">${t * k}</div><div class="bar-sublabel">P≤${t}</div>`; | |
| bar.title = `After ${t} panel(s): ${t * k} total seats. Groups of size ≥ n/${t*k} get represented.`; | |
| container.appendChild(bar); | |
| } | |
| } | |
| buildPrefixDiagram(); | |
| // ============ CHAIN VISUALIZATION ============ | |
| function buildChainViz() { | |
| const svg = document.getElementById('chainViz'); | |
| const levels = ['G¹', 'G²', 'G³', 'G⁴']; | |
| const colors = ['#6c63ff', '#00d2ff', '#ffd93d', '#ff6b9d']; | |
| let html = ''; | |
| const cx = [120, 300, 500, 680]; | |
| const cy = 80; | |
| for (let i = 0; i < 4; i++) { | |
| const r = 30 - i * 4; | |
| html += `<circle cx="${cx[i]}" cy="${cy}" r="${r}" fill="${colors[i]}22" stroke="${colors[i]}" stroke-width="2"> | |
| <animate attributeName="r" values="${r};${r+4};${r}" dur="3s" begin="${i*0.5}s" repeatCount="indefinite"/> | |
| </circle>`; | |
| html += `<text x="${cx[i]}" y="${cy+5}" text-anchor="middle" fill="${colors[i]}" font-family="Space Grotesk" font-size="14" font-weight="600">${levels[i]}</text>`; | |
| html += `<text x="${cx[i]}" y="${cy+42}" text-anchor="middle" fill="#a0a0b8" font-size="11">r=${(4-i)*3}</text>`; | |
| if (i < 3) { | |
| const x1 = cx[i] + r + 4; | |
| const x2 = cx[i+1] - (30 - (i+1)*4) - 4; | |
| html += `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="${colors[i]}" stroke-width="2" stroke-dasharray="6,4"> | |
| <animate attributeName="stroke-dashoffset" from="0" to="-20" dur="2s" repeatCount="indefinite"/> | |
| </line>`; | |
| html += `<polygon points="${x2-6},${cy-5} ${x2},${cy} ${x2-6},${cy+5}" fill="${colors[i]}"/>`; | |
| } | |
| } | |
| html += `<text x="400" y="148" text-anchor="middle" fill="#a0a0b8" font-size="12" font-style="italic">Representing G⁴ automatically covers G¹, G², G³</text>`; | |
| svg.innerHTML = html; | |
| } | |
| buildChainViz(); | |
| // ============ SIMULATION ============ | |
| let simState = { people: [], clusters: [], panels: [], currentPanel: 0, animFrame: null }; | |
| function getVal(id) { return parseInt(document.getElementById(id).value); } | |
| document.getElementById('popSlider').addEventListener('input', e => { | |
| document.getElementById('popLabel').textContent = e.target.value; | |
| }); | |
| document.getElementById('panelSizeSlider').addEventListener('input', e => { | |
| document.getElementById('panelSizeLabel').textContent = e.target.value; | |
| }); | |
| document.getElementById('numPanelsSlider').addEventListener('input', e => { | |
| document.getElementById('numPanelsLabel').textContent = e.target.value; | |
| }); | |
| function generatePeople(n, canvas) { | |
| const w = canvas.width, h = canvas.height; | |
| const clusters = []; | |
| const numClusters = 4 + Math.floor(Math.random() * 4); | |
| for (let c = 0; c < numClusters; c++) { | |
| const cx = 60 + Math.random() * (w - 120); | |
| const cy = 60 + Math.random() * (h - 120); | |
| const spread = 20 + Math.random() * 50; | |
| const size = Math.max(3, Math.floor(n / numClusters + (Math.random() - 0.5) * n / numClusters)); | |
| clusters.push({ cx, cy, spread, size, color: `hsl(${c * 360 / numClusters + 200}, 70%, 65%)` }); | |
| } | |
| const people = []; | |
| let remaining = n; | |
| for (let c = 0; c < clusters.length && remaining > 0; c++) { | |
| const count = c === clusters.length - 1 ? remaining : Math.min(clusters[c].size, remaining); | |
| for (let i = 0; i < count; i++) { | |
| const angle = Math.random() * Math.PI * 2; | |
| const r = Math.random() * clusters[c].spread; | |
| people.push({ | |
| x: clusters[c].cx + Math.cos(angle) * r, | |
| y: clusters[c].cy + Math.sin(angle) * r, | |
| cluster: c, | |
| color: clusters[c].color, | |
| selected: false, | |
| panel: -1, | |
| alpha: 0.4, | |
| radius: 3 | |
| }); | |
| } | |
| remaining -= count; | |
| } | |
| return { people, clusters }; | |
| } | |
| function greedyCapture(people, k) { | |
| const n = people.length; | |
| const threshold = n / k; | |
| const selected = []; | |
| const covered = new Array(n).fill(false); | |
| for (let s = 0; s < k; s++) { | |
| let bestCenter = -1; | |
| let bestRadius = Infinity; | |
| for (let i = 0; i < n; i++) { | |
| const dists = []; | |
| for (let j = 0; j < n; j++) { | |
| if (!covered[j]) { | |
| const dx = people[i].x - people[j].x; | |
| const dy = people[i].y - people[j].y; | |
| dists.push({ idx: j, dist: Math.sqrt(dx * dx + dy * dy) }); | |
| } | |
| } | |
| dists.sort((a, b) => a.dist - b.dist); | |
| if (dists.length >= threshold) { | |
| const r = dists[Math.floor(threshold) - 1].dist; | |
| if (r < bestRadius) { | |
| bestRadius = r; | |
| bestCenter = i; | |
| } | |
| } | |
| } | |
| if (bestCenter === -1) { | |
| // fallback: pick uncovered person | |
| for (let i = 0; i < n; i++) { | |
| if (!covered[i] && !selected.includes(i)) { | |
| bestCenter = i; | |
| break; | |
| } | |
| } | |
| if (bestCenter === -1) break; | |
| } | |
| selected.push(bestCenter); | |
| // cover nearby | |
| for (let j = 0; j < n; j++) { | |
| const dx = people[bestCenter].x - people[j].x; | |
| const dy = people[bestCenter].y - people[j].y; | |
| if (Math.sqrt(dx * dx + dy * dy) <= bestRadius * 1.5) { | |
| covered[j] = true; | |
| } | |
| } | |
| } | |
| return selected; | |
| } | |
| function resetSim() { | |
| if (simState.animFrame) cancelAnimationFrame(simState.animFrame); | |
| simState = { people: [], clusters: [], panels: [], currentPanel: 0, animFrame: null }; | |
| const canvas = document.getElementById('simCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| canvas.width = canvas.offsetWidth * 2; | |
| canvas.height = canvas.offsetHeight * 2; | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| document.getElementById('statCoverage').textContent = '0%'; | |
| document.getElementById('statPanelNum').textContent = '0/0'; | |
| document.getElementById('statFairness').textContent = '—'; | |
| document.getElementById('statGroups').textContent = '0'; | |
| } | |
| function runSimulation() { | |
| resetSim(); | |
| const canvas = document.getElementById('simCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| canvas.width = canvas.offsetWidth * 2; | |
| canvas.height = canvas.offsetHeight * 2; | |
| const n = getVal('popSlider'); | |
| const k = getVal('panelSizeSlider'); | |
| const numPanels = getVal('numPanelsSlider'); | |
| const { people, clusters } = generatePeople(n, canvas); | |
| simState.people = people; | |
| simState.clusters = clusters; | |
| // Generate panels using greedy capture approximation | |
| const allSelected = new Set(); | |
| const panels = []; | |
| for (let p = 0; p < numPanels; p++) { | |
| const available = people.map((_, i) => i).filter(i => !allSelected.has(i)); | |
| if (available.length < k) break; | |
| // Simple greedy: pick k diverse members | |
| const panelMembers = []; | |
| const used = new Set(); | |
| for (let s = 0; s < k; s++) { | |
| let best = -1, bestMinDist = -1; | |
| for (const i of available) { | |
| if (used.has(i)) continue; | |
| let minDist = Infinity; | |
| for (const j of panelMembers) { | |
| const dx = people[i].x - people[j].x; | |
| const dy = people[i].y - people[j].y; | |
| minDist = Math.min(minDist, Math.sqrt(dx * dx + dy * dy)); | |
| } | |
| if (panelMembers.length === 0) minDist = Math.random() * 1000; | |
| if (minDist > bestMinDist) { | |
| bestMinDist = minDist; | |
| best = i; | |
| } | |
| } | |
| if (best !== -1) { | |
| panelMembers.push(best); | |
| used.add(best); | |
| allSelected.add(best); | |
| } | |
| } | |
| panels.push(panelMembers); | |
| } | |
| simState.panels = panels; | |
| simState.currentPanel = 0; | |
| // Animate panel-by-panel | |
| let panelIdx = 0; | |
| let memberIdx = 0; | |
| let frame = 0; | |
| function animate() { | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Draw connections between panel members and nearby citizens | |
| for (let p = 0; p <= panelIdx && p < panels.length; p++) { | |
| for (const idx of panels[p]) { | |
| if (people[idx].panel >= 0) { | |
| for (let j = 0; j < people.length; j++) { | |
| const dx = people[idx].x - people[j].x; | |
| const dy = people[idx].y - people[j].y; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| if (dist < 60) { | |
| ctx.beginPath(); | |
| ctx.moveTo(people[idx].x, people[idx].y); | |
| ctx.lineTo(people[j].x, people[j].y); | |
| ctx.strokeStyle = `rgba(108,99,255,${0.05 * (1 - dist / 60)})`; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Draw all people | |
| for (const p of people) { | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2); | |
| ctx.fillStyle = p.panel >= 0 | |
| ? p.color | |
| : `rgba(120,120,150,${p.alpha})`; | |
| ctx.fill(); | |
| if (p.panel >= 0) { | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, p.radius + 4, 0, Math.PI * 2); | |
| ctx.strokeStyle = p.color.replace('65%', '80%'); | |
| ctx.lineWidth = 1.5; | |
| ctx.globalAlpha = 0.4 + 0.2 * Math.sin(frame * 0.05 + p.x * 0.01); | |
| ctx.stroke(); | |
| ctx.globalAlpha = 1; | |
| } | |
| } | |
| // Draw panel labels | |
| const panelColors = ['#6c63ff', '#00d2ff', '#ffd93d', '#ff6b9d', '#40e0d0', '#ff8c00', '#da70d6', '#00fa9a']; | |
| for (let p = 0; p <= panelIdx && p < panels.length; p++) { | |
| for (const idx of panels[p]) { | |
| if (people[idx].panel >= 0) { | |
| ctx.beginPath(); | |
| ctx.arc(people[idx].x, people[idx].y, 8, 0, Math.PI * 2); | |
| ctx.fillStyle = panelColors[p % panelColors.length]; | |
| ctx.fill(); | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = 'bold 10px Inter'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(p + 1, people[idx].x, people[idx].y); | |
| } | |
| } | |
| } | |
| // Animate selection | |
| if (panelIdx < panels.length) { | |
| if (memberIdx < panels[panelIdx].length) { | |
| if (frame % 8 === 0) { | |
| const idx = panels[panelIdx][memberIdx]; | |
| people[idx].selected = true; | |
| people[idx].panel = panelIdx; | |
| people[idx].radius = 6; | |
| memberIdx++; | |
| } | |
| } else { | |
| panelIdx++; | |
| memberIdx = 0; | |
| simState.currentPanel = panelIdx; | |
| } | |
| } | |
| // Update stats | |
| const totalSelected = people.filter(p => p.panel >= 0).length; | |
| const coveredClusters = new Set(people.filter(p => p.panel >= 0).map(p => p.cluster)); | |
| const coverage = Math.round((totalSelected / people.length) * 100 * 10) / 10; | |
| // Coverage: count people within range of selected members | |
| let coveredPeople = 0; | |
| for (let i = 0; i < people.length; i++) { | |
| for (const panel of panels.slice(0, panelIdx + 1)) { | |
| for (const sel of panel) { | |
| if (people[sel].panel >= 0) { | |
| const dx = people[i].x - people[sel].x; | |
| const dy = people[i].y - people[sel].y; | |
| if (Math.sqrt(dx * dx + dy * dy) < 80) { | |
| coveredPeople++; | |
| break; | |
| } | |
| } | |
| } | |
| if (coveredPeople > i) break; | |
| } | |
| } | |
| const cov = Math.round(Math.min(100, coveredPeople / people.length * 100)); | |
| document.getElementById('statCoverage').textContent = cov + '%'; | |
| document.getElementById('statPanelNum').textContent = Math.min(panelIdx + 1, panels.length) + '/' + panels.length; | |
| document.getElementById('statFairness').textContent = (panels.length * k / n * 100).toFixed(1) + '%'; | |
| document.getElementById('statGroups').textContent = coveredClusters.size + '/' + clusters.length; | |
| frame++; | |
| if (panelIdx < panels.length || frame < panels.length * k * 8 + 60) { | |
| simState.animFrame = requestAnimationFrame(animate); | |
| } | |
| } | |
| animate(); | |
| } | |
| // ============ SCROLL REVEAL ============ | |
| const observer = new IntersectionObserver((entries) => { | |
| entries.forEach(entry => { | |
| if (entry.isIntersecting) { | |
| entry.target.classList.add('visible'); | |
| } | |
| }); | |
| }, { threshold: 0.1 }); | |
| document.querySelectorAll('.reveal, .timeline-item').forEach(el => observer.observe(el)); | |
| // ============ COUNTERS ============ | |
| let counted = false; | |
| const counterObserver = new IntersectionObserver((entries) => { | |
| entries.forEach(entry => { | |
| if (entry.isIntersecting && !counted) { | |
| counted = true; | |
| animateCounter('counter1', 3, 1200); | |
| animateCounter('counter2', 3, 1200); | |
| } | |
| }); | |
| }, { threshold: 0.5 }); | |
| document.querySelectorAll('.counter').forEach(el => counterObserver.observe(el)); | |
| function animateCounter(id, target, duration) { | |
| const el = document.getElementById(id); | |
| let start = 0; | |
| const startTime = performance.now(); | |
| function step(now) { | |
| const progress = Math.min((now - startTime) / duration, 1); | |
| const eased = 1 - Math.pow(1 - progress, 3); | |
| el.textContent = Math.round(eased * target); | |
| if (progress < 1) requestAnimationFrame(step); | |
| } | |
| requestAnimationFrame(step); | |
| } | |
| // Auto-run sim on first view | |
| const simObserver = new IntersectionObserver((entries) => { | |
| entries.forEach(entry => { | |
| if (entry.isIntersecting && simState.people.length === 0) { | |
| setTimeout(runSimulation, 500); | |
| } | |
| }); | |
| }, { threshold: 0.3 }); | |
| simObserver.observe(document.getElementById('simulation')); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment