{"spec_id":"heatmap-rainflow","library":"muix","language":"javascript","code":"// anyplot.ai\n// heatmap-rainflow: Rainflow Counting Matrix for Fatigue Analysis\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-08-25\nimport { ScatterChart } from \"@mui/x-charts/ScatterChart\";\nimport { ContinuousColorLegend } from \"@mui/x-charts/ChartsLegend\";\nimport Box from \"@mui/material/Box\";\nimport Typography from \"@mui/material/Typography\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// Deterministic LCG (seed 42) — no Math.random() in the browser harness\nfunction makeLcg(seed) {\n  let state = seed >>> 0;\n  return () => {\n    state = (Math.imul(state, 1664525) + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rng = makeLcg(42);\n\n// --- Data: synthetic wind-turbine blade-root flapwise bending moment ------\n// A slow operating-condition trend (gusts, pitch changes) carries fast\n// turbulent fluctuations. Rainflow counting turns this load history into a\n// 2D matrix of cycle amplitude vs. cycle mean — the standard input for\n// fatigue-life (Miner's rule / S-N curve) calculations.\nconst SAMPLE_COUNT = 6000;\nconst loadSignal = [];\nfor (let i = 0; i < SAMPLE_COUNT; i += 1) {\n  const trend = 620 + 180 * Math.sin((2 * Math.PI * i) / 900) + 90 * Math.sin((2 * Math.PI * i) / 2600 + 1.1);\n  const turbulence =\n    140 * Math.sin((2 * Math.PI * i) / 41) +\n    70 * Math.sin((2 * Math.PI * i) / 17 + 0.6) +\n    40 * Math.sin((2 * Math.PI * i) / 6.3 + 2.4) +\n    50 * (rng() - 0.5);\n  loadSignal.push(trend + turbulence);\n}\n\n// Reduce the signal to its turning points (local peaks/valleys) — rainflow\n// counting only operates on reversals, not every sample.\nfunction findTurningPoints(series) {\n  const points = [series[0]];\n  for (let i = 1; i < series.length - 1; i += 1) {\n    const risingIn = series[i] > series[i - 1];\n    const risingOut = series[i + 1] > series[i];\n    if (risingIn !== risingOut) points.push(series[i]);\n  }\n  points.push(series[series.length - 1]);\n  return points;\n}\n\n// ASTM E1049 three-point rainflow counting: extract a closed cycle whenever\n// the innermost range is no larger than its neighbor, then collapse it out\n// of the stack. Full cycles get weight 1; the leftover residual sequence is\n// counted as half-cycles (weight 0.5), the standard convention.\nfunction countRainflowCycles(turningPoints) {\n  const stack = [];\n  const cycles = [];\n  turningPoints.forEach((point) => {\n    stack.push(point);\n    while (stack.length >= 3) {\n      const n = stack.length;\n      const innerRange = Math.abs(stack[n - 2] - stack[n - 3]);\n      const outerRange = Math.abs(stack[n - 1] - stack[n - 2]);\n      if (innerRange > outerRange) break;\n      cycles.push({ range: innerRange, mean: (stack[n - 2] + stack[n - 3]) / 2, weight: 1 });\n      stack.splice(n - 3, 2);\n    }\n  });\n  for (let i = 0; i < stack.length - 1; i += 1) {\n    cycles.push({ range: Math.abs(stack[i + 1] - stack[i]), mean: (stack[i + 1] + stack[i]) / 2, weight: 0.5 });\n  }\n  return cycles;\n}\n\nconst cycles = countRainflowCycles(findTurningPoints(loadSignal));\nconst amplitudes = cycles.map((cycle) => cycle.range / 2);\nconst means = cycles.map((cycle) => cycle.mean);\nconst amplitudeMax = Math.max(...amplitudes);\nconst meanMin = Math.min(...means);\nconst meanMax = Math.max(...means);\n\n// Bin into a 20x20 amplitude-by-mean matrix (typical rainflow matrix size).\nconst BIN_COUNT = 20;\nconst amplitudeBinWidth = amplitudeMax / BIN_COUNT;\nconst meanBinWidth = (meanMax - meanMin) / BIN_COUNT;\n\nconst binWeights = new Map();\ncycles.forEach((cycle, i) => {\n  const amplitudeIndex = Math.min(BIN_COUNT - 1, Math.floor(amplitudes[i] / amplitudeBinWidth));\n  const meanIndex = Math.min(BIN_COUNT - 1, Math.floor((cycle.mean - meanMin) / meanBinWidth));\n  const key = `${amplitudeIndex}-${meanIndex}`;\n  binWeights.set(key, (binWeights.get(key) ?? 0) + cycle.weight);\n});\n\n// Zero-count bins are simply never added, so they stay fully transparent —\n// the page background shows through instead of a drawn (misleadingly\n// \"zero-but-colored\") cell.\nlet cycleCountMax = 0;\nlet cycleCountMin = Infinity;\nlet peakBinKey = null;\nconst matrixPoints = [];\nbinWeights.forEach((weight, key) => {\n  const [amplitudeIndex, meanIndex] = key.split(\"-\").map(Number);\n  const count = Math.round(weight);\n  if (count <= 0) return;\n  if (count > cycleCountMax) {\n    cycleCountMax = count;\n    peakBinKey = key;\n  }\n  cycleCountMin = Math.min(cycleCountMin, count);\n  matrixPoints.push({\n    id: key,\n    x: meanMin + (meanIndex + 0.5) * meanBinWidth,\n    y: (amplitudeIndex + 0.5) * amplitudeBinWidth,\n    z: Math.log1p(count),\n  });\n});\n// Color on a log scale — rainflow matrices are heavily right-skewed (many\n// low-count bins, a few dominant ones), so log contrast reads much better\n// than linear. The domain (and legend) bottom anchors on the true rendered\n// minimum (count=1's log1p), not log1p(0), since zero-count bins are never\n// drawn at all — anchoring at 0 would understate how saturated the palest\n// rendered cell actually is.\nconst colorDomainMin = Math.log1p(cycleCountMin);\nconst colorDomainMax = Math.log1p(cycleCountMax);\n\n// Community @mui/x-charts has no Heatmap component (that's Pro-only) — a\n// ScatterChart with a custom rect marker, sized from the bin width via the\n// underlying linear scales, reproduces a true tiled heatmap grid instead of\n// circular bubbles.\nfunction MatrixCell(props) {\n  const { series, xScale, yScale, colorGetter, color } = props;\n  const cellWidth = Math.abs(xScale(meanBinWidth) - xScale(0));\n  const cellHeight = Math.abs(yScale(amplitudeBinWidth) - yScale(0));\n\n  return (\n    <g>\n      {series.data.map((point, i) => {\n        // Outline the single dominant cell — the spec calls out \"identifying\n        // dominant cycle combinations\" as a use case, so the peak bin gets a\n        // visible focal point instead of blending into the ramp.\n        const isPeak = point.id === peakBinKey;\n        return (\n          <rect\n            key={point.id}\n            x={xScale(point.x) - cellWidth / 2}\n            y={yScale(point.y) - cellHeight / 2}\n            width={Math.max(cellWidth - 2, 0)}\n            height={Math.max(cellHeight - 2, 0)}\n            fill={colorGetter ? colorGetter(i) : color}\n            stroke={isPeak ? t.ink : \"none\"}\n            strokeWidth={isPeak ? 3 : 0}\n          />\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) -----------\nexport default function Chart() {\n  const { width, height } = window.ANYPLOT_SIZE;\n  const titleHeight = 60;\n  const legendEdgeBuffer = 28; // ContinuousColorLegend right-aligns to the\n  // chart's own width, not the margin box — trim the chart width so the\n  // legend's max-value label doesn't sit flush against the canvas edge.\n  const chartWidth = width - legendEdgeBuffer;\n\n  return (\n    <Box sx={{ width, height, bgcolor: t.pageBg, display: \"flex\", flexDirection: \"column\" }}>\n      <Typography\n        sx={{\n          color: t.ink,\n          fontSize: 26,\n          fontWeight: 600,\n          textAlign: \"center\",\n          lineHeight: 1.2,\n          pt: \"18px\",\n          height: titleHeight,\n          fontFamily: \"inherit\",\n        }}\n      >\n        heatmap-rainflow · javascript · muix · anyplot.ai\n      </Typography>\n      <ScatterChart\n        width={chartWidth}\n        height={height - titleHeight}\n        series={[\n          {\n            id: \"rainflow-matrix\",\n            type: \"scatter\",\n            data: matrixPoints,\n            label: \"Cycle count\",\n            zAxisId: \"count\",\n          },\n        ]}\n        xAxis={[\n          {\n            scaleType: \"linear\",\n            min: meanMin,\n            max: meanMax,\n            label: \"Cycle mean, blade-root flapwise moment (kN·m)\",\n            tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n            labelStyle: { fontSize: 16, fill: t.ink },\n          },\n        ]}\n        yAxis={[\n          {\n            scaleType: \"linear\",\n            min: 0,\n            max: amplitudeMax,\n            label: \"Cycle amplitude, half-range (kN·m)\",\n            // tickFontSize only drives the y-axis label's clearance from the\n            // tick text (it does not size the ticks themselves, that's\n            // tickLabelStyle below) — bump it so the rotated label doesn't\n            // sit on top of wide 3-4 digit tick numbers.\n            tickFontSize: 42,\n            tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n            labelStyle: { fontSize: 16, fill: t.ink },\n          },\n        ]}\n        zAxis={[\n          {\n            id: \"count\",\n            min: colorDomainMin,\n            max: colorDomainMax,\n            colorMap: {\n              type: \"continuous\",\n              min: colorDomainMin,\n              max: colorDomainMax,\n              color: [t.seq[0], t.seq[1]],\n            },\n          },\n        ]}\n        margin={{ top: 24, right: 150, bottom: 88, left: 130 }}\n        slots={{ scatter: MatrixCell }}\n        slotProps={{ legend: { hidden: true } }}\n        skipAnimation\n      >\n        <ContinuousColorLegend\n          axisId=\"count\"\n          axisDirection=\"z\"\n          direction=\"column\"\n          position={{ horizontal: \"right\", vertical: \"middle\" }}\n          length=\"55%\"\n          thickness={16}\n          minLabel={`${cycleCountMin}`}\n          maxLabel={`${cycleCountMax}`}\n          labelStyle={{ fontSize: 14, fill: t.inkSoft, fontFamily: \"inherit\" }}\n        />\n      </ScatterChart>\n    </Box>\n  );\n}\n"}