Rainflow Counting Matrix for Fatigue Analysis — Chart.js

A rainflow counting matrix visualizes the results of rainflow cycle counting from a load or stress time history. The matrix displays cycle counts as a 2D heatmap where one axis represents cycle amplitude (half-range), the other represents cycle mean value, and color intensity represents the frequency of each cycle combination. This is a fundamental tool in fatigue analysis and durability engineering, used to characterize variable-amplitude loading for fatigue life prediction.

Rainflow Counting Matrix for Fatigue Analysis rendered with Chart.js

Renders

JavaScript source (Chart.js)

// anyplot.ai
// heatmap-rainflow: Rainflow Counting Matrix for Fatigue Analysis
// Library: chartjs 4.4.7 | JavaScript 22.23.2
// Quality: 88/100 | Created: 2026-08-25
//# anyplot-orientation: square

const t = window.ANYPLOT_TOKENS;

// --- Data: synthetic turbine blade root bending-moment load history --------
// Deterministic LCG — the browser has no seeded RNG.
function lcg(seed) {
  let s = seed;
  return () => {
    s = (s * 1664525 + 1013904223) % 4294967296;
    return s / 4294967296;
  };
}
const rand = lcg(42);

const N_SAMPLES = 4000;
const signal = new Array(N_SAMPLES);
for (let i = 0; i < N_SAMPLES; i++) {
  const tt = i / 40;
  const gust = rand() < 0.01 ? (rand() - 0.5) * 90 : 0;
  signal[i] =
    60 * Math.sin(tt * 0.31) +
    25 * Math.sin(tt * 1.7 + 1.1) +
    12 * Math.sin(tt * 4.3 + 0.4) +
    18 * (rand() - 0.5) +
    gust;
}

// Turning points (local extrema) — rainflow counting operates on peaks/valleys.
const turningPoints = [signal[0]];
for (let i = 1; i < N_SAMPLES - 1; i++) {
  const prev = signal[i - 1];
  const cur = signal[i];
  const next = signal[i + 1];
  if ((cur - prev) * (next - cur) < 0) turningPoints.push(cur);
}
turningPoints.push(signal[N_SAMPLES - 1]);

// --- Rainflow cycle counting (ASTM E1049 four-point stack algorithm) -------
const cycles = [];
const stack = [];
for (const point of turningPoints) {
  stack.push(point);
  while (stack.length >= 4) {
    const k = stack.length;
    const b = stack[k - 4];
    const c = stack[k - 3];
    const d = stack[k - 2];
    const e = stack[k - 1];
    const innerRange = Math.abs(d - c);
    const outerRange = Math.abs(e - d);
    if (outerRange >= innerRange) {
      cycles.push({ amplitude: innerRange / 2, mean: (c + d) / 2, weight: 1 });
      stack.splice(k - 3, 2);
    } else {
      break;
    }
  }
}
// Points left on the stack close as residual half-cycles.
for (let i = 0; i < stack.length - 1; i++) {
  const range = Math.abs(stack[i + 1] - stack[i]);
  cycles.push({
    amplitude: range / 2,
    mean: (stack[i] + stack[i + 1]) / 2,
    weight: 0.5,
  });
}

// --- Bin cycles into an amplitude x mean matrix -----------------------------
const N_BINS = 20;
const maxAmplitude = Math.max(...cycles.map((cy) => cy.amplitude));
const meanMin = Math.min(...cycles.map((cy) => cy.mean));
const meanMax = Math.max(...cycles.map((cy) => cy.mean));
const ampStep = maxAmplitude / N_BINS;
const meanStep = (meanMax - meanMin) / N_BINS;

const matrix = Array.from({ length: N_BINS }, () => new Array(N_BINS).fill(0));
for (const { amplitude, mean, weight } of cycles) {
  const ampIdx = Math.min(N_BINS - 1, Math.floor(amplitude / ampStep));
  const meanIdx = Math.min(N_BINS - 1, Math.floor((mean - meanMin) / meanStep));
  matrix[ampIdx][meanIdx] += weight;
}

