S-N Curve (Wöhler Curve) — D3.js

An S-N curve (also known as a Wöhler curve) visualizes the relationship between alternating stress amplitude and the number of cycles to failure for a material under fatigue loading. Both axes typically use logarithmic scales, with stress on the y-axis and cycle count on the x-axis. This plot is fundamental for predicting material fatigue life and identifying key material properties such as ultimate strength, yield strength, and endurance limit.

S-N Curve (Wöhler Curve) rendered with D3.js

Renders

JavaScript source (D3.js)

// anyplot.ai
// sn-curve-basic: S-N Curve (Wöhler Curve)
// Library: d3 7.9.0 | JavaScript 22.23.2
// Quality: 90/100 | Created: 2026-09-02

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;
const margin = { top: 110, right: 90, bottom: 90, left: 100 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;

// --- Data (in-memory, deterministic) ----------------------------------------
// AISI 4140 quenched-and-tempered steel: fully-reversed axial fatigue coupons.
function makeLcg(seed) {
  let s = seed;
  return () => {
    s = (s * 1664525 + 1013904223) % 4294967296;
    return s / 4294967296;
  };
}
const rand = makeLcg(42);

const ultimateStrength = 950; // MPa
const yieldStrength = 770; // MPa
const enduranceLimit = 430; // MPa
const transitionCycles = 2e6; // where the Basquin curve meets the endurance limit

// Basquin fit calibrated through (1e3 MPa @ 1e3 cycles) and (endurance @ transition)
const basquinN1 = 1e3;
const basquinS1 = 700;
const basquinB = Math.log(enduranceLimit / basquinS1) / Math.log(transitionCycles / basquinN1);
const basquinA = basquinS1 / Math.pow(basquinN1, basquinB);
const cyclesAtStress = (stress) => Math.pow(stress / basquinA, 1 / basquinB);
const stressAtCycles = (n) => (n <= transitionCycles ? basquinA * Math.pow(n, basquinB) : enduranceLimit);

const stressLevels = [700, 650, 600, 550, 500, 470, 450, 440];
const specimensPerLevel = 3;
const data = [];
for (const stress of stressLevels) {
  const baseCycles = cyclesAtStress(stress);
  for (let i = 0; i < specimensPerLevel; i++) {
    const jitterDecades = (rand() - 0.5) * 0.3; // scatter typical of coupon-to-coupon variation
    data.push({ stress, cycles: baseCycles * Math.pow(10, jitterDecades) });
  }
}

const fitMaxCycles = 1e7;
const fitSamples = d3.range(0, 121).map((i) => {
  const n = basquinN1 * Math.pow(fitMaxCycles / basquinN1, i / 120);
  return { cycles: n, stress: stressAtCycles(n) };
});

// --- Scales -------------------------------------------------------------------
// Derive the x domain from the actual jittered data (padded) instead of hardcoding
// [1e3, 1e7] — the 700 MPa level's base cycle count sits right at 1e3, and jitter
// can push some specimens below that fixed boundary.
const cycleExtent = d3.extent([...data.map((d) => d.cycles), basquinN1, fitMaxCycles]);
const domainPad = 1.08; // headroom so edge markers never clip against the axes
const x = d3
  .scaleLog()
  .domain([cycleExtent[0] / domainPad, cycleExtent[1] * domainPad])
  .range([0, iw]);
const y = d3.scaleLog().domain([380, 1000]).range([ih, 0]);

// --- SVG mount ------------------------------------------------------------
const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height);
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

// --- Grid (y-axis only, subtle) --------------------------------------------
g.append("g")
  .selectAll("line")
  .data(y.ticks(7))
  .join("line")
  .attr("x1", 0)
  .attr("x2", iw)
  .attr("y1", (d) => y(d))
  .attr("y2", (d) => y(d))
  .attr("stroke", t.grid)
  .attr("stroke-width", 1);

// --- Axes -----------------------------------------------------------------
const superscriptMap = { "-": "⁻", 0: "⁰", 1: "¹", 2: "²", 3: "³", 4: "⁴", 5: "⁵", 6: "⁶", 7: "⁷", 8: "⁸", 9: "⁹" };
const toSuperscript = (n) =>
  String(n)
    .split("")
    .map((c) => superscriptMap[c] ?? c)
    .join("");

const xTickValues = [1e3, 1e4, 1e5, 1e6, 1e7];
const xAxis = g
  .append("g")
  .attr("transform", `translate(0,${ih})`)
  .call(
    d3
      .axisBottom(x)
      .tickValues(xTickValues)
      .tickFormat((d) => `10${toSuperscript(Math.round(Math.log10(d)))}`)
      .tickSize(0)
      .tickPadding(12),
  );
xAxis.selectAll("text").attr("fill", t.inkSoft).style("font-size", "15px");
xAxis.select(".domain").attr("stroke", t.inkSoft);

const yAxis = g.append("g").call(
  d3
    .axisLeft(y)
    .ticks(7)
    .tickFormat((d) => d3.format(",")(d))
    .tickSize(0)
    .tickPadding(10),
);
yAxis.selectAll("text").attr("fill", t.inkSoft).style("font-size", "15px");
yAxis.select(".domain").attr("stroke", t.inkSoft);

// --- Reference lines: Ultimate Strength, Yield Strength, Endurance Limit ---
const references = [
  { label: "Ultimate strength", value: ultimateStrength },
  { label: "Yield strength", value: yieldStrength },
  { label: "Endurance limit", value: enduranceLimit },
];
const refLayer = g.append("g");
for (const ref of references) {
  const refY = y(ref.value);
  refLayer
    .append("line")
    .attr("x1", 0)
    .attr("x2", iw)
    .attr("y1", refY)
    .attr("y2", refY)
    .attr("stroke", t.inkSoft)
    .attr("stroke-width", 1.5)
    .attr("stroke-dasharray", "6 5")
    .attr("opacity", 0.55);
  refLayer
    .append("text")
    .attr("x", 6)
    .attr("y", refY - 8)
    .attr("fill", t.inkSoft)
    .style("font-size", "14px")
    .text(`${ref.label} — ${ref.value} MPa`);
}

// --- Basquin fit line --------------------------------------------------------
const line = d3
  .line()
  .x((d) => x(d.cycles))
  .y((d) => y(d.stress));
g.append("path")
  .datum(fitSamples)
  .attr("fill", "none")
  .attr("stroke", t.palette[1])
  .attr("stroke-width", 3)
  .attr("d", line);

// --- Data points ---------------------------------------------------------
g.selectAll("circle")
  .data(data)
  .join("circle")
  .attr("cx", (d) => x(d.cycles))
  .attr("cy", (d) => y(d.stress))
  .attr("r", 8)
  .attr("fill", t.palette[0])
  .attr("fill-opacity", 0.8)
  .attr("stroke", t.pageBg)
  .attr("stroke-width", 1.5);

// --- Legend ----------------------------------------------------------------
// Anchored below the endurance-limit line, which is always the lowest reference
// line and stays clear of the data cloud (no stress level plots below it) —
// unlike a fixed y, this never collides with a reference line or the scatter.
const legendY = y(enduranceLimit) + 25;
const legend = g.append("g").attr("transform", `translate(${iw - 300},${legendY})`);
legend
  .append("circle")
  .attr("cx", 8)
  .attr("cy", 0)
  .attr("r", 8)
  .attr("fill", t.palette[0])
  .attr("fill-opacity", 0.8)
  .attr("stroke", t.pageBg)
  .attr("stroke-width", 1.5);
legend.append("text").attr("x", 24).attr("y", 5).attr("fill", t.ink).style("font-size", "15px").text("Coupon test result");
legend
  .append("line")
  .attr("x1", 0)
  .attr("x2", 16)
  .attr("y1", 34)
  .attr("y2", 34)
  .attr("stroke", t.palette[1])
  .attr("stroke-width", 3);
legend
  .append("text")
  .attr("x", 24)
  .attr("y", 39)
  .attr("fill", t.ink)
  .style("font-size", "15px")
  .text("Basquin fit: σ = A·N ᵇ");

// --- Axis labels -------------------------------------------------------------
g.append("text")
  .attr("x", iw / 2)
  .attr("y", ih + 60)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "17px")
  .text("Cycles to Failure, N");

g.append("text")
  .attr("transform", "rotate(-90)")
  .attr("x", -ih / 2)
  .attr("y", -70)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "17px")
  .text("Stress Amplitude, σ (MPa)");

// --- Title -------------------------------------------------------------------
svg
  .append("text")
  .attr("x", width / 2)
  .attr("y", 48)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "22px")
  .style("font-weight", "600")
  .text("sn-curve-basic · javascript · d3 · anyplot.ai");

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/sn-curve-basic/d3/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "sn-curve-basic",
  "language": "javascript",
  "library": "d3",
  "page": "https://anyplot.ai/sn-curve-basic/javascript/d3",
  "hub": "https://anyplot.ai/sn-curve-basic",
  "code_json": "https://api.anyplot.ai/specs/sn-curve-basic/d3/code",
  "spec_json": "https://api.anyplot.ai/specs/sn-curve-basic",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/sn-curve-basic/javascript/d3/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/sn-curve-basic/javascript/d3/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/sn-curve-basic/javascript/d3/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/sn-curve-basic/javascript/d3/plot-dark.html",
  "quality_score": 90.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of S-N Curve (Wöhler Curve) on anyplot.ai.

Other implementations