19

Solow-Swan Growth

drag Y to set savings rate, click to reset capital

The Solow-Swan model is the workhorse of long-run growth economics. Capital per worker evolves as with Cobb-Douglas production , savings rate , population growth , and depreciation . Diminishing returns () bend the production curve over while breakeven investment rises linearly, so they cross at a unique steady state to which every trajectory converges. The top panel shows that phase picture; the bottom shows asymptoting to the dashed line. Crank savings up by dragging the mouse: rises sub-linearly because of the same diminishing returns that gave us the convergence in the first place. Click to reseed โ€” high or low โ€” and watch the trajectory bend back toward the same equilibrium, the model's famous prediction of conditional convergence across economies that share fundamentals.

idle
279 lines ยท vanilla
view source
// Solow-Swan one-sector growth model.
// dk/dt = s f(k) - (n + delta) k, with f(k) = k^alpha (Cobb-Douglas).
// Top panel: phase diagram in (k, output) space โ€” production, savings, depreciation, k*.
// Bottom panel: time series k(t) integrated with Euler from k0.

const ALPHA = 0.3;
const N_GROWTH = 0.02;   // population growth
const DELTA = 0.05;      // depreciation
const S_MIN = 0.05;
const S_MAX = 0.40;

// k-axis for phase diagram. Pick a generous upper bound that comfortably contains
// k* for the entire savings-rate range so the curves don't crawl off-canvas.
const K_MIN = 0;
const K_MAX = 12;
const K_SAMPLES = 200;

// Time series ring buffer
const T_BUFFER = 800;
let series;     // Float32Array of k(t) values
let seriesHead;
let seriesCount;

let k;          // current capital per worker
let kInit;      // initial value (for display / reset)
let s;          // current savings rate (driven by mouseY)
let W, H;
let lastClickConsumed = 0;
let elapsedSimTime;

function fProd(kVal) {
  return Math.pow(Math.max(kVal, 0), ALPHA);
}

function kStar(savings) {
  // (s / (n + delta))^(1 / (1 - alpha))
  return Math.pow(savings / (N_GROWTH + DELTA), 1 / (1 - ALPHA));
}

function pushSeries(v) {
  series[seriesHead] = v;
  seriesHead = (seriesHead + 1) % T_BUFFER;
  if (seriesCount < T_BUFFER) seriesCount++;
}

function resetSeries(k0) {
  seriesHead = 0;
  seriesCount = 0;
  series.fill(0);
  k = k0;
  kInit = k0;
  elapsedSimTime = 0;
  pushSeries(k);
}

function init({ canvas, ctx, width, height }) {
  W = width;
  H = height;
  series = new Float32Array(T_BUFFER);
  s = 0.22;
  resetSeries(0.1);
  ctx.fillStyle = '#05070b';
  ctx.fillRect(0, 0, W, H);
}

