viewof bspline_viz = {
// ── Cox–de Boor in degree form (scoped to this cell) ──
const safeDiv = (n, d) => d === 0 ? 0 : n / d;
const rampUp = (x, K, i, m) => safeDiv(x - K[i], K[i + m] - K[i]);
const rampDown = (x, K, i, m) => safeDiv(K[i + m + 1] - x, K[i + m + 1] - K[i + 1]);
function bspline(x, K, i, m) {
if (m === 0) return (x >= K[i] && x < K[i + 1]) ? 1 : 0;
const a = Math.max(0, rampUp(x, K, i, m));
const b = Math.max(0, rampDown(x, K, i, m));
return a * bspline(x, K, i, m - 1) + b * bspline(x, K, i + 1, m - 1);
}
const sub = n => String(n).split("").map(c => /\d/.test(c) ? "₀₁₂₃₄₅₆₇₈₉"[+c] : c).join("");
const sup = n => String(n).split("").map(c => /\d/.test(c) ? "⁰¹²³⁴⁵⁶⁷⁸⁹"[+c] : c).join("");
const wrapper = document.createElement("div");
wrapper.style.cssText = "font-family:system-ui,-apple-system,sans-serif;max-width:1000px;margin:0 auto;";
wrapper.appendChild(injectStyle());
// Colour key — one per decomposed piece
const C = {
rampUp: "#dc2626", leftSub: "#f59e0b", leftComp: "#dc2626",
rampDown: "#16a34a", rightSub: "#0891b2", rightComp: "#16a34a",
result: "#7c3aed", other: "#94a3b8", knot: "#1e293b"
};
const LAYERS = {
rampUp: C.rampUp, leftSub: C.leftSub, leftComp: C.leftComp,
rampDown: C.rampDown, rightSub: C.rightSub, rightComp: C.rightComp,
result: C.result
};
// ── Controls row: degree + index on one line ──
const ctrlRow = document.createElement("div");
ctrlRow.style.cssText = "display:flex;gap:18px;margin-bottom:10px;align-items:center;flex-wrap:wrap;";
const makeGroup = (label) => {
const g = document.createElement("div");
g.style.cssText = "display:flex;gap:6px;align-items:center;flex-wrap:wrap;";
const l = document.createElement("span");
l.style.cssText = "font-size:12px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:0.3px;";
l.textContent = label;
g.appendChild(l);
return g;
};
const degGroup = makeGroup("Degree m:");
let degree = 2;
const degBtns = {};
for (const d of [0, 1, 2, 3]) {
const b = createButton(String(d), d === degree ? "go" : "pause");
b.el.style.cssText += ";flex:0 0 auto;padding:6px 12px;min-width:34px;";
degBtns[d] = b;
degGroup.appendChild(b.el);
}
ctrlRow.appendChild(degGroup);
const iGroup = makeGroup("Index i:");
const initialKnots = [0, 1.2, 2.4, 3.5, 4.8, 6.0, 7.3, 8.5];
const knots = initialKnots.slice();
const KNOT_MIN = 0, KNOT_MAX = 9;
let iIdx = 1;
const iBtns = [];
const maxIForDegree = m => knots.length - m - 2;
function rebuildIButtons() {
for (const b of iBtns) iGroup.removeChild(b.el);
iBtns.length = 0;
const maxI = maxIForDegree(degree);
iIdx = Math.max(0, Math.min(maxI, iIdx));
for (let i = 0; i <= maxI; i++) {
const b = createButton(String(i), i === iIdx ? "go" : "pause");
b.el.style.cssText += ";flex:0 0 auto;padding:6px 10px;min-width:30px;";
b.el.addEventListener("click", () => { iIdx = i; rebuildIButtons(); draw(); });
iBtns.push(b);
iGroup.appendChild(b.el);
}
}
ctrlRow.appendChild(iGroup);
wrapper.appendChild(ctrlRow);
rebuildIButtons();
// ── Formula (just the formula, no extra prose — the plot and prose above carry the explanation) ──
const formulaBox = document.createElement("div");
formulaBox.style.cssText = `
padding:14px 16px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;
margin-bottom:12px;font-size:15px;line-height:1.4;
font-family:"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
color:#0f172a;overflow-x:auto;white-space:nowrap;
`;
wrapper.appendChild(formulaBox);
let highlighted = null;
// ── SVG ──
const W = 1000, H = 470;
const margin = { top: 22, right: 22, bottom: 68, left: 62 };
const innerW = W - margin.left - margin.right;
const innerH = H - margin.top - margin.bottom;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${W} ${H}`)
.attr("width", "100%").style("max-width", W + "px").style("height", "auto")
.style("display", "block")
.style("border", "1px solid #e2e8f0")
.style("border-radius", "8px")
.style("background", "#fff");
const plotG = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
plotG.append("rect")
.attr("x", -0.5).attr("y", -0.5)
.attr("width", innerW + 1).attr("height", innerH + 1)
.attr("fill", "#fafbfc").attr("stroke", "#e2e8f0");
const gridLayer = plotG.append("g");
const knotLayer = plotG.append("g");
const otherBasisLayer = plotG.append("g");
const leftSubLayer = plotG.append("g");
const rightSubLayer = plotG.append("g");
const rampUpLayer = plotG.append("g");
const rampDownLayer = plotG.append("g");
const leftCompLayer = plotG.append("g");
const rightCompLayer = plotG.append("g");
const resultLayer = plotG.append("g");
const crosshairLayer = plotG.append("g").style("pointer-events", "none");
const knotDragLayer = plotG.append("g");
const xScale = d3.scaleLinear().domain([KNOT_MIN, KNOT_MAX]).range([0, innerW]);
const yScale = d3.scaleLinear().domain([-0.05, 1.1]).range([innerH, 0]);
// Static grid
gridLayer.append("line")
.attr("x1", 0).attr("x2", innerW)
.attr("y1", yScale(0)).attr("y2", yScale(0))
.attr("stroke", "#cbd5e1").attr("stroke-width", 1);
for (const t of [0.5, 1]) {
gridLayer.append("line")
.attr("x1", 0).attr("x2", innerW)
.attr("y1", yScale(t)).attr("y2", yScale(t))
.attr("stroke", "#e2e8f0").attr("stroke-dasharray", "2,4");
}
for (const t of [0, 0.5, 1]) {
gridLayer.append("text")
.attr("x", -10).attr("y", yScale(t))
.attr("text-anchor", "end").attr("dominant-baseline", "middle")
.attr("font-family", `"SF Mono",Menlo,Consolas,monospace`)
.attr("font-size", 14).attr("fill", "#475569")
.text(t);
}
// Sampling helpers
const GRID_N = 800;
function sampleFn(fn) {
const pts = [];
for (let j = 0; j <= GRID_N; j++) {
const x = KNOT_MIN + (KNOT_MAX - KNOT_MIN) * j / GRID_N;
pts.push({ x, y: fn(x) });
}
return pts;
}
const linePath = d3.line().defined(d => isFinite(d.y)).x(d => xScale(d.x)).y(d => yScale(d.y));
const areaPath = d3.area().defined(d => isFinite(d.y)).x(d => xScale(d.x)).y0(yScale(0)).y1(d => yScale(d.y));
// Crosshair
const crossX = crosshairLayer.append("line")
.attr("y1", 0).attr("y2", innerH)
.attr("stroke", "#0f172a").attr("stroke-width", 1)
.attr("stroke-dasharray", "3,3").style("opacity", 0);
const crossDots = {};
for (const key of Object.keys(LAYERS)) {
crossDots[key] = crosshairLayer.append("circle")
.attr("r", 5.5).attr("fill", LAYERS[key])
.attr("stroke", "#fff").attr("stroke-width", 2)
.style("opacity", 0);
}
const crossLabel = crosshairLayer.append("g").style("opacity", 0);
const crossLabelBg = crossLabel.append("rect")
.attr("fill", "#fff").attr("stroke", "#1e293b").attr("stroke-width", 1)
.attr("rx", 4).attr("ry", 4);
const crossLabelText = crossLabel.append("text")
.attr("font-family", `"SF Mono",Menlo,Consolas,monospace`)
.attr("font-size", 15).attr("font-weight", 600).attr("fill", "#0f172a")
.attr("dominant-baseline", "hanging");
// Hover hit area
const hitRect = plotG.append("rect")
.attr("x", 0).attr("y", 0).attr("width", innerW).attr("height", innerH)
.attr("fill", "transparent").style("cursor", "crosshair");
let hoverX = null;
hitRect.on("mousemove", function(event) {
const [mx] = d3.pointer(event, plotG.node());
hoverX = Math.max(0, Math.min(innerW, mx));
updateCrosshair();
}).on("mouseleave", () => { hoverX = null; updateCrosshair(); });
// Persistent knot drag handles
knotDragLayer.selectAll("g.knot-handle")
.data(knots.map((v, idx) => ({ idx, val: v })))
.join("g")
.attr("class", "knot-handle")
.style("cursor", "ew-resize")
.each(function() {
const g = d3.select(this);
g.append("rect").attr("x", -14).attr("y", -2).attr("width", 28).attr("height", 32).attr("fill", "transparent");
g.append("polygon").attr("class", "arrow").attr("points", "-8,0 8,0 0,13");
g.append("text").attr("class", "label").attr("text-anchor", "middle").attr("y", 30)
.attr("font-family", `"SF Mono",Menlo,Consolas,monospace`)
.attr("font-size", 14).attr("font-weight", 700);
})
.call(d3.drag().on("drag", function(event, d) {
let nv = xScale.invert(event.x);
const lo = d.idx === 0 ? KNOT_MIN : knots[d.idx - 1] + 0.05;
const hi = d.idx === knots.length - 1 ? KNOT_MAX : knots[d.idx + 1] - 0.05;
nv = Math.max(lo, Math.min(hi, nv));
knots[d.idx] = nv;
d.val = nv;
draw();
}));
function drawKnotLines() {
const m = degree, i = iIdx;
const supStart = i, supEnd = i + m + 1;
knotLayer.selectAll("line.knot-line")
.data(knots.map((v, idx) => ({ idx, val: v, inSup: idx >= supStart && idx <= supEnd })))
.join(
enter => enter.append("line").attr("class", "knot-line"),
update => update,
exit => exit.remove()
)
.attr("x1", d => xScale(d.val)).attr("x2", d => xScale(d.val))
.attr("y1", 0).attr("y2", innerH)
.attr("stroke", d => d.inSup ? "#475569" : "#cbd5e1")
.attr("stroke-width", d => d.inSup ? 1 : 0.5)
.attr("stroke-dasharray", d => d.inSup ? "3,3" : "1,3");
knotDragLayer.selectAll("g.knot-handle")
.data(knots.map((v, idx) => ({ idx, val: v })))
.attr("transform", d => `translate(${xScale(d.val)}, ${innerH})`);
knotDragLayer.selectAll("g.knot-handle polygon.arrow")
.attr("fill", d => (d.idx >= supStart && d.idx <= supEnd) ? C.knot : "#94a3b8");
knotDragLayer.selectAll("g.knot-handle text.label")
.attr("fill", d => (d.idx >= supStart && d.idx <= supEnd) ? "#0f172a" : "#94a3b8")
.text(d => `x${sub(d.idx)}`);
}
function draw() {
const m = degree, i = iIdx;
const maxI = maxIForDegree(m);
// Other basis functions (grey background)
otherBasisLayer.selectAll("path").remove();
for (let k = 0; k <= maxI; k++) {
if (k === i) continue;
otherBasisLayer.append("path")
.datum(sampleFn(x => bspline(x, knots, k, m)))
.attr("d", linePath)
.attr("fill", "none").attr("stroke", C.other).attr("stroke-width", 1.2)
.attr("opacity", 0.55);
}
for (const L of [leftSubLayer, rightSubLayer, rampUpLayer, rampDownLayer, leftCompLayer, rightCompLayer, resultLayer]) {
L.selectAll("*").remove();
}
if (m >= 1) {
const leftSubFn = x => bspline(x, knots, i, m - 1);
const rightSubFn = x => bspline(x, knots, i + 1, m - 1);
const rampUpFn = x => (x < knots[i] || x > knots[i + m]) ? NaN : Math.max(0, Math.min(1, rampUp(x, knots, i, m)));
const rampDownFn = x => (x < knots[i + 1] || x > knots[i + m + 1]) ? NaN : Math.max(0, Math.min(1, rampDown(x, knots, i, m)));
const leftCompFn = x => Math.max(0, rampUp(x, knots, i, m)) * leftSubFn(x);
const rightCompFn = x => Math.max(0, rampDown(x, knots, i, m)) * rightSubFn(x);
leftSubLayer.append("path").attr("data-key", "leftSub").attr("data-role", "line")
.datum(sampleFn(leftSubFn)).attr("d", linePath)
.attr("fill", "none").attr("stroke", C.leftSub).attr("stroke-width", 2).attr("stroke-dasharray", "3,4");
rightSubLayer.append("path").attr("data-key", "rightSub").attr("data-role", "line")
.datum(sampleFn(rightSubFn)).attr("d", linePath)
.attr("fill", "none").attr("stroke", C.rightSub).attr("stroke-width", 2).attr("stroke-dasharray", "3,4");
rampUpLayer.append("path").attr("data-key", "rampUp").attr("data-role", "line")
.datum(sampleFn(rampUpFn)).attr("d", linePath)
.attr("fill", "none").attr("stroke", C.rampUp).attr("stroke-width", 2).attr("stroke-dasharray", "7,4");
rampDownLayer.append("path").attr("data-key", "rampDown").attr("data-role", "line")
.datum(sampleFn(rampDownFn)).attr("d", linePath)
.attr("fill", "none").attr("stroke", C.rampDown).attr("stroke-width", 2).attr("stroke-dasharray", "7,4");
leftCompLayer.append("path").attr("data-key", "leftComp").attr("data-role", "area")
.datum(sampleFn(leftCompFn)).attr("d", areaPath)
.attr("fill", C.leftComp).attr("fill-opacity", 0.22);
leftCompLayer.append("path").attr("data-key", "leftComp").attr("data-role", "line")
.datum(sampleFn(leftCompFn)).attr("d", linePath)
.attr("fill", "none").attr("stroke", C.leftComp).attr("stroke-width", 2.8);
rightCompLayer.append("path").attr("data-key", "rightComp").attr("data-role", "area")
.datum(sampleFn(rightCompFn)).attr("d", areaPath)
.attr("fill", C.rightComp).attr("fill-opacity", 0.22);
rightCompLayer.append("path").attr("data-key", "rightComp").attr("data-role", "line")
.datum(sampleFn(rightCompFn)).attr("d", linePath)
.attr("fill", "none").attr("stroke", C.rightComp).attr("stroke-width", 2.8);
}
resultLayer.append("path").attr("data-key", "result").attr("data-role", "line")
.datum(sampleFn(x => bspline(x, knots, i, m))).attr("d", linePath)
.attr("fill", "none").attr("stroke", C.result).attr("stroke-width", 3.5);
drawKnotLines();
attachCurveHover();
applyHighlight();
updateCrosshair();
updateFormula();
}
// Highlight sync between formula and curves
const setHighlight = k => { highlighted = k; applyHighlight(); updateFormulaHighlight(); };
// Base stroke widths by key — used to compute "boost" on hover
const BASE_SW = {
rampUp: 2, rampDown: 2,
leftSub: 2, rightSub: 2,
leftComp: 2.8, rightComp: 2.8,
result: 3.5
};
function applyHighlight() {
plotG.selectAll("path[data-key]").each(function() {
const el = d3.select(this);
const key = el.attr("data-key");
const role = el.attr("data-role");
const isHi = key === highlighted;
const hasHi = highlighted != null;
// Opacity: fade non-highlighted strongly; highlighted gets 1.0
el.style("opacity", hasHi ? (isHi ? 1 : 0.08) : null);
if (role === "line") {
const base = BASE_SW[key] || 2;
el.attr("stroke-width", isHi ? base + 2.5 : base);
} else if (role === "area") {
el.attr("fill-opacity", isHi ? 0.45 : 0.22);
}
});
otherBasisLayer.selectAll("path").style("opacity", highlighted ? 0.08 : 0.55);
}
function attachCurveHover() {
plotG.selectAll("path[data-key]")
.style("cursor", "pointer")
.on("mouseenter", function() { setHighlight(d3.select(this).attr("data-key")); })
.on("mouseleave", () => setHighlight(null));
}
function updateFormula() {
const m = degree, i = iIdx;
// Plain labelled term (inline)
const S = (key, text) => `<span data-key="${key}" style="color:${LAYERS[key]};font-weight:700;cursor:pointer;padding:1px 3px;border-radius:3px;">${text}</span>`;
// Stacked fraction — numerator over denominator with a dividing bar, aligned to the middle of the line
const F = (key, num, den) => `<span data-key="${key}" style="display:inline-flex;flex-direction:column;vertical-align:middle;text-align:center;color:${LAYERS[key]};font-weight:700;cursor:pointer;padding:1px 4px;border-radius:3px;line-height:1.15;"><span style="padding:0 6px 2px;">${num}</span><span style="padding:2px 6px 0;border-top:1.5px solid currentColor;">${den}</span></span>`;
if (m === 0) {
formulaBox.innerHTML = `${S("result", `B${sub(i)}⁰(x)`)} = 1 when x${sub(i)} ≤ x < x${sub(i+1)}, otherwise 0`;
attachFormulaHover();
return;
}
const xi = knots[i].toFixed(2), xi1 = knots[i+1].toFixed(2);
const xim = knots[i+m].toFixed(2), xim1 = knots[i+m+1].toFixed(2);
formulaBox.innerHTML = `
${S("result", `B${sub(i)}${sup(m)}(x)`)}
=
${F("rampUp", `x − ${xi}`, `${xim} − ${xi}`)}
·
${S("leftSub", `B${sub(i)}${sup(m-1)}(x)`)}
+
${F("rampDown", `${xim1} − x`, `${xim1} − ${xi1}`)}
·
${S("rightSub", `B${sub(i+1)}${sup(m-1)}(x)`)}
`;
attachFormulaHover();
}
function attachFormulaHover() {
formulaBox.querySelectorAll("[data-key]").forEach(el => {
el.addEventListener("mouseenter", () => setHighlight(el.getAttribute("data-key")));
el.addEventListener("mouseleave", () => setHighlight(null));
});
}
function updateFormulaHighlight() {
formulaBox.querySelectorAll("[data-key]").forEach(el => {
const on = highlighted === el.getAttribute("data-key");
el.style.background = on ? "#fef3c7" : "";
});
}
// Crosshair — show the identity α·B_left + β·B_right = B, nothing extra
function updateCrosshair() {
if (hoverX == null) {
crossX.style("opacity", 0);
for (const k of Object.keys(crossDots)) crossDots[k].style("opacity", 0);
crossLabel.style("opacity", 0);
return;
}
const x = xScale.invert(hoverX);
const m = degree, i = iIdx;
crossX.attr("x1", hoverX).attr("x2", hoverX).style("opacity", 0.6);
const vals = { result: bspline(x, knots, i, m) };
if (m >= 1) {
vals.leftSub = bspline(x, knots, i, m - 1);
vals.rightSub = bspline(x, knots, i + 1, m - 1);
const a = rampUp(x, knots, i, m), b = rampDown(x, knots, i, m);
vals.rampUp = (x >= knots[i] && x <= knots[i + m]) ? Math.max(0, Math.min(1, a)) : null;
vals.rampDown = (x >= knots[i + 1] && x <= knots[i + m + 1]) ? Math.max(0, Math.min(1, b)) : null;
vals.leftComp = Math.max(0, a) * vals.leftSub;
vals.rightComp = Math.max(0, b) * vals.rightSub;
}
for (const k of Object.keys(crossDots)) {
const v = vals[k];
if (v == null || !isFinite(v)) crossDots[k].style("opacity", 0);
else crossDots[k].attr("cx", hoverX).attr("cy", yScale(v)).style("opacity", 0.95);
}
// Tight readout: just the identity, no per-piece ramp/sub-spline values
const lines = [`x = ${x.toFixed(3)}`];
if (m === 0) {
lines.push(`B${sub(i)}⁰ = ${vals.result.toFixed(3)}`);
} else {
lines.push(`α·B${sub(i)}${sup(m-1)} = ${vals.leftComp.toFixed(3)}`);
lines.push(`β·B${sub(i+1)}${sup(m-1)} = ${vals.rightComp.toFixed(3)}`);
lines.push(`──────────`);
lines.push(`B${sub(i)}${sup(m)} = ${vals.result.toFixed(3)}`);
}
crossLabelText.selectAll("tspan").remove();
lines.forEach((ln, k) => crossLabelText.append("tspan").attr("x", 0).attr("dy", k === 0 ? 0 : 13).text(ln));
const flip = hoverX > innerW * 0.65;
crossLabel.attr("transform", `translate(${flip ? hoverX - 150 : hoverX + 12}, 8)`).style("opacity", 1);
const bbox = crossLabelText.node().getBBox();
crossLabelBg.attr("x", bbox.x - 6).attr("y", bbox.y - 4)
.attr("width", bbox.width + 12).attr("height", bbox.height + 8).attr("opacity", 0.94);
}
// Wire degree buttons
for (const k of Object.keys(degBtns)) {
degBtns[k].el.addEventListener("click", () => {
degree = +k;
for (const dk of Object.keys(degBtns)) {
degBtns[dk].el.className = "sl-btn " + (+dk === degree ? "sl-btn-go" : "sl-btn-pause");
}
rebuildIButtons();
draw();
});
}
// Reset
const resetRow = document.createElement("div");
resetRow.style.cssText = "margin-top:12px;display:flex;justify-content:flex-end;";
const resetBtn = createButton("Reset knots", "reset");
resetBtn.el.style.cssText += ";flex:0 0 auto;padding:6px 14px;";
resetBtn.el.addEventListener("click", () => {
for (let k = 0; k < initialKnots.length; k++) knots[k] = initialKnots[k];
draw();
});
resetRow.appendChild(resetBtn.el);
wrapper.appendChild(svg.node());
wrapper.appendChild(resetRow);
draw();
return wrapper;
}