13
Competitive Exclusion: Tilman's R*
drag Y to tune species 1 growth, click to reset
idle
211 lines ยท vanilla
view source
// Tilman's R*-rule and competitive exclusion.
// Two species N1, N2 compete for one limiting resource R.
// g_i(R) = mu_i * R / (K_i + R) - m_i
// R_i* = K_i * m_i / (mu_i - m_i) (solve g_i(R*) = 0)
// dN_i/dt = N_i * g_i(R)
// dR/dt = S - r R - sum_i N_i * mu_i * R / (K_i + R) * q_i
// The species with the LOWER R* wins competitive exclusion.
const S_IN = 1.5; // resource supply rate
const R_LOSS = 0.10; // abiotic resource loss
const MU2 = 0.85; // species 2 max growth rate (fixed)
const K1 = 1.20;
const K2 = 0.55;
const M1 = 0.18;
const M2 = 0.18;
const Q1 = 0.018; // per-capita resource consumption coeff (species 1)
const Q2 = 0.018;
const DT_SIM = 0.05;
const SUBSTEPS = 4;
const HISTORY = 600;
const RESET_T = 240; // auto reset after this much sim time
let W = 0, H = 0;
let N1 = 0, N2 = 0, R = 0;
let mu1Mult = 1.6; // mouseY-controlled multiplier on mu1 baseline
const MU1_BASE = MU2; // baseline equals MU2; multiplier scales it
let frame = 0;
let simTime = 0;
let hist; // {N1, N2, R, t}
let pendingClicks = 0;
function rStar(mu, K, m) {
if (mu <= m) return Infinity;
return (K * m) / (mu - m);
}
function clamp(x, lo, hi) { return x < lo ? lo : x > hi ? hi : x; }
function reset() {
N1 = 1.0;
N2 = 1.0;
R = 1.0;
simTime = 0;
hist.length = 0;
}
function derivs(n1, n2, r, mu1) {
const uptake1 = mu1 * r / (K1 + r);
const uptake2 = MU2 * r / (K2 + r);
const dn1 = n1 * (uptake1 - M1);
const dn2 = n2 * (uptake2 - M2);
const dr = S_IN - R_LOSS * r - n1 * uptake1 * Q1 - n2 * uptake2 * Q2;
return [dn1, dn2, dr];
}
function step(mu1) {
// RK4 on (N1, N2, R)
const k1 = derivs(N1, N2, R, mu1);
const k2 = derivs(N1 + 0.5 * DT_SIM * k1[0], N2 + 0.5 * DT_SIM * k1[1], R + 0.5 * DT_SIM * k1[2], mu1);
const k3 = derivs(N1 + 0.5 * DT_SIM * k2[0], N2 + 0.5 * DT_SIM * k2[1], R + 0.5 * DT_SIM * k2[2], mu1);
const k4 = derivs(N1 + DT_SIM * k3[0], N2 + DT_SIM * k3[1], R + DT_SIM * k3[2], mu1);
N1 = Math.max(0, N1 + (DT_SIM / 6) * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]));
N2 = Math.max(0, N2 + (DT_SIM / 6) * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]));
R = Math.max(0, R + (DT_SIM / 6) * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2]));
simTime += DT_SIM;
}
function init({ width, height }) {
W = width; H = height;
hist = [];
reset();
}
function tick({ ctx, dt, width, height, input }) {
W = width; H = height;
frame++;
// input -> mu1 multiplier (mouseY top = stronger species 1, bottom = weaker)
if (input && typeof input.mouseY === 'number' && input.mouseY >= 0 && input.mouseY <= H) {
const yn = clamp(input.mouseY / H, 0, 1);
// top of canvas => high mu1 (advantage), bottom => low mu1 (disadvantage)
mu1Mult = 2.2 - yn * 1.7; // range ~[0.5, 2.2]
}
const mu1 = MU1_BASE * mu1Mult;
// clicks reset
if (input && typeof input.consumeClicks === 'function') {
pendingClicks += input.consumeClicks();
}
if (pendingClicks > 0) {
reset();
pendingClicks = 0;
}
// integrate
for (let s = 0; s < SUBSTEPS; s++) step(mu1);
// record
if ((frame & 1) === 0) {
hist.push({ N1, N2, R, t: simTime });
if (hist.length > HISTORY) hist.shift();
}
// auto-loop: once a steady state has held, restart
if (simTime > RESET_T) reset();
// ---- render ----
ctx.fillStyle = '#07090d';
ctx.fillRect(0, 0, W, H);
// layout: top panel = N1, N2 lines; bottom panel = R with R1*, R2* dashed lines
const padL = 56, padR = 18, padT = 36, padB = 92;
const gap = 14;
const plotW = W - padL - padR;
const plotH = (H - padT - padB - gap) * 0.55;
const rPlotH = (H - padT - padB - gap) - plotH;
const popY0 = padT;
const popY1 = popY0 + plotH;
const rY0 = popY1 + gap;
const rY1 = rY0 + rPlotH;
const R1s = rStar(mu1, K1, M1);
const R2s = rStar(MU2, K2, M2);
// dynamic ranges
let nMax = 50;
let rMax = 4;
for (const h of hist) {
if (h.N1 > nMax) nMax = h.N1;
if (h.N2 > nMax) nMax = h.N2;
if (h.R > rMax) rMax = h.R;
}
if (Number.isFinite(R1s)) rMax = Math.max(rMax, R1s * 1.15);
if (Number.isFinite(R2s)) rMax = Math.max(rMax, R2s * 1.15);
nMax *= 1.1;
const tMax = Math.max(simTime, RESET_T * 0.25);
// panel frames
ctx.strokeStyle = '#1d2530';
ctx.lineWidth = 1;
ctx.strokeRect(padL, popY0, plotW, plotH);
ctx.strokeRect(padL, rY0, plotW, rPlotH);
// axes labels
ctx.fillStyle = '#7a8494';
ctx.font = '11px monospace';
ctx.fillText('populations N1, N2', padL + 6, popY0 + 14);
ctx.fillText('resource R', padL + 6, rY0 + 14);
ctx.fillText('time โ', padL + plotW - 60, rY1 + 14);
// y-axis ticks (N)
ctx.strokeStyle = '#141a23';
ctx.beginPath();
for (let k = 1; k <= 3; k++) {
const y = popY0 + plotH * (1 - k / 4);
ctx.moveTo(padL, y); ctx.lineTo(padL + plotW, y);
}
for (let k = 1; k <= 3; k++) {
const y = rY0 + rPlotH * (1 - k / 4);
ctx.moveTo(padL, y); ctx.lineTo(padL + plotW, y);
}
ctx.stroke();
// N axis label numbers
ctx.fillStyle = '#5a6473';
ctx.font = '10px monospace';
for (let k = 0; k <= 4; k++) {
const v = (nMax * k / 4);
const y = popY0 + plotH * (1 - k / 4);
ctx.fillText(v.toFixed(0), 8, y + 3);
}
for (let k = 0; k <= 4; k++) {
const v = (rMax * k / 4);
const y = rY0 + rPlotH * (1 - k / 4);
ctx.fillText(v.toFixed(2), 8, y + 3);
}
// R1* and R2* dashed lines (only valid in the resource panel)
function drawDashedY(value, color, label) {
if (!Number.isFinite(value) || value < 0 || value > rMax) return;
const y = rY0 + rPlotH * (1 - value / rMax);
ctx.save();
ctx.setLineDash([5, 4]);
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(padL, y); ctx.lineTo(padL + plotW, y);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = color;
ctx.font = '11px monospace';
ctx.fillText(label, padL + plotW - 78, y - 4);
ctx.restore();
}
drawDashedY(R1s, 'rgba(255,140,90,0.85)', `R1* = ${Number.isFinite(R1s) ? R1s.toFixed(2) : 'โ'}`);
drawDashedY(R2s, 'rgba(120,200,255,0.85)', `R2* = ${R2s.toFixed(2)}`);
// plot histories
function plotSeries(panelY0, panelH, key, yMax, stroke, lw) {
if (hist.length < 2) return;
ctx.strokeStyle = stroke;
ctx.lineWidth = lw;
ctx.beginPath();
for (let i = 0; i < hist.length; i++) {
const h = hist[i];
const px = padL + (h.t / tMax) * plotW;
const v = h[key];
const py = panelY0 + panelH * (1 - clamp(v / yMax, 0, 1));
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke();
}
plotSeries(popY0, plotH, 'N1', nMax, '#ff8c5a', 1.8);
plotSeries(popY0, plotH, 'N2', nMax, '#78c8ff', 1.8);
plotSeries(rY0, rPlotH, 'R', rMax, '#e8d27a', 1.6);
// legend dots at last value
if (hist.length) {
const last = hist[hist.length - 1];
const lx = padL + (last.t / tMax) * plotW;
ctx.fillStyle = '#ff8c5a';
ctx.beginPath();
ctx.arc(lx, popY0 + plotH * (1 - clamp(last.N1 / nMax, 0, 1)), 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#78c8ff';
ctx.beginPath();
ctx.arc(lx, popY0 + plotH * (1 - clamp(last.N2 / nMax, 0, 1)), 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#e8d27a';
ctx.beginPath();
ctx.arc(lx, rY0 + rPlotH * (1 - clamp(last.R / rMax, 0, 1)), 3, 0, Math.PI * 2);
ctx.fill();
}
// title
ctx.fillStyle = '#d8dee9';
ctx.font = '14px monospace';
ctx.fillText("Tilman's R*-rule โ competitive exclusion", padL, 20);
// big R* readout, predicted winner
const winner = R1s < R2s ? 1 : (R2s < R1s ? 2 : 0);
const winnerStr = winner === 0 ? 'tie' : (winner === 1 ? 'species 1 wins' : 'species 2 wins');
const winnerCol = winner === 1 ? '#ff8c5a' : winner === 2 ? '#78c8ff' : '#cfd6e0';
// bottom HUD
const hudY = rY1 + 22;
ctx.fillStyle = 'rgba(255,140,90,0.95)';
ctx.font = '14px monospace';
ctx.fillText(`R1* = ${Number.isFinite(R1s) ? R1s.toFixed(3) : 'โ'}`, padL, hudY);
ctx.fillStyle = 'rgba(120,200,255,0.95)';
ctx.fillText(`R2* = ${R2s.toFixed(3)}`, padL + 160, hudY);
ctx.fillStyle = winnerCol;
ctx.fillText(`predicted: ${winnerStr}`, padL + 310, hudY);
ctx.fillStyle = '#7a8494';
ctx.font = '11px monospace';
ctx.fillText(`mu1 = ${mu1.toFixed(2)} mu2 = ${MU2.toFixed(2)} N1 = ${N1.toFixed(2)} N2 = ${N2.toFixed(2)} R = ${R.toFixed(3)} t = ${simTime.toFixed(1)}`, padL, hudY + 18);
ctx.fillStyle = '#5a6473';
ctx.fillText('drag Y to tune mu1 โข click to reset', padL, hudY + 36);
// mouseY indicator bar on the right
if (input && typeof input.mouseY === 'number' && input.mouseY >= 0 && input.mouseY <= H) {
const bx = W - padR + 4;
ctx.strokeStyle = '#1f2733';
ctx.strokeRect(bx, padT, 4, H - padT - padB);
ctx.fillStyle = '#cfd6e0';
ctx.fillRect(bx, clamp(input.mouseY, padT, H - padB) - 1, 4, 3);
}
}
Comments (0)
Log in to comment.