{"spec_id":"heatmap-rainflow","library":"d3","language":"javascript","code":"// anyplot.ai\n// heatmap-rainflow: Rainflow Counting Matrix for Fatigue Analysis\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-08-25\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Deterministic PRNG (LCG) + Box-Muller normal sampler --------------------\n// The browser has no seeded RNG, so a tiny linear-congruential generator\n// stands in for numpy's `seed(42)` — same role, reproducible cycle counts.\nlet lcgState = 42;\nfunction rand() {\n  lcgState = (1664525 * lcgState + 1013904223) >>> 0;\n  return lcgState / 4294967296;\n}\nfunction normal() {\n  const u1 = Math.max(rand(), 1e-12);\n  const u2 = rand();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n// --- Synthetic load history -> binned rainflow matrix ------------------------\n// Simulates the output of rainflow cycle counting on a variable-amplitude\n// stress history: many small, widely-scattered cycles and a tapering tail of\n// rare large-amplitude cycles that cluster near zero mean stress — the classic\n// \"teardrop\" envelope seen in measured fatigue load spectra.\nconst ampMax = 300; // MPa, half-range\nconst meanMin = -150;\nconst meanMax = 150; // MPa\nconst ampBins = 20;\nconst meanBins = 20;\nconst ampStep = ampMax / ampBins;\nconst meanStep = (meanMax - meanMin) / meanBins;\nconst cycleCount = 15000;\n\nconst matrix = Array.from({ length: ampBins }, () => new Array(meanBins).fill(0));\nfor (let i = 0; i < cycleCount; i++) {\n  const amplitude = Math.min(ampMax - 1e-6, Math.abs(normal()) * 55);\n  const meanSigma = 20 + 70 * Math.exp(-amplitude / 90);\n  const mean = Math.min(meanMax - 1e-6, Math.max(meanMin, normal() * meanSigma));\n\n  const ai = Math.min(ampBins - 1, Math.floor(amplitude / ampStep));\n  const mi = Math.min(meanBins - 1, Math.max(0, Math.floor((mean - meanMin) / meanStep)));\n  matrix[ai][mi]++;\n}\n\nconst cells = [];\nfor (let ai = 0; ai < ampBins; ai++) {\n  for (let mi = 0; mi < meanBins; mi++) {\n    cells.push({\n      ampLo: ai * ampStep,\n      ampHi: (ai + 1) * ampStep,\n      meanLo: meanMin + mi * meanStep,\n      meanHi: meanMin + (mi + 1) * meanStep,\n      count: matrix[ai][mi],\n    });\n  }\n}\nconst maxCount = d3.max(cells, (d) => d.count);\n\n// --- Layout -------------------------------------------------------------------\nconst margin = { top: 110, right: 230, bottom: 140, left: 140 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\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// --- Scales -------------------------------------------------------------------\nconst x = d3.scaleLinear().domain([meanMin, meanMax]).range([0, iw]);\nconst y = d3.scaleLinear().domain([0, ampMax]).range([ih, 0]);\n\n// Cycle counts are single-polarity (non-negative), so the Imprint sequential\n// ramp applies. A sqrt transform spreads the many low-count bins across more\n// of the ramp instead of leaving them a flat near-uniform green.\nconst colorPos = d3.scaleSqrt().domain([0, maxCount]).range([0, 1]).clamp(true);\nconst cellColor = (count) => d3.interpolateRgbBasis(t.seq)(colorPos(count));\n\n// --- Matrix cells ---------------------------------------------------------\n// No per-cell stroke: at 20x20 resolution a uniform grid of strokes plus the\n// outer frame reads busy. The outer frame alone is enough to enclose the grid.\ng.selectAll(\"rect.cell\")\n  .data(cells)\n  .join(\"rect\")\n  .attr(\"class\", \"cell\")\n  .attr(\"x\", (d) => x(d.meanLo))\n  .attr(\"y\", (d) => y(d.ampHi))\n  .attr(\"width\", (d) => x(d.meanHi) - x(d.meanLo))\n  .attr(\"height\", (d) => y(d.ampLo) - y(d.ampHi))\n  .attr(\"fill\", (d) => (d.count > 0 ? cellColor(d.count) : t.pageBg));\n\ng.append(\"rect\")\n  .attr(\"x\", 0)\n  .attr(\"y\", 0)\n  .attr(\"width\", iw)\n  .attr(\"height\", ih)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\n\n// --- Dominant-cluster callout -----------------------------------------------\n// Gives the highest-count bin an explicit focal point, directly supporting the\n// spec's \"identifying dominant cycle combinations\" fatigue-analysis use case.\nconst peakCell = cells.reduce((best, d) => (d.count > best.count ? d : best), cells[0]);\nconst peakX = x((peakCell.meanLo + peakCell.meanHi) / 2);\nconst peakY = y((peakCell.ampLo + peakCell.ampHi) / 2);\nconst calloutX = Math.min(peakX + 90, iw - 210);\nconst calloutY = Math.max(peakY - 70, 28);\n\ng.append(\"circle\")\n  .attr(\"cx\", peakX)\n  .attr(\"cy\", peakY)\n  .attr(\"r\", 5)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 1.5);\n\ng.append(\"line\")\n  .attr(\"x1\", peakX)\n  .attr(\"y1\", peakY)\n  .attr(\"x2\", calloutX - 6)\n  .attr(\"y2\", calloutY + 10)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\n\ng.append(\"text\")\n  .attr(\"x\", calloutX)\n  .attr(\"y\", calloutY)\n  .attr(\"text-anchor\", \"start\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"15px\")\n  .style(\"font-weight\", \"600\")\n  .text(`Dominant cluster · ${peakCell.count} cycles`);\n\n// --- Axes -----------------------------------------------------------------\nconst xAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).ticks(6));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(6));\nfor (const axisG of [xAxis, yAxis]) {\n  axisG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"15px\");\n  axisG.selectAll(\"line\").attr(\"stroke\", t.grid);\n  axisG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\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\", \"16px\")\n  .text(\"Mean Stress (MPa)\");\n\ng.append(\"text\")\n  .attr(\"transform\", `translate(${-95},${ih / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Stress Amplitude (MPa)\");\n\n// --- Colorbar legend (cycle count) -----------------------------------------\nconst legendW = 24;\nconst legendH = ih;\nconst legendX = iw + 55;\n\nconst gradientId = \"rainflow-count-gradient\";\nconst gradient = svg\n  .append(\"defs\")\n  .append(\"linearGradient\")\n  .attr(\"id\", gradientId)\n  .attr(\"x1\", \"0%\")\n  .attr(\"y1\", \"100%\")\n  .attr(\"x2\", \"0%\")\n  .attr(\"y2\", \"0%\");\ngradient.append(\"stop\").attr(\"offset\", \"0%\").attr(\"stop-color\", t.seq[0]);\ngradient.append(\"stop\").attr(\"offset\", \"100%\").attr(\"stop-color\", t.seq[1]);\n\ng.append(\"rect\")\n  .attr(\"x\", legendX)\n  .attr(\"y\", 0)\n  .attr(\"width\", legendW)\n  .attr(\"height\", legendH)\n  .attr(\"fill\", `url(#${gradientId})`)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\n\n// Same sqrt transform as the cell fill, so ticks land at the pixel position\n// that matches each value's actual displayed color.\nconst legendScale = d3.scaleSqrt().domain([0, maxCount]).range([legendH, 0]);\nconst legendAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(${legendX + legendW},0)`)\n  .call(d3.axisRight(legendScale).ticks(5).tickFormat(d3.format(\",.0f\")));\nlegendAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"15px\");\nlegendAxis.selectAll(\"line\").attr(\"stroke\", t.grid);\nlegendAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\ng.append(\"text\")\n  .attr(\"transform\", `translate(${legendX + legendW + 60},${legendH / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"Cycle count\");\n\n// --- Title ------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 46)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"26px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"heatmap-rainflow · javascript · d3 · anyplot.ai\");\n"}