katexReady = {
if (window.katex && typeof window.katex.renderToString === "function") {
return window.katex;
}
// Fall back to loading dynamically if header inject didn't land yet.
return new Promise((resolve, reject) => {
const check = () => {
if (window.katex && typeof window.katex.renderToString === "function") {
resolve(window.katex);
} else {
setTimeout(check, 30);
}
};
check();
setTimeout(() => reject(new Error("katex failed to load")), 8000);
});
}
// KaTeX-rendered HTML label for sliders. Must be a block cell — Quarto OJS
// does not scan function bodies for reactive deps, so a bare `function`
// declaration referencing `katexReady` errors at call time.
katexLabel = {
const ready = katexReady;
return function (mathStr, trailing = "") {
const span = document.createElement("span");
const mathSpan = document.createElement("span");
mathSpan.innerHTML = ready.renderToString(mathStr, { throwOnError: false });
span.appendChild(mathSpan);
if (trailing) span.appendChild(document.createTextNode(trailing));
return span;
};
}
// ── SVG text label with math-style sub/superscripts and italic math runs ──
// `_X` / `_{XX}` → subscript, `^X` / `^{XX}` → superscript, `$...$` → italic
// (math) run. `math: true` option makes the entire string italic by default.
function texLabel(parent, px, py, str, {
color = "#000", fontSize = 12, anchor = "middle", bold = false, rotated = false, math = false
} = {}) {
const textEl = parent.append("text")
.attr("fill", color)
.attr("font-size", fontSize)
.attr("font-family", "serif")
.attr("font-weight", bold ? "bold" : "normal")
.attr("text-anchor",
anchor === "start" ? "start" :
anchor === "end" ? "end" : "middle")
.attr("dominant-baseline", "middle")
.attr("transform",
rotated
? `translate(${px},${py}) rotate(-90)`
: `translate(${px},${py})`);
let i = 0;
let inMath = math;
while (i < str.length) {
const c = str[i];
if (c === "$") { inMath = !inMath; i++; continue; }
if (c === "_" || c === "^") {
i++;
let content = "";
if (str[i] === "{") {
i++;
while (i < str.length && str[i] !== "}") content += str[i++];
i++;
} else {
content = str[i++] || "";
}
const tspan = textEl.append("tspan")
.attr("baseline-shift", c === "_" ? "sub" : "super")
.attr("font-size", `${fontSize * 0.72}px`)
.text(content);
if (inMath) tspan.attr("font-style", "italic");
} else {
let run = "";
while (i < str.length && str[i] !== "_" && str[i] !== "^" && str[i] !== "$") {
run += str[i++];
}
if (run) {
const tspan = textEl.append("tspan").text(run);
if (inMath) tspan.attr("font-style", "italic");
}
}
}
return textEl;
}
function svgAxisLabel(parent, px, py, text, {rotated = false, fontSize = 12} = {}) {
return parent.append("text")
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("font-size", fontSize)
.attr("font-family", "serif")
.attr("fill", "#000")
.attr("transform",
rotated
? `translate(${px},${py}) rotate(-90)`
: `translate(${px},${py})`)
.text(text);
}
// ── Collision-avoiding label placer (for charts that need it) ──
function makeLabelPlacer({g, x, y, iw, ih}) {
const placedBBs = [];
function approxBB(str, px, py, anchor, fs = 11) {
const hasSub = /_/.test(str);
const w = str.length * (fs * 0.62);
const h = hasSub ? fs * 2.3 : fs * 1.5;
let x0 = px;
if (anchor === "middle") x0 = px - w / 2;
else if (anchor === "end") x0 = px - w;
return {x0, y0: py - h / 2, x1: x0 + w, y1: py + h / 2};
}
function segHitsBB(ax, ay, bx, by, bb) {
if ((ax < bb.x0 && bx < bb.x0) || (ax > bb.x1 && bx > bb.x1) ||
(ay < bb.y0 && by < bb.y0) || (ay > bb.y1 && by > bb.y1)) return false;
if (ax >= bb.x0 && ax <= bb.x1 && ay >= bb.y0 && ay <= bb.y1) return true;
if (bx >= bb.x0 && bx <= bb.x1 && by >= bb.y0 && by <= bb.y1) return true;
function si(p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y) {
const s1x = p1x - p0x, s1y = p1y - p0y;
const s2x = p3x - p2x, s2y = p3y - p2y;
const denom = -s2x * s1y + s1x * s2y;
if (denom === 0) return false;
const s = (-s1y * (p0x - p2x) + s1x * (p0y - p2y)) / denom;
const t = ( s2x * (p0y - p2y) - s2y * (p0x - p2x)) / denom;
return s >= 0 && s <= 1 && t >= 0 && t <= 1;
}
return si(ax, ay, bx, by, bb.x0, bb.y0, bb.x1, bb.y0) ||
si(ax, ay, bx, by, bb.x1, bb.y0, bb.x1, bb.y1) ||
si(ax, ay, bx, by, bb.x1, bb.y1, bb.x0, bb.y1) ||
si(ax, ay, bx, by, bb.x0, bb.y1, bb.x0, bb.y0);
}
function bbHitsCurve(bb, pts) {
for (let k = 0; k < pts.length - 1; k++) {
const [cx1, cy1] = pts[k];
const [cx2, cy2] = pts[k + 1];
if (segHitsBB(x(cx1), y(cy1), x(cx2), y(cy2), bb)) return true;
}
return false;
}
function bbHitsBB(a, b) {
return !(a.x1 < b.x0 || a.x0 > b.x1 || a.y1 < b.y0 || a.y0 > b.y1);
}
function inPlot(bb) {
return bb.x0 >= 2 && bb.y0 >= 2 && bb.x1 <= iw - 2 && bb.y1 <= ih - 2;
}
function placeLabel(str, candidates, avoid, color, bold, texStr) {
const labelTeX = texStr || str;
const pad = 3;
for (const c of candidates) {
const px = x(c.x), py = y(c.y);
const raw = approxBB(str, px, py, c.anchor || "start");
const bb = {x0: raw.x0 - pad, y0: raw.y0 - pad, x1: raw.x1 + pad, y1: raw.y1 + pad};
if (!inPlot(bb)) continue;
let hit = false;
for (const curve of avoid) if (bbHitsCurve(bb, curve)) { hit = true; break; }
if (hit) continue;
for (const o of placedBBs) if (bbHitsBB(bb, o)) { hit = true; break; }
if (hit) continue;
placedBBs.push(bb);
return texLabel(g, px, py, labelTeX, {color, fontSize: 11, anchor: c.anchor || "start", bold, math: true});
}
const c = candidates[0];
return texLabel(g, x(c.x), y(c.y), labelTeX, {color, fontSize: 11, anchor: c.anchor || "start", bold, math: true});
}
function labelOnCurve(str, curve, side, avoid, color, bold, opts = {}) {
const fracs = opts.fracs || [0.04, 0.96, 0.08, 0.92, 0.12, 0.88, 0.16, 0.84, 0.20, 0.80, 0.25, 0.75];
const offsets = opts.offsets || [3, 5, 7, 10, 14, 19];
const fontSizes = opts.fontSizes || [11, 10, 9, 8, 7];
const anc = opts.anchor || "middle";
const labelTeX = opts.tex || str;
const originPx = x(0), originPy = y(0);
for (const fs of fontSizes) {
for (const off of offsets) {
for (const f of fracs) {
const i = Math.max(1, Math.min(curve.length - 2, Math.floor((curve.length - 1) * f)));
if (!curve[i]) continue;
const [cx, cy] = curve[i];
const i0 = Math.max(0, i - 3), i1 = Math.min(curve.length - 1, i + 3);
const tx = x(curve[i1][0]) - x(curve[i0][0]);
const ty = y(curve[i1][1]) - y(curve[i0][1]);
const tlen = Math.hypot(tx, ty) || 1;
const baseNx = -ty / tlen, baseNy = tx / tlen;
const cPx = x(cx), cPy = y(cy);
const sides = side === 0 ? [+1, -1] : [side];
for (const s of sides) {
const dot = baseNx * (originPx - cPx) + baseNy * (originPy - cPy);
let nnx = baseNx, nny = baseNy;
if ((s === -1 && dot < 0) || (s === +1 && dot > 0)) { nnx = -baseNx; nny = -baseNy; }
const lpx = cPx + nnx * off;
const lpy = cPy + nny * off;
const raw = approxBB(str, lpx, lpy, anc, fs);
const pad = 2;
const bb = {x0: raw.x0 - pad, y0: raw.y0 - pad, x1: raw.x1 + pad, y1: raw.y1 + pad};
if (!inPlot(bb)) continue;
if (bbHitsCurve(bb, curve)) continue;
let hit = false;
for (const c of avoid) if (bbHitsCurve(bb, c)) { hit = true; break; }
if (hit) continue;
for (const o of placedBBs) if (bbHitsBB(bb, o)) { hit = true; break; }
if (hit) continue;
placedBBs.push(bb);
return texLabel(g, lpx, lpy, labelTeX, {color, fontSize: fs, anchor: anc, bold, math: true});
}
}
}
}
const fs = fontSizes[fontSizes.length - 1];
const fi = Math.max(1, Math.min(curve.length - 2, Math.floor((curve.length - 1) * fracs[0])));
const [cx, cy] = curve[fi];
return texLabel(g, x(cx) + 6, y(cy) - 20, labelTeX, {color, fontSize: fs, anchor: anc, bold, math: true});
}
return {placedBBs, approxBB, segHitsBB, bbHitsCurve, bbHitsBB, inPlot, placeLabel, labelOnCurve};
}
// ── Synced slider panel (subset selectable per slide) ──
function sliderPanel(keys = ["S", "F", "c", "b", "stage"], opts = {}) {
const all = {
S: Inputs.bind(Inputs.range([100, 1000], {value: 500, step: 50, label: katexLabel("S", " (Home market size)")}), viewof S),
Sf: Inputs.bind(Inputs.range([100, 1000], {value: 250, step: 50, label: katexLabel("S^{*}", " (Foreign market size)")}), viewof Sf),
F: Inputs.bind(Inputs.range([50, 500], {value: 200, step: 25, label: katexLabel("F", " (fixed cost)")}), viewof F),
c: Inputs.bind(Inputs.range([0.5, 2.0], {value: 1.0, step: 0.1, label: katexLabel("c", " (marginal cost)")}), viewof c),
b: Inputs.bind(Inputs.range([0.005, 0.05], {value: 0.01, step: 0.005, label: katexLabel("b", " (demand slope)")}), viewof b),
sigma: Inputs.bind(Inputs.range([2, 8], {value: 4, step: 0.5, label: katexLabel("\\sigma", " (CES elasticity)")}), viewof sigma),
tau: Inputs.bind(Inputs.range([1.0, 1.5], {value: 1.0, step: 0.05, label: katexLabel("\\tau", " (iceberg trade cost)")}), viewof tau),
n: Inputs.bind(Inputs.range([5, 25], {value: 10, step: 1, label: katexLabel("n", " (number of firms)")}), viewof n),
Pbar: Inputs.bind(Inputs.range([1, 6], {value: 5, step: 0.5, label: katexLabel("\\bar{P}", " (avg competitor price)")}), viewof Pbar),
Qfirm: Inputs.bind(Inputs.range([20, 200], {value: 50, step: 5, label: katexLabel("Q_{i}", " (firm's output)")}), viewof Qfirm),
stage: Inputs.bind(Inputs.range([1, 5], {value: 5, step: 1, label: "reveal stage"}), viewof stage)
};
const div = document.createElement("div");
div.style.fontSize = "13px";
if (opts.columns && opts.columns > 1) {
div.style.display = "grid";
div.style.gridTemplateColumns = `repeat(${opts.columns}, 1fr)`;
div.style.columnGap = "16px";
div.style.rowGap = "2px";
}
for (const k of keys) all[k].style.maxWidth = "100%";
for (const k of keys) div.appendChild(all[k]);
return div;
}