{"spec_id":"sn-curve-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// sn-curve-basic: S-N Curve (Wöhler Curve)\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 110, right: 90, bottom: 90, left: 100 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// AISI 4140 quenched-and-tempered steel: fully-reversed axial fatigue coupons.\nfunction makeLcg(seed) {\n  let s = seed;\n  return () => {\n    s = (s * 1664525 + 1013904223) % 4294967296;\n    return s / 4294967296;\n  };\n}\nconst rand = makeLcg(42);\n\nconst ultimateStrength = 950; // MPa\nconst yieldStrength = 770; // MPa\nconst enduranceLimit = 430; // MPa\nconst transitionCycles = 2e6; // where the Basquin curve meets the endurance limit\n\n// Basquin fit calibrated through (1e3 MPa @ 1e3 cycles) and (endurance @ transition)\nconst basquinN1 = 1e3;\nconst basquinS1 = 700;\nconst basquinB = Math.log(enduranceLimit / basquinS1) / Math.log(transitionCycles / basquinN1);\nconst basquinA = basquinS1 / Math.pow(basquinN1, basquinB);\nconst cyclesAtStress = (stress) => Math.pow(stress / basquinA, 1 / basquinB);\nconst stressAtCycles = (n) => (n <= transitionCycles ? basquinA * Math.pow(n, basquinB) : enduranceLimit);\n\nconst stressLevels = [700, 650, 600, 550, 500, 470, 450, 440];\nconst specimensPerLevel = 3;\nconst data = [];\nfor (const stress of stressLevels) {\n  const baseCycles = cyclesAtStress(stress);\n  for (let i = 0; i < specimensPerLevel; i++) {\n    const jitterDecades = (rand() - 0.5) * 0.3; // scatter typical of coupon-to-coupon variation\n    data.push({ stress, cycles: baseCycles * Math.pow(10, jitterDecades) });\n  }\n}\n\nconst fitMaxCycles = 1e7;\nconst fitSamples = d3.range(0, 121).map((i) => {\n  const n = basquinN1 * Math.pow(fitMaxCycles / basquinN1, i / 120);\n  return { cycles: n, stress: stressAtCycles(n) };\n});\n\n// --- Scales -------------------------------------------------------------------\n// Derive the x domain from the actual jittered data (padded) instead of hardcoding\n// [1e3, 1e7] — the 700 MPa level's base cycle count sits right at 1e3, and jitter\n// can push some specimens below that fixed boundary.\nconst cycleExtent = d3.extent([...data.map((d) => d.cycles), basquinN1, fitMaxCycles]);\nconst domainPad = 1.08; // headroom so edge markers never clip against the axes\nconst x = d3\n  .scaleLog()\n  .domain([cycleExtent[0] / domainPad, cycleExtent[1] * domainPad])\n  .range([0, iw]);\nconst y = d3.scaleLog().domain([380, 1000]).range([ih, 0]);\n\n// --- SVG mount ------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Grid (y-axis only, subtle) --------------------------------------------\ng.append(\"g\")\n  .selectAll(\"line\")\n  .data(y.ticks(7))\n  .join(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", iw)\n  .attr(\"y1\", (d) => y(d))\n  .attr(\"y2\", (d) => y(d))\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\n\n// --- Axes -----------------------------------------------------------------\nconst superscriptMap = { \"-\": \"⁻\", 0: \"⁰\", 1: \"¹\", 2: \"²\", 3: \"³\", 4: \"⁴\", 5: \"⁵\", 6: \"⁶\", 7: \"⁷\", 8: \"⁸\", 9: \"⁹\" };\nconst toSuperscript = (n) =>\n  String(n)\n    .split(\"\")\n    .map((c) => superscriptMap[c] ?? c)\n    .join(\"\");\n\nconst xTickValues = [1e3, 1e4, 1e5, 1e6, 1e7];\nconst xAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(\n    d3\n      .axisBottom(x)\n      .tickValues(xTickValues)\n      .tickFormat((d) => `10${toSuperscript(Math.round(Math.log10(d)))}`)\n      .tickSize(0)\n      .tickPadding(12),\n  );\nxAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"15px\");\nxAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\nconst yAxis = g.append(\"g\").call(\n  d3\n    .axisLeft(y)\n    .ticks(7)\n    .tickFormat((d) => d3.format(\",\")(d))\n    .tickSize(0)\n    .tickPadding(10),\n);\nyAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"15px\");\nyAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n// --- Reference lines: Ultimate Strength, Yield Strength, Endurance Limit ---\nconst references = [\n  { label: \"Ultimate strength\", value: ultimateStrength },\n  { label: \"Yield strength\", value: yieldStrength },\n  { label: \"Endurance limit\", value: enduranceLimit },\n];\nconst refLayer = g.append(\"g\");\nfor (const ref of references) {\n  const refY = y(ref.value);\n  refLayer\n    .append(\"line\")\n    .attr(\"x1\", 0)\n    .attr(\"x2\", iw)\n    .attr(\"y1\", refY)\n    .attr(\"y2\", refY)\n    .attr(\"stroke\", t.inkSoft)\n    .attr(\"stroke-width\", 1.5)\n    .attr(\"stroke-dasharray\", \"6 5\")\n    .attr(\"opacity\", 0.55);\n  refLayer\n    .append(\"text\")\n    .attr(\"x\", 6)\n    .attr(\"y\", refY - 8)\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"14px\")\n    .text(`${ref.label} — ${ref.value} MPa`);\n}\n\n// --- Basquin fit line --------------------------------------------------------\nconst line = d3\n  .line()\n  .x((d) => x(d.cycles))\n  .y((d) => y(d.stress));\ng.append(\"path\")\n  .datum(fitSamples)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[1])\n  .attr(\"stroke-width\", 3)\n  .attr(\"d\", line);\n\n// --- Data points ---------------------------------------------------------\ng.selectAll(\"circle\")\n  .data(data)\n  .join(\"circle\")\n  .attr(\"cx\", (d) => x(d.cycles))\n  .attr(\"cy\", (d) => y(d.stress))\n  .attr(\"r\", 8)\n  .attr(\"fill\", t.palette[0])\n  .attr(\"fill-opacity\", 0.8)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.5);\n\n// --- Legend ----------------------------------------------------------------\n// Anchored below the endurance-limit line, which is always the lowest reference\n// line and stays clear of the data cloud (no stress level plots below it) —\n// unlike a fixed y, this never collides with a reference line or the scatter.\nconst legendY = y(enduranceLimit) + 25;\nconst legend = g.append(\"g\").attr(\"transform\", `translate(${iw - 300},${legendY})`);\nlegend\n  .append(\"circle\")\n  .attr(\"cx\", 8)\n  .attr(\"cy\", 0)\n  .attr(\"r\", 8)\n  .attr(\"fill\", t.palette[0])\n  .attr(\"fill-opacity\", 0.8)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.5);\nlegend.append(\"text\").attr(\"x\", 24).attr(\"y\", 5).attr(\"fill\", t.ink).style(\"font-size\", \"15px\").text(\"Coupon test result\");\nlegend\n  .append(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", 16)\n  .attr(\"y1\", 34)\n  .attr(\"y2\", 34)\n  .attr(\"stroke\", t.palette[1])\n  .attr(\"stroke-width\", 3);\nlegend\n  .append(\"text\")\n  .attr(\"x\", 24)\n  .attr(\"y\", 39)\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"15px\")\n  .text(\"Basquin fit: σ = A·N ᵇ\");\n\n// --- Axis labels -------------------------------------------------------------\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 60)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"17px\")\n  .text(\"Cycles to Failure, N\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -70)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"17px\")\n  .text(\"Stress Amplitude, σ (MPa)\");\n\n// --- Title -------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 48)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"sn-curve-basic · javascript · d3 · anyplot.ai\");\n"}