0

Three-Species Trophic Cascade

click to cull foxes, drag Y to tune fox death rate

A generalized three-level Lotka-Volterra food chain — grass , rabbits , foxes — integrated with RK4 at :

with , , , , , , and scrubbed live via mouse-Y in . The left panel scrolls the three populations through time; the right panel renders the trajectory in space via an oblique 2-D projection , where you can see the system spiral toward a stable focus, a limit cycle, or — if is pushed high enough that foxes can't survive — collapse onto the two-species sub-attractor. The headline is the trophic cascade: click anywhere to instantly halve the fox population and watch the wave propagate downward. Released from predation, rabbits boom; the rabbit boom flattens grass; only once foxes have rebuilt on the rabbit surplus does the chain settle back to its attractor — a textbook example of top-down control reaching all the way to the producers.

idle
242 lines · vanilla
view source
// Three-species generalized Lotka-Volterra food chain: grass -> rabbits -> foxes.
// Left panel: scrolling time series of G, R, F.
// Right panel: oblique-projected 3D phase trajectory in (G,R,F) space.

const R_G = 1.0;     // grass intrinsic growth
const K   = 1.0;     // grass carrying capacity
const A_GR = 1.2;    // grass -> rabbit grazing rate
const E_GR = 0.7;    // grass-to-rabbit conversion efficiency
const A_RF = 1.0;    // rabbit -> fox predation rate
const E_RF = 0.5;    // rabbit-to-fox conversion efficiency
const D_R  = 0.4;    // rabbit death rate
const D_F_MIN = 0.18;
const D_F_MAX = 0.55;
let   D_F  = 0.3;    // fox death rate (mouseY-controlled)

const DT_SIM = 0.01;
const SUBSTEPS = 4;

const TS_MAX = 720;   // time-series buffer (frames worth of samples)
const PHASE_MAX = 3000;

let W, H;
let state;            // [G, R, F]
let ts;               // Float32Array, 3*TS_MAX
let tsHead, tsCount;
let phase;            // Float32Array, 3*PHASE_MAX
let phHead, phCount;
let simTime;
let lastPerturbT;
let perturbFlash;

// Scratch slots for rk4 — inlined to avoid per-step allocations.
let _k1G = 0, _k1R = 0, _k1F = 0;
let _k2G = 0, _k2R = 0, _k2F = 0;
let _k3G = 0, _k3R = 0, _k3F = 0;
let _k4G = 0, _k4R = 0, _k4F = 0;

function _setDerivs(G, R, F, target) {
  const dG = R_G * G * (1 - G / K) - A_GR * G * R;
  const dR = E_GR * A_GR * G * R - A_RF * R * F - D_R * R;
  const dF = E_RF * A_RF * R * F - D_F * F;
  if (target === 1) { _k1G = dG; _k1R = dR; _k1F = dF; }
  else if (target === 2) { _k2G = dG; _k2R = dR; _k2F = dF; }
  else if (target === 3) { _k3G = dG; _k3R = dR; _k3F = dF; }
  else { _k4G = dG; _k4R = dR; _k4F = dF; }
}

function rk4Step(s, h) {
  const G = s[0], R = s[1], F = s[2];
  _setDerivs(G, R, F, 1);
  _setDerivs(G + 0.5 * h * _k1G, R + 0.5 * h * _k1R, F + 0.5 * h * _k1F, 2);
  _setDerivs(G + 0.5 * h * _k2G, R + 0.5 * h * _k2R, F + 0.5 * h * _k2F, 3);
  _setDerivs(G + h * _k3G,       R + h * _k3R,       F + h * _k3F,       4);
  let nG = G + (h / 6) * (_k1G + 2 * _k2G + 2 * _k3G + _k4G);
  let nR = R + (h / 6) * (_k1R + 2 * _k2R + 2 * _k3R + _k4R);
  let nF = F + (h / 6) * (_k1F + 2 * _k2F + 2 * _k3F + _k4F);
  if (nG < 0) nG = 0;
  if (nR < 0) nR = 0;
  if (nF < 0) nF = 0;
  s[0] = nG; s[1] = nR; s[2] = nF;
  return s;
}