const ampLabels = Array.from({ length: N_BINS }, (_, i) =>
  String(Math.round((i + 0.5) * ampStep)),
);
const meanLabels = Array.from({ length: N_BINS }, (_, i) =>
  String(Math.round(meanMin + (i + 0.5) * meanStep)),
);

const maxCount = Math.max(...matrix.flat());
const maxLog = Math.log10(maxCount + 1);
let maxAmpIdx = 0;
let maxMeanIdx = 0;
for (let ampIdx = 0; ampIdx < N_BINS; ampIdx++) {
  for (let meanIdx = 0; meanIdx < N_BINS; meanIdx++) {
    if (matrix[ampIdx][meanIdx] === maxCount) {
      maxAmpIdx = ampIdx;
      maxMeanIdx = meanIdx;
    }
  }
}

// --- Color helpers (Imprint imprint_seq, single-polarity) ------------------
function hexToRgb(hex) {
  const num = parseInt(hex.replace("#", ""), 16);
  return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
}
function lerp(a, b, f) {
  return Math.round(a + (b - a) * f);
}
function sequentialRgb(frac) {
  const [lo, hi] = t.seq;
  const a = hexToRgb(lo);
  const b = hexToRgb(hi);
  return [lerp(a[0], b[0], frac), lerp(a[1], b[1], frac), lerp(a[2], b[2], frac)];
}
function rgbToCss(rgb) {
  return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
}

// --- Mount -------------------------------------------------------------------
const canvas = document.createElement("canvas");
document.getElementById("container").appendChild(canvas);

const cellData = [];
for (let ampIdx = 0; ampIdx < N_BINS; ampIdx++) {
  for (let meanIdx = 0; meanIdx < N_BINS; meanIdx++) {
    cellData.push({ x: meanIdx, y: ampIdx, count: matrix[ampIdx][meanIdx] });
  }
}

