{"spec_id":"heatmap-rainflow","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// heatmap-rainflow: Rainflow Counting Matrix for Fatigue Analysis\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-08-25\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: rainflow-counted suspension-strut load history -------------------\n// A synthetic variable-amplitude strut load (kN) is generated, its turning\n// points extracted, and a real ASTM-style four-point rainflow algorithm\n// extracts (amplitude, mean) cycles from it — the matrix below is genuine\n// counted output, not a hand-drawn distribution. The core Highcharts bundle\n// has no heatmap/colorAxis-mapping module loaded, so the binned matrix is\n// drawn cell-by-cell with the SVG renderer.\nlet seed = 20260825;\nfunction rand() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\n\nconst N_SAMPLES = 4000;\nconst signal = new Float64Array(N_SAMPLES);\nfor (let i = 0; i < N_SAMPLES; i++) {\n  const tt = i / 40;\n  // Slow envelope modulation mimics bursts of rough road between smooth stretches.\n  const envelope = 8 + 6 * Math.sin(tt / 25) ** 2;\n  const s1 = envelope * Math.sin(tt);\n  const s2 = 0.4 * envelope * Math.sin(tt * 2.7 + 0.6);\n  const s3 = 0.2 * envelope * Math.sin(tt * 5.3 + 1.1);\n  const noise = (rand() - 0.5) * 3;\n  signal[i] = 12 + s1 + s2 + s3 + noise; // 12 kN static preload\n}\n\n// Turning points: keep only local extrema (drop monotonic run interiors).\nfunction turningPoints(sig) {\n  const tp = [sig[0]];\n  for (let i = 1; i < sig.length - 1; i++) {\n    const d1 = sig[i] - sig[i - 1];\n    const d2 = sig[i + 1] - sig[i];\n    if ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) tp.push(sig[i]);\n  }\n  tp.push(sig[sig.length - 1]);\n  return tp;\n}\n\n// Four-point rainflow: a closed cycle is extracted whenever the middle range\n// of the last four points read is no larger than either flanking range. Full\n// cycles count as 1; the unresolved residual left on the stack is closed out\n// as half-cycles (standard rainflow practice for a finite-length signal).\nfunction rainflowCycles(points) {\n  const cycles = [];\n  const stack = [];\n  for (const p of points) {\n    stack.push(p);\n    while (stack.length >= 4) {\n      const n = stack.length;\n      const [a, b, c, d] = [stack[n - 4], stack[n - 3], stack[n - 2], stack[n - 1]];\n      const rangeAB = Math.abs(b - a);\n      const rangeBC = Math.abs(c - b);\n      const rangeCD = Math.abs(d - c);\n      if (rangeBC <= rangeAB && rangeBC <= rangeCD) {\n        cycles.push({ range: rangeBC, mean: (b + c) / 2, count: 1 });\n        stack.splice(n - 3, 2); // discard b, c — keep a, d for the next window\n      } else {\n        break;\n      }\n    }\n  }\n  for (let k = 0; k < stack.length - 1; k++) {\n    cycles.push({ range: Math.abs(stack[k + 1] - stack[k]), mean: (stack[k + 1] + stack[k]) / 2, count: 0.5 });\n  }\n  return cycles;\n}\n\nconst cycles = rainflowCycles(turningPoints(signal)).map((c) => ({ ...c, amplitude: c.range / 2 }));\n\n// --- Binning: amplitude x mean, ~20x20 (spec: 10x10-64x64 typical) ---------\nconst N_AMP = 20;\nconst N_MEAN = 20;\nlet maxAmp = 0;\nlet minMean = Infinity;\nlet maxMean = -Infinity;\ncycles.forEach((c) => {\n  if (c.amplitude > maxAmp) maxAmp = c.amplitude;\n  if (c.mean < minMean) minMean = c.mean;\n  if (c.mean > maxMean) maxMean = c.mean;\n});\nconst AMP_MAX = Math.ceil(maxAmp * 1.05 * 10) / 10;\nconst MEAN_MIN = Math.floor(minMean * 10) / 10;\nconst MEAN_MAX = Math.ceil(maxMean * 10) / 10;\nconst ampEdges = Array.from({ length: N_AMP + 1 }, (_, i) => (AMP_MAX * i) / N_AMP);\nconst meanEdges = Array.from({ length: N_MEAN + 1 }, (_, i) => MEAN_MIN + ((MEAN_MAX - MEAN_MIN) * i) / N_MEAN);\n\nconst matrix = Array.from({ length: N_AMP }, () => new Array(N_MEAN).fill(0));\nfunction binIndex(value, edges) {\n  const n = edges.length - 1;\n  for (let i = 0; i < n; i++) {\n    if (value >= edges[i] && (value < edges[i + 1] || i === n - 1)) return i;\n  }\n  return -1;\n}\nlet totalCycles = 0;\ncycles.forEach((c) => {\n  const row = binIndex(c.amplitude, ampEdges);\n  const col = binIndex(c.mean, meanEdges);\n  if (row >= 0 && col >= 0) {\n    matrix[row][col] += c.count;\n    totalCycles += c.count;\n  }\n});\n\nlet maxCount = 0;\nlet peakRow = 0;\nlet peakCol = 0;\nmatrix.forEach((row, r) =>\n  row.forEach((v, c) => {\n    if (v > maxCount) {\n      maxCount = v;\n      peakRow = r;\n      peakCol = c;\n    }\n  })\n);\nconst maxLog = Math.log10(maxCount + 1);\n\n// --- Color: imprint_seq on a log scale — cycle counts are heavily skewed ---\nfunction hexToRgb(hex) {\n  return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];\n}\nconst SEQ_LO = hexToRgb(t.seq[0]); // #009E73\nconst SEQ_HI = hexToRgb(t.seq[1]); // #4467A3\nfunction countFill(count) {\n  const f = Math.log10(count + 1) / maxLog;\n  const rgb = SEQ_LO.map((lo, i) => Math.round(lo + (SEQ_HI[i] - lo) * f));\n  return `rgb(${rgb.join(',')})`;\n}\n\n// --- Title (fontsize scaled off the 67-char baseline) ----------------------\nconst TITLE_TEXT = 'Suspension-Strut Rainflow Matrix · heatmap-rainflow · javascript · highcharts · anyplot.ai';\nconst TITLE_FS = Math.max(Math.round(22 * Math.min(1, 67 / TITLE_TEXT.length)), 14);\n\n// Fixed chart geometry (square canvas, harness-guaranteed 1200x1200 CSS px) —\n// single source of truth for the margin, the grid, and the invisible hover\n// layer below, so everything lines up without a runtime resync.\nconst CHART_MARGIN = [130, 195, 150, 125]; // [top, right, bottom, left]\nconst CELL_W = (window.ANYPLOT_SIZE.width - CHART_MARGIN[1] - CHART_MARGIN[3]) / N_MEAN;\nconst CELL_H = (window.ANYPLOT_SIZE.height - CHART_MARGIN[0] - CHART_MARGIN[2]) / N_AMP;\nconst MARKER_RADIUS = Math.max(Math.min(CELL_W, CELL_H) / 2 - 2, 3);\n\nconst drawn = [];\nfunction clearDrawn() {\n  drawn.forEach((el) => {\n    try {\n      el.destroy();\n    } catch (_err) {\n      // already removed\n    }\n  });\n  drawn.length = 0;\n}\n\n// Intentional: no heatmap/colorAxis module is loaded in the core bundle, so\n// drawAll() paints the matrix cell-by-cell via chart.renderer primitives\n// instead of a Highcharts series — a deliberate workaround, not an oversight.\nfunction drawAll() {\n  const chart = this;\n  clearDrawn();\n  const r = chart.renderer;\n\n  const cellW = chart.plotWidth / N_MEAN;\n  const cellH = chart.plotHeight / N_AMP;\n\n  // Grid cells — amplitude increases upward (row 0 = bottom), mean rightward.\n  for (let row = 0; row < N_AMP; row++) {\n    for (let col = 0; col < N_MEAN; col++) {\n      const count = matrix[row][col];\n      const x = chart.plotLeft + col * cellW;\n      const y = chart.plotTop + (N_AMP - 1 - row) * cellH;\n      const isZero = count <= 0;\n      drawn.push(\n        r\n          .rect(x + 0.5, y + 0.5, cellW - 1, cellH - 1, 1)\n          .attr({\n            fill: isZero ? 'transparent' : countFill(count),\n            stroke: isZero ? t.grid : 'none',\n            'stroke-width': isZero ? 1 : 0,\n            zIndex: 2,\n          })\n          .add()\n      );\n    }\n  }\n\n  // Explicit callout on the dominant high-count cluster (DE-03: sharpen the\n  // story instead of relying solely on implicit color/spatial pattern). The\n  // label is pushed past the last occupied row within +/-2 columns of the\n  // peak so it never lands on top of colored data (VQ-02: no overlap).\n  const peakX = chart.plotLeft + (peakCol + 0.5) * cellW;\n  const peakCellY = chart.plotTop + (N_AMP - 1 - peakRow) * cellH;\n  const labelAbove = peakRow < N_AMP / 2;\n  const neighborhoodClear = (row) => {\n    for (let col = Math.max(0, peakCol - 2); col <= Math.min(N_MEAN - 1, peakCol + 2); col++) {\n      if (matrix[row][col] > 0) return false;\n    }\n    return true;\n  };\n  let clearRow = peakRow;\n  if (labelAbove) {\n    while (clearRow + 1 < N_AMP && !neighborhoodClear(clearRow + 1)) clearRow++;\n  } else {\n    while (clearRow - 1 >= 0 && !neighborhoodClear(clearRow - 1)) clearRow--;\n  }\n  const boundaryY = labelAbove ? chart.plotTop + (N_AMP - 1 - clearRow) * cellH : chart.plotTop + (N_AMP - clearRow) * cellH;\n  const labelY = labelAbove ? Math.max(boundaryY - 24, chart.plotTop + 14) : Math.min(boundaryY + 24, chart.plotTop + chart.plotHeight - 6);\n  drawn.push(\n    r\n      .rect(chart.plotLeft + peakCol * cellW + 0.5, peakCellY + 0.5, cellW - 1, cellH - 1, 1)\n      .attr({ fill: 'none', stroke: t.ink, 'stroke-width': 2.5, zIndex: 3 })\n      .add()\n  );\n  drawn.push(\n    r\n      .path(['M', peakX, boundaryY, 'L', peakX, labelY + (labelAbove ? 12 : -12)])\n      .attr({ stroke: t.ink, 'stroke-width': 1.5, zIndex: 3 })\n      .add()\n  );\n  drawn.push(\n    r\n      .text(`Peak: ${Math.round(maxCount)} cycles`, peakX, labelY)\n      .attr({ align: 'center', zIndex: 3 })\n      .css({ color: t.ink, fontSize: '13px', fontWeight: '600' })\n      .add()\n  );\n\n  // Amplitude tick labels (left, every 4th bin edge) + rotated axis title.\n  for (let row = 0; row <= N_AMP; row += 4) {\n    const cy = chart.plotTop + (N_AMP - row) * cellH + 5;\n    drawn.push(\n      r\n        .text(ampEdges[row].toFixed(1), chart.plotLeft - 12, cy)\n        .attr({ align: 'right', zIndex: 2 })\n        .css({ color: t.inkSoft, fontSize: '14px' })\n        .add()\n    );\n  }\n  drawn.push(\n    r\n      .text('Cycle amplitude, half-range (kN)', 0, 0)\n      .attr({\n        align: 'center',\n        rotation: -90,\n        x: chart.plotLeft - 78,\n        y: chart.plotTop + chart.plotHeight / 2,\n        zIndex: 2,\n      })\n      .css({ color: t.ink, fontSize: '16px' })\n      .add()\n  );\n\n  // Mean tick labels (bottom, every 4th bin edge, rotated) + axis title.\n  for (let col = 0; col <= N_MEAN; col += 4) {\n    const cx = chart.plotLeft + col * cellW;\n    drawn.push(\n      r\n        .text(meanEdges[col].toFixed(1), cx, chart.plotTop + chart.plotHeight + 18)\n        .attr({ align: 'right', rotation: -40, zIndex: 2 })\n        .css({ color: t.inkSoft, fontSize: '14px' })\n        .add()\n    );\n  }\n  drawn.push(\n    r\n      .text('Cycle mean load (kN)', chart.plotLeft + chart.plotWidth / 2, chart.plotTop + chart.plotHeight + 90)\n      .attr({ align: 'center', zIndex: 2 })\n      .css({ color: t.ink, fontSize: '16px' })\n      .add()\n  );\n\n  // Log-scaled sequential colorbar in the freed right margin.\n  const barLeft = chart.plotLeft + chart.plotWidth + 45;\n  const barTop = chart.plotTop + 10;\n  const barWidth = 26;\n  const barHeight = chart.plotHeight - 20;\n  const segments = 50;\n  const segH = barHeight / segments;\n  for (let i = 0; i < segments; i++) {\n    const f = 1 - i / (segments - 1);\n    const count = Math.pow(10, f * maxLog) - 1;\n    drawn.push(\n      r\n        .rect(barLeft, barTop + i * segH, barWidth, segH + 0.5)\n        .attr({ fill: countFill(count), zIndex: 2 })\n        .add()\n    );\n  }\n  drawn.push(\n    r\n      .rect(barLeft, barTop, barWidth, barHeight)\n      .attr({ fill: 'none', stroke: t.inkSoft, 'stroke-width': 1, zIndex: 2 })\n      .add()\n  );\n  // Ticks at round powers of ten up to maxCount, plus the exact max.\n  const tickCounts = [1, 10, 100, 1000].filter((v) => v < maxCount);\n  tickCounts.push(maxCount);\n  tickCounts.forEach((count) => {\n    const frac = 1 - Math.log10(count + 1) / maxLog;\n    drawn.push(\n      r\n        .text(count >= 10 ? Math.round(count).toLocaleString() : count.toFixed(1), barLeft + barWidth + 10, barTop + frac * barHeight + 5)\n        .attr({ align: 'left', zIndex: 2 })\n        .css({ color: t.inkSoft, fontSize: '13px' })\n        .add()\n    );\n  });\n  drawn.push(\n    r\n      .text('Cycle count (log scale)', barLeft, barTop - 16)\n      .attr({ align: 'left', zIndex: 2 })\n      .css({ color: t.inkSoft, fontSize: '14px', fontWeight: '500' })\n      .add()\n  );\n  drawn.push(\n    r\n      .rect(barLeft, barTop + barHeight + 10, barWidth, barWidth * 0.6)\n      .attr({ fill: 'transparent', stroke: t.grid, 'stroke-width': 1, zIndex: 2 })\n      .add()\n  );\n  drawn.push(\n    r\n      .text('0 cycles', barLeft + barWidth + 10, barTop + barHeight + 10 + barWidth * 0.35 + 4)\n      .attr({ align: 'left', zIndex: 2 })\n      .css({ color: t.inkSoft, fontSize: '13px' })\n      .add()\n  );\n}\n\n// Invisible scatter layer aligned to each drawn cell so hovering exposes a\n// real Highcharts tooltip — the core bundle has no heatmap/colorAxis module,\n// but a matched-axis scatter series recovers native hover interactivity\n// without disturbing the hand-drawn grid above it.\nconst cellPoints = [];\nfor (let row = 0; row < N_AMP; row++) {\n  for (let col = 0; col < N_MEAN; col++) {\n    cellPoints.push({\n      x: col + 0.5,\n      y: row + 0.5,\n      count: matrix[row][col],\n      ampLo: ampEdges[row],\n      ampHi: ampEdges[row + 1],\n      meanLo: meanEdges[col],\n      meanHi: meanEdges[col + 1],\n    });\n  }\n}\n\nHighcharts.chart('container', {\n  chart: {\n    backgroundColor: 'transparent',\n    animation: false,\n    style: { fontFamily: 'inherit' },\n    margin: CHART_MARGIN,\n    events: { load: drawAll, redraw: drawAll },\n  },\n  credits: { enabled: false },\n  title: {\n    text: TITLE_TEXT,\n    style: { color: t.ink, fontSize: TITLE_FS + 'px', fontWeight: '600' },\n  },\n  subtitle: {\n    text: `Rainflow-counted cycles from a simulated highway load history · ${Math.round(totalCycles)} cycles across ${N_AMP}×${N_MEAN} bins`,\n    style: { color: t.inkSoft, fontSize: '14px' },\n  },\n  xAxis: { visible: false, min: 0, max: N_MEAN },\n  yAxis: { visible: false, gridLineWidth: 0, min: 0, max: N_AMP },\n  legend: { enabled: false },\n  tooltip: {\n    enabled: true,\n    backgroundColor: t.elevatedBg,\n    borderColor: t.inkSoft,\n    borderRadius: 6,\n    style: { color: t.ink, fontSize: '13px' },\n    formatter: function () {\n      const p = this.point;\n      return (\n        `<b>amplitude ${p.ampLo.toFixed(1)}–${p.ampHi.toFixed(1)} kN</b><br/>` +\n        `mean ${p.meanLo.toFixed(1)}–${p.meanHi.toFixed(1)} kN<br/>` +\n        `${p.count.toFixed(1)} cycles`\n      );\n    },\n  },\n  plotOptions: {\n    series: { animation: false },\n    scatter: {\n      enableMouseTracking: true,\n      stickyTracking: false,\n      marker: {\n        enabled: true,\n        symbol: 'circle',\n        radius: MARKER_RADIUS,\n        fillColor: 'rgba(0,0,0,0.001)',\n        lineWidth: 0,\n        states: { hover: { enabled: false } },\n      },\n    },\n  },\n  series: [\n    {\n      type: 'scatter',\n      name: 'Cycle count',\n      data: cellPoints,\n    },\n  ],\n});\n"}