function drawPhasePanel(ctx, x, y, w, h, kCur, savings) {
  // background + frame
  ctx.fillStyle = '#0a0d14';
  ctx.fillRect(x, y, w, h);
  ctx.strokeStyle = '#1a2030';
  ctx.lineWidth = 1;
  ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);

  const padL = 44, padR = 12, padT = 26, padB = 22;
  const px = x + padL, py = y + padT;
  const pw = w - padL - padR, ph = h - padT - padB;

  // y-axis upper bound: max of production curve evaluated at K_MAX
  const yMax = fProd(K_MAX) * 1.05;
  const yMin = 0;

  // axis grid
  ctx.strokeStyle = '#15202e';
  ctx.lineWidth = 1;
  for (let i = 0; i <= 4; i++) {
    const gy = py + (ph * i) / 4;
    ctx.beginPath(); ctx.moveTo(px, gy); ctx.lineTo(px + pw, gy); ctx.stroke();
  }
  for (let i = 0; i <= 4; i++) {
    const gx = px + (pw * i) / 4;
    ctx.beginPath(); ctx.moveTo(gx, py); ctx.lineTo(gx, py + ph); ctx.stroke();
  }

  const toPx = (kv) => px + pw * (kv - K_MIN) / (K_MAX - K_MIN);
  const toPy = (yv) => py + ph - ph * (yv - yMin) / (yMax - yMin);

  // depreciation / breakeven line: (n + delta) * k
  ctx.strokeStyle = '#ff7ad1';
  ctx.lineWidth = 1.6;
  ctx.beginPath();
  {
    const x0 = toPx(K_MIN), y0 = toPy((N_GROWTH + DELTA) * K_MIN);
    const x1 = toPx(K_MAX), y1Raw = (N_GROWTH + DELTA) * K_MAX;
    const y1 = toPy(Math.min(y1Raw, yMax));
    ctx.moveTo(x0, y0);
    ctx.lineTo(x1, y1);
  }
  ctx.stroke();

  // production curve f(k) = k^alpha
  ctx.strokeStyle = '#5fd3ff';
  ctx.lineWidth = 1.8;
  ctx.beginPath();
  for (let i = 0; i < K_SAMPLES; i++) {
    const kv = K_MIN + (K_MAX - K_MIN) * i / (K_SAMPLES - 1);
    const yv = fProd(kv);
    const cx = toPx(kv);
    const cy = toPy(yv);
    if (i === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
  }
  ctx.stroke();

  // savings curve s f(k) โ€” filled below for visual punch
  ctx.fillStyle = 'rgba(124, 240, 138, 0.10)';
  ctx.beginPath();
  ctx.moveTo(toPx(K_MIN), toPy(0));
  for (let i = 0; i < K_SAMPLES; i++) {
    const kv = K_MIN + (K_MAX - K_MIN) * i / (K_SAMPLES - 1);
    const yv = savings * fProd(kv);
    ctx.lineTo(toPx(kv), toPy(yv));
  }
  ctx.lineTo(toPx(K_MAX), toPy(0));
  ctx.closePath();
  ctx.fill();

  ctx.strokeStyle = '#7cf08a';
  ctx.lineWidth = 1.8;
  ctx.beginPath();
  for (let i = 0; i < K_SAMPLES; i++) {
    const kv = K_MIN + (K_MAX - K_MIN) * i / (K_SAMPLES - 1);
    const yv = savings * fProd(kv);
    const cx = toPx(kv);
    const cy = toPy(yv);
    if (i === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
  }
  ctx.stroke();

  // steady state intersection
  const kS = kStar(savings);
  if (kS > 0 && kS < K_MAX) {
    const sx = toPx(kS);
    const sy = toPy(savings * fProd(kS));
    ctx.strokeStyle = 'rgba(255, 207, 102, 0.55)';
    ctx.setLineDash([3, 3]);
    ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(sx, py); ctx.lineTo(sx, py + ph); ctx.stroke();
    ctx.setLineDash([]);
    ctx.fillStyle = '#ffcf66';
    ctx.beginPath();
    ctx.arc(sx, sy, 4, 0, Math.PI * 2);
    ctx.fill();
    ctx.font = '10px monospace';
    ctx.textAlign = 'left';
    ctx.fillStyle = '#ffcf66';
    ctx.fillText('k*=' + kS.toFixed(2), sx + 6, py + 12);
  }

  // marker for current k on the production curve
  if (kCur > K_MIN && kCur < K_MAX) {
    const mx = toPx(kCur);
    const my = toPy(fProd(kCur));
    ctx.strokeStyle = '#e8ecf4';
    ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(mx, py + ph); ctx.lineTo(mx, my); ctx.stroke();
    ctx.fillStyle = '#e8ecf4';
    ctx.beginPath();
    ctx.arc(mx, my, 3, 0, Math.PI * 2);
    ctx.fill();
  }

  // axis labels
  ctx.fillStyle = '#5a6478';
  ctx.font = '10px monospace';
  ctx.textAlign = 'right';
  ctx.fillText(yMax.toFixed(2), px - 4, py + 8);
  ctx.fillText('0', px - 4, py + ph);
  ctx.textAlign = 'center';
  ctx.fillText('k=0', px, y + h - 6);
  ctx.fillText('k=' + K_MAX.toFixed(0), px + pw, y + h - 6);

  // legend
  ctx.font = 'bold 12px monospace';
  ctx.textAlign = 'left';
  ctx.fillStyle = '#e8ecf4';
  ctx.fillText('Phase diagram', x + 10, y + 16);
  ctx.font = '10px monospace';
  let lx = x + 10;
  const ly = y + h - 6;
  // Inline legend along the top, below the title
  const ty = y + 32;
  ctx.fillStyle = '#5fd3ff'; ctx.fillText('f(k)=k^a', x + 10, ty);
  ctx.fillStyle = '#7cf08a'; ctx.fillText('s f(k)',   x + 88, ty);
  ctx.fillStyle = '#ff7ad1'; ctx.fillText('(n+d) k',  x + 144, ty);
}

function drawTimePanel(ctx, x, y, w, h, savings) {
  ctx.fillStyle = '#0a0d14';
  ctx.fillRect(x, y, w, h);
  ctx.strokeStyle = '#1a2030';
  ctx.lineWidth = 1;
  ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);

  const padL = 44, padR = 12, padT = 22, padB = 22;
  const px = x + padL, py = y + padT;
  const pw = w - padL - padR, ph = h - padT - padB;

  // dynamic y range: a little above max(k*, observed max)
  const kS = kStar(savings);
  let yObsMax = kS;
  for (let i = 0; i < seriesCount; i++) {
    const v = series[i];
    if (v > yObsMax) yObsMax = v;
  }
  const yMax = Math.max(yObsMax * 1.15, 0.5);
  const yMin = 0;

  ctx.strokeStyle = '#15202e';
  ctx.lineWidth = 1;
  for (let i = 0; i <= 4; i++) {
    const gy = py + (ph * i) / 4;
    ctx.beginPath(); ctx.moveTo(px, gy); ctx.lineTo(px + pw, gy); ctx.stroke();
  }

  // steady-state guide line
  if (kS > 0 && kS < yMax) {
    const ky = py + ph - ph * (kS - yMin) / (yMax - yMin);
    ctx.strokeStyle = 'rgba(255, 207, 102, 0.55)';
    ctx.setLineDash([4, 4]);
    ctx.beginPath(); ctx.moveTo(px, ky); ctx.lineTo(px + pw, ky); ctx.stroke();
    ctx.setLineDash([]);
    ctx.fillStyle = '#ffcf66';
    ctx.font = '10px monospace';
    ctx.textAlign = 'right';
    ctx.fillText('k* = ' + kS.toFixed(2), px + pw - 4, ky - 4);
  }

  // k(t) trace
  if (seriesCount > 1) {
    ctx.strokeStyle = '#7cf08a';
    ctx.lineWidth = 1.8;
    ctx.beginPath();
    const start = (seriesHead - seriesCount + T_BUFFER) % T_BUFFER;
    for (let i = 0; i < seriesCount; i++) {
      const idx = (start + i) % T_BUFFER;
      const v = series[idx];
      const cx = px + pw * (i / (T_BUFFER - 1));
      const cy = py + ph - ph * (v - yMin) / (yMax - yMin);
      if (i === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
    }
    ctx.stroke();

    // head dot
    const lastIdx = (seriesHead - 1 + T_BUFFER) % T_BUFFER;
    const lastV = series[lastIdx];
    const headX = px + pw * ((seriesCount - 1) / (T_BUFFER - 1));
    const headY = py + ph - ph * (lastV - yMin) / (yMax - yMin);
    ctx.fillStyle = '#e8ecf4';
    ctx.beginPath();
    ctx.arc(headX, headY, 3, 0, Math.PI * 2);
    ctx.fill();
  }

  ctx.fillStyle = '#5a6478';
  ctx.font = '10px monospace';
  ctx.textAlign = 'right';
  ctx.fillText(yMax.toFixed(2), px - 4, py + 8);
  ctx.fillText('0', px - 4, py + ph);
  ctx.textAlign = 'center';
  ctx.fillText('t=0', px, y + h - 6);
  ctx.fillText('t=now', px + pw, y + h - 6);

  ctx.fillStyle = '#e8ecf4';
  ctx.font = 'bold 12px monospace';
  ctx.textAlign = 'left';
  ctx.fillText('k(t) trajectory', x + 10, y + 16);

  ctx.font = '10px monospace';
  ctx.textAlign = 'right';
  ctx.fillStyle = '#7cf08a';
  ctx.fillText('k=' + k.toFixed(3) + '  k0=' + kInit.toFixed(2), x + w - 10, y + 16);
}

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

  // mouseY controls savings rate. y=0 (top) -> max savings, y=H (bottom) -> min savings.
  // This matches the intuitive "drag up to invest more" feel.
  const my = input.mouseY;
  if (typeof my === 'number' && my === my) {
    const t = Math.max(0, Math.min(1, 1 - my / Math.max(1, H)));
    s = S_MIN + (S_MAX - S_MIN) * t;
  }

  // Clicks: reset k to a random initial value to see convergence.
  const clicks = input.consumeClicks();
  if (clicks && clicks.length) {
    // Mix of low and high starts so you can see overshoot from above and slow climb from below.
    const r = Math.random();
    const newK0 = r < 0.5 ? 0.05 + Math.random() * 0.5 : 6 + Math.random() * 4;
    resetSeries(newK0);
  }

  // Integrate Euler step. Use sim-time scale of ~3 model time units per real second
  // so convergence is visible without being instant.
  const TIME_SCALE = 3.0;
  const stepDt = 0.05; // model time per substep
  let modelDt = (dt && isFinite(dt) ? dt : 1 / 60) * TIME_SCALE;
  if (modelDt > 0.5) modelDt = 0.5; // cap on tab-resume blowups
  let remaining = modelDt;
  while (remaining > 1e-6) {
    const h = Math.min(stepDt, remaining);
    const dk = s * fProd(k) - (N_GROWTH + DELTA) * k;
    k = Math.max(0, k + h * dk);
    remaining -= h;
    elapsedSimTime += h;
  }
  pushSeries(k);

  // Draw scene
  ctx.fillStyle = '#05070b';
  ctx.fillRect(0, 0, W, H);

  // header
  ctx.fillStyle = '#e8ecf4';
  ctx.font = 'bold 13px monospace';
  ctx.textAlign = 'left';
  ctx.fillText('Solow-Swan  a=' + ALPHA + '  n=' + N_GROWTH + '  d=' + DELTA, 10, 18);
  ctx.font = '11px monospace';
  ctx.textAlign = 'right';
  ctx.fillStyle = '#ffcf66';
  ctx.fillText('s = ' + s.toFixed(3) + '   (drag Y)', W - 10, 18);

  const top = 28;
  const gap = 6;
  const totalH = H - top - 6;
  const panelH = (totalH - gap) / 2;
  drawPhasePanel(ctx, 4, top,                W - 8, panelH, k, s);
  drawTimePanel (ctx, 4, top + panelH + gap, W - 8, panelH, s);
}

Comments (0)

Log in to comment.