homeBundle = ({
tag: "H",
ppf: ppfPoints,
qcAut, qwAut, uAut, priceAut,
prod: prodH, inc: incH,
consC: consCH, consW: consWH,
uTrade: uTradeH,
// Fixed axis bounds so changing endowment shifts the PPF within the
// frame instead of rescaling the frame. Sized for the largest PPF
// intercept achievable under slider ranges (max K = max L = 200).
bounds: ({ xMax: 190, yMax: 190 }),
qcMax, qwMax,
starLabel: false
})
foreignBundle = ({
tag: "F",
ppf: ppfPointsF,
qcAut: qcAutF, qwAut: qwAutF, uAut: uAutF, priceAut: priceAutF,
prod: prodF, inc: incF,
consC: consCF, consW: consWF,
uTrade: uTradeF,
bounds: ({ xMax: 210, yMax: 210 }),
qcMax: qcMaxF, qwMax: qwMaxF,
starLabel: true
})
worldBundle = ({
homeRS: homeRSpoints, foreignRS: foreignRSpoints,
worldRS: worldRSpoints, worldRD: worldRDpoints,
rsAutH, rsAutF, priceAut, priceAutF,
rsStar, pStar
})
// ── ppfPanel: render one country's PPF panel (autarky → trade prod → trade cons) ──
function ppfPanel({width, height, stage, country}) {
const margin = {top: 15, right: 22, bottom: 44, left: 50};
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;
const xMax = country.bounds.xMax;
const yMax = country.bounds.yMax;
const x = d3.scaleLinear().domain([0, xMax]).range([0, iw]);
const y = d3.scaleLinear().domain([0, yMax]).range([ih, 0]);
const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
g.append("g").attr("transform", `translate(0,${ih})`)
.call(d3.axisBottom(x).tickValues([country.qcMax]).tickFormat(d3.format(".0f")));
g.append("g")
.call(d3.axisLeft(y).tickValues([country.qwMax]).tickFormat(d3.format(".0f")));
const star = country.starLabel ? "^*" : "";
texLabel(g, iw / 2, ih + 34, `$Q_C${star}$ (cloth)`);
texLabel(g, -38, ih / 2, `$Q_W${star}$ (wine)`, {rotated: true});
const clipId = `ppfPanel-clip-${country.tag}`;
svg.append("defs").append("clipPath").attr("id", clipId)
.append("rect").attr("x", 0).attr("y", 0).attr("width", iw).attr("height", ih);
const proj = "#3e3e3e";
function project(qc, qw) {
g.append("line").attr("x1", x(qc)).attr("y1", y(qw)).attr("x2", x(qc)).attr("y2", y(0))
.attr("stroke", proj).attr("stroke-width", 0.5).attr("stroke-dasharray", "2,2");
g.append("line").attr("x1", x(qc)).attr("y1", y(qw)).attr("x2", x(0)).attr("y2", y(qw))
.attr("stroke", proj).attr("stroke-width", 0.5).attr("stroke-dasharray", "2,2");
}
// PPF curve
const ppfXY = country.ppf.map(d => [d[0], d[1]]);
g.append("path").datum(country.ppf)
.attr("clip-path", `url(#${clipId})`)
.attr("d", d3.line().x(d => x(d[0])).y(d => y(d[1])).curve(d3.curveMonotoneX))
.attr("fill", "none").attr("stroke", "#800000").attr("stroke-width", 2.5);
const L = makeLabelPlacer({g, x, y, iw, ih});
// ── Stage 1: autarky ──
const icAut = icPoints(country.uAut, xMax, yMax);
const icAutXY = icAut.map(d => [d.qc, d.qw]);
g.append("path").datum(icAut)
.attr("clip-path", `url(#${clipId})`)
.attr("d", d3.line().x(d => x(d.qc)).y(d => y(d.qw)).curve(d3.curveMonotoneX))
.attr("fill", "none").attr("stroke", "#2d8f2d").attr("stroke-width", 1.5).attr("opacity", 0.7);
const tan1Qw0 = country.qwAut + country.priceAut * country.qcAut;
const tan1Qc1 = country.qcAut + country.qwAut / country.priceAut;
g.append("line")
.attr("clip-path", `url(#${clipId})`)
.attr("x1", x(0)).attr("y1", y(tan1Qw0))
.attr("x2", x(tan1Qc1)).attr("y2", y(0))
.attr("stroke", "#ffa319").attr("stroke-width", 1).attr("stroke-dasharray", "5,3");
project(country.qcAut, country.qwAut);
g.append("circle").attr("cx", x(country.qcAut)).attr("cy", y(country.qwAut)).attr("r", 5)
.attr("fill", "#ffa319").attr("stroke", "#800000").attr("stroke-width", 1.5);
// Point label for autarky bundle — tried up-right, up-left, down-right, down-left.
const aLabel = country.starLabel ? "A^*" : "A";
L.placeLabel(aLabel, [
{x: country.qcAut + 0.06 * xMax, y: country.qwAut + 0.05 * yMax, anchor: "start"},
{x: country.qcAut - 0.06 * xMax, y: country.qwAut + 0.05 * yMax, anchor: "end"},
{x: country.qcAut + 0.06 * xMax, y: country.qwAut - 0.05 * yMax, anchor: "start"},
{x: country.qcAut - 0.06 * xMax, y: country.qwAut - 0.05 * yMax, anchor: "end"}
], [ppfXY, icAutXY], "#800000", true, aLabel);
// PPF curve label — prefer the outside (away from origin) near an endpoint.
const ppfLabel = country.starLabel ? "PPF^*" : "PPF";
L.labelOnCurve(ppfLabel, ppfXY, +1, [icAutXY], "#800000", true,
{tex: ppfLabel, fontSizes: [12, 11, 10, 9, 8]});
// ── Stage 2: trade production ──
if (stage >= 2) {
const tan2Qw0 = country.prod.qw + pStar * country.prod.qc;
const tan2Qc1 = country.prod.qc + country.prod.qw / pStar;
g.append("line")
.attr("clip-path", `url(#${clipId})`)
.attr("x1", x(0)).attr("y1", y(tan2Qw0))
.attr("x2", x(tan2Qc1)).attr("y2", y(0))
.attr("stroke", "#005ab5").attr("stroke-width", 1.2).attr("stroke-dasharray", "5,3");
project(country.prod.qc, country.prod.qw);
const ax0 = x(country.qcAut), ay0 = y(country.qwAut);
const ax1 = x(country.prod.qc), ay1 = y(country.prod.qw);
const adx = ax1 - ax0, ady = ay1 - ay0;
const alen = Math.hypot(adx, ady) || 1;
const aPad = 9;
const arrId = `arrow-panel-${country.tag}`;
if (!svg.select("defs").select(`#${arrId}`).size()) {
svg.select("defs").append("marker")
.attr("id", arrId).attr("viewBox", "0 -5 10 10")
.attr("refX", 8).attr("refY", 0)
.attr("markerWidth", 6).attr("markerHeight", 6).attr("orient", "auto")
.append("path").attr("d", "M0,-4L8,0L0,4").attr("fill", "#ffa319");
}
if (alen > 2 * aPad) {
g.append("line")
.attr("x1", ax0 + (adx / alen) * aPad).attr("y1", ay0 + (ady / alen) * aPad)
.attr("x2", ax1 - (adx / alen) * aPad).attr("y2", ay1 - (ady / alen) * aPad)
.attr("stroke", "#ffa319").attr("stroke-width", 1.4)
.attr("marker-end", `url(#${arrId})`);
}
g.append("circle").attr("cx", x(country.prod.qc)).attr("cy", y(country.prod.qw)).attr("r", 5)
.attr("fill", "#ffa319").attr("stroke", "#005ab5").attr("stroke-width", 1.5);
const qLabel = country.starLabel ? "Q^*" : "Q";
L.placeLabel(qLabel, [
{x: country.prod.qc + 0.06 * xMax, y: country.prod.qw + 0.05 * yMax, anchor: "start"},
{x: country.prod.qc - 0.06 * xMax, y: country.prod.qw + 0.05 * yMax, anchor: "end"},
{x: country.prod.qc + 0.06 * xMax, y: country.prod.qw - 0.05 * yMax, anchor: "start"},
{x: country.prod.qc - 0.06 * xMax, y: country.prod.qw - 0.05 * yMax, anchor: "end"}
], [ppfXY, icAutXY], "#005ab5", true, qLabel);
}
// ── Stage 3: trade consumption ──
if (stage >= 3) {
g.append("line")
.attr("clip-path", `url(#${clipId})`)
.attr("x1", x(0)).attr("y1", y(country.inc))
.attr("x2", x(country.inc / pStar)).attr("y2", y(0))
.attr("stroke", "#3e3e3e").attr("stroke-width", 1).attr("stroke-dasharray", "4,4")
.attr("opacity", 0.7);
const icTr = icPoints(country.uTrade, xMax, yMax);
const icTrXY = icTr.map(d => [d.qc, d.qw]);
g.append("path").datum(icTr)
.attr("clip-path", `url(#${clipId})`)
.attr("d", d3.line().x(d => x(d.qc)).y(d => y(d.qw)).curve(d3.curveMonotoneX))
.attr("fill", "none").attr("stroke", "#2d8f2d").attr("stroke-width", 1.8);
project(country.consC, country.consW);
g.append("circle").attr("cx", x(country.consC)).attr("cy", y(country.consW)).attr("r", 5)
.attr("fill", "#2d8f2d").attr("stroke", "#1a5f1a").attr("stroke-width", 1.5);
const cLabel = country.starLabel ? "C^*" : "C";
L.placeLabel(cLabel, [
{x: country.consC - 0.06 * xMax, y: country.consW + 0.05 * yMax, anchor: "end"},
{x: country.consC + 0.06 * xMax, y: country.consW + 0.05 * yMax, anchor: "start"},
{x: country.consC - 0.06 * xMax, y: country.consW - 0.05 * yMax, anchor: "end"},
{x: country.consC + 0.06 * xMax, y: country.consW - 0.05 * yMax, anchor: "start"}
], [ppfXY, icAutXY, icTrXY], "#1a5f1a", true, cLabel);
// ── Trade brackets on cloth (x) and wine (y) axes ──
const padBracket = 12;
const tick = 4;
function bracket(x1px, y1px, x2px, y2px, label, color, side) {
const aId = `arr-panel-${country.tag}-${color.replace("#","")}-${side}`;
if (!svg.select("defs").select(`#${aId}`).size()) {
svg.select("defs").append("marker").attr("id", aId)
.attr("viewBox", "0 -4 8 8").attr("refX", 6).attr("refY", 0)
.attr("markerWidth", 6).attr("markerHeight", 6).attr("orient", "auto")
.append("path").attr("d", "M0,-3L6,0L0,3").attr("fill", color);
}
if (side === "x") {
const ypx = y1px;
g.append("line").attr("x1", x1px).attr("y1", ypx - tick)
.attr("x2", x1px).attr("y2", ypx + tick)
.attr("stroke", color).attr("stroke-width", 1.2);
g.append("line").attr("x1", x2px).attr("y1", ypx - tick)
.attr("x2", x2px).attr("y2", ypx + tick)
.attr("stroke", color).attr("stroke-width", 1.2);
g.append("line").attr("x1", x1px + 2).attr("y1", ypx)
.attr("x2", x2px - 2).attr("y2", ypx)
.attr("stroke", color).attr("stroke-width", 1.2)
.attr("marker-end", `url(#${aId})`).attr("marker-start", `url(#${aId})`);
texLabel(g, (x1px + x2px) / 2, ypx + 13, label,
{color, fontSize: 9, anchor: "middle", bold: true});
} else {
const xpx = x1px;
g.append("line").attr("x1", xpx - tick).attr("y1", y1px)
.attr("x2", xpx + tick).attr("y2", y1px)
.attr("stroke", color).attr("stroke-width", 1.2);
g.append("line").attr("x1", xpx - tick).attr("y1", y2px)
.attr("x2", xpx + tick).attr("y2", y2px)
.attr("stroke", color).attr("stroke-width", 1.2);
g.append("line").attr("x1", xpx).attr("y1", y1px + 2)
.attr("x2", xpx).attr("y2", y2px - 2)
.attr("stroke", color).attr("stroke-width", 1.2)
.attr("marker-end", `url(#${aId})`).attr("marker-start", `url(#${aId})`);
texLabel(g, xpx - 5, (y1px + y2px) / 2, label,
{color, fontSize: 9, anchor: "end", bold: true, rotated: true});
}
}
const cClo = Math.min(country.prod.qc, country.consC);
const cChi = Math.max(country.prod.qc, country.consC);
if (cChi - cClo > xMax * 0.01) {
const isImport = country.consC > country.prod.qc;
bracket(x(cClo), ih + padBracket, x(cChi), ih + padBracket,
(isImport ? "Imp" : "Exp") + (country.starLabel ? "$^{*}$ (C)" : " (C)"),
isImport ? "#005ab5" : "#800000", "x");
}
const wWlo = Math.min(country.prod.qw, country.consW);
const wWhi = Math.max(country.prod.qw, country.consW);
if (wWhi - wWlo > yMax * 0.01) {
const isImport = country.consW > country.prod.qw;
bracket(-padBracket, y(wWlo), -padBracket, y(wWhi),
(isImport ? "Imp" : "Exp") + (country.starLabel ? "$^{*}$ (W)" : " (W)"),
isImport ? "#005ab5" : "#800000", "y");
}
}
return svg.node();
}
// ── rsrdPanel: render the world RS / RD panel (stage-gated equilibrium marker) ──
function rsrdPanel({width, height, stage, world}) {
const margin = {top: 15, right: 22, bottom: 44, left: 50};
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;
const xMax = 3;
const yMax = 2;
const x = d3.scaleLinear().domain([0, xMax]).range([0, iw]);
const y = d3.scaleLinear().domain([0, yMax]).range([ih, 0]);
const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
g.append("g").attr("transform", `translate(0,${ih})`).call(d3.axisBottom(x).ticks(4));
g.append("g").call(d3.axisLeft(y).ticks(4));
texLabel(g, iw / 2, ih + 34, "$Q_C / Q_W$");
texLabel(g, -38, ih / 2, "$p = p_C / p_W$", {rotated: true});
const clipId = "rsrdPanel-clip";
svg.append("defs").append("clipPath").attr("id", clipId)
.append("rect").attr("x", 0).attr("y", 0).attr("width", iw).attr("height", ih);
const within = pts => pts.filter(d =>
d.rs >= -0.05 && d.rs <= xMax * 1.5 &&
d.p >= -0.05 && d.p <= yMax * 1.5
);
function drawCurve(data, color, {dashed = false, width: sw = 2, opacity = 1} = {}) {
const sel = g.append("path").datum(within(data))
.attr("clip-path", `url(#${clipId})`)
.attr("d", d3.line()
.x(d => x(d.rs)).y(d => y(d.p))
.curve(d3.curveMonotoneY))
.attr("fill", "none").attr("stroke", color)
.attr("stroke-width", sw).attr("opacity", opacity);
if (dashed) sel.attr("stroke-dasharray", "5,3");
return sel;
}
drawCurve(world.homeRS, "#005ab5", {dashed: true, width: 1.2, opacity: 0.45});
drawCurve(world.foreignRS, "#dc3230", {dashed: true, width: 1.2, opacity: 0.45});
drawCurve(world.worldRS, "#005ab5", {width: 2.4});
drawCurve(world.worldRD, "#800000", {width: 2.4});
// Convert {rs, p} → [rs, p] arrays, clipped to plot window, for label placer.
const toXY = pts => within(pts)
.filter(d => d.rs <= xMax * 1.05 && d.p <= yMax * 1.05)
.map(d => [d.rs, d.p]);
const homeRSxy = toXY(world.homeRS);
const foreignRSxy = toXY(world.foreignRS);
const worldRSxy = toXY(world.worldRS);
const worldRDxy = toXY(world.worldRD);
const L = makeLabelPlacer({g, x, y, iw, ih});
function marker(rs, p, fill, label, texStr, color) {
if (rs > xMax * 1.05 || p > yMax * 1.05) return;
g.append("line")
.attr("x1", x(rs)).attr("y1", y(p)).attr("x2", x(rs)).attr("y2", y(0))
.attr("stroke", "#3e3e3e").attr("stroke-width", 0.5).attr("stroke-dasharray", "2,2");
g.append("line")
.attr("x1", x(rs)).attr("y1", y(p)).attr("x2", x(0)).attr("y2", y(p))
.attr("stroke", "#3e3e3e").attr("stroke-width", 0.5).attr("stroke-dasharray", "2,2");
g.append("circle").attr("cx", x(rs)).attr("cy", y(p)).attr("r", 4.5)
.attr("fill", fill).attr("stroke", "#3e3e3e").attr("stroke-width", 1);
if (label) {
// Try candidate offsets around the marker; accept the first clear one.
const cands = [
{x: rs + 0.06 * xMax, y: p + 0.04 * yMax, anchor: "start"},
{x: rs + 0.06 * xMax, y: p - 0.04 * yMax, anchor: "start"},
{x: rs - 0.06 * xMax, y: p + 0.04 * yMax, anchor: "end"},
{x: rs - 0.06 * xMax, y: p - 0.04 * yMax, anchor: "end"},
{x: rs + 0.10 * xMax, y: p, anchor: "start"},
{x: rs - 0.10 * xMax, y: p, anchor: "end"}
];
L.placeLabel(label, cands,
[homeRSxy, foreignRSxy, worldRSxy, worldRDxy],
color || "#3e3e3e", true, texStr);
}
}
marker(world.rsAutH, world.priceAut, "#ffa319", "p_{aut}", "p_{aut}", "#3e3e3e");
marker(world.rsAutF, world.priceAutF, "#ffa319", "p_{aut}^*", "p_{aut}^*", "#3e3e3e");
if (stage >= 2) {
marker(world.rsStar, world.pStar, "#ffd700", "p^*", "p^*", "#3e3e3e");
}
// Curve labels — end-biased, collision-avoiding. World curves get priority;
// the two dashed country RS curves are faded (opacity 0.6) on their labels.
L.labelOnCurve("RS_W", worldRSxy, +1,
[worldRDxy, homeRSxy, foreignRSxy],
"#005ab5", true, {tex: "RS_W", fontSizes: [12, 11, 10, 9, 8]});
L.labelOnCurve("RD_W", worldRDxy, +1,
[worldRSxy, homeRSxy, foreignRSxy],
"#800000", true, {tex: "RD_W", fontSizes: [12, 11, 10, 9, 8]});
L.labelOnCurve("RS_H", homeRSxy, +1,
[worldRSxy, worldRDxy, foreignRSxy],
"#005ab5", false, {tex: "RS_H"}).attr("opacity", 0.6);
L.labelOnCurve("RS_F^*", foreignRSxy, +1,
[worldRSxy, worldRDxy, homeRSxy],
"#dc3230", false, {tex: "RS_F^*"}).attr("opacity", 0.6);
return svg.node();
}