Choose how many lineages to sample and how large the population is, then step the coalescent backward in time. At each generation, any pair of lineages has a small chance of sharing a parent — that is a coalescence event. Watch the tree form from the tips down to the root.
<!-- Coalescent demo – controls and canvas -->
<div class="controls">
<label>Sample size (k): <input id="sampleSize" type="range" min="2" max="8" value="5" />
<span id="sampleVal">5</span></label>
<label>Population (N): <input id="popSize" type="range" min="10" max="200" step="10" value="50" />
<span id="popVal">50</span></label>
<div class="btn-row">
<button id="btnStep" type="button">Step back 1 generation</button>
<button id="btnRun" type="button">Run</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
</div>
<canvas id="canvas" width="480" height="300"></canvas>
<div id="status" class="status">Press "Step" or "Run" to trace lineages backward.</div>
/* Coalescent demo styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; flex-direction: column; gap: .4rem; margin-bottom: .5rem; }
label { font-size: .88rem; display: flex; align-items: center; gap: .5rem; }
input[type=range] { flex: 1; max-width: 180px; }
span { font-weight: 700; min-width: 2.2rem; }
.btn-row { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
canvas { display: block; width: 100%; height: auto; border-radius: 8px;
background: #f4f7fa; border: 1px solid #dce4ec; }
.status { font-size: .9rem; font-weight: 600; margin-top: .4rem; min-height: 1.4em; }
.status.done { color: #0a7d33; }
.status.running { color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Coalescent simulator – Kingman model (diploid, Wright-Fisher) */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
/* Line colours for each starting lineage */
const COLORS = ['#e63946','#457b9d','#2a9d8f','#e9c46a','#f4a261','#6a4c93','#264653','#a8dadc'];
/* Simulation state */
let lineages = []; /* one object per sampled lineage */
let gen = 0; /* current generation (counting backward from 0) */
let N = 50; /* effective population size (diploid) */
let maxGen = 0; /* canvas scaling horizon */
let done = false; /* true once all lineages have coalesced to MRCA */
let running = false; /* true while auto-run timer is active */
let animTimer = null;
/* Data structure for a single lineage
lineage = { id, color, x (current canvas-x), history: [{gen, x}], alive: bool } */
function init() {
const k = +document.getElementById('sampleSize').value;
N = +document.getElementById('popSize').value;
gen = 0; done = false; running = false;
if (animTimer) { clearInterval(animTimer); animTimer = null; }
maxGen = Math.ceil(4 * N * (1 - 1 / k) * 2.5);
/* Spread starting lineages evenly across canvas width */
const margin = 40;
const step = (W - 2 * margin) / Math.max(k - 1, 1);
lineages = [];
for (let i = 0; i < k; i++) {
const x = margin + i * step;
lineages.push({ id: i, color: COLORS[i % COLORS.length], x, history: [{ gen: 0, x }], alive: true });
}
document.getElementById('btnStep').disabled = false;
document.getElementById('btnRun').disabled = false;
setStatus('Press "Step" or "Run" to trace lineages backward.', '');
draw();
}
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'status ' + cls;
}
/* Draw the current state of the coalescent tree onto the canvas */
function draw() {
ctx.clearRect(0, 0, W, H);
const alive = lineages.filter(l => l.alive);
const totalGens = Math.max(gen, 1);
/* y increases downward; generation 0 is at the bottom, past is up */
const yFor = g => H - 20 - (g / Math.max(maxGen, totalGens)) * (H - 40);
/* Draw line segments for each lineage's path through time */
for (const lin of lineages) {
if (lin.history.length < 2) continue;
ctx.strokeStyle = lin.color;
ctx.lineWidth = 2.2;
ctx.lineJoin = 'round';
ctx.beginPath();
for (let i = lin.history.length - 1; i >= 0; i--) {
const { gen: g, x } = lin.history[i];
const y = yFor(g);
i === lin.history.length - 1 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
}
/* Draw a dot at the present-day tip of each lineage */
for (const lin of lineages) {
const tip = lin.history[0];
ctx.fillStyle = lin.color;
ctx.beginPath();
ctx.arc(tip.x, yFor(tip.gen), 5, 0, 2 * Math.PI);
ctx.fill();
}
/* If the simulation is complete, mark the MRCA with a filled circle */
if (done && alive.length === 1) {
const root = alive[0].history[alive[0].history.length - 1];
ctx.fillStyle = '#222';
ctx.beginPath();
ctx.arc(root.x, yFor(root.gen), 7, 0, 2 * Math.PI);
ctx.fill();
/* Label the MRCA node */
ctx.fillStyle = '#222';
ctx.font = 'bold 12px system-ui';
ctx.textAlign = 'center';
ctx.fillText('MRCA', root.x, yFor(root.gen) - 11);
}
/* Show the current generation counter in the top-left */
ctx.fillStyle = '#888';
ctx.font = '11px system-ui';
ctx.textAlign = 'left';
ctx.fillText(i18nFmt('Generations back: %gen%', { gen }), 6, 14);
}
/* Advance the coalescent by one generation backward */
function step() {
if (done) return;
gen++;
const alive = lineages.filter(l => l.alive);
if (alive.length <= 1) { finishSim(alive); return; }
/* In each generation each pair independently has probability 1/(2N) of sharing
a parent. We scan all pairs and merge the first one that coalesces.
In each generation each pair independently has probability 1/(2N) of sharing
a parent. We scan all pairs and merge the first one that coalesces. */
let merged = false;
for (let i = 0; i < alive.length && !merged; i++) {
for (let j = i + 1; j < alive.length && !merged; j++) {
if (Math.random() < 1 / (2 * N)) {
/* Two lineages coalesce – their paths converge to the midpoint */
const winner = alive[i];
const loser = alive[j];
const newX = (winner.x + loser.x) / 2;
/* Record the new position for both lineages at this generation */
winner.history.push({ gen, x: newX });
loser.history.push({ gen, x: newX });
winner.x = newX;
loser.alive = false;
merged = true;
}
}
}
/* Surviving lineages drift a little horizontally for visual clarity.
Surviving lineages drift a little horizontally for visual clarity. */
for (const lin of lineages) {
if (!lin.alive) continue;
const drift = (Math.random() - 0.5) * 12;
const clampX = Math.min(W - 20, Math.max(20, lin.x + drift));
if (!merged || lin.x !== lineages.filter(l => l.alive).find(l => l === lin)?.x) {
lin.x = clampX;
lin.history.push({ gen, x: clampX });
}
}
const stillAlive = lineages.filter(l => l.alive);
if (stillAlive.length <= 1) { finishSim(stillAlive); return; }
draw();
}
function finishSim(alive) {
done = true;
if (animTimer) { clearInterval(animTimer); animTimer = null; running = false; }
document.getElementById('btnStep').disabled = true;
document.getElementById('btnRun').disabled = true;
draw();
setStatus(i18nFmt('MRCA reached at generation %gen% ago.', { gen }), 'done');
}
function runAuto() {
if (running) { clearInterval(animTimer); animTimer = null; running = false;
document.getElementById('btnRun').textContent = 'Run'; return; }
running = true;
document.getElementById('btnRun').textContent = 'Pause';
animTimer = setInterval(() => {
step();
if (done) { running = false; document.getElementById('btnRun').textContent = 'Run'; }
}, 120);
}
/* Wire up UI controls */
document.getElementById('sampleSize').addEventListener('input', function() {
document.getElementById('sampleVal').textContent = this.value; init();
});
document.getElementById('popSize').addEventListener('input', function() {
document.getElementById('popVal').textContent = this.value; init();
});
document.getElementById('btnStep').addEventListener('click', step);
document.getElementById('btnRun').addEventListener('click', runAuto);
document.getElementById('btnReset').addEventListener('click', init);
init();
<!-- Coalescent demo – controls and canvas -->
<div class="controls">
<label>Sample size (k): <input id="sampleSize" type="range" min="2" max="8" value="5" />
<span id="sampleVal">5</span></label>
<label>Population (N): <input id="popSize" type="range" min="10" max="200" step="10" value="50" />
<span id="popVal">50</span></label>
<div class="btn-row">
<button id="btnStep" type="button">Step back 1 generation</button>
<button id="btnRun" type="button">Run</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
</div>
<canvas id="canvas" width="480" height="300"></canvas>
<div id="status" class="status">Press "Step" or "Run" to trace lineages backward.</div>
/* Coalescent demo styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; flex-direction: column; gap: .4rem; margin-bottom: .5rem; }
label { font-size: .88rem; display: flex; align-items: center; gap: .5rem; }
input[type=range] { flex: 1; max-width: 180px; }
span { font-weight: 700; min-width: 2.2rem; }
.btn-row { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
canvas { display: block; width: 100%; height: auto; border-radius: 8px;
background: #f4f7fa; border: 1px solid #dce4ec; }
.status { font-size: .9rem; font-weight: 600; margin-top: .4rem; min-height: 1.4em; }
.status.done { color: #0a7d33; }
.status.running { color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Coalescent simulator – Kingman model (diploid, Wright-Fisher) */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
/* Line colours for each starting lineage */
const COLORS = ['#e63946','#457b9d','#2a9d8f','#e9c46a','#f4a261','#6a4c93','#264653','#a8dadc'];
/* Simulation state */
let lineages = []; /* one object per sampled lineage */
let gen = 0; /* current generation (counting backward from 0) */
let N = 50; /* effective population size (diploid) */
let maxGen = 0; /* canvas scaling horizon */
let done = false; /* true once all lineages have coalesced to MRCA */
let running = false; /* true while auto-run timer is active */
let animTimer = null;
/* Data structure for a single lineage
lineage = { id, color, x (current canvas-x), history: [{gen, x}], alive: bool } */
function init() {
const k = +document.getElementById('sampleSize').value;
N = +document.getElementById('popSize').value;
gen = 0; done = false; running = false;
if (animTimer) { clearInterval(animTimer); animTimer = null; }
maxGen = Math.ceil(4 * N * (1 - 1 / k) * 2.5);
/* Spread starting lineages evenly across canvas width */
const margin = 40;
const step = (W - 2 * margin) / Math.max(k - 1, 1);
lineages = [];
for (let i = 0; i < k; i++) {
const x = margin + i * step;
lineages.push({ id: i, color: COLORS[i % COLORS.length], x, history: [{ gen: 0, x }], alive: true });
}
document.getElementById('btnStep').disabled = false;
document.getElementById('btnRun').disabled = false;
setStatus('Press "Step" or "Run" to trace lineages backward.', '');
draw();
}
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'status ' + cls;
}
/* Draw the current state of the coalescent tree onto the canvas */
function draw() {
ctx.clearRect(0, 0, W, H);
const alive = lineages.filter(l => l.alive);
const totalGens = Math.max(gen, 1);
/* y increases downward; generation 0 is at the bottom, past is up */
const yFor = g => H - 20 - (g / Math.max(maxGen, totalGens)) * (H - 40);
/* Draw line segments for each lineage's path through time */
for (const lin of lineages) {
if (lin.history.length < 2) continue;
ctx.strokeStyle = lin.color;
ctx.lineWidth = 2.2;
ctx.lineJoin = 'round';
ctx.beginPath();
for (let i = lin.history.length - 1; i >= 0; i--) {
const { gen: g, x } = lin.history[i];
const y = yFor(g);
i === lin.history.length - 1 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
}
/* Draw a dot at the present-day tip of each lineage */
for (const lin of lineages) {
const tip = lin.history[0];
ctx.fillStyle = lin.color;
ctx.beginPath();
ctx.arc(tip.x, yFor(tip.gen), 5, 0, 2 * Math.PI);
ctx.fill();
}
/* If the simulation is complete, mark the MRCA with a filled circle */
if (done && alive.length === 1) {
const root = alive[0].history[alive[0].history.length - 1];
ctx.fillStyle = '#222';
ctx.beginPath();
ctx.arc(root.x, yFor(root.gen), 7, 0, 2 * Math.PI);
ctx.fill();
/* Label the MRCA node */
ctx.fillStyle = '#222';
ctx.font = 'bold 12px system-ui';
ctx.textAlign = 'center';
ctx.fillText('MRCA', root.x, yFor(root.gen) - 11);
}
/* Show the current generation counter in the top-left */
ctx.fillStyle = '#888';
ctx.font = '11px system-ui';
ctx.textAlign = 'left';
ctx.fillText(i18nFmt('Generations back: %gen%', { gen }), 6, 14);
}
/* Advance the coalescent by one generation backward */
function step() {
if (done) return;
gen++;
const alive = lineages.filter(l => l.alive);
if (alive.length <= 1) { finishSim(alive); return; }
/* In each generation each pair independently has probability 1/(2N) of sharing
a parent. We scan all pairs and merge the first one that coalesces.
In each generation each pair independently has probability 1/(2N) of sharing
a parent. We scan all pairs and merge the first one that coalesces. */
let merged = false;
for (let i = 0; i < alive.length && !merged; i++) {
for (let j = i + 1; j < alive.length && !merged; j++) {
if (Math.random() < 1 / (2 * N)) {
/* Two lineages coalesce – their paths converge to the midpoint */
const winner = alive[i];
const loser = alive[j];
const newX = (winner.x + loser.x) / 2;
/* Record the new position for both lineages at this generation */
winner.history.push({ gen, x: newX });
loser.history.push({ gen, x: newX });
winner.x = newX;
loser.alive = false;
merged = true;
}
}
}
/* Surviving lineages drift a little horizontally for visual clarity.
Surviving lineages drift a little horizontally for visual clarity. */
for (const lin of lineages) {
if (!lin.alive) continue;
const drift = (Math.random() - 0.5) * 12;
const clampX = Math.min(W - 20, Math.max(20, lin.x + drift));
if (!merged || lin.x !== lineages.filter(l => l.alive).find(l => l === lin)?.x) {
lin.x = clampX;
lin.history.push({ gen, x: clampX });
}
}
const stillAlive = lineages.filter(l => l.alive);
if (stillAlive.length <= 1) { finishSim(stillAlive); return; }
draw();
}
function finishSim(alive) {
done = true;
if (animTimer) { clearInterval(animTimer); animTimer = null; running = false; }
document.getElementById('btnStep').disabled = true;
document.getElementById('btnRun').disabled = true;
draw();
setStatus(i18nFmt('MRCA reached at generation %gen% ago.', { gen }), 'done');
}
function runAuto() {
if (running) { clearInterval(animTimer); animTimer = null; running = false;
document.getElementById('btnRun').textContent = 'Run'; return; }
running = true;
document.getElementById('btnRun').textContent = 'Pause';
animTimer = setInterval(() => {
step();
if (done) { running = false; document.getElementById('btnRun').textContent = 'Run'; }
}, 120);
}
/* Wire up UI controls */
document.getElementById('sampleSize').addEventListener('input', function() {
document.getElementById('sampleVal').textContent = this.value; init();
});
document.getElementById('popSize').addEventListener('input', function() {
document.getElementById('popVal').textContent = this.value; init();
});
document.getElementById('btnStep').addEventListener('click', step);
document.getElementById('btnRun').addEventListener('click', runAuto);
document.getElementById('btnReset').addEventListener('click', init);
init();
Notice what changes when you double the population size: the expected waiting time between coalescence events roughly doubles too. A large population keeps lineages separated for longer before they happen to pick the same ancestor. That relationship — waiting time scales with population size — is the core signal that lets geneticists reconstruct ancient demography from modern DNA.
Comments
Loading comments...