// PPF welfare chart factory — kinked budget lines + CD indifference curves.
// Factory pattern (returns a function that creates a fresh SVG on each call)
// keeps reactive state shared while letting the chart render on its own slide.
ppfDecompChartFactory = {
[tariff, revealStage, K, L, beta,
qcA, qwA, qcB, qwB,
I1, I2, I3, I3tilde, cRevenue, dConsDWL, csLossTotal,
ppfPoints, pWorld, pHome];
const stage = revealStage;
return () => {
// Two-panel layout: PPF on the left (wider), zoomed ΔPS gauge on the right
// (narrower). The gauge magnifies the I1 → I2 → I3 wine-axis intercepts
// because at realistic tariffs (t = 0.1–0.3), the decomposition gap is only
// a few wine units — too small to read on the main PPF frame.
const w = 780, h = 440;
const ppfPanelW = 560;
const gaugePanelW = w - ppfPanelW;
const ppfMargin = {top: 20, right: 90, bottom: 50, left: 65};
const gaugeMargin = {top: 30, right: 85, bottom: 50, left: 20};
const iwPPF = ppfPanelW - ppfMargin.left - ppfMargin.right;
const iwG = gaugePanelW - gaugeMargin.left - gaugeMargin.right;
const ih = h - ppfMargin.top - ppfMargin.bottom;
const svg = d3.create("svg")
.attr("viewBox", [0, 0, w, h])
.attr("width", w).attr("height", h)
.style("max-width", "100%")
.style("font-family", "serif");
const gPPF = svg.append("g")
.attr("transform", `translate(${ppfMargin.left},${ppfMargin.top})`);
const gGauge = svg.append("g")
.attr("transform", `translate(${ppfPanelW + gaugeMargin.left},${gaugeMargin.top})`);
// ── PPF panel scales ──
// Fixed at 170×170 to give the budget lines visible separation at default
// (Home K=140, L=100). PPF y-intercept ≈ 127, x-intercept ≈ 107, and budget
// lines extend to ~145 — all fit. Sliders pushing K, L past ~150 will clip
// the PPF; that's a deliberate trade-off in favor of pedagogy at default.
const xMax = 170, yMax = 170;
const x = d3.scaleLinear().domain([0, xMax]).range([0, iwPPF]);
const y = d3.scaleLinear().domain([0, yMax]).range([ih, 0]);
gPPF.append("defs").append("clipPath").attr("id", "ppf-decomp-clip")
.append("rect").attr("width", iwPPF).attr("height", ih);
const plot = gPPF.append("g").attr("clip-path", "url(#ppf-decomp-clip)");
// Axis groups — kept around so zoom can rescale them in place.
const gxAxisPPF = gPPF.append("g").attr("transform", `translate(0,${ih})`);
const gyAxisPPF = gPPF.append("g");
texLabel(gPPF, iwPPF / 2, ih + 40, "Cloth ($Q_C$)", {fontSize: 13});
texLabel(gPPF, -50, ih / 2, "Wine ($Q_W$)", {rotated: true, fontSize: 13});
// FT consumer optimum (data values; pixel positions depend on current scale).
const cFT_C = beta * I1 / pWorld;
const cFT_W = (1 - beta) * I1;
const V1 = Math.pow(cFT_C, beta) * Math.pow(cFT_W, 1 - beta);
// ── drawPPF(sx, sy): renders all scale-dependent elements on the PPF panel.
// Called once initially with (x, y), and again on each zoom event with
// rescaled scales. The clipped plot region is cleared and redrawn; axes
// update in place. Gauge panel is independent and untouched.
const drawPPF = (sx, sy) => {
gxAxisPPF.call(d3.axisBottom(sx).ticks(6))
.selectAll("text").style("font-family", "serif");
gyAxisPPF.call(d3.axisLeft(sy).ticks(6))
.selectAll("text").style("font-family", "serif");
plot.selectAll("*").remove();
// Helpers redefined inside drawPPF so they capture the current scales.
const drawBudget = (intercept, slope, color, width = 1.8, dashStyle = "solid") => {
const p = slope, yInt = intercept, xInt = intercept / p;
let pts = [[0, Math.min(yInt, yMax)], [Math.min(xInt, xMax), Math.max(0, yInt - p * Math.min(xInt, xMax))]];
if (yInt > yMax) pts[0] = [(yInt - yMax) / p, yMax];
if (xInt > xMax) pts[1] = [xMax, yInt - p * xMax];
const line = plot.append("line")
.attr("x1", sx(pts[0][0])).attr("y1", sy(pts[0][1]))
.attr("x2", sx(pts[1][0])).attr("y2", sy(pts[1][1]))
.attr("stroke", color).attr("stroke-width", width);
if (dashStyle === "dashed") line.attr("stroke-dasharray", "6 4");
else if (dashStyle === "dotted") line.attr("stroke-dasharray", "1.5 3");
return line;
};
const drawKinkBudget = (Pc, Pw) => {
const xRight = Pc + Pw / pHome;
const xR = Math.min(xMax, xRight);
const yR = Math.max(0, Pw - pHome * (xR - Pc));
plot.append("line")
.attr("x1", sx(Pc)).attr("y1", sy(Pw))
.attr("x2", sx(xR)).attr("y2", sy(yR))
.attr("stroke", "#dc3230").attr("stroke-width", 2.4);
const wAtZero = Pw + pWorld * Pc;
const yL = Math.min(yMax, wAtZero);
const xL = (yL < yMax) ? 0 : Pc - (yMax - Pw) / pWorld;
plot.append("line")
.attr("x1", sx(Pc)).attr("y1", sy(Pw))
.attr("x2", sx(xL)).attr("y2", sy(yL))
.attr("stroke", "#ffa319").attr("stroke-width", 1.6)
.attr("stroke-dasharray", "5 4").attr("opacity", 0.6);
};
const drawIndiff = (V, color, opts = {}) => {
const {dashed = false, opacity = 1, width = 1.7} = opts;
const exp = beta / (1 - beta);
const coef = Math.pow(V, 1 / (1 - beta));
const N = 140;
const cMinFit = coef / Math.pow(yMax * 1.15, 1 / Math.max(exp, 0.01));
const cMin = Math.max(cMinFit, 0.5);
const cMax = xMax * 1.2;
const pts = [];
for (let i = 0; i <= N; i++) {
const cC = cMin + (i / N) * (cMax - cMin);
const cW = coef / Math.pow(cC, exp);
if (isFinite(cW) && cW >= 0) pts.push([cC, cW]);
}
const lineFn = d3.line()
.defined(d => d[0] >= 0 && d[0] <= xMax && d[1] >= 0 && d[1] <= yMax * 1.05)
.x(d => sx(d[0])).y(d => sy(d[1]));
const path = plot.append("path")
.attr("d", lineFn(pts))
.attr("fill", "none").attr("stroke", color)
.attr("stroke-width", width).attr("opacity", opacity);
if (dashed) path.attr("stroke-dasharray", "5 4");
return path;
};
// PPF curve
const ppfLineLocal = d3.line()
.defined(d => d[0] >= 0 && d[0] <= xMax && d[1] >= 0 && d[1] <= yMax)
.x(d => sx(d[0])).y(d => sy(d[1]));
plot.append("path")
.attr("d", ppfLineLocal(ppfPoints))
.attr("fill", "none").attr("stroke", "#3e3e3e").attr("stroke-width", 2.2);
if (stage <= 1 || tariff === 0) {
drawBudget(I1, pWorld, "#ffa319", 2.4, "solid");
drawIndiff(V1, "#005ab5");
plot.append("circle")
.attr("cx", sx(cFT_C)).attr("cy", sy(cFT_W))
.attr("r", 4.5).attr("fill", "#005ab5")
.attr("stroke", "white").attr("stroke-width", 1.5);
texLabel(plot, sx(cFT_C) + 8, sy(cFT_W) - 5, "C^{FT}",
{math: true, bold: true, fontSize: 12, color: "#005ab5", anchor: "start"});
} else {
let Pc, Pw, Ieff, label;
if (stage === 2) { Pc = qcA; Pw = qwA; Ieff = I2; label = "C'"; }
else if (stage === 3) { Pc = qcB; Pw = qwB; Ieff = I3; label = "C''"; }
else { Pc = qcB; Pw = qwB + cRevenue; Ieff = I3 + cRevenue; label = "D"; }
const cCurr_C = beta * Ieff / pHome;
const cCurr_W = (1 - beta) * Ieff;
const Vcurr = Math.pow(cCurr_C, beta) * Math.pow(cCurr_W, 1 - beta);
drawKinkBudget(Pc, Pw);
if (stage >= 5) {
drawIndiff(V1, "#005ab5", {opacity: 0.9, width: 2.0});
drawIndiff(Vcurr, "#3e3e3e", {opacity: 1, width: 2.0});
} else {
drawIndiff(V1, "#005ab5", {dashed: true, opacity: 0.4, width: 1.4});
drawIndiff(Vcurr, "#005ab5", {opacity: 1, width: 1.7});
}
plot.append("circle")
.attr("cx", sx(cFT_C)).attr("cy", sy(cFT_W))
.attr("r", 3.5).attr("fill", "#005ab5").attr("opacity", 0.45)
.attr("stroke", "white").attr("stroke-width", 1);
plot.append("circle")
.attr("cx", sx(cCurr_C)).attr("cy", sy(cCurr_W))
.attr("r", 4.5).attr("fill", "#005ab5")
.attr("stroke", "white").attr("stroke-width", 1.5);
texLabel(plot, sx(cCurr_C) + 8, sy(cCurr_W) - 5, label,
{math: true, bold: true, fontSize: 12, color: "#005ab5", anchor: "start"});
if (stage >= 5) {
const expI = beta / (1 - beta);
const wOnU1 = Math.pow(V1, 1 / (1 - beta)) / Math.pow(cFT_C, expI);
const wOnUc = Math.pow(Vcurr, 1 / (1 - beta)) / Math.pow(cFT_C, expI);
const xCurve = sx(cFT_C);
const yU1 = sy(wOnU1); // == sy(cFT_W); coincides with the C^{FT} dot
const yUc = sy(wOnUc);
const xSpine = xCurve - 18;
// Anchor dot on the lower indifference curve at x = cFT_C.
// (Top end sits on the existing faded C^{FT} marker, which already lies
// on U_{FT}, so no extra dot is needed there.)
plot.append("circle")
.attr("cx", xCurve).attr("cy", yUc).attr("r", 3)
.attr("fill", "#dc3230").attr("stroke", "white").attr("stroke-width", 1);
// "[" bracket: horizontal arms reach inward to the curves at xCurve,
// vertical spine sits to the left so it doesn't overlap the consumer dots.
plot.append("path")
.attr("d",
`M ${xCurve} ${yU1} L ${xSpine} ${yU1} ` +
`L ${xSpine} ${yUc} L ${xCurve} ${yUc}`)
.attr("fill", "none")
.attr("stroke", "#dc3230").attr("stroke-width", 1.8)
.attr("stroke-linecap", "round")
.attr("stroke-linejoin", "round");
// Label: vertically centered on the bracket when there's room;
// otherwise placed just below it so the small-DWL case stays readable.
const bracketSpan = yUc - yU1;
if (bracketSpan >= 22) {
texLabel(plot, xSpine - 5, (yU1 + yUc) / 2,
"DWL", {fontSize: 11, color: "#dc3230", bold: true, anchor: "end"});
} else {
texLabel(plot, xSpine - 2, yUc + 9,
"DWL", {fontSize: 11, color: "#dc3230", bold: true, anchor: "end"});
}
}
}
// Production point markers
const marker = (qc, qw, label, dx, dy, anchor, color = "#800000", r = 4.5) => {
plot.append("circle")
.attr("cx", sx(qc)).attr("cy", sy(qw))
.attr("r", r).attr("fill", color)
.attr("stroke", "white").attr("stroke-width", 1.5);
texLabel(plot, sx(qc) + dx, sy(qw) + dy, label,
{math: true, bold: true, fontSize: 14, color, anchor});
};
marker(qcA, qwA, "A", -10, -8, "end");
if (stage >= 3 && tariff > 0) marker(qcB, qwB, "B", 10, 14, "start");
if (stage >= 4 && tariff > 0)
marker(qcB, qwB + cRevenue, "B'", 12, -4, "start", "#800000", 4);
};
drawPPF(x, y);
// ── d3.zoom: scroll to scale, drag to pan, double-click to reset ──
// Attached to `svg` for consistency with the LOE charts (Safari has trouble
// with the `<rect fill="none" pointer-events="all">` overlay pattern). The
// gauge panel is drawn statically in `gGauge` and is not redrawn on zoom, so
// wheeling over either panel only rescales the PPF.
// The current transform is cached on `window` so slider-driven re-renders
// preserve the user's zoom/pan instead of snapping back to identity.
const zoom = d3.zoom()
.scaleExtent([1, 8])
.translateExtent([[0, 0], [iwPPF, ih]])
.extent([[0, 0], [iwPPF, ih]])
.filter((event) => !event.ctrlKey && !event.button)
.on("zoom", (event) => {
window.__ppfZoomT = event.transform;
drawPPF(event.transform.rescaleX(x), event.transform.rescaleY(y));
});
svg.call(zoom);
svg.on("dblclick.zoom", null);
svg.on("dblclick", () => {
svg.transition().duration(300).call(zoom.transform, d3.zoomIdentity);
});
if (window.__ppfZoomT && window.__ppfZoomT.k !== 1) {
svg.call(zoom.transform, window.__ppfZoomT);
}
// ── Gauge panel: 5-segment welfare decomposition (matches Graph 2 a/b/c/d) ──
// Stacking from bottom to top, anchored to the PPF wine axis where possible:
// I3tilde ─────────── bottom of bar
// │ b │ production-DWL (red)
// I1 ────────────
// │ rect │ part of a (maroon)
// I2 ────────────
// │ env │ part of a (maroon-light)
// I3 ────────────
// │ c │ tariff revenue (gold)
// ────────────────
// │ d │ consumption-DWL (red-light)
// ─────────────── top of bar (= I3tilde + total CS loss)
//
// The bottom three boundaries (I3tilde, I1, I2, I3) align with the four
// budget-line intercepts on the PPF panel; c and d stack above I3 as
// pure consumption-side pieces with no PPF anchor.
if (tariff > 0) {
const csTop = I3 + cRevenue + dConsDWL; // == I3tilde + csLossTotal
const span = Math.max(csTop - I3tilde, 0.01);
const pad = span * 0.12;
const yG = d3.scaleLinear()
.domain([I3tilde - pad, csTop + pad])
.range([ih, 0]);
const bx = 14, bw = 42;
// Helper: draw one stacked segment
const drawSeg = (yLo, yHi, fill, opacity, strokeColor) => {
gGauge.append("rect")
.attr("x", bx).attr("y", yG(yHi))
.attr("width", bw).attr("height", yG(yLo) - yG(yHi))
.attr("fill", fill).attr("fill-opacity", opacity)
.attr("stroke", strokeColor || fill).attr("stroke-width", 0.5);
};
// Helper: small label to the right of a segment with letter + caption
const segLabel = (yMid, letter, caption, color) => {
texLabel(gGauge, bx + bw + 9, yG(yMid) - 5, letter,
{fontSize: 14, color, bold: true, anchor: "start", math: true});
texLabel(gGauge, bx + bw + 9, yG(yMid) + 9, caption,
{fontSize: 9, color, anchor: "start"});
};
// ── Stage-gated segments (aligned with kinked-line narrative) ──────────
// stage 3+ : a (PS gain — appears when production responds, kink at B)
// stage 4+ : c (tariff revenue — appears with rebate, kink at B')
// stage 5 : b + d (DWL revealed — net welfare loss)
if (stage >= 3) {
drawSeg(I1, I3, "#800000", 0.75); // a (PS gain, aggregated)
segLabel((I1 + I3) / 2, "a", "PS gain", "#800000");
}
if (stage >= 4) {
drawSeg(I3, I3 + cRevenue, "#ffa319", 0.85, "#b87400"); // c
segLabel(I3 + cRevenue / 2, "c", "revenue", "#8f5a00");
}
if (stage >= 5) {
drawSeg(I3tilde, I1, "#dc3230", 0.75); // b
segLabel((I3tilde + I1) / 2, "b", "prod DWL", "#a31616");
drawSeg(I3 + cRevenue, csTop, "#dc3230", 0.40); // d
segLabel(I3 + cRevenue + dConsDWL / 2, "d", "cons DWL", "#a31616");
}
// ── Tick marks at PPF wine-axis anchors (left of bar) ────────────────────
const tickX0 = bx - 8, tickX1 = bx;
const drawTick = (val, label, color, gated = true) => {
if (!gated) return;
gGauge.append("line")
.attr("x1", tickX0).attr("x2", tickX1)
.attr("y1", yG(val)).attr("y2", yG(val))
.attr("stroke", color).attr("stroke-width", 1.2);
texLabel(gGauge, tickX0 - 3, yG(val), label,
{fontSize: 10, color, bold: true, anchor: "end", math: true});
};
// Tick marks at the wine-axis intercepts that anchor the gauge segments.
// I1 / I3 : appear with stage 3 (a segment — production response)
// I3tilde : appears with stage 5 (b segment — DWL revealed)
drawTick(I1, "I_{1}", "#3e3e3e", stage >= 3);
drawTick(I3, "I_{3}", "#3e3e3e", stage >= 3);
drawTick(I3tilde, "I_{3}", "#3e3e3e", stage >= 5);
if (stage >= 5) {
// Position the tilde directly over the "I" of the I_3 label.
gGauge.append("text")
.attr("transform", `translate(${tickX0 - 9},${yG(I3tilde) - 5})`)
.attr("text-anchor", "middle").attr("font-size", "10px")
.attr("font-family", "serif").attr("fill", "#3e3e3e")
.text("˜");
}
// ── Total CS-loss bracket at stage 5 ─────────────────────────────────────
if (stage >= 5) {
const brX = bx + bw + 70;
gGauge.append("line")
.attr("x1", brX - 4).attr("x2", brX)
.attr("y1", yG(I3tilde)).attr("y2", yG(I3tilde))
.attr("stroke", "#333").attr("stroke-width", 1);
gGauge.append("line")
.attr("x1", brX - 4).attr("x2", brX)
.attr("y1", yG(csTop)).attr("y2", yG(csTop))
.attr("stroke", "#333").attr("stroke-width", 1);
gGauge.append("line")
.attr("x1", brX).attr("x2", brX)
.attr("y1", yG(I3tilde)).attr("y2", yG(csTop))
.attr("stroke", "#333").attr("stroke-width", 1);
texLabel(gGauge, brX + 4, (yG(I3tilde) + yG(csTop)) / 2 - 6, "CS",
{fontSize: 11, color: "#333", bold: true, anchor: "start"});
texLabel(gGauge, brX + 4, (yG(I3tilde) + yG(csTop)) / 2 + 7, "loss",
{fontSize: 11, color: "#333", bold: true, anchor: "start"});
}
// Gauge panel title
texLabel(gGauge, iwG / 2, -14, "Welfare (wine units, zoomed)",
{fontSize: 11, color: "#555", anchor: "middle"});
}
return svg.node();
};
}