divCurlDemo = {
const d3 = await require("d3@7");
const W = 560, H = 450;
const ML = 20, MR = 20, MT = 20, MB = 40;
const innerW = W - ML - MR;
const innerH = H - MT - MB;
const container = d3.create("div").style("max-width", W+"px").style("font-family", "sans-serif").style("margin", "0 auto");
const controls = container.append("div").style("margin-bottom", "15px").style("display", "flex").style("justify-content", "space-between").style("align-items", "center");
const radioDiv = controls.append("div").style("display", "flex").style("gap", "15px").style("background", "#f8fafc").style("padding", "8px 12px").style("border-radius", "8px").style("border", "1px solid #e2e8f0");
const types = [
{id: "source", label: "Source", dx: (x,y)=>x, dy: (x,y)=>y, div: "+2", curl: "0"},
{id: "sink", label: "Sink", dx: (x,y)=>-x, dy: (x,y)=>-y, div: "-2", curl: "0"},
{id: "rotation", label: "Rotation", dx: (x,y)=>-y, dy: (x,y)=>x, div: "0", curl: "+2"},
{id: "shear", label: "Shear", dx: (x,y)=>y, dy: (x,y)=>0, div: "0", curl: "-1"}
];
types.forEach((t, i) => {
const lbl = radioDiv.append("label").style("display", "flex").style("align-items", "center").style("gap", "5px").style("cursor", "pointer").style("font-size", "0.9rem");
const inp = lbl.append("input").attr("type", "radio").attr("name", "vf_type").attr("value", t.id).style("margin", "0");
if(i === 0) inp.property("checked", true);
lbl.append("span").text(t.label);
inp.on("change", update);
});
const readout = controls.append("div").style("display", "flex").style("gap", "20px");
const divOut = readout.append("div").style("padding", "8px 12px").style("background", "#fee2e2").style("border", "1px solid #fca5a5").style("border-radius", "6px").style("font-weight", "bold").style("color", "#991b1b").style("font-size", "0.95rem");
const curlOut = readout.append("div").style("padding", "8px 12px").style("background", "#e0e7ff").style("border", "1px solid #a5b4fc").style("border-radius", "6px").style("font-weight", "bold").style("color", "#3730a3").style("font-size", "0.95rem");
const svg = container.append("svg").attr("width", W).attr("height", H).attr("viewBox", `0 0 ${W} ${H}`);
const g = svg.append("g").attr("transform", `translate(${ML},${MT})`);
const xyLim = 3.5;
const xScale = d3.scaleLinear([-xyLim, xyLim], [0, innerW]);
const yScale = d3.scaleLinear([-xyLim, xyLim], [innerH, 0]);
// Grid
g.append("line").attr("x1", xScale(-xyLim)).attr("y1", yScale(0)).attr("x2", xScale(xyLim)).attr("y2", yScale(0)).attr("stroke", "#cbd5e1");
g.append("line").attr("x1", xScale(0)).attr("y1", yScale(-xyLim)).attr("x2", xScale(0)).attr("y2", yScale(xyLim)).attr("stroke", "#cbd5e1");
g.append("rect").attr("width", innerW).attr("height", innerH).attr("fill", "none").attr("stroke", "#94a3b8");
const arrowGroup = g.append("g");
const arrowGridSize = Math.floor(innerW / 14);
const arrowsData = [];
for(let x=0; x<=innerW + 1; x+=arrowGridSize) {
for(let y=0; y<=innerH + 1; y+=arrowGridSize) {
arrowsData.push({x, y, dx: xScale.invert(x), dy: yScale.invert(y)});
}
}
const arrows = arrowGroup.selectAll("g.arrow").data(arrowsData).enter().append("g").attr("class", "arrow");
arrows.append("line").attr("stroke", "#3b82f6").attr("stroke-width", 2).attr("stroke-linecap", "round");
arrows.append("polygon").attr("fill", "#3b82f6");
function update() {
const selectedId = radioDiv.select('input[name="vf_type"]:checked').property("value");
const fn = types.find(t => t.id === selectedId);
divOut.html(`\u2207 \u00B7 F = ${fn.div}`);
curlOut.html(`\u2207 \u00D7 F = ${fn.curl} k`);
arrows.each(function(d) {
const u = fn.dx(d.dx, d.dy);
const v = fn.dy(d.dx, d.dy);
const speed = Math.sqrt(u*u + v*v);
const gNode = d3.select(this);
if(speed < 0.1) {
gNode.style("display", "none");
return;
}
gNode.style("display", "block");
// Max field length at edge is approx xyLim (3.5)
const arrowLen = Math.min((speed / xyLim) * (arrowGridSize * 0.9), arrowGridSize);
const dirX = u / speed;
const dirY = v / speed;
// SVG y is inverted
const sx = d.x;
const sy = d.y;
const ex = sx + dirX * arrowLen;
const ey = sy - dirY * arrowLen;
gNode.select("line").attr("x1", sx).attr("y1", sy).attr("x2", ex).attr("y2", ey);
const angle = Math.atan2(ey - sy, ex - sx);
const r = 5;
gNode.select("polygon").attr("points", `${ex},${ey} ${ex - r*Math.cos(angle - 0.5)},${ey - r*Math.sin(angle - 0.5)} ${ex - r*Math.cos(angle + 0.5)},${ey - r*Math.sin(angle + 0.5)}`);
});
}
update();
return container.node();
}