function pushTS(G, R, F) {
  ts[tsHead * 3]     = G;
  ts[tsHead * 3 + 1] = R;
  ts[tsHead * 3 + 2] = F;
  tsHead = (tsHead + 1) % TS_MAX;
  if (tsCount < TS_MAX) tsCount++;
}

function pushPhase(G, R, F) {
  phase[phHead * 3]     = G;
  phase[phHead * 3 + 1] = R;
  phase[phHead * 3 + 2] = F;
  phHead = (phHead + 1) % PHASE_MAX;
  if (phCount < PHASE_MAX) phCount++;
}

function init({ width, height }) {
  W = width;
  H = height;

  state = [0.6, 0.3, 0.2];
  ts = new Float32Array(TS_MAX * 3);
  tsHead = 0; tsCount = 0;
  phase = new Float32Array(PHASE_MAX * 3);
  phHead = 0; phCount = 0;
  simTime = 0;
  lastPerturbT = -1e9;
  perturbFlash = 0;

  // burn in so we land on/near the attractor
  for (let i = 0; i < 800; i++) {
    rk4Step(state, DT_SIM);
    if (i % 4 === 0) pushPhase(state[0], state[1], state[2]);
  }
}

// Project (G, R, F) into 2D using a simple oblique projection.
// X = G + 0.45*F,  Y = R + 0.30*F  (then mapped into panel pixel space).
// Writes result into module-scoped _projX/_projY to avoid per-call allocations.
let _projX = 0, _projY = 0;
function project(G, R, F, panelX, panelY, panelW, panelH) {
  const sX = G + 0.45 * F;
  const sY = R + 0.30 * F;
  const SX_MAX = 1.7;
  const SY_MAX = 1.7;
  _projX = panelX + 30 + (sX / SX_MAX) * (panelW - 50);
  _projY = panelY + panelH - 30 - (sY / SY_MAX) * (panelH - 50);
}

function drawAxes3D(ctx, panelX, panelY, panelW, panelH) {
  ctx.strokeStyle = '#2a3344';
  ctx.lineWidth = 1;
  // origin
  project(0, 0, 0, panelX, panelY, panelW, panelH); const ox = _projX, oy = _projY;
  // G axis -> (1,0,0)
  project(1, 0, 0, panelX, panelY, panelW, panelH); const gx = _projX, gy = _projY;
  // R axis -> (0,1,0)
  project(0, 1, 0, panelX, panelY, panelW, panelH); const rx = _projX, ry = _projY;
  // F axis -> (0,0,1)
  project(0, 0, 1, panelX, panelY, panelW, panelH); const fx = _projX, fy = _projY;

  ctx.beginPath();
  ctx.moveTo(ox, oy); ctx.lineTo(gx, gy);
  ctx.moveTo(ox, oy); ctx.lineTo(rx, ry);
  ctx.moveTo(ox, oy); ctx.lineTo(fx, fy);
  ctx.stroke();

  ctx.font = '11px monospace';
  ctx.fillStyle = '#7fdc7f'; ctx.fillText('G', gx + 4, gy + 4);
  ctx.fillStyle = '#f0a040'; ctx.fillText('R', rx - 12, ry - 2);
  ctx.fillStyle = '#d04848'; ctx.fillText('F', fx + 4, fy - 4);
}

