19
Solow-Swan Growth
drag Y to set savings rate, click to reset capital
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.