{
// ── Fourier partial sum builder with phasor panel ──────────────────────
// Panel A: waveform reconstruction Panel B: frequency spectrum
// Panel C: phasor wheel (inset)
const d3 = await require("d3@7");
// Palette
const AMBER = "#f59e0b";
const BLUE = "#6b90c4";
const GREY = "#9ca3af";
const GREY_FAINT = "#e5e7eb";
const RED = "#ef4444";
// Dimensions
const WA = 520, HA = 180; // Panel A waveform
const WB = 520, HB = 90; // Panel B spectrum
const WC = 130, HC = 130; // Panel C phasor
const margin = { t: 16, r: 16, b: 32, l: 40 };
// ── Wave functions (period 2π, L = π) ──────────────────────────────────
function targetSquare(x) { return x > 0 ? 1 : -1; }
function targetSawtooth(x) { return x / Math.PI; }
function targetTriangle(x) {
const t = ((x + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI;
return 1 - (2 / Math.PI) * Math.abs(t);
}
// ── Fourier coefficients (bₙ only for odd, pure sine series) ───────────
function bCoeff(n, waveType) {
if (waveType === "square") {
return n % 2 === 1 ? 4 / (n * Math.PI) : 0;
} else if (waveType === "sawtooth") {
return (n % 2 === 0 ? -1 : 1) * 2 / n; // alternate signs: 2(-1)^{n+1}/n
// Correct: b_n = 2(-1)^{n+1}/n
} else { // triangle
if (n % 2 === 0) return 0;
const k = (n - 1) / 2;
return (k % 2 === 0 ? 8 : -8) / (n * n * Math.PI * Math.PI);
}
}
// ── Partial sum ─────────────────────────────────────────────────────────
function partialSum(x, N, waveType) {
let s = 0;
for (let n = 1; n <= N; n++) s += bCoeff(n, waveType) * Math.sin(n * x);
return s;
}
// Gibbs overshoot x-position (first peak of partial sum near x=0)
function gibbsPeakX(N) {
return Math.PI / N; // first peak near jump at x=0 (actually π/(N+1) approx)
}
// ── Viewof controls ─────────────────────────────────────────────────────
const container = document.createElement("div");
container.style.cssText = "font-family:inherit; max-width:620px;";
// Controls row
const ctrlRow = document.createElement("div");
ctrlRow.style.cssText = "display:flex; gap:1.5rem; align-items:center; margin-bottom:0.5rem; flex-wrap:wrap;";
// N slider
const sliderLabel = document.createElement("label");
sliderLabel.style.cssText = "font-size:0.88em; color:#374151; display:flex; align-items:center; gap:0.4rem;";
sliderLabel.textContent = "Terms N = ";
const nDisplay = document.createElement("strong");
nDisplay.style.cssText = "color:" + AMBER + "; min-width:2ch;";
nDisplay.textContent = "5";
const nSlider = document.createElement("input");
nSlider.type = "range"; nSlider.min = 1; nSlider.max = 30; nSlider.value = 5;
nSlider.style.cssText = "width:160px; accent-color:" + AMBER + ";";
sliderLabel.appendChild(nSlider);
sliderLabel.appendChild(nDisplay);
ctrlRow.appendChild(sliderLabel);
// Wave type radio
const waveTypes = [["square","Square"], ["sawtooth","Sawtooth"], ["triangle","Triangle"]];
const radioGroup = document.createElement("div");
radioGroup.style.cssText = "display:flex; gap:0.75rem; font-size:0.88em;";
let currentWave = "square";
waveTypes.forEach(([val, lab]) => {
const lbl = document.createElement("label");
lbl.style.cssText = "display:flex; align-items:center; gap:0.25rem; cursor:pointer;";
const r = document.createElement("input");
r.type = "radio"; r.name = "waveViz1"; r.value = val;
r.checked = (val === "square");
r.style.accentColor = AMBER;
r.onchange = () => { currentWave = val; draw(); };
lbl.appendChild(r); lbl.appendChild(document.createTextNode(lab));
radioGroup.appendChild(lbl);
});
ctrlRow.appendChild(radioGroup);
container.appendChild(ctrlRow);
// ── SVG canvas ──────────────────────────────────────────────────────────
const totalW = WA + 16 + WC; // waveform + gap + phasor
const totalH = HA + 8 + HB + margin.b;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${totalW + margin.l + margin.r} ${totalH + margin.t}`)
.attr("width", "100%")
.style("display","block");
const gA = svg.append("g").attr("transform", `translate(${margin.l},${margin.t})`);
const gB = svg.append("g").attr("transform", `translate(${margin.l},${margin.t + HA + 8})`);
const gC = svg.append("g").attr("transform", `translate(${margin.l + WA + 16},${margin.t})`);
// ── Panel A setup ───────────────────────────────────────────────────────
const xA = d3.scaleLinear().domain([-Math.PI, Math.PI]).range([0, WA]);
const yA = d3.scaleLinear().domain([-1.3, 1.3]).range([HA, 0]);
gA.append("line").attr("x1",0).attr("x2",WA).attr("y1",yA(0)).attr("y2",yA(0))
.attr("stroke", GREY_FAINT).attr("stroke-width",1);
gA.append("g").call(d3.axisBottom(xA).tickValues([-Math.PI,-Math.PI/2,0,Math.PI/2,Math.PI])
.tickFormat(d => {
const m = {"-3.14159":"−π","-1.5708":"−π/2","0":"0","1.5708":"π/2","3.14159":"π"};
return m[d.toFixed(5)] || "";
})).attr("transform", `translate(0,${HA})`)
.call(g => { g.select(".domain").attr("stroke",GREY_FAINT); g.selectAll("text").style("font-size","10px").attr("fill",GREY); g.selectAll("line").attr("stroke",GREY_FAINT); });
gA.append("g").call(d3.axisLeft(yA).ticks(5))
.call(g => { g.select(".domain").remove(); g.selectAll("text").style("font-size","10px").attr("fill",GREY); g.selectAll("line").attr("stroke",GREY_FAINT); });
gA.append("text").attr("x", WA/2).attr("y", -4).attr("text-anchor","middle")
.attr("font-size","11px").attr("fill","#374151").text("Waveform reconstruction");
// Target path (blue, drawn once per wave change)
const targetPath = gA.append("path").attr("fill","none").attr("stroke",BLUE)
.attr("stroke-width",2).attr("stroke-dasharray","4,3").attr("opacity",0.7);
// Partial sum path (amber)
const sumPath = gA.append("path").attr("fill","none").attr("stroke",AMBER).attr("stroke-width",2.5);
// Gibbs annotation
const gibbsLine = gA.append("line").attr("stroke","#dc2626").attr("stroke-width",1)
.attr("stroke-dasharray","3,2").style("display","none");
const gibbsLabel = gA.append("text").attr("font-size","9.5px").attr("fill","#dc2626")
.style("display","none");
// N label
const nLabel = gA.append("text").attr("x",8).attr("y",14).attr("font-size","11px")
.attr("fill",AMBER).attr("font-weight","bold");
// ── Panel B setup ───────────────────────────────────────────────────────
const xB = d3.scaleLinear().domain([0.5, 30.5]).range([0, WB]);
const yBmax = 1.4;
const yB = d3.scaleLinear().domain([0, yBmax]).range([HB - 22, 0]);
gB.append("text").attr("x",WB/2).attr("y",-4).attr("text-anchor","middle")
.attr("font-size","11px").attr("fill","#374151").text("Frequency spectrum |bₙ|");
gB.append("g").call(d3.axisBottom(xB).ticks(10).tickFormat(d => Number.isInteger(d) ? d : ""))
.attr("transform", `translate(0,${HB-22})`)
.call(g => { g.select(".domain").attr("stroke",GREY_FAINT); g.selectAll("text").style("font-size","9px").attr("fill",GREY); g.selectAll("line").attr("stroke",GREY_FAINT); });
gB.append("text").attr("x",WB+2).attr("y",HB-22+4).attr("font-size","9px").attr("fill",GREY).text("n");
// ── Panel C setup (phasor wheel) ────────────────────────────────────────
const cx = WC / 2, cy = HC / 2, r0 = WC * 0.38;
gC.append("circle").attr("cx",cx).attr("cy",cy).attr("r",r0)
.attr("fill","none").attr("stroke",GREY_FAINT).attr("stroke-width",1.5);
gC.append("line").attr("x1",cx).attr("x2",cx).attr("y1",cy-r0-4).attr("y2",cy+r0+4)
.attr("stroke",GREY_FAINT).attr("stroke-width",0.5);
gC.append("line").attr("x1",cx-r0-4).attr("x2",cx+r0+4).attr("y1",cy).attr("y2",cy)
.attr("stroke",GREY_FAINT).attr("stroke-width",0.5);
gC.append("text").attr("x",WC/2).attr("y",HC-2).attr("text-anchor","middle")
.attr("font-size","9px").attr("fill",GREY).text("Phasor wheel");
// Phasor arrows (up to 3 harmonics for square wave)
const phasorArrows = [1,3,5].map((_, i) => ({
line: gC.append("line").attr("stroke", i===0 ? AMBER : i===1 ? BLUE : "#a78bfa")
.attr("stroke-width", 2-i*0.4).attr("marker-end","url(#arrowC)"),
label: gC.append("text").attr("font-size","8px").attr("fill", i===0 ? AMBER : i===1 ? BLUE : "#a78bfa")
}));
// Arrow marker
svg.append("defs").append("marker").attr("id","arrowC").attr("markerWidth",6).attr("markerHeight",6)
.attr("refX",5).attr("refY",3).attr("orient","auto")
.append("path").attr("d","M0,0 L0,6 L6,3 z").attr("fill",AMBER);
// Projection dot
const projDot = gC.append("circle").attr("r",4).attr("fill",RED);
// Tip trace path
const traceData = [];
const tracePath = gC.append("path").attr("fill","none").attr("stroke",RED)
.attr("stroke-width",1).attr("opacity",0.4);
// ── Animation state ─────────────────────────────────────────────────────
let theta = 0;
let animFrame = null;
function getNHarmonics() {
const N = +nSlider.value;
if (currentWave === "square") {
const odds = [1,3,5,7,9,11,13,15,17,19,21,23,25,27,29].filter(n => n <= N);
return odds.slice(0, 3);
} else if (currentWave === "sawtooth") {
return [1,2,3].filter(n => n <= N);
} else {
const odds = [1,3,5,7,9,11].filter(n => n <= N);
return odds.slice(0, 3);
}
}
function drawBars(N) {
const barW = Math.max(1, WB / 32);
gB.selectAll(".specbar").remove();
for (let n = 1; n <= 30; n++) {
const coeff = Math.abs(bCoeff(n, currentWave));
const active = n <= N;
gB.append("rect").attr("class","specbar")
.attr("x", xB(n) - barW/2).attr("y", active ? yB(coeff) : HB-22)
.attr("width", barW).attr("height", active ? (HB-22) - yB(coeff) : 0)
.attr("fill", active ? AMBER : "none")
.attr("stroke", active ? AMBER : GREY_FAINT)
.attr("stroke-width", active ? 0 : 1)
.attr("opacity", active ? 1 : 0.5);
if (!active && coeff > 0.02) {
// ghost outline
const ghostH = Math.max(1, (HB-22) - yB(coeff));
gB.append("rect").attr("class","specbar")
.attr("x", xB(n) - barW/2).attr("y", yB(coeff))
.attr("width", barW).attr("height", ghostH)
.attr("fill","none").attr("stroke",GREY_FAINT).attr("stroke-width",1);
}
}
}
function draw() {
const N = +nSlider.value;
nDisplay.textContent = N;
nLabel.text(`N = ${N} terms`);
const nPts = 400;
const xs = d3.range(nPts).map(i => -Math.PI + (2*Math.PI)*i/(nPts-1));
// Target
const target = currentWave === "square" ? targetSquare :
currentWave === "sawtooth" ? targetSawtooth : targetTriangle;
const lineGen = d3.line().x(d => xA(d[0])).y(d => yA(d[1]));
targetPath.datum(xs.map(x => [x, target(x) || 0])).attr("d", lineGen);
// Partial sum
sumPath.datum(xs.map(x => [x, partialSum(x, N, currentWave)])).attr("d", lineGen);
// Gibbs annotation (square wave only, N >= 5)
if (currentWave === "square" && N >= 5) {
const GIBBS_Y = 1.089;
gibbsLine.style("display","block")
.attr("x1",0).attr("x2",WA).attr("y1",yA(GIBBS_Y)).attr("y2",yA(GIBBS_Y));
gibbsLabel.style("display","block")
.attr("x",WA-4).attr("y",yA(GIBBS_Y)-3)
.attr("text-anchor","end").text("≈ 8.9% overshoot");
} else {
gibbsLine.style("display","none");
gibbsLabel.style("display","none");
}
drawBars(N);
}
function animatePhasor() {
const N = +nSlider.value;
const harmonics = getNHarmonics();
theta += 0.025;
// Draw phasor arrows (chain from centre)
let px = cx, py = cy;
const tips = [];
harmonics.forEach((n, i) => {
const coeff = Math.abs(bCoeff(n, currentWave));
const len = coeff / 1.5 * r0; // scale to fit
const angle = -(n * theta) - Math.PI/2; // -π/2 so n=1 starts pointing up
const ex = px + len * Math.cos(angle);
const ey = py + len * Math.sin(angle);
phasorArrows[i].line
.attr("x1", px).attr("y1", py)
.attr("x2", ex).attr("y2", ey)
.style("display", n <= N ? "block" : "none");
phasorArrows[i].label
.attr("x", ex + 4).attr("y", ey)
.text(n <= N ? `n=${n}` : "")
.style("display", n <= N ? "block" : "none");
px = ex; py = ey;
tips.push([ex, ey]);
});
// Hide unused arrows
for (let i = harmonics.length; i < 3; i++) {
phasorArrows[i].line.style("display","none");
phasorArrows[i].label.style("display","none");
}
// Projection onto real axis (horizontal = Re)
const tipX = tips[tips.length-1][0];
const tipY = tips[tips.length-1][1];
projDot.attr("cx", cx).attr("cy", tipY); // project to vertical axis (Im → 0 = Re projection)
// Trace tip
traceData.push([tipX, tipY]);
if (traceData.length > 80) traceData.shift();
tracePath.datum(traceData).attr("d", d3.line().x(d=>d[0]).y(d=>d[1]).curve(d3.curveLinear));
animFrame = requestAnimationFrame(animatePhasor);
}
nSlider.oninput = () => { draw(); };
container.appendChild(svg.node());
// Legend
const legend = document.createElement("div");
legend.style.cssText = "display:flex; gap:1rem; font-size:0.8em; margin-top:0.25rem; flex-wrap:wrap;";
[["─── Partial sum S_N(x)", AMBER], ["--- Target function", BLUE], ["| Gibbs limit (square)", "#dc2626"]]
.forEach(([lab, col]) => {
const s = document.createElement("span");
s.style.cssText = `color:${col};`;
s.textContent = lab;
legend.appendChild(s);
});
container.appendChild(legend);
draw();
animatePhasor();
// Cleanup on disconnect
const obs = new MutationObserver(() => {
if (!document.contains(container)) { cancelAnimationFrame(animFrame); obs.disconnect(); }
});
obs.observe(document.body, {childList:true, subtree:true});
return container;
}