function tick({ ctx, dt, width, height, input }) {
  if (width !== W || height !== H) {
    W = width;
    H = height;
  }

  // mouseY scrubs fox death rate
  if (input && typeof input.mouseY === 'number' && input.mouseY >= 0) {
    const t = Math.max(0, Math.min(1, input.mouseY / H));
    // top of screen = low death rate (predators thrive), bottom = high death rate
    D_F = D_F_MIN + (1 - t) * (D_F_MAX - D_F_MIN);
  }

  // clicks cull 50% of foxes
  if (input && input.consumeClicks) {
    const clicks = input.consumeClicks();
    if (clicks && clicks.length > 0) {
      state[2] *= 0.5;
      lastPerturbT = simTime;
      perturbFlash = 1.0;
    }
  }

  // integrate
  const stepsPerFrame = Math.max(1, Math.min(8, Math.round(SUBSTEPS * (dt / (1 / 60)))));
  for (let i = 0; i < stepsPerFrame; i++) {
    rk4Step(state, DT_SIM);
    simTime += DT_SIM;
  }
  pushTS(state[0], state[1], state[2]);
  pushPhase(state[0], state[1], state[2]);

  // bg
  ctx.fillStyle = '#0a0e14';
  ctx.fillRect(0, 0, W, H);

  // layout
  const margin = 14;
  const leftW = Math.floor((W - margin * 3) * 0.55);
  const rightW = W - margin * 3 - leftW;
  const PX_L = margin, PY_L = 36, PW_L = leftW, PH_L = H - 56;
  const PX_R = margin * 2 + leftW, PY_R = 36, PW_R = rightW, PH_R = H - 56;

  // headline title
  ctx.fillStyle = '#cfd6e0';
  ctx.font = '13px monospace';
  ctx.fillText('Three-Species Trophic Cascade', margin, 20);

  // === LEFT PANEL: scrolling time series ===
  ctx.strokeStyle = '#1a2030';
  ctx.strokeRect(PX_L, PY_L, PW_L, PH_L);

  // gridlines
  ctx.strokeStyle = '#141a26';
  ctx.lineWidth = 1;
  for (let i = 1; i < 4; i++) {
    const yy = PY_L + (i / 4) * PH_L;
    ctx.beginPath();
    ctx.moveTo(PX_L, yy);
    ctx.lineTo(PX_L + PW_L, yy);
    ctx.stroke();
  }

  // y-axis label
  ctx.fillStyle = '#6a7388';
  ctx.font = '10px monospace';
  ctx.fillText('1.5', PX_L + 4, PY_L + 11);
  ctx.fillText('0',   PX_L + 4, PY_L + PH_L - 4);
  ctx.fillText('time →', PX_L + PW_L - 56, PY_L + PH_L + 14);

  const Y_MAX = 1.5;
  const sx = (i, n) => PX_L + 4 + ((i) / (TS_MAX - 1)) * (PW_L - 8);
  const sy = (v) => PY_L + PH_L - 4 - Math.min(1, v / Y_MAX) * (PH_L - 14);

  if (tsCount > 1) {
    const startIdx = (tsHead - tsCount + TS_MAX) % TS_MAX;
    const drawSeries = (offset, color) => {
      ctx.strokeStyle = color;
      ctx.lineWidth = 1.6;
      ctx.beginPath();
      for (let i = 0; i < tsCount; i++) {
        const idx = (startIdx + i) % TS_MAX;
        const v = ts[idx * 3 + offset];
        const px = sx(i + (TS_MAX - tsCount), TS_MAX);
        const py = sy(v);
        if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
      }
      ctx.stroke();
    };
    drawSeries(0, '#7fdc7f');  // grass
    drawSeries(1, '#f0a040');  // rabbits
    drawSeries(2, '#d04848');  // foxes
  }

  // legend (top-right of left panel)
  const legendX = PX_L + PW_L - 110;
  const legendY = PY_L + 8;
  ctx.font = '11px monospace';
  ctx.fillStyle = '#7fdc7f'; ctx.fillRect(legendX, legendY, 10, 2); ctx.fillText('grass G', legendX + 14, legendY + 4);
  ctx.fillStyle = '#f0a040'; ctx.fillRect(legendX, legendY + 12, 10, 2); ctx.fillText('rabbits R', legendX + 14, legendY + 16);
  ctx.fillStyle = '#d04848'; ctx.fillRect(legendX, legendY + 24, 10, 2); ctx.fillText('foxes F',  legendX + 14, legendY + 28);

  // current values + dF readout at bottom of left panel
  ctx.font = '11px monospace';
  ctx.fillStyle = '#7fdc7f'; ctx.fillText('G=' + state[0].toFixed(3), PX_L + 8, PY_L + PH_L - 28);
  ctx.fillStyle = '#f0a040'; ctx.fillText('R=' + state[1].toFixed(3), PX_L + 100, PY_L + PH_L - 28);
  ctx.fillStyle = '#d04848'; ctx.fillText('F=' + state[2].toFixed(3), PX_L + 192, PY_L + PH_L - 28);
  ctx.fillStyle = '#cfd6e0'; ctx.fillText('d_F = ' + D_F.toFixed(3), PX_L + 8, PY_L + PH_L - 12);

  // === RIGHT PANEL: 3D phase trajectory ===
  ctx.strokeStyle = '#1a2030';
  ctx.strokeRect(PX_R, PY_R, PW_R, PH_R);
  ctx.fillStyle = '#6a7388';
  ctx.font = '10px monospace';
  ctx.fillText('phase space  (G, R, F)', PX_R + 8, PY_R + 14);

  drawAxes3D(ctx, PX_R, PY_R, PW_R, PH_R);

  if (phCount > 1) {
    const startIdx = (phHead - phCount + PHASE_MAX) % PHASE_MAX;
    ctx.globalCompositeOperation = 'lighter';
    ctx.lineWidth = 1.2;
    ctx.lineCap = 'round';

    let prevPX = -1, prevPY = -1;
    for (let i = 0; i < phCount; i++) {
      const idx = (startIdx + i) % PHASE_MAX;
      const G = phase[idx * 3];
      const R = phase[idx * 3 + 1];
      const F = phase[idx * 3 + 2];
      project(G, R, F, PX_R, PY_R, PW_R, PH_R);
      const px = _projX, py = _projY;
      if (i > 0) {
        const t = i / phCount;
        const alpha = 0.04 + t * 0.55;
        // colour by which species dominates locally
        const denom = G + R + F + 1e-6;
        const cr = Math.round(80 + 175 * (F / denom));
        const cg = Math.round(80 + 175 * (G / denom));
        const cb = Math.round(80 + 130 * (R / denom));
        ctx.strokeStyle = `rgba(${cr},${cg},${cb},${alpha.toFixed(3)})`;
        ctx.beginPath();
        ctx.moveTo(prevPX, prevPY);
        ctx.lineTo(px, py);
        ctx.stroke();
      }
      prevPX = px; prevPY = py;
    }

    // current point
    ctx.globalCompositeOperation = 'source-over';
    project(state[0], state[1], state[2], PX_R, PY_R, PW_R, PH_R);
    const cpx = _projX, cpy = _projY;
    ctx.fillStyle = 'rgba(255,230,140,0.25)';
    ctx.beginPath(); ctx.arc(cpx, cpy, 10, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = '#ffe680';
    ctx.beginPath(); ctx.arc(cpx, cpy, 3.4, 0, Math.PI * 2); ctx.fill();
  }
  ctx.globalCompositeOperation = 'source-over';

  // perturbation flash overlay on right panel
  if (perturbFlash > 0) {
    ctx.fillStyle = `rgba(208, 72, 72, ${(perturbFlash * 0.18).toFixed(3)})`;
    ctx.fillRect(PX_R, PY_R, PW_R, PH_R);
    perturbFlash = Math.max(0, perturbFlash - dt * 1.5);
  }

  // footer hint
  ctx.fillStyle = '#6a7388';
  ctx.font = '11px monospace';
  ctx.fillText('click: cull 50% of foxes  ·  mouse Y: tune fox death rate', margin, H - 8);
}

Comments (0)

Log in to comment.