{
async function loadThemeConfig() {
const paths = [
"/config/graphics-colors.json",
"../config/graphics-colors.json",
"config/graphics-colors.json",
];
for (const path of paths) {
try {
const response = await fetch(path);
if (response.ok) {
return await response.json();
}
} catch (error) {
// Continue to the next path candidate.
}
}
throw new Error("Could not load config/graphics-colors.json");
}
function hexToRgba(hexColor, alpha) {
const clean = hexColor.replace("#", "").trim();
const expanded = clean.length === 3
? clean.split("").map((char) => char + char).join("")
: clean;
const value = Number.parseInt(expanded, 16);
if (Number.isNaN(value) || expanded.length !== 6) {
return `rgba(255, 255, 255, ${alpha})`;
}
const red = (value >> 16) & 255;
const green = (value >> 8) & 255;
const blue = value & 255;
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
const themeConfig = await loadThemeConfig();
const selectedTheme = (
window.GRAPHICS_THEME ??
window.SLIDE_THEME ??
themeConfig.default_theme
);
const theme = (
themeConfig.themes[selectedTheme] ??
themeConfig.themes[themeConfig.default_theme]
);
const fontConfig = themeConfig.font ?? {};
const BG = theme.background;
const PARETO = theme.primary;
const ACCENT = theme.accent;
const SECONDARY = theme.secondary;
const ERROR = theme.error;
const TEXT = theme.text_primary;
const GRID = theme.grid;
const STROKE = theme.white;
const FONT = (
fontConfig.ojs_css_stack ??
'"Source Sans 3", "Inter", "Segoe UI", "DejaVu Sans", "Arial", sans-serif'
);
const BUTTON_BG = hexToRgba(STROKE, 0.12);
const BUTTON_BORDER = hexToRgba(STROKE, 0.25);
const width = 800, height = 700;
const margin = { top: 50, right: 70, bottom: 110, left: 70 };
const plotBaseW = width - margin.left - margin.right;
const plotBaseH = height - margin.top - margin.bottom;
const iSize = Math.min(plotBaseW, plotBaseH);
const iW = iSize;
const iH = iSize;
const plotLeft = margin.left + (plotBaseW - iW) / 2;
// Minimal non-convex teaching example: 5 Pareto points
// Unsupported point (4,4) lies above hull segment (2,5)→(5,2).
const paretoF1 = [1, 2, 4, 5, 7];
const paretoF2 = [7, 5, 4, 2, 1];
const paretoData = paretoF1.map((f1, i) => ({ f1, f2: paretoF2[i] }));
function findOptimal(eps) {
const feasible = paretoData.filter(d => d.f2 <= eps);
if (feasible.length === 0) return null;
return feasible.reduce((a, b) => a.f1 < b.f1 ? a : b);
}
// Stops chosen so each reveals a NEW optimal point
const keyStops = [
{ eps: 7.0 }, // → (1, 7)
{ eps: 5.0 }, // → (2, 5)
{ eps: 4.0 }, // → (4, 4) — non-convex
{ eps: 2.0 }, // → (5, 2)
{ eps: 1.0 }, // → (7, 1)
];
const x = d3.scaleLinear().domain([0, 8]).range([0, iW]);
const y = d3.scaleLinear().domain([0, 8]).range([iH, 0]);
const line = d3.line().x(d => x(d.f1)).y(d => y(d.f2)).curve(d3.curveMonotoneX);
const container = d3.create("div").style("position", "relative");
const svg = container.append("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.style("max-width", "100%").style("height", "auto")
.style("background", BG).style("border-radius", "8px")
.style("font-family", FONT);
const g = svg.append("g").attr("transform", `translate(${plotLeft},${margin.top})`);
// Grid
for (let i = 1; i <= 7; i++) {
g.append("line").attr("x1", x(i)).attr("x2", x(i)).attr("y1", 0).attr("y2", iH)
.attr("stroke", GRID).attr("stroke-dasharray", "2,4").attr("opacity", 0.5);
g.append("line").attr("x1", 0).attr("x2", iW).attr("y1", y(i)).attr("y2", y(i))
.attr("stroke", GRID).attr("stroke-dasharray", "2,4").attr("opacity", 0.5);
}
// Axes
g.append("g").attr("transform", `translate(0,${iH})`).call(d3.axisBottom(x).ticks(8))
.call(g => g.selectAll("text").attr("fill", TEXT).style("font-size", "14px"))
.call(g => g.selectAll("line,path").attr("stroke", TEXT).attr("opacity", 0.5));
g.append("g").call(d3.axisLeft(y).ticks(8))
.call(g => g.selectAll("text").attr("fill", TEXT).style("font-size", "14px"))
.call(g => g.selectAll("line,path").attr("stroke", TEXT).attr("opacity", 0.5));
g.append("text").attr("x", iW / 2).attr("y", iH + 45).attr("text-anchor", "middle")
.attr("fill", TEXT).style("font-size", "16px").text("f\u2081(x) \u2014 minimized");
g.append("text").attr("transform", "rotate(-90)").attr("x", -iH / 2).attr("y", -50)
.attr("text-anchor", "middle").attr("fill", TEXT).style("font-size", "16px")
.text("f\u2082(x) \u2014 constrained");
// Title
g.append("text").attr("x", iW / 2).attr("y", -20).attr("text-anchor", "middle")
.attr("fill", TEXT).style("font-size", "18px").style("font-weight", "600")
.text("Sweeping \u03b5\u2082 discovers Pareto-optimal points");
// Legend (bottom row, one item per column)
const legend = svg.append("g").attr("transform", `translate(${plotLeft + 6}, ${height - 28})`);
const legendColW = 150;
legend.append("line").attr("x1", 0).attr("x2", 16).attr("y1", 0).attr("y2", 0)
.attr("stroke", PARETO).attr("stroke-width", 2.5);
legend.append("circle").attr("cx", 8).attr("cy", 0).attr("r", 4.5)
.attr("fill", PARETO).attr("stroke", STROKE).attr("stroke-width", 1);
legend.append("text").attr("x", 22).attr("y", 1).attr("fill", TEXT)
.attr("dominant-baseline", "middle").style("font-size", "11px").text("Pareto front");
legend.append("circle").attr("cx", legendColW + 8).attr("cy", 0).attr("r", 7)
.attr("fill", SECONDARY).attr("stroke", STROKE).attr("stroke-width", 2);
legend.append("text").attr("x", legendColW + 22).attr("y", 1).attr("fill", TEXT)
.attr("dominant-baseline", "middle").style("font-size", "11px").text("Found by \u03b5-constraints");
legend.append("line")
.attr("x1", 2 * legendColW).attr("x2", 2 * legendColW + 16)
.attr("y1", 0).attr("y2", 0)
.attr("stroke", ACCENT).attr("stroke-width", 2).attr("stroke-dasharray", "6,4");
legend.append("text").attr("x", 2 * legendColW + 22).attr("y", 1).attr("fill", TEXT)
.attr("dominant-baseline", "middle").style("font-size", "11px").text("\u03b5\u2082 bound / feasible region");
legend.append("line")
.attr("x1", 3 * legendColW).attr("x2", 3 * legendColW + 16)
.attr("y1", 0).attr("y2", 0)
.attr("stroke", STROKE).attr("stroke-width", 2.5).attr("stroke-dasharray", "10,6");
legend.append("text").attr("x", 3 * legendColW + 22).attr("y", 1).attr("fill", TEXT)
.attr("dominant-baseline", "middle").style("font-size", "11px").text("Weighted-sum hull limit");
legend.append("line")
.attr("x1", 4 * legendColW).attr("x2", 4 * legendColW + 16)
.attr("y1", 0).attr("y2", 0)
.attr("stroke", SECONDARY).attr("stroke-width", 2).attr("stroke-dasharray", "7,5");
legend.append("text").attr("x", 4 * legendColW + 22).attr("y", 1).attr("fill", TEXT)
.attr("dominant-baseline", "middle").style("font-size", "11px").text("min f\u2081 search");
// Convex hull dashed line (shows which points weighted-sum would find)
const hullPoints = [
{ f1: 1, f2: 7 }, { f1: 2, f2: 5 }, { f1: 5, f2: 2 }, { f1: 7, f2: 1 },
];
const hullLine = d3.line().x(d => x(d.f1)).y(d => y(d.f2));
g.append("path").datum(hullPoints).attr("d", hullLine)
.attr("fill", "none").attr("stroke", STROKE)
.attr("stroke-width", 2.5).attr("stroke-dasharray", "10,6").attr("opacity", 0.4);
// Shaded gap between hull and front (non-convex region)
g.append("path")
.attr("d", `M${x(2)},${y(5)} L${x(5)},${y(2)} L${x(4)},${y(4)} Z`)
.attr("fill", ACCENT).attr("opacity", 0.08);
g.append("text").attr("x", x(4.1)).attr("y", y(3.35))
.attr("text-anchor", "middle").attr("fill", ACCENT).style("font-size", "11px")
.style("font-style", "italic").attr("opacity", 0.6).text("non-convex region");
// Non-convex point index (f1=4)
const ncIndices = new Set([4]);
// Pareto front curve + dots
g.append("path").datum(paretoData).attr("d", line)
.attr("fill", "none").attr("stroke", PARETO).attr("stroke-width", 2.5);
g.selectAll(".pareto-pt").data(paretoData).join("circle")
.attr("cx", d => x(d.f1)).attr("cy", d => y(d.f2))
.attr("r", 5).attr("fill", PARETO).attr("stroke", STROKE).attr("stroke-width", 1);
// Animated elements
const feasibleRect = g.append("rect")
.attr("x", 0).attr("width", iW).attr("y", y(8)).attr("height", 0)
.attr("fill", ACCENT).attr("opacity", 0);
const epsLine2 = g.append("line")
.attr("x1", 0).attr("x2", iW).attr("y1", y(8)).attr("y2", y(8))
.attr("stroke", ACCENT).attr("stroke-width", 2).attr("stroke-dasharray", "8,5")
.attr("opacity", 0);
const epsLabel = g.append("text")
.attr("x", iW + 5).attr("y", y(8)).attr("fill", ACCENT)
.attr("dominant-baseline", "middle").style("font-size", "16px").style("font-weight", "600")
.attr("opacity", 0);
const solveCounter = g.append("text")
.attr("x", iW).attr("y", -5).attr("text-anchor", "end")
.attr("fill", ACCENT).style("font-size", "16px").style("font-weight", "600")
.text("Solves: 0");
const minLayer = g.append("g");
const minLine = minLayer.append("line")
.attr("stroke", SECONDARY).attr("stroke-width", 2)
.attr("stroke-dasharray", "7,5").attr("opacity", 0);
const minLabel = minLayer.append("text")
.attr("fill", SECONDARY).style("font-size", "12px").style("font-weight", "600")
.attr("opacity", 0);
const foundGroup = g.append("g");
const delay = ms => new Promise(r => setTimeout(r, ms));
const fullSweepStopIndices = new Set([0, 2, 4]);
async function animateMinSearch(eps, targetF1, mode = "short") {
const yTop = y(eps);
const yBottom = y(0);
const xStart = x(8);
const xTarget = x(targetF1);
minLine.interrupt()
.attr("x1", xStart).attr("x2", xStart)
.attr("y1", yTop).attr("y2", yBottom)
.attr("opacity", 0.95);
minLabel.interrupt()
.attr("x", x(0.8))
.attr("y", Math.max(14, yTop - 8))
.text("min f\u2081: move left")
.attr("opacity", 0.95);
if (mode === "full") {
await minLine.transition().duration(420).ease(d3.easeCubicOut)
.attr("x1", xTarget).attr("x2", xTarget).end();
await delay(150);
} else {
const cueF1 = Math.min(7.8, targetF1 + 0.8);
await minLine.transition().duration(170).ease(d3.easeCubicOut)
.attr("x1", x(cueF1)).attr("x2", x(cueF1)).end();
await minLine.transition().duration(120).ease(d3.easeLinear)
.attr("x1", xTarget).attr("x2", xTarget).end();
await delay(80);
}
await minLine.transition().duration(120).attr("opacity", 0).end();
minLabel.transition().duration(120).attr("opacity", 0);
}
async function animate() {
foundGroup.selectAll("*").remove();
solveCounter.text("Solves: 0");
minLine.interrupt().attr("opacity", 0);
minLabel.interrupt().attr("opacity", 0).text("");
epsLine2.attr("y1", y(8)).attr("y2", y(8)).attr("opacity", 1);
epsLabel.attr("y", y(8)).text("\u03b5\u2082 = 8").attr("opacity", 1);
feasibleRect.attr("y", y(8)).attr("height", y(0) - y(8)).attr("opacity", 0.08);
await delay(500);
let solveCount = 0, prevEps = 8;
const alreadyFound = new Set();
for (let stopIdx = 0; stopIdx < keyStops.length; stopIdx += 1) {
const stop = keyStops[stopIdx];
const dur = 800 + Math.abs(prevEps - stop.eps) * 150;
await epsLine2.transition().duration(dur).ease(d3.easeLinear)
.attr("y1", y(stop.eps)).attr("y2", y(stop.eps)).end();
feasibleRect.transition().duration(dur).ease(d3.easeLinear)
.attr("y", y(stop.eps)).attr("height", y(0) - y(stop.eps));
epsLabel.transition().duration(dur).ease(d3.easeLinear)
.attr("y", y(stop.eps)).tween("text", function() {
const interp = d3.interpolateNumber(prevEps, stop.eps);
return t => d3.select(this).text(`\u03b5\u2082 = ${interp(t).toFixed(1)}`);
});
await delay(dur);
solveCount++;
solveCounter.text(`Solves: ${solveCount}`);
const fp = findOptimal(stop.eps);
if (fp) {
const sweepMode = fullSweepStopIndices.has(stopIdx) ? "full" : "short";
await animateMinSearch(stop.eps, fp.f1, sweepMode);
const key = `${fp.f1},${fp.f2}`;
if (!alreadyFound.has(key)) {
alreadyFound.add(key);
const pt = foundGroup.append("circle")
.attr("cx", x(fp.f1)).attr("cy", y(fp.f2))
.attr("r", 0).attr("fill", SECONDARY)
.attr("stroke", STROKE).attr("stroke-width", 2.5);
await pt.transition().duration(400).ease(d3.easeBackOut).attr("r", 10).end();
// Label: show coordinate and highlight non-convex points
const isNC = ncIndices.has(fp.f1);
const labelColor = isNC ? ACCENT : TEXT;
const labelText = isNC
? `(${fp.f1}, ${fp.f2}) \u2190 non-convex!`
: `min f\u2081 = ${fp.f1}`;
foundGroup.append("text")
.attr("x", x(fp.f1)).attr("y", y(fp.f2) - 16)
.attr("text-anchor", "middle")
.attr("fill", labelColor).style("font-size", isNC ? "13px" : "12px")
.style("font-weight", isNC ? "600" : "400")
.text(labelText)
.attr("opacity", 0).transition().duration(300).attr("opacity", 1);
// Extra ring highlight for non-convex points
if (isNC) {
foundGroup.append("circle")
.attr("cx", x(fp.f1)).attr("cy", y(fp.f2))
.attr("r", 10).attr("fill", "none").attr("stroke", ACCENT)
.attr("stroke-width", 2.5).attr("opacity", 0)
.transition().duration(400).attr("opacity", 1).attr("r", 15)
.transition().duration(300).attr("r", 12).attr("opacity", 0.7);
}
}
}
prevEps = stop.eps;
await delay(700);
}
epsLabel.text("\u03b5\u2082 = " + keyStops[keyStops.length - 1].eps);
}
container.append("button").text("\u25b6 Replay")
.style("position", "absolute").style("top", "8px").style("left", "8px")
.style("background", BUTTON_BG).style("color", TEXT)
.style("border", `1px solid ${BUTTON_BORDER}`).style("border-radius", "4px")
.style("padding", "4px 12px").style("cursor", "pointer")
.style("font-family", FONT).style("font-size", "13px")
.on("click", () => animate());
const node = container.node();
const obs = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) { animate(); obs.disconnect(); }
}, { threshold: 0.3 });
requestAnimationFrame(() => obs.observe(node));
yield node;
}