nlArithmetic = {
const d3 = await require("d3@7");
const W = 560, H = 170;
const ML = 40, MR = 40, MT = 60, MB = 30;
const innerW = W - ML - MR;
const xMin = -10, xMax = 10;
const xScale = d3.scaleLinear([xMin, xMax], [0, innerW]);
const container = d3.create("div")
.style("font-family", "sans-serif")
.style("max-width", W + "px");
// --- controls row ---
const controls = container.append("div")
.style("display", "flex")
.style("gap", "16px")
.style("align-items", "flex-end")
.style("flex-wrap", "wrap")
.style("margin-bottom", "8px");
function makeNumberInput(parent, labelText, defaultVal) {
const wrap = parent.append("label")
.style("display", "flex")
.style("flex-direction", "column")
.style("font-size", "0.85rem")
.style("gap", "3px");
wrap.append("span").text(labelText);
const inp = wrap.append("input")
.attr("type", "range")
.attr("min", -10)
.attr("max", 10)
.attr("step", 1)
.attr("value", defaultVal)
.style("width", "120px");
const val = wrap.append("span")
.style("text-align", "center")
.style("font-weight", "bold");
return { inp, val };
}
const { inp: inpA, val: valA } = makeNumberInput(controls, "A (start)", 3);
const opWrap = controls.append("label")
.style("display", "flex")
.style("flex-direction", "column")
.style("font-size", "0.85rem")
.style("gap", "3px");
opWrap.append("span").text("Operation");
const opSel = opWrap.append("select")
.style("font-size", "1rem")
.style("padding", "2px 6px");
opSel.append("option").attr("value", "add").text("+ (add)");
opSel.append("option").attr("value", "sub").text("− (subtract)");
const { inp: inpB, val: valB } = makeNumberInput(controls, "B (amount)", -5);
// --- result line ---
const resultDiv = container.append("div")
.style("font-size", "1rem")
.style("text-align", "center")
.style("margin-bottom", "6px")
.style("font-weight", "bold")
.style("color", "#1e293b");
// --- SVG ---
const svg = container.append("svg")
.attr("width", W)
.attr("height", H)
.attr("viewBox", `0 0 ${W} ${H}`)
.attr("aria-label", "Number line showing arithmetic as movement");
const g = svg.append("g")
.attr("transform", `translate(${ML},${MT})`);
// axis
g.append("line")
.attr("x1", 0).attr("y1", 0)
.attr("x2", innerW).attr("y2", 0)
.attr("stroke", "#333").attr("stroke-width", 2);
g.append("polygon")
.attr("points", `${innerW},0 ${innerW-8},-4 ${innerW-8},4`)
.attr("fill", "#333");
g.append("polygon")
.attr("points", `0,0 8,-4 8,4`)
.attr("fill", "#333");
// ticks
for (let v = xMin; v <= xMax; v++) {
const x = xScale(v);
g.append("line")
.attr("x1", x).attr("y1", -5).attr("x2", x).attr("y2", 5)
.attr("stroke", v === 0 ? "#222" : "#888")
.attr("stroke-width", v === 0 ? 2 : 1);
g.append("text")
.attr("x", x).attr("y", 20)
.attr("text-anchor", "middle")
.attr("font-size", "11px")
.attr("fill", "#444")
.text(v);
}
// movement arrow (drawn below axis)
const moveArrow = g.append("g");
const moveLine = moveArrow.append("line")
.attr("y1", 34).attr("y2", 34)
.attr("stroke", "#7c3aed").attr("stroke-width", 3);
const moveHead = moveArrow.append("polygon")
.attr("fill", "#7c3aed");
const moveLabel = moveArrow.append("text")
.attr("y", 55)
.attr("text-anchor", "middle")
.attr("font-size", "11px")
.attr("fill", "#5b21b6");
// point A
const ptA = g.append("g");
ptA.append("circle").attr("r", 8)
.attr("fill", "#0369a1").attr("stroke", "#075985").attr("stroke-width", 2);
ptA.append("text")
.attr("y", -14).attr("text-anchor", "middle")
.attr("font-size", "12px").attr("fill", "#075985").attr("font-weight", "bold")
.attr("class", "ptA-label");
ptA.append("text")
.attr("y", -26).attr("text-anchor", "middle")
.attr("font-size", "10px").attr("fill", "#0369a1")
.text("A");
// point result
const ptR = g.append("g");
ptR.append("circle").attr("r", 8)
.attr("fill", "#16a34a").attr("stroke", "#15803d").attr("stroke-width", 2);
ptR.append("text")
.attr("y", -14).attr("text-anchor", "middle")
.attr("font-size", "12px").attr("fill", "#15803d").attr("font-weight", "bold")
.attr("class", "ptR-label");
ptR.append("text")
.attr("y", -26).attr("text-anchor", "middle")
.attr("font-size", "10px").attr("fill", "#16a34a")
.text("Result");
function clamp(v) {
return Math.max(xMin, Math.min(xMax, v));
}
function update() {
const a = +inpA.property("value");
const b = +inpB.property("value");
const op = opSel.property("value");
const result = clamp(op === "add" ? a + b : a - b);
const opChar = op === "add" ? "+" : "−";
const bDisplay = op === "sub" ? b : b;
valA.text(a);
valB.text(b);
const xA = xScale(a);
const xR = xScale(result);
ptA.attr("transform", `translate(${xA},0)`);
ptA.select(".ptA-label").text(a);
ptR.attr("transform", `translate(${xR},0)`);
ptR.select(".ptR-label").text(result);
// movement arrow
if (a !== result) {
const leftX = Math.min(xA, xR);
const rightX = Math.max(xA, xR);
moveLine.attr("x1", leftX + 4).attr("x2", rightX - 4).attr("visibility", "visible");
if (result > a) {
moveHead.attr("points", `${rightX},34 ${rightX-8},30 ${rightX-8},38`).attr("visibility", "visible");
} else {
moveHead.attr("points", `${leftX},34 ${leftX+8},30 ${leftX+8},38`).attr("visibility", "visible");
}
moveLabel
.attr("x", (leftX + rightX) / 2)
.attr("visibility", "visible")
.text(`move ${Math.abs(result - a)} ${result > a ? "right" : "left"}`);
} else {
moveLine.attr("visibility", "hidden");
moveHead.attr("visibility", "hidden");
moveLabel.attr("visibility", "hidden");
}
resultDiv.text(`${a} ${opChar} ${b} = ${result}${Math.abs(a + (op === "add" ? b : -b)) > 10 ? " (clamped to line range)" : ""}`);
}
update();
inpA.on("input", update);
inpB.on("input", update);
opSel.on("change", update);
// legend
const legend = container.append("div")
.style("display", "flex").style("gap", "16px").style("font-size", "0.8rem")
.style("margin-top", "4px").style("flex-wrap", "wrap");
const items = [
{ color: "#0369a1", label: "A — starting value" },
{ color: "#16a34a", label: "Result" },
{ color: "#7c3aed", label: "Movement (the operation)" }
];
items.forEach(({ color, label }) => {
const item = legend.append("div").style("display", "flex").style("align-items", "center").style("gap", "5px");
item.append("div")
.style("width", "14px").style("height", "14px")
.style("border-radius", "50%").style("background", color).style("flex-shrink", "0");
item.append("span").text(label);
});
return container.node();
}