macroSimulator = {
const root = html`<section class="macro-app" aria-label="Interactive IS MP PC model">
<div class="macro-workspace">
<details class="macro-control-drawer" open>
<summary>Model inputs and preset shocks <span>(click to collapse or expand)</span></summary>
<div class="macro-controls" data-controls></div>
</details>
<div class="macro-main">
<div class="macro-titlebar">
<strong>Initial vs. new equilibrium</strong>
<span class="macro-help">Drag a slider or type a value</span>
</div>
<div class="macro-legend">
<span><i class="macro-swatch"></i>Baseline (0)</span>
<span><i class="macro-swatch shocked"></i>Scenario (1)</span>
</div>
<div class="macro-graphs">
<div class="macro-panel"><h3>IS curve</h3><div class="macro-plot" data-chart="IS"></div></div>
<div class="macro-panel"><h3>MP curve</h3><div class="macro-plot" data-chart="MP"></div></div>
<div class="macro-panel"><h3>AD–AS</h3><div class="macro-plot" data-chart="ADAS"></div></div>
</div>
<div class="macro-tools">
<label><input type="checkbox" data-reveal checked> Show new equilibrium</label>
<button type="button" data-action="fit">Fit Axes</button>
<button type="button" data-action="default-axes">Restore Initial Axes</button>
</div>
<div class="macro-equilibrium">
<h3>Equilibrium values</h3>
<table class="macro-table">
<thead><tr><th>Endogenous variable</th><th>Baseline (0)</th><th>Scenario (1)</th><th>Change (1 − 0)</th></tr></thead>
<tbody data-results></tbody>
</table>
<p class="macro-note">Numerical values appear here rather than as numbered axis ticks. Equality labels indicate an unchanged coordinate. Red dashed curves indicate relationships identical to their black baseline.</p>
<p class="macro-status" data-status aria-live="polite"></p>
</div>
</div>
</div>
</section>`;
const doc = root.ownerDocument;
const C0 = "#20252d";
const C1 = "#b1252b";
const gray = "#a5aeb9";
const defaults = Object.freeze({
yStar: 100, piStar: 2, rStar: 2,
epsIS: 0, epsPC: 0, expectedPi: 2,
alpha: 1.5, beta: 0.75, delta: 0.55
});
const fields = [
{group: "Exogenous variables", key: "yStar", label: "Natural output (y*)", min: 80, max: 120, step: 0.5},
{group: "Exogenous variables", key: "piStar", label: "Inflation target (π*)", min: -2, max: 8, step: 0.1},
{group: "Exogenous variables", key: "rStar", label: "Natural real rate (r*)", min: -2, max: 8, step: 0.1},
{group: "Exogenous variables", key: "expectedPi", label: "Expected inflation E[πₜ₊₁]", min: -2, max: 8, step: 0.1},
{group: "Exogenous variables", key: "epsIS", label: "IS shock (εᴵˢ)", min: -12, max: 12, step: 0.25},
{group: "Exogenous variables", key: "epsPC", label: "PC shock (εᴾᶜ)", min: -3, max: 3, step: 0.1},
{group: "Parameters", key: "alpha", label: "IS responsiveness (α)", min: 0.1, max: 5, step: 0.05},
{group: "Parameters", key: "beta", label: "Monetary-policy response (β)", min: 0.1, max: 5, step: 0.05},
{group: "Parameters", key: "delta", label: "Phillips-curve slope (δ)", min: 0, max: 2, step: 0.05}
];
const presets = [
{label: "Increase IS shock: Δεᴵˢ = +5", key: "epsIS", change: 5},
{label: "Increase PC shock: Δεᴾᶜ = +1", key: "epsPC", change: 1},
{label: "Increase natural real rate: Δr* = +1", key: "rStar", change: 1},
{label: "Increase natural output: Δy* = +5", key: "yStar", change: 5},
{label: "Increase inflation target: Δπ* = +1", key: "piStar", change: 1}
];
let baseline = {...defaults};
let current = {...defaults};
let showNew = true;
const initialAxes = {
y: [95, 109], pi: [-1, 5], r: [-1, 5]
};
const cloneAxes = a => ({y: [...a.y], pi: [...a.pi], r: [...a.r]});
let axes = cloneAxes(initialAxes);
const controls = root.querySelector("[data-controls]");
const inputs = new Map();
// Solve IS, MP, PC exactly; no numerical optimizer is necessary.
function equilibrium(p) {
const denominator = p.delta + 1/(p.alpha*p.beta);
const y = p.yStar +
(p.piStar + p.epsIS/(p.alpha*p.beta) - p.expectedPi - p.epsPC)/denominator;
const pi = p.expectedPi + p.delta*(y-p.yStar) + p.epsPC;
const r = p.rStar + p.beta*(pi-p.piStar);
return {y, pi, r};
}
function schedule(p, name) {
if (name === "IS") return y => p.rStar + (p.yStar + p.epsIS - y)/p.alpha;
if (name === "MP") return pi => p.rStar + p.beta*(pi-p.piStar);
if (name === "AS") return y => p.expectedPi + p.delta*(y-p.yStar) + p.epsPC;
return y => p.piStar + (p.yStar + p.epsIS - y)/(p.alpha*p.beta);
}
function approx(a, b) { return Math.abs(a-b) < 1e-8; }
function fmt(v) { return (Math.abs(v) < 0.0005 ? 0 : v).toFixed(3); }
function fmtChange(v) { return (v > 0.0005 ? "+" : "") + fmt(v); }
function copy(p) { return {...p}; }
function clamp(v, f) { return Math.max(f.min, Math.min(f.max, v)); }
function syncControls() {
for (const f of fields) {
const pair = inputs.get(f.key);
pair.slider.min = Math.min(f.min, current[f.key]);
pair.slider.max = Math.max(f.max, current[f.key]);
pair.slider.value = current[f.key];
pair.number.min = Math.min(f.min, current[f.key]);
pair.number.max = Math.max(f.max, current[f.key]);
pair.number.value = current[f.key];
}
}
function setValue(key, value) {
current[key] = value;
syncControls();
render();
}
let fieldGrid;
for (let i = 0; i < fields.length; i++) {
const f = fields[i];
if (i === 0 || fields[i-1].group !== f.group) {
const group = doc.createElement("div");
group.className = "macro-control-group";
const title = doc.createElement("h3");
title.textContent = f.group;
group.appendChild(title);
fieldGrid = doc.createElement("div");
fieldGrid.className = "macro-field-grid";
group.appendChild(fieldGrid);
controls.appendChild(group);
}
const wrap = doc.createElement("div");
wrap.className = "macro-input";
const label = doc.createElement("label");
const id = "macro-" + f.key;
label.setAttribute("for", id);
label.textContent = f.label;
const row = doc.createElement("div");
row.className = "macro-input-row";
const slider = doc.createElement("input");
slider.type = "range";
slider.id = id;
slider.min = f.min;
slider.max = f.max;
slider.step = f.step;
slider.value = current[f.key];
slider.setAttribute("aria-label", f.label + " slider");
const number = doc.createElement("input");
number.type = "number";
number.min = f.min;
number.max = f.max;
number.step = "any";
number.value = current[f.key];
number.setAttribute("aria-label", f.label + " exact numerical value");
slider.addEventListener("input", () => setValue(f.key, Number(slider.value)));
number.addEventListener("input", () => {
if (number.value.trim() === "" || !number.validity.valid) return;
const value = Number(number.value);
if (!Number.isFinite(value) || value < f.min || value > f.max) return;
current[f.key] = value;
slider.value = value;
render();
});
number.addEventListener("change", () => {
const value = Number(number.value);
setValue(f.key, Number.isFinite(value) ? clamp(value, f) : current[f.key]);
});
row.append(slider, number);
wrap.append(label, row);
fieldGrid.appendChild(wrap);
inputs.set(f.key, {slider, number});
}
const actionContainer = doc.createElement("div");
actionContainer.className = "macro-control-actions";
const presetHeading = doc.createElement("h3");
presetHeading.textContent = "Preset isolated shocks";
actionContainer.appendChild(presetHeading);
const actionRow = doc.createElement("div");
actionRow.className = "macro-action-row";
const presetBox = doc.createElement("div");
presetBox.className = "macro-presets";
for (const preset of presets) {
const button = doc.createElement("button");
button.type = "button";
button.textContent = preset.label;
button.addEventListener("click", () => {
current = copy(baseline);
const f = fields.find(d => d.key === preset.key);
current[preset.key] = baseline[preset.key] + preset.change;
syncControls();
render();
});
presetBox.appendChild(button);
}
actionRow.appendChild(presetBox);
const btnBox = doc.createElement("div");
btnBox.className = "macro-btn-grid";
for (const setting of [
{label: "Set New Baseline", key: "baseline", className: "macro-primary"},
{label: "Reset to Defaults", key: "reset"}
]) {
const button = doc.createElement("button");
button.type = "button";
button.textContent = setting.label;
if (setting.className) button.className = setting.className;
button.addEventListener("click", () => {
if (setting.key === "baseline") {
baseline = copy(current);
} else {
baseline = copy(defaults);
current = copy(defaults);
axes = cloneAxes(initialAxes);
showNew = true;
root.querySelector("[data-reveal]").checked = true;
syncControls();
}
render();
});
btnBox.appendChild(button);
}
actionRow.appendChild(btnBox);
actionContainer.appendChild(actionRow);
controls.appendChild(actionContainer);
root.querySelector("[data-reveal]").addEventListener("change", e => {
showNew = e.target.checked;
render();
});
root.querySelector('[data-action="default-axes"]').addEventListener("click", () => {
axes = cloneAxes(initialAxes);
render();
});
root.querySelector('[data-action="fit"]').addEventListener("click", () => {
const b = equilibrium(baseline), s = equilibrium(current);
function padded(a, z, minSpan) {
const low = Math.min(a, z), high = Math.max(a, z);
const span = Math.max(minSpan, (high - low)*1.75 + minSpan*0.32);
const midpoint = (low + high)/2;
return [midpoint - span/2, midpoint + span/2];
}
axes = {
y: padded(b.y, s.y, 14),
pi: padded(b.pi, s.pi, 6),
r: padded(b.r, s.r, 6)
};
render();
});
const W = 440, H = 341;
const M = {left: 64, right: 24, top: 20, bottom: 51};
const left = M.left, right = W-M.right, top = M.top, bottom = H-M.bottom;
const specs = [
{name: "IS", x: "y", y: "r", curves: ["IS"]},
{name: "MP", x: "pi", y: "r", curves: ["MP"]},
{name: "ADAS", x: "y", y: "pi", curves: ["AS", "AD"]}
];
const charts = new Map();
for (const [i, spec] of specs.entries()) {
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${W} ${H}`)
.attr("role", "img")
.attr("aria-label", spec.name === "ADAS" ? "Aggregate demand and aggregate supply graph" : spec.name + " graph")
.style("font-family", "system-ui, sans-serif");
const clip = `macro-clip-${i}`;
svg.append("defs").append("clipPath").attr("id", clip)
.append("rect")
.attr("x", left).attr("y", top)
.attr("width", right-left).attr("height", bottom-top);
const axesLayer = svg.append("g").attr("class", "macro-axes");
const curvesLayer = svg.append("g").attr("clip-path", `url(#${clip})`);
const annotations = svg.append("g").attr("class", "macro-annotations");
root.querySelector(`[data-chart="${spec.name}"]`).appendChild(svg.node());
charts.set(spec.name, {spec, svg, axesLayer, curvesLayer, annotations});
}
const sub0 = "₀", sub1 = "₁";
function axisSymbol(k) { return k === "pi" ? "π" : k; }
function write(layer, x, y, txt, color = C0, attrs = {}) {
const s = layer.append("text").attr("x", x).attr("y", y)
.attr("fill", color).text(txt);
for (const [key, value] of Object.entries(attrs)) s.attr(key, value);
return s;
}
function line(layer, x1,y1,x2,y2, color, width, dash) {
const path = layer.append("line")
.attr("x1",x1).attr("y1",y1).attr("x2",x2).attr("y2",y2)
.attr("stroke", color).attr("stroke-width", width);
if (dash) path.attr("stroke-dasharray", dash);
return path;
}
function projectionLabels(layer, ax, p0, p1, showScenario, axis, name) {
const f = axis === "x" ? ax.x : ax.y;
const v0 = p0[name], v1 = p1[name];
const x0 = axis === "x" ? f(v0) : left;
const y0 = axis === "x" ? bottom : f(v0);
const x1 = axis === "x" ? f(v1) : left;
const y1 = axis === "x" ? bottom : f(v1);
const same = approx(v0,v1);
const sym = axisSymbol(name);
const inBounds = value => axis === "x" ? value >= left-1 && value <= right+1 : value >= top-1 && value <= bottom+1;
function drawTick(x,y,color) {
if (!inBounds(axis === "x" ? x : y)) return;
if (axis === "x") line(layer,x,bottom,x,bottom+5,color,1.2);
else line(layer,left-5,y,left,y,color,1.2);
}
drawTick(x0,y0,C0);
if (showScenario && !same) drawTick(x1,y1,C1);
if (inBounds(axis === "x" ? x0 : y0)) {
const t = showScenario && same ? `${sym}${sub0} = ${sym}${sub1}` : `${sym}${sub0}`;
write(layer, axis === "x" ? x0 : left-10,
axis === "x" ? bottom+22 : y0+4,
t, C0,
{class:"axis-value", "text-anchor":axis === "x" ? "middle":"end"});
}
if (showScenario && !same && inBounds(axis === "x" ? x1 : y1)) {
const near = Math.abs(axis === "x" ? x1-x0 : y1-y0) < 52;
write(layer, axis === "x" ? x1 : left-10,
axis === "x" ? bottom+(near?39:22) : y1+(near?15:4),
`${sym}${sub1}`, C1,
{class:"axis-value", "text-anchor":axis === "x" ? "middle":"end"});
}
}
function drawPanel(chart, e0, e1) {
const {spec, axesLayer, curvesLayer, annotations} = chart;
const x = d3.scaleLinear().domain(axes[spec.x]).range([left,right]);
const y = d3.scaleLinear().domain(axes[spec.y]).range([bottom,top]);
axesLayer.selectAll("*").remove();
annotations.selectAll("*").remove();
const ax = {x,y};
// Simple arrowheads avoid any library-dependent marker styling.
line(axesLayer,left,bottom,right+7,bottom,C0,1.35);
line(axesLayer,left,bottom,left,top-8,C0,1.35);
axesLayer.append("path").attr("d",`M ${right+7} ${bottom} l -7 -3 l 0 6 Z`).attr("fill",C0);
axesLayer.append("path").attr("d",`M ${left} ${top-8} l -3 7 l 6 0 Z`).attr("fill",C0);
write(axesLayer,right+6,bottom+32,axisSymbol(spec.x),C0,
{"text-anchor":"end","font-size":17,"font-style":"italic"});
write(axesLayer,left-29,top-3,axisSymbol(spec.y),C0,
{"text-anchor":"middle","font-size":17,"font-style":"italic"});
const curveData = [];
for (const name of spec.curves) {
const oldFn = schedule(baseline,name), newFn = schedule(current,name);
const same = approx(oldFn(axes[spec.x][0]),newFn(axes[spec.x][0])) &&
approx(oldFn(axes[spec.x][1]),newFn(axes[spec.x][1]));
curveData.push({id:name+"0",name,version:0,fn:oldFn,same});
curveData.push({id:name+"1",name,version:1,fn:newFn,same});
}
const computePath = d => `M ${x(axes[spec.x][0])},${y(d.fn(axes[spec.x][0]))} L ${x(axes[spec.x][1])},${y(d.fn(axes[spec.x][1]))}`;
const curves = curvesLayer.selectAll("path.macro-curve")
.data(curveData,d=>d.id)
.join("path")
.attr("class","macro-curve")
.attr("fill","none")
.attr("stroke",d=>d.version ? C1 : C0)
.attr("stroke-width",d=>d.version && d.same ? 1.5 : 2.7)
.attr("stroke-dasharray",d=>d.version && d.same ? "6 5" : null)
.attr("opacity",d=>d.version && d.same ? 0.76 : 1)
.attr("stroke-linecap","round");
curves.interrupt().transition().duration(135).ease(d3.easeLinear).attr("d",computePath);
// Curve labels follow their lines while remaining inside the plotting area.
const occupied = [];
for (const d of curveData) {
if (d.same && d.version === 1) continue;
let best = null;
for (let j=0; j<=90; j++) {
const t = 0.94 - j*0.0085;
const px = left+(right-left)*t;
const value = axes[spec.x][0]+t*(axes[spec.x][1]-axes[spec.x][0]);
const py = y(d.fn(value));
if (py < top+12 || py > bottom-13) continue;
const close = occupied.some(o=>Math.hypot(o.x-px,o.y-py)<37);
if (!close) { best = {px,py}; break; }
if (!best) best = {px,py};
}
if (!best) continue;
occupied.push({x:best.px,y:best.py});
const text = d.same ? `${d.name}${sub0} = ${d.name}${sub1}` : `${d.name}${d.version ? sub1 : sub0}`;
// Flip long labels to the left of the curve end so they stay inside the panel.
const overflows = best.px + 5 + text.length*8 > W - 4;
const rising = d.fn(axes[spec.x][1]) > d.fn(axes[spec.x][0]);
write(annotations,
overflows ? best.px-5 : best.px+5,
overflows && !rising ? best.py+17 : best.py-6,
text,
d.version ? C1 : C0,
{class:"curve-label","font-size":13,"text-anchor":overflows ? "end" : "start"});
}
const p0 = {x:e0[spec.x],y:e0[spec.y]};
const p1 = {x:e1[spec.x],y:e1[spec.y]};
const bothSame = approx(p0.x,p1.x) && approx(p0.y,p1.y);
function marker(p,color) {
const cx=x(p.x),cy=y(p.y);
if (cx<left || cx>right || cy<top || cy>bottom) return;
line(annotations,cx,bottom,cx,cy,gray,1.0,"3 4");
line(annotations,left,cy,cx,cy,gray,1.0,"3 4");
annotations.append("circle").attr("cx",cx).attr("cy",cy).attr("r",3.9).attr("fill",color);
}
marker(p0,C0);
if (showNew && !bothSame) marker(p1,C1);
if (showNew && bothSame) {
const cx=x(p1.x),cy=y(p1.y);
if (cx>=left && cx<=right && cy>=top && cy<=bottom) {
annotations.append("circle").attr("cx",cx).attr("cy",cy).attr("r",7.3)
.attr("fill","none").attr("stroke",C1).attr("stroke-width",1.5);
}
}
const oldX=x(p0.x),oldY=y(p0.y),newX=x(p1.x),newY=y(p1.y);
if (oldX>=left && oldX<=right && oldY>=top && oldY<=bottom) {
const near = showNew && !bothSame && Math.hypot(oldX-newX,oldY-newY)<63;
write(annotations,oldX-(near?8:8),oldY+(near?19:-11),
showNew && bothSame ? "E₀ = E₁" : "E₀",C0,
{class:"point-label","text-anchor":near?"end":"end"});
}
if (showNew && !bothSame && newX>=left && newX<=right && newY>=top && newY<=bottom) {
write(annotations,newX+9,newY-11,"E₁",C1,{class:"point-label"});
}
projectionLabels(annotations,ax,e0,e1,showNew,"x",spec.x);
projectionLabels(annotations,ax,e0,e1,showNew,"y",spec.y);
}
function render() {
const e0 = equilibrium(baseline), e1 = equilibrium(current);
for (const spec of specs) drawPanel(charts.get(spec.name),e0,e1);
const resultBody = root.querySelector("[data-results]");
resultBody.replaceChildren();
for (const v of [
{key:"y",label:"Real GDP (y)"},
{key:"pi",label:"Inflation (π)"},
{key:"r",label:"Real short-term rate (r)"}
]) {
const tr = doc.createElement("tr");
const data = [v.label,fmt(e0[v.key]),showNew ? fmt(e1[v.key]) : "Hidden",showNew ? fmtChange(e1[v.key]-e0[v.key]) : "Hidden"];
data.forEach(t => { const cell=doc.createElement("td"); cell.textContent=t; tr.appendChild(cell); });
resultBody.appendChild(tr);
}
const outside = specs.some(s=> [e0,e1].some((eq,i)=> (i===0 || showNew) &&
(eq[s.x]<axes[s.x][0] || eq[s.x]>axes[s.x][1] || eq[s.y]<axes[s.y][0] || eq[s.y]>axes[s.y][1])));
root.querySelector("[data-status]").textContent = outside ?
"An equilibrium lies outside the current viewing range. Select Fit Axes to display it." : "";
}
syncControls();
render();
return root;
}Macro Simulator
Macro
Simulate shocks within an IS-MP-PC model
Interactive simulator
Change any exogenous variable or parameter. Black represents the stored baseline; red represents the current scenario. The baseline remains fixed until you select Set New Baseline. Presets apply one isolated shock relative to that baseline.
Model reference
Variables and parameters
| Symbol | Definition | Type |
|---|---|---|
| \(y_t\) | Real GDP (level) | Endogenous |
| \(\pi_t\) | Inflation rate (percent) | Endogenous |
| \(r_t\) | Real short-term risk-free interest rate (percent) | Endogenous |
| \(y^*\) | Natural level of real GDP | Exogenous |
| \(\pi^*\) | Target inflation rate (percent) | Exogenous |
| \(r^*\) | Natural real short-term risk-free interest rate (percent) | Exogenous |
| \(\epsilon_t^{IS}\) | IS curve shock | Exogenous |
| \(\epsilon_t^{PC}\) | Phillips curve shock | Exogenous |
| \(E_t[\pi_{t+1}]\) | Expected inflation for the next period (percent) | Exogenous |
| \(\alpha\) | Sensitivity of demand/output to the real-rate gap | Parameter, \(\alpha>0\) |
| \(\beta\) | Monetary-policy responsiveness to inflation | Parameter, \(\beta>0\) |
| \(\delta\) | Phillips-curve slope | Parameter, \(\delta\geq0\) |
Structural equations
\[ \begin{aligned} \text{IS:}\qquad y_t &= y^* - \alpha(r_t-r^*) + \epsilon_t^{IS},\\[5pt] \text{MP:}\qquad r_t &= r^* + \beta(\pi_t-\pi^*),\\[5pt] \text{PC:}\qquad \pi_t &= E_t[\pi_{t+1}] + \delta(y_t-y^*) + \epsilon_t^{PC}. \end{aligned} \]
Curves in the graphs
The IS curve relates output to the real interest rate; the MP curve relates inflation to the real interest rate. Combining IS with MP gives the aggregate-demand curve, while the Phillips curve is the aggregate-supply curve:
\[ \begin{aligned} \text{IS:}\quad r_t &= r^* + \frac{y^*+\epsilon_t^{IS}-y_t}{\alpha},\\[6pt] \text{MP:}\quad r_t &= r^* + \beta(\pi_t-\pi^*),\\[6pt] \text{AD:}\quad \pi_t &= \pi^* + \frac{y^*+\epsilon_t^{IS}-y_t}{\alpha\beta},\\[6pt] \text{AS:}\quad \pi_t &= E_t[\pi_{t+1}] + \delta(y_t-y^*) + \epsilon_t^{PC}. \end{aligned} \]
Equilibrium solutions
\[ \boxed{\displaystyle y_t^{eq}=y^*+ \frac{\pi^*+\frac{\epsilon_t^{IS}}{\alpha\beta} -E_t[\pi_{t+1}]-\epsilon_t^{PC}} {\delta+\frac{1}{\alpha\beta}} } \]
\[ \boxed{\displaystyle \pi_t^{eq}=E_t[\pi_{t+1}] +\delta(y_t^{eq}-y^*)+\epsilon_t^{PC} } \]
\[ \boxed{\displaystyle r_t^{eq}=r^*+\beta(\pi_t^{eq}-\pi^*) } \]