// --- Chart --------------------------------------------------------------
// Chart.js has no native matrix/heatmap type; a scatter chart supplies
// properly scaled category axes while a custom plugin paints the cells and
// colorbar directly on the canvas. Zero-count bins are left unpainted so
// they blend into the page background, per the spec's "visually distinct"
// note for empty amplitude-mean combinations.
const heatmapPlugin = {
  id: "rainflowHeatmap",
  afterDraw(chart) {
    const { ctx, chartArea, scales } = chart;
    const xScale = scales.x;
    const yScale = scales.y;
    const cellW = xScale.getPixelForValue(1) - xScale.getPixelForValue(0);
    const cellH = yScale.getPixelForValue(1) - yScale.getPixelForValue(0);

    const cellRadius = Math.min(4, Math.abs(cellH) / 4, cellW / 4);

    ctx.save();
    for (const { x: col, y: row, count } of cellData) {
      const cx = xScale.getPixelForValue(col);
      const cy = yScale.getPixelForValue(row);
      ctx.fillStyle =
        count > 0
          ? rgbToCss(sequentialRgb(Math.log10(count + 1) / maxLog))
          : t.pageBg;
      ctx.beginPath();
      ctx.roundRect(
        cx - cellW / 2,
        cy - Math.abs(cellH) / 2,
        cellW,
        Math.abs(cellH),
        cellRadius,
      );
      ctx.fill();
    }
    ctx.strokeStyle = t.pageBg;
    ctx.lineWidth = 1;
    for (const { x: col, y: row } of cellData) {
      const cx = xScale.getPixelForValue(col);
      const cy = yScale.getPixelForValue(row);
      ctx.beginPath();
      ctx.roundRect(
        cx - cellW / 2,
        cy - Math.abs(cellH) / 2,
        cellW,
        Math.abs(cellH),
        cellRadius,
      );
      ctx.stroke();
    }

    // Highlight the peak cell — the single most-populated amplitude-mean
    // combination, i.e. the dominant fatigue-damage contributor.
    const peakCx = xScale.getPixelForValue(maxMeanIdx);
    const peakCy = yScale.getPixelForValue(maxAmpIdx);
    ctx.strokeStyle = t.ink;
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.roundRect(
      peakCx - cellW / 2 + 1,
      peakCy - Math.abs(cellH) / 2 + 1,
      cellW - 2,
      Math.abs(cellH) - 2,
      cellRadius,
    );
    ctx.stroke();

    // --- Colorbar (log-scaled cycle count) ----------------------------------
    const barW = 34;
    const barX = chartArea.right + 60;
    const barTop = chartArea.top;
    const barBottom = chartArea.bottom;

    const gradient = ctx.createLinearGradient(0, barTop, 0, barBottom);
    const STOPS = 12;
    for (let s = 0; s <= STOPS; s++) {
      gradient.addColorStop(s / STOPS, rgbToCss(sequentialRgb(1 - s / STOPS)));
    }
    const barRadius = 6;
    ctx.fillStyle = gradient;
    ctx.beginPath();
    ctx.roundRect(barX, barTop, barW, barBottom - barTop, barRadius);
    ctx.fill();
    ctx.strokeStyle = t.inkSoft;
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.roundRect(barX, barTop, barW, barBottom - barTop, barRadius);
    ctx.stroke();

    ctx.font = "16px sans-serif";
    ctx.fillStyle = t.inkSoft;
    ctx.textAlign = "left";
    ctx.textBaseline = "middle";
    for (const frac of [0, 1 / 3, 2 / 3, 1]) {
      const value = Math.round(10 ** (frac * maxLog) - 1);
      const y = barBottom - frac * (barBottom - barTop);
      ctx.fillText(String(value), barX + barW + 10, y);
    }

    ctx.save();
    ctx.translate(barX + barW + 58, (barTop + barBottom) / 2);
    ctx.rotate(-Math.PI / 2);
    ctx.textAlign = "center";
    ctx.font = "18px sans-serif";
    ctx.fillStyle = t.ink;
    ctx.fillText("Cycle Count (log scale)", 0, 0);
    ctx.restore();

    ctx.restore();
  },
};

new Chart(canvas, {
  type: "scatter",
  data: {
    datasets: [
      {
        data: [
          { x: 0, y: 0 },
          { x: N_BINS - 1, y: N_BINS - 1 },
        ],
        pointStyle: false,
        showLine: false,
      },
    ],
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    animation: false,
    layout: {
      padding: { top: 10, right: 210, bottom: 10, left: 10 },
    },
    plugins: {
      title: {
        display: true,
        text: "Turbine Blade Load Spectrum · heatmap-rainflow · javascript · chartjs · anyplot.ai",
        color: t.ink,
        font: { size: 18 },
        padding: { bottom: 24 },
      },
      legend: { display: false },
      tooltip: { enabled: false },
    },
    scales: {
      x: {
        type: "category",
        labels: meanLabels,
        bounds: "ticks",
        offset: true,
        position: "bottom",
        grid: { display: false, drawTicks: false },
        border: { display: false },
        ticks: { color: t.inkSoft, font: { size: 14 }, autoSkip: true, maxTicksLimit: 10 },
        title: {
          display: true,
          text: "Cycle Mean (MPa)",
          color: t.ink,
          font: { size: 16 },
        },
      },
      y: {
        type: "category",
        labels: ampLabels,
        bounds: "ticks",
        offset: true,
        reverse: true,
        grid: { display: false, drawTicks: false },
        border: { display: false },
        ticks: { color: t.inkSoft, font: { size: 14 }, autoSkip: true, maxTicksLimit: 10 },
        title: {
          display: true,
          text: "Cycle Amplitude (MPa)",
          color: t.ink,
          font: { size: 16 },
        },
      },
    },
  },
  plugins: [heatmapPlugin],
});

Part of Rainflow Counting Matrix for Fatigue Analysis on anyplot.ai.

Other implementations