closedLoopViz = {
const d3 = await require("d3@7");
// ---- Layout constants ----
const W = 640, H = 480;
const BLUE = "#2563eb", GREEN = "#059669", RED = "#ef4444", AMBER = "#f59e0b";
const GREY_DASH = "#9ca3af";
const container = d3.create("div")
.style("font-family", "sans-serif")
.style("max-width", W + "px");
// K slider
const sliderRow = container.append("div")
.style("display", "flex")
.style("align-items", "center")
.style("gap", "0.75rem")
.style("margin-bottom", "0.5rem");
sliderRow.append("label")
.style("font-size", "0.9rem")
.style("white-space", "nowrap")
.text("Gain K:");
const slider = sliderRow.append("input")
.attr("type", "range")
.attr("min", 0.1)
.attr("max", 20)
.attr("step", 0.1)
.attr("value", 2)
.style("flex", "1");
const kLabel = sliderRow.append("span")
.style("font-weight", "bold")
.style("min-width", "2.5rem");
// Main SVG
const svg = container.append("svg")
.attr("width", W)
.attr("height", H)
.attr("viewBox", `0 0 ${W} ${H}`)
.attr("aria-label", "Closed-loop feedback explorer");
// ---- Block diagram (top half, y 0-210) ----
const BD = svg.append("g").attr("transform", "translate(0,0)");
// Draw block diagram elements
// Signal arrows: r -> summing junction -> controller -> plant -> y (output)
// feedback path from y back to summing junction
// Positions
const SJ = {x: 130, y: 80}; // summing junction centre
const CB = {x: 260, y: 60, w: 80, h: 40}; // controller box
const PB = {x: 420, y: 60, w: 80, h: 40}; // plant box
const outX = 550; // output signal x
// r arrow
BD.append("line")
.attr("x1", 50).attr("y1", SJ.y)
.attr("x2", SJ.x - 18).attr("y2", SJ.y)
.attr("stroke", "#333").attr("stroke-width", 1.5)
.attr("marker-end", "url(#arrowBlack)");
BD.append("text").attr("x", 30).attr("y", SJ.y - 8)
.attr("font-size", "13px").attr("fill", "#333").text("r(t)");
// summing junction circle
BD.append("circle")
.attr("cx", SJ.x).attr("cy", SJ.y).attr("r", 18)
.attr("fill", "none").attr("stroke", "#333").attr("stroke-width", 1.5);
BD.append("text").attr("x", SJ.x - 6).attr("y", SJ.y + 5)
.attr("font-size", "14px").attr("fill", "#333").text("+");
BD.append("text").attr("x", SJ.x - 5).attr("y", SJ.y + 22)
.attr("font-size", "11px").attr("fill", "#ef4444").text("−");
// e(t) label between SJ and controller
BD.append("line")
.attr("x1", SJ.x + 18).attr("y1", SJ.y)
.attr("x2", CB.x).attr("y2", SJ.y)
.attr("stroke", "#333").attr("stroke-width", 1.5)
.attr("marker-end", "url(#arrowBlack)");
BD.append("text").attr("x", SJ.x + 22).attr("y", SJ.y - 8)
.attr("font-size", "12px").attr("fill", "#6b7280").text("e(t)");
// Controller box
BD.append("rect")
.attr("x", CB.x).attr("y", CB.y).attr("width", CB.w).attr("height", CB.h)
.attr("rx", 6).attr("fill", "#f3f4f6").attr("stroke", "#9ca3af").attr("stroke-width", 1.5);
const kBoxLabel = BD.append("text")
.attr("x", CB.x + CB.w / 2).attr("y", CB.y + CB.h / 2 + 5)
.attr("text-anchor", "middle").attr("font-size", "14px").attr("fill", BLUE);
// Arrow controller to plant
BD.append("line")
.attr("x1", CB.x + CB.w).attr("y1", SJ.y)
.attr("x2", PB.x).attr("y2", SJ.y)
.attr("stroke", "#333").attr("stroke-width", 1.5)
.attr("marker-end", "url(#arrowBlack)");
BD.append("text").attr("x", CB.x + CB.w + 5).attr("y", SJ.y - 8)
.attr("font-size", "12px").attr("fill", "#6b7280").text("u(t)");
// Plant box
BD.append("rect")
.attr("x", PB.x).attr("y", PB.y).attr("width", PB.w).attr("height", PB.h)
.attr("rx", 6).attr("fill", "#f3f4f6").attr("stroke", "#9ca3af").attr("stroke-width", 1.5);
BD.append("text")
.attr("x", PB.x + PB.w / 2).attr("y", PB.y + PB.h / 2 + 5)
.attr("text-anchor", "middle").attr("font-size", "13px").attr("fill", "#374151")
.text("G(s)=1/(5s+1)");
// Arrow plant to output
BD.append("line")
.attr("x1", PB.x + PB.w).attr("y1", SJ.y)
.attr("x2", outX).attr("y2", SJ.y)
.attr("stroke", "#333").attr("stroke-width", 1.5)
.attr("marker-end", "url(#arrowBlack)");
BD.append("text").attr("x", outX + 5).attr("y", SJ.y + 5)
.attr("font-size", "13px").attr("fill", "#333").text("y(t)");
// Feedback path (y -> down -> left -> up -> summing junction bottom)
const fbY = SJ.y + 70;
BD.append("polyline")
.attr("points", `${outX - 5},${SJ.y} ${outX - 5},${fbY} ${SJ.x},${fbY} ${SJ.x},${SJ.y + 18}`)
.attr("fill", "none").attr("stroke", "#333").attr("stroke-width", 1.5)
.attr("marker-end", "url(#arrowBlack)");
BD.append("text").attr("x", (SJ.x + outX) / 2).attr("y", fbY + 16)
.attr("text-anchor", "middle").attr("font-size", "11px").attr("fill", "#6b7280")
.text("unity feedback");
// Arrowhead marker def
const defs = svg.append("defs");
defs.append("marker")
.attr("id", "arrowBlack").attr("markerWidth", 8).attr("markerHeight", 8)
.attr("refX", 6).attr("refY", 3).attr("orient", "auto")
.append("path").attr("d", "M0,0 L0,6 L8,3 z").attr("fill", "#333");
// ---- Step response panel (bottom left, y 220-480, x 0-320) ----
const SP_X = 30, SP_Y = 200, SP_W = 280, SP_H = 200;
const spG = svg.append("g").attr("transform", `translate(${SP_X},${SP_Y})`);
spG.append("text").attr("x", SP_W / 2).attr("y", 0)
.attr("text-anchor", "middle").attr("font-size", "12px").attr("fill", "#374151")
.text("Step response y(t)");
// Axes
spG.append("line").attr("x1", 30).attr("y1", 10).attr("x2", 30).attr("y2", SP_H - 20)
.attr("stroke", "#6b7280").attr("stroke-width", 1.5);
spG.append("line").attr("x1", 30).attr("y1", SP_H - 20).attr("x2", SP_W - 10).attr("y2", SP_H - 20)
.attr("stroke", "#6b7280").attr("stroke-width", 1.5);
spG.append("text").attr("x", SP_W - 8).attr("y", SP_H - 17)
.attr("font-size", "10px").attr("fill", "#6b7280").text("t");
spG.append("text").attr("x", 0).attr("y", 10)
.attr("font-size", "10px").attr("fill", "#6b7280").text("y");
// Reference line (dashed grey at y=1)
const spXmin = 30, spXmax = SP_W - 10, spYmin = 10, spYmax = SP_H - 20;
const yScale = d3.scaleLinear([0, 1.5], [spYmax, spYmin]);
spG.append("line")
.attr("x1", spXmin).attr("y1", yScale(1))
.attr("x2", spXmax).attr("y2", yScale(1))
.attr("stroke", GREY_DASH).attr("stroke-width", 1).attr("stroke-dasharray", "5,3");
spG.append("text").attr("x", spXmax + 2).attr("y", yScale(1) + 4)
.attr("font-size", "10px").attr("fill", GREY_DASH).text("r=1");
// Error shading group and response path
const errorShade = spG.append("path")
.attr("fill", RED).attr("fill-opacity", 0.12).attr("stroke", "none");
const responsePath = spG.append("path")
.attr("fill", "none").attr("stroke", BLUE).attr("stroke-width", 2);
// ---- Pole location panel (bottom right, y 220-480, x 330-640) ----
const PP_X = 340, PP_Y = 200, PP_W = 260, PP_H = 200;
const ppG = svg.append("g").attr("transform", `translate(${PP_X},${PP_Y})`);
ppG.append("text").attr("x", PP_W / 2).attr("y", 0)
.attr("text-anchor", "middle").attr("font-size", "12px").attr("fill", "#374151")
.text("Pole location (real axis)");
// s-plane: x-axis only, range -6 to 0.5
const poleXscale = d3.scaleLinear([-6, 0.5], [10, PP_W - 10]);
const poleY = 80;
// Stable region shading
ppG.append("rect")
.attr("x", 10).attr("y", 20)
.attr("width", poleXscale(0) - 10).attr("height", 100)
.attr("fill", GREEN).attr("fill-opacity", 0.08);
// Axis
ppG.append("line")
.attr("x1", 10).attr("y1", poleY).attr("x2", PP_W - 10).attr("y2", poleY)
.attr("stroke", "#6b7280").attr("stroke-width", 1.5);
// Imaginary axis (vertical dashed)
ppG.append("line")
.attr("x1", poleXscale(0)).attr("y1", 10)
.attr("x2", poleXscale(0)).attr("y2", 150)
.attr("stroke", "#9ca3af").attr("stroke-width", 1).attr("stroke-dasharray", "4,3");
ppG.append("text").attr("x", poleXscale(0) + 2).attr("y", 16)
.attr("font-size", "9px").attr("fill", "#9ca3af").text("stability boundary");
// Axis ticks
[-5,-4,-3,-2,-1,0].forEach(v => {
const x = poleXscale(v);
ppG.append("line").attr("x1", x).attr("y1", poleY - 5)
.attr("x2", x).attr("y2", poleY + 5).attr("stroke", "#9ca3af");
ppG.append("text").attr("x", x).attr("y", poleY + 18)
.attr("text-anchor", "middle").attr("font-size", "10px").attr("fill", "#6b7280")
.text(v);
});
ppG.append("text").attr("x", PP_W - 8).attr("y", poleY + 5)
.attr("font-size", "10px").attr("fill", "#6b7280").text("Re(s)");
ppG.append("text").attr("x", poleXscale(-3)).attr("y", 16)
.attr("text-anchor", "middle").attr("font-size", "10px").attr("fill", GREEN)
.text("stable region");
const poleCircle = ppG.append("circle")
.attr("cy", poleY).attr("r", 8)
.attr("fill", BLUE).attr("stroke", "#1e40af").attr("stroke-width", 1.5);
const poleLabelEl = ppG.append("text")
.attr("text-anchor", "middle").attr("y", poleY - 15)
.attr("font-size", "11px").attr("fill", BLUE).attr("font-weight", "bold");
// Readouts
const readoutG = svg.append("g").attr("transform", `translate(${PP_X}, ${PP_Y + 140})`);
const settlingTxt = readoutG.append("text")
.attr("font-size", "12px").attr("fill", AMBER);
const errorTxt = readoutG.append("text")
.attr("y", 20).attr("font-size", "12px").attr("fill", AMBER);
// ---- Update function ----
function update(K) {
kLabel.text(K.toFixed(1));
kBoxLabel.text(`C(s) = ${K.toFixed(1)}`);
// Plant G(s) = 1/(5s+1), C(s) = K
// Closed-loop pole: s = -(1+K)/5
const pole = -(1 + K) / 5;
const tau_cl = 5 / (1 + K); // closed-loop time constant
const ss_gain = K / (1 + K); // steady-state output for unit step
const ss_error = 1 / (1 + K); // steady-state error
// Settling time approx 4*tau_cl
const t_settle = 4 * tau_cl;
// Step response: y(t) = ss_gain * (1 - e^{-t/tau_cl})
const tMax = Math.max(30, t_settle * 1.6);
const nPts = 200;
const pts = d3.range(nPts).map(i => {
const t = (i / (nPts - 1)) * tMax;
return [t, ss_gain * (1 - Math.exp(-t / tau_cl))];
});
const tScale = d3.scaleLinear([0, tMax], [spXmin, spXmax]);
// Error shading area
const areaGen = d3.area()
.x(d => tScale(d[0]))
.y0(yScale(1))
.y1(d => yScale(Math.min(d[1], 1.4)));
errorShade.attr("d", areaGen(pts));
// Response path
const lineGen = d3.line().x(d => tScale(d[0])).y(d => yScale(Math.min(d[1], 1.4)));
responsePath.attr("d", lineGen(pts));
// Pole position
poleCircle.attr("cx", poleXscale(pole));
poleLabelEl.attr("x", poleXscale(pole)).text(`s = ${pole.toFixed(2)}`);
settlingTxt.text(`Settling time ≈ ${t_settle.toFixed(1)} s`);
errorTxt.text(`Steady-state error = ${(ss_error * 100).toFixed(1)}%`);
}
slider.on("input", function() { update(+this.value); });
update(2);
return container.node();
}