{"spec_id":"heatmap-rainflow","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// heatmap-rainflow: Rainflow Counting Matrix for Fatigue Analysis\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-08-25\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: synthetic turbine blade root bending-moment load history --------\n// Deterministic LCG — the browser has no seeded RNG.\nfunction lcg(seed) {\n  let s = seed;\n  return () => {\n    s = (s * 1664525 + 1013904223) % 4294967296;\n    return s / 4294967296;\n  };\n}\nconst rand = lcg(42);\n\nconst N_SAMPLES = 4000;\nconst signal = new Array(N_SAMPLES);\nfor (let i = 0; i < N_SAMPLES; i++) {\n  const tt = i / 40;\n  const gust = rand() < 0.01 ? (rand() - 0.5) * 90 : 0;\n  signal[i] =\n    60 * Math.sin(tt * 0.31) +\n    25 * Math.sin(tt * 1.7 + 1.1) +\n    12 * Math.sin(tt * 4.3 + 0.4) +\n    18 * (rand() - 0.5) +\n    gust;\n}\n\n// Turning points (local extrema) — rainflow counting operates on peaks/valleys.\nconst turningPoints = [signal[0]];\nfor (let i = 1; i < N_SAMPLES - 1; i++) {\n  const prev = signal[i - 1];\n  const cur = signal[i];\n  const next = signal[i + 1];\n  if ((cur - prev) * (next - cur) < 0) turningPoints.push(cur);\n}\nturningPoints.push(signal[N_SAMPLES - 1]);\n\n// --- Rainflow cycle counting (ASTM E1049 four-point stack algorithm) -------\nconst cycles = [];\nconst stack = [];\nfor (const point of turningPoints) {\n  stack.push(point);\n  while (stack.length >= 4) {\n    const k = stack.length;\n    const b = stack[k - 4];\n    const c = stack[k - 3];\n    const d = stack[k - 2];\n    const e = stack[k - 1];\n    const innerRange = Math.abs(d - c);\n    const outerRange = Math.abs(e - d);\n    if (outerRange >= innerRange) {\n      cycles.push({ amplitude: innerRange / 2, mean: (c + d) / 2, weight: 1 });\n      stack.splice(k - 3, 2);\n    } else {\n      break;\n    }\n  }\n}\n// Points left on the stack close as residual half-cycles.\nfor (let i = 0; i < stack.length - 1; i++) {\n  const range = Math.abs(stack[i + 1] - stack[i]);\n  cycles.push({\n    amplitude: range / 2,\n    mean: (stack[i] + stack[i + 1]) / 2,\n    weight: 0.5,\n  });\n}\n\n// --- Bin cycles into an amplitude x mean matrix -----------------------------\nconst N_BINS = 20;\nconst maxAmplitude = Math.max(...cycles.map((cy) => cy.amplitude));\nconst meanMin = Math.min(...cycles.map((cy) => cy.mean));\nconst meanMax = Math.max(...cycles.map((cy) => cy.mean));\nconst ampStep = maxAmplitude / N_BINS;\nconst meanStep = (meanMax - meanMin) / N_BINS;\n\nconst matrix = Array.from({ length: N_BINS }, () => new Array(N_BINS).fill(0));\nfor (const { amplitude, mean, weight } of cycles) {\n  const ampIdx = Math.min(N_BINS - 1, Math.floor(amplitude / ampStep));\n  const meanIdx = Math.min(N_BINS - 1, Math.floor((mean - meanMin) / meanStep));\n  matrix[ampIdx][meanIdx] += weight;\n}\n\nconst ampLabels = Array.from({ length: N_BINS }, (_, i) =>\n  String(Math.round((i + 0.5) * ampStep)),\n);\nconst meanLabels = Array.from({ length: N_BINS }, (_, i) =>\n  String(Math.round(meanMin + (i + 0.5) * meanStep)),\n);\n\nconst maxCount = Math.max(...matrix.flat());\nconst maxLog = Math.log10(maxCount + 1);\nlet maxAmpIdx = 0;\nlet maxMeanIdx = 0;\nfor (let ampIdx = 0; ampIdx < N_BINS; ampIdx++) {\n  for (let meanIdx = 0; meanIdx < N_BINS; meanIdx++) {\n    if (matrix[ampIdx][meanIdx] === maxCount) {\n      maxAmpIdx = ampIdx;\n      maxMeanIdx = meanIdx;\n    }\n  }\n}\n\n// --- Color helpers (Imprint imprint_seq, single-polarity) ------------------\nfunction hexToRgb(hex) {\n  const num = parseInt(hex.replace(\"#\", \"\"), 16);\n  return [(num >> 16) & 255, (num >> 8) & 255, num & 255];\n}\nfunction lerp(a, b, f) {\n  return Math.round(a + (b - a) * f);\n}\nfunction sequentialRgb(frac) {\n  const [lo, hi] = t.seq;\n  const a = hexToRgb(lo);\n  const b = hexToRgb(hi);\n  return [lerp(a[0], b[0], frac), lerp(a[1], b[1], frac), lerp(a[2], b[2], frac)];\n}\nfunction rgbToCss(rgb) {\n  return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;\n}\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\nconst cellData = [];\nfor (let ampIdx = 0; ampIdx < N_BINS; ampIdx++) {\n  for (let meanIdx = 0; meanIdx < N_BINS; meanIdx++) {\n    cellData.push({ x: meanIdx, y: ampIdx, count: matrix[ampIdx][meanIdx] });\n  }\n}\n\n// --- Chart --------------------------------------------------------------\n// Chart.js has no native matrix/heatmap type; a scatter chart supplies\n// properly scaled category axes while a custom plugin paints the cells and\n// colorbar directly on the canvas. Zero-count bins are left unpainted so\n// they blend into the page background, per the spec's \"visually distinct\"\n// note for empty amplitude-mean combinations.\nconst heatmapPlugin = {\n  id: \"rainflowHeatmap\",\n  afterDraw(chart) {\n    const { ctx, chartArea, scales } = chart;\n    const xScale = scales.x;\n    const yScale = scales.y;\n    const cellW = xScale.getPixelForValue(1) - xScale.getPixelForValue(0);\n    const cellH = yScale.getPixelForValue(1) - yScale.getPixelForValue(0);\n\n    const cellRadius = Math.min(4, Math.abs(cellH) / 4, cellW / 4);\n\n    ctx.save();\n    for (const { x: col, y: row, count } of cellData) {\n      const cx = xScale.getPixelForValue(col);\n      const cy = yScale.getPixelForValue(row);\n      ctx.fillStyle =\n        count > 0\n          ? rgbToCss(sequentialRgb(Math.log10(count + 1) / maxLog))\n          : t.pageBg;\n      ctx.beginPath();\n      ctx.roundRect(\n        cx - cellW / 2,\n        cy - Math.abs(cellH) / 2,\n        cellW,\n        Math.abs(cellH),\n        cellRadius,\n      );\n      ctx.fill();\n    }\n    ctx.strokeStyle = t.pageBg;\n    ctx.lineWidth = 1;\n    for (const { x: col, y: row } of cellData) {\n      const cx = xScale.getPixelForValue(col);\n      const cy = yScale.getPixelForValue(row);\n      ctx.beginPath();\n      ctx.roundRect(\n        cx - cellW / 2,\n        cy - Math.abs(cellH) / 2,\n        cellW,\n        Math.abs(cellH),\n        cellRadius,\n      );\n      ctx.stroke();\n    }\n\n    // Highlight the peak cell — the single most-populated amplitude-mean\n    // combination, i.e. the dominant fatigue-damage contributor.\n    const peakCx = xScale.getPixelForValue(maxMeanIdx);\n    const peakCy = yScale.getPixelForValue(maxAmpIdx);\n    ctx.strokeStyle = t.ink;\n    ctx.lineWidth = 2;\n    ctx.beginPath();\n    ctx.roundRect(\n      peakCx - cellW / 2 + 1,\n      peakCy - Math.abs(cellH) / 2 + 1,\n      cellW - 2,\n      Math.abs(cellH) - 2,\n      cellRadius,\n    );\n    ctx.stroke();\n\n    // --- Colorbar (log-scaled cycle count) ----------------------------------\n    const barW = 34;\n    const barX = chartArea.right + 60;\n    const barTop = chartArea.top;\n    const barBottom = chartArea.bottom;\n\n    const gradient = ctx.createLinearGradient(0, barTop, 0, barBottom);\n    const STOPS = 12;\n    for (let s = 0; s <= STOPS; s++) {\n      gradient.addColorStop(s / STOPS, rgbToCss(sequentialRgb(1 - s / STOPS)));\n    }\n    const barRadius = 6;\n    ctx.fillStyle = gradient;\n    ctx.beginPath();\n    ctx.roundRect(barX, barTop, barW, barBottom - barTop, barRadius);\n    ctx.fill();\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 1.5;\n    ctx.beginPath();\n    ctx.roundRect(barX, barTop, barW, barBottom - barTop, barRadius);\n    ctx.stroke();\n\n    ctx.font = \"16px sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    for (const frac of [0, 1 / 3, 2 / 3, 1]) {\n      const value = Math.round(10 ** (frac * maxLog) - 1);\n      const y = barBottom - frac * (barBottom - barTop);\n      ctx.fillText(String(value), barX + barW + 10, y);\n    }\n\n    ctx.save();\n    ctx.translate(barX + barW + 58, (barTop + barBottom) / 2);\n    ctx.rotate(-Math.PI / 2);\n    ctx.textAlign = \"center\";\n    ctx.font = \"18px sans-serif\";\n    ctx.fillStyle = t.ink;\n    ctx.fillText(\"Cycle Count (log scale)\", 0, 0);\n    ctx.restore();\n\n    ctx.restore();\n  },\n};\n\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      {\n        data: [\n          { x: 0, y: 0 },\n          { x: N_BINS - 1, y: N_BINS - 1 },\n        ],\n        pointStyle: false,\n        showLine: false,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: {\n      padding: { top: 10, right: 210, bottom: 10, left: 10 },\n    },\n    plugins: {\n      title: {\n        display: true,\n        text: \"Turbine Blade Load Spectrum · heatmap-rainflow · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 18 },\n        padding: { bottom: 24 },\n      },\n      legend: { display: false },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: {\n        type: \"category\",\n        labels: meanLabels,\n        bounds: \"ticks\",\n        offset: true,\n        position: \"bottom\",\n        grid: { display: false, drawTicks: false },\n        border: { display: false },\n        ticks: { color: t.inkSoft, font: { size: 14 }, autoSkip: true, maxTicksLimit: 10 },\n        title: {\n          display: true,\n          text: \"Cycle Mean (MPa)\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n      y: {\n        type: \"category\",\n        labels: ampLabels,\n        bounds: \"ticks\",\n        offset: true,\n        reverse: true,\n        grid: { display: false, drawTicks: false },\n        border: { display: false },\n        ticks: { color: t.inkSoft, font: { size: 14 }, autoSkip: true, maxTicksLimit: 10 },\n        title: {\n          display: true,\n          text: \"Cycle Amplitude (MPa)\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n    },\n  },\n  plugins: [heatmapPlugin],\n});\n"}