Skip to content

Instantly share code, notes, and snippets.

@jbdamask
Created February 22, 2026 14:47
Show Gist options
  • Select an option

  • Save jbdamask/efed80f990c5ee3241df94231384a8be to your computer and use it in GitHub Desktop.

Select an option

Save jbdamask/efed80f990c5ee3241df94231384a8be to your computer and use it in GitHub Desktop.
NowIGetIt: temporarl-patterns.pdf
<!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