15

Cobweb Model: Supply Lags Demand

drag Y to tune supply slope, click to reset price

A textbook market with a one-period production lag. Naive producers look at today's price and commit next period's supply ; consumers then clear that quantity at whatever price the demand curve assigns. With linear demand and supply , the recursion is , and the dynamics are governed entirely by the slope ratio : less than 1 and prices spiral inward to the equilibrium ; exactly 1 and they orbit forever; greater than 1 and the market booms-and-busts itself off the chart. The trajectory traces a literal cobweb between supply and demand โ€” each horizontal segment is the market clearing a quantity producers already committed to, each vertical segment is producers reacting to the new price. Drag the mouse vertically to tune the supply slope across the stability transition; click anywhere to reset the starting price and watch a new spiral form.

idle
304 lines ยท vanilla
view source
// Cobweb model: linear demand D(p) = a - b*p, linear supply S(p) = c + d*p.
// Producers commit q_{t+1}^s = S(p_t); consumers buy q_{t+1}^d = D(p_{t+1}).
// Market clears: a - b * p_{t+1} = c + d * p_t  ->  p_{t+1} = (a - c - d*p_t) / b.
// Equilibrium: p* = (a - c) / (b + d), q* = (a*d + b*c) / (b + d).
// Stability ratio R = d / b: stable if |R| < 1, marginal if = 1, explosive if > 1.

const A = 10;      // demand intercept (q at p=0)
const B = 1.0;     // demand slope magnitude
const C = 1;       // supply intercept (q at p=0)
let   D = 0.6;     // supply slope (mouse-controlled)
const D_MIN = 0.3;
const D_MAX = 3.0;

const P_MIN = 0;
const P_MAX = 10;
const Q_MIN = 0;
const Q_MAX = 12;

const STEP_PERIOD = 0.55;  // seconds per cobweb step
const TRAIL_MAX = 80;       // how many segments to retain

let W = 0, H = 0;
let plot;                  // { x, y, w, h } pixel rect of the chart
let p_now;                 // current price
let q_now;                 // current quantity (from supply at previous p)
let phase;                 // 0 = drawing horizontal-to-supply, 1 = drawing vertical-to-demand
let phaseT;                // 0..1 progress within the current phase
let stepSinceReset;
let segments;              // ring of completed cobweb segments
let segHead;
let segCount;

function supplyQ(p) { return C + D * p; }
function demandQ(p) { return A - B * p; }
function priceFromDemandQ(q) { return (A - q) / B; }   // invert demand to get p given q
function priceFromSupplyQ(q) { return (q - C) / D; }   // not used but kept for clarity

function equilibrium() {
  const p = (A - C) / (B + D);
  const q = (A * D + B * C) / (B + D);
  return { p, q };
}

function pToPx(p) {
  return plot.x + plot.w * (p - P_MIN) / (P_MAX - P_MIN);
}
function qToPy(q) {
  return plot.y + plot.h - plot.h * (q - Q_MIN) / (Q_MAX - Q_MIN);
}

function resetTrajectory(p0) {
  p_now = Math.max(P_MIN + 0.2, Math.min(P_MAX - 0.2, p0));
  q_now = supplyQ(p_now);  // producers had committed based on starting price
  phase = 0;
  phaseT = 0;
  stepSinceReset = 0;
  segments = new Float32Array(TRAIL_MAX * 4); // x1,y1,x2,y2 in price/quantity coords
  segHead = 0;
  segCount = 0;
}

function pushSegment(p1, q1, p2, q2) {
  const i = segHead * 4;
  segments[i] = p1;
  segments[i + 1] = q1;
  segments[i + 2] = p2;
  segments[i + 3] = q2;
  segHead = (segHead + 1) % TRAIL_MAX;
  if (segCount < TRAIL_MAX) segCount++;
}

function recomputeLayout(width, height) {
  W = width;
  H = height;
  const padL = 56;
  const padR = 16;
  const padT = 92;
  const padB = 38;
  plot = {
    x: padL,
    y: padT,
    w: Math.max(40, W - padL - padR),
    h: Math.max(40, H - padT - padB),
  };
}

function init({ canvas, ctx, width, height }) {
  recomputeLayout(width, height);
  resetTrajectory(P_MIN + (P_MAX - P_MIN) * 0.85);
}

function drawGrid(ctx) {
  ctx.fillStyle = '#05070c';
  ctx.fillRect(0, 0, W, H);

  // plot bg
  ctx.fillStyle = '#0a0e16';
  ctx.fillRect(plot.x, plot.y, plot.w, plot.h);
  ctx.strokeStyle = '#1a2030';
  ctx.lineWidth = 1;
  ctx.strokeRect(plot.x + 0.5, plot.y + 0.5, plot.w - 1, plot.h - 1);

  // gridlines
  ctx.strokeStyle = '#141a26';
  ctx.lineWidth = 1;
  ctx.beginPath();
  for (let p = 0; p <= 10; p += 2) {
    const x = pToPx(p);
    ctx.moveTo(x, plot.y);
    ctx.lineTo(x, plot.y + plot.h);
  }
  for (let q = 0; q <= 12; q += 2) {
    const y = qToPy(q);
    ctx.moveTo(plot.x, y);
    ctx.lineTo(plot.x + plot.w, y);
  }
  ctx.stroke();

  // axis labels
  ctx.fillStyle = '#5a6478';
  ctx.font = '10px monospace';
  ctx.textAlign = 'center';
  for (let p = 0; p <= 10; p += 2) {
    ctx.fillText(p.toFixed(0), pToPx(p), plot.y + plot.h + 14);
  }
  ctx.textAlign = 'right';
  for (let q = 0; q <= 12; q += 2) {
    ctx.fillText(q.toFixed(0), plot.x - 6, qToPy(q) + 3);
  }
  ctx.textAlign = 'center';
  ctx.fillStyle = '#7a8497';
  ctx.font = 'bold 11px monospace';
  ctx.fillText('price  p', plot.x + plot.w / 2, plot.y + plot.h + 28);
  ctx.save();
  ctx.translate(plot.x - 38, plot.y + plot.h / 2);
  ctx.rotate(-Math.PI / 2);
  ctx.fillText('quantity  q', 0, 0);
  ctx.restore();
}

function drawCurves(ctx) {
  // demand: q = A - B*p  (downward sloping)
  ctx.strokeStyle = '#5fd3ff';
  ctx.lineWidth = 2;
  ctx.beginPath();
  {
    const p0 = P_MIN, p1 = P_MAX;
    ctx.moveTo(pToPx(p0), qToPy(demandQ(p0)));
    ctx.lineTo(pToPx(p1), qToPy(demandQ(p1)));
  }
  ctx.stroke();

  // supply: q = C + D*p
  ctx.strokeStyle = '#ff8a5c';
  ctx.lineWidth = 2;
  ctx.beginPath();
  {
    const p0 = P_MIN, p1 = P_MAX;
    ctx.moveTo(pToPx(p0), qToPy(supplyQ(p0)));
    ctx.lineTo(pToPx(p1), qToPy(supplyQ(p1)));
  }
  ctx.stroke();

  // labels
  ctx.font = 'bold 11px monospace';
  ctx.textAlign = 'left';
  ctx.fillStyle = '#5fd3ff';
  {
    // place demand label near p ~ 1
    const lp = 0.8;
    ctx.fillText('Demand  D(p) = ' + A + ' - ' + B.toFixed(1) + 'p',
      pToPx(lp) + 6, qToPy(demandQ(lp)) - 6);
  }
  ctx.fillStyle = '#ff8a5c';
  {
    // place supply label near p ~ 8
    const lp = 6.4;
    ctx.fillText('Supply  S(p) = ' + C + ' + ' + D.toFixed(2) + 'p',
      pToPx(lp) + 6, qToPy(supplyQ(lp)) - 6);
  }

  // equilibrium dot
  const { p: ps, q: qs } = equilibrium();
  if (ps >= P_MIN && ps <= P_MAX && qs >= Q_MIN && qs <= Q_MAX) {
    ctx.strokeStyle = '#9aa3b8';
    ctx.setLineDash([3, 3]);
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(pToPx(ps), plot.y + plot.h);
    ctx.lineTo(pToPx(ps), qToPy(qs));
    ctx.lineTo(plot.x, qToPy(qs));
    ctx.stroke();
    ctx.setLineDash([]);

    ctx.fillStyle = '#ffe066';
    ctx.beginPath();
    ctx.arc(pToPx(ps), qToPy(qs), 4, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = '#1a1108';
    ctx.lineWidth = 1.2;
    ctx.stroke();
  }
}

function drawCobweb(ctx) {
  // Older segments fainter
  const startIdx = (segHead - segCount + TRAIL_MAX) % TRAIL_MAX;
  for (let i = 0; i < segCount; i++) {
    const idx = (startIdx + i) % TRAIL_MAX;
    const j = idx * 4;
    const p1 = segments[j];
    const q1 = segments[j + 1];
    const p2 = segments[j + 2];
    const q2 = segments[j + 3];
    const age = (segCount - i) / TRAIL_MAX;          // 0 = freshest
    const alpha = 0.18 + 0.7 * (1 - age);
    ctx.strokeStyle = `rgba(229, 200, 255, ${alpha.toFixed(3)})`;
    ctx.lineWidth = 1.4;
    ctx.beginPath();
    ctx.moveTo(pToPx(p1), qToPy(q1));
    ctx.lineTo(pToPx(p2), qToPy(q2));
    ctx.stroke();
  }

  // Animated current segment
  // phase 0: from (p_now, q_now) horizontally to supply curve at p_now -> i.e. quantity moves
  //   ACTUALLY: the cobweb goes:
  //     start at price p_t on the supply curve (q_t+1 = S(p_t))
  //     horizontal to demand at the same q -> gives p_{t+1}
  //     vertical to supply at the same p -> gives q_{t+2}
  //   We render as: (price axis p_t, q produced) -> horizontal -> (p_{t+1}, q produced) -> vertical -> (p_{t+1}, q_new)
  // Our state: p_now is the CURRENT operating price; q_now = S(p_now) is the quantity producers brought.
  // Phase 0: horizontal from (p_now, q_now) to demand curve: new price p_next = (A - q_now)/B at same q_now.
  // Phase 1: vertical from (p_next, q_now) to supply curve: new quantity q_next = S(p_next).
  // Then commit: p_now <- p_next, q_now <- q_next, repeat.
  const p_next = priceFromDemandQ(q_now);
  const q_next = supplyQ(p_next);

  ctx.lineWidth = 2;
  ctx.lineCap = 'round';
  if (phase === 0) {
    const px = pToPx(p_now);
    const py = qToPy(q_now);
    const tx = pToPx(p_next);
    const x = px + (tx - px) * phaseT;
    ctx.strokeStyle = '#e5c8ff';
    ctx.beginPath();
    ctx.moveTo(px, py);
    ctx.lineTo(x, py);
    ctx.stroke();
  } else {
    const px = pToPx(p_next);
    const py0 = qToPy(q_now);
    const py1 = qToPy(q_next);
    // first show the full horizontal already done
    ctx.strokeStyle = 'rgba(229, 200, 255, 0.85)';
    ctx.beginPath();
    ctx.moveTo(pToPx(p_now), py0);
    ctx.lineTo(px, py0);
    ctx.stroke();
    // then animate vertical
    ctx.strokeStyle = '#e5c8ff';
    ctx.beginPath();
    ctx.moveTo(px, py0);
    ctx.lineTo(px, py0 + (py1 - py0) * phaseT);
    ctx.stroke();
  }

  // Marker at the current "operating point" (price, quantity-produced)
  ctx.fillStyle = '#ffffff';
  ctx.beginPath();
  ctx.arc(pToPx(p_now), qToPy(q_now), 3.2, 0, Math.PI * 2);
  ctx.fill();
}

function drawHUD(ctx) {
  const { p: ps, q: qs } = equilibrium();
  const ratio = D / B;
  let regime, regimeColor;
  if (ratio < 0.98) { regime = 'STABLE (spiral inward)'; regimeColor = '#7cf08a'; }
  else if (ratio > 1.02) { regime = 'EXPLOSIVE (spiral outward)'; regimeColor = '#ff6b6b'; }
  else { regime = 'MARGINAL (closed orbit)'; regimeColor = '#ffe066'; }

  ctx.textAlign = 'left';
  ctx.fillStyle = '#e8ecf4';
  ctx.font = 'bold 14px monospace';
  ctx.fillText('Cobweb Model: Supply Lags Demand', 12, 20);

  ctx.font = '11px monospace';
  ctx.fillStyle = '#9aa3b8';
  ctx.fillText('producers commit q_{t+1} = S(p_t); price clears next period via D(p_{t+1}) = q_{t+1}',
    12, 38);

  ctx.font = 'bold 11px monospace';
  ctx.fillStyle = '#ff8a5c';
  ctx.fillText('supply slope d = ' + D.toFixed(2), 12, 58);
  ctx.fillStyle = '#5fd3ff';
  ctx.fillText('demand slope b = ' + B.toFixed(2), 160, 58);
  ctx.fillStyle = regimeColor;
  ctx.fillText('|d/b| = ' + ratio.toFixed(2) + '  โ†’  ' + regime, 310, 58);

  ctx.fillStyle = '#ffe066';
  ctx.font = '11px monospace';
  ctx.fillText('equilibrium  p* = ' + ps.toFixed(2) + '   q* = ' + qs.toFixed(2),
    12, 76);

  ctx.fillStyle = '#5a6478';
  ctx.font = '10px monospace';
  ctx.fillText('drag mouse Y to tune supply slope  ยท  click to reset starting price',
    12, H - 12);

  // Mini period count
  ctx.textAlign = 'right';
  ctx.fillStyle = '#7a8497';
  ctx.font = '10px monospace';
  ctx.fillText('period ' + stepSinceReset, W - 16, 20);
}

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

  // Mouse Y controls supply slope d in [D_MIN, D_MAX] (top of canvas = max slope, bottom = min)
  let my = input.mouseY;
  if (typeof my === 'number' && my >= 0 && my <= H) {
    const t = 1 - (my / H);
    D = D_MIN + (D_MAX - D_MIN) * Math.max(0, Math.min(1, t));
    // Re-sync q_now if user is tuning so the marker stays on the new supply curve
    q_now = supplyQ(p_now);
  }

  // Click resets starting price based on mouseX
  const clicks = input.consumeClicks ? input.consumeClicks() : [];
  if (clicks && clicks.length) {
    const c = clicks[clicks.length - 1];
    let mx = c.x;
    if (typeof mx !== 'number') mx = W / 2;
    // map x to price within plot region; clamp
    let p0;
    if (mx <= plot.x) p0 = P_MIN + 0.5;
    else if (mx >= plot.x + plot.w) p0 = P_MAX - 0.5;
    else p0 = P_MIN + (P_MAX - P_MIN) * (mx - plot.x) / plot.w;
    resetTrajectory(p0);
  }

  // Advance cobweb step
  const stepDt = Math.max(0, Math.min(0.1, dt || 1 / 60));
  phaseT += stepDt / (STEP_PERIOD * 0.5);  // each phase takes half a step
  while (phaseT >= 1) {
    phaseT -= 1;
    if (phase === 0) {
      // commit horizontal segment as completed: from (p_now, q_now) to (p_next, q_now)
      const p_next = priceFromDemandQ(q_now);
      pushSegment(p_now, q_now, p_next, q_now);
      phase = 1;
    } else {
      // commit vertical segment, then update operating point
      const p_next = priceFromDemandQ(q_now);
      const q_next = supplyQ(p_next);
      pushSegment(p_next, q_now, p_next, q_next);
      p_now = p_next;
      q_now = q_next;
      phase = 0;
      stepSinceReset++;

      // Safety: if explosive and we've gone out of frame, gently rebound
      if (p_now < P_MIN - 2 || p_now > P_MAX + 4 || q_now < Q_MIN - 2 || q_now > Q_MAX + 4) {
        // keep the wreckage on screen but stop accruing trail to avoid garbage
        // reset to a random nearby price so the user sees the regime stays explosive
        const { p: ps } = equilibrium();
        resetTrajectory(ps + (Math.random() - 0.5) * 0.6);
      }
    }
  }

  drawGrid(ctx);
  drawCurves(ctx);
  drawCobweb(ctx);
  drawHUD(ctx);
}

Comments (0)

Log in to comment.