{"spec_id":"heatmap-rainflow","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nheatmap-rainflow: Rainflow Counting Matrix for Fatigue Analysis\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 88/100 | Updated: 2026-06-02\n\"\"\"\n\nimport math\nimport os\nimport sys\n\nimport numpy as np\n\n\n# Theme tokens — Imprint palette chrome\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint sequential colormap: brand-green → blue (single-polarity continuous data)\nN_CMAP = 16\nIMPRINT_SEQ = [\n    \"#{:02X}{:02X}{:02X}\".format(\n        round(0x00 + (0x44 - 0x00) * i / (N_CMAP - 1)),\n        round(0x9E + (0x67 - 0x9E) * i / (N_CMAP - 1)),\n        round(0x73 + (0xA3 - 0x73) * i / (N_CMAP - 1)),\n    )\n    for i in range(N_CMAP)\n]\n\n# This file shares its name with the pygal package; temporarily remove the\n# current directory from sys.path so the real package is found first.\nsys.path, _saved_path = sys.path[1:], sys.path[0]\nfrom pygal.graph.graph import Graph\nfrom pygal.style import Style\n\n\nsys.path.insert(0, _saved_path)\n\n\nclass RainflowHeatmap(Graph):\n    def __init__(self, *args, **kwargs):\n        self.matrix_data = kwargs.pop(\"matrix_data\", [])\n        self.row_labels = kwargs.pop(\"row_labels\", [])\n        self.col_labels = kwargs.pop(\"col_labels\", [])\n        self.colormap = kwargs.pop(\"colormap\", [])\n        self.vmax = kwargs.pop(\"vmax\", 1)\n        self.x_axis_title = kwargs.pop(\"x_axis_title\", \"\")\n        self.y_axis_title = kwargs.pop(\"y_axis_title\", \"\")\n        self.colorbar_title = kwargs.pop(\"colorbar_title\", \"\")\n        self.subtitle_text = kwargs.pop(\"subtitle_text\", \"\")\n        super().__init__(*args, **kwargs)\n\n    def _plot(self):\n        if not self.matrix_data:\n            return\n\n        cmap = self.colormap\n        log_max = math.log10(self.vmax + 1)\n\n        def color_at(t):\n            t = max(0.0, min(1.0, t))\n            pos = t * (len(cmap) - 1)\n            lo = int(pos)\n            hi = min(lo + 1, len(cmap) - 1)\n            f = pos - lo\n            c1, c2 = cmap[lo], cmap[hi]\n            rgb = tuple(int(int(c1[k : k + 2], 16) * (1 - f) + int(c2[k : k + 2], 16) * f) for k in (1, 3, 5))\n            return f\"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}\"\n\n        def log_norm(value):\n            if value <= 0:\n                return -1.0\n            return math.log10(value + 1) / log_max\n\n        def svg_text(parent, x, y, label, size, **kw):\n            node = self.svg.node(parent, \"text\", x=x, y=y)\n            node.set(\"text-anchor\", kw.get(\"anchor\", \"middle\"))\n            node.set(\"fill\", kw.get(\"fill\", INK))\n            weight = \"bold\" if kw.get(\"bold\") else \"normal\"\n            if kw.get(\"weight\"):\n                weight = kw[\"weight\"]\n            style_str = f\"font-size:{size}px;font-weight:{weight};font-family:sans-serif\"\n            if kw.get(\"letter_spacing\"):\n                style_str += f\";letter-spacing:{kw['letter_spacing']}px\"\n            node.set(\"style\", style_str)\n            if \"rotation\" in kw:\n                node.set(\"transform\", f\"rotate({kw['rotation']}, {x}, {y})\")\n            node.text = label\n\n        nr = len(self.matrix_data)\n        nc = len(self.matrix_data[0])\n        pw, ph = self.view.width, self.view.height\n\n        # Proportional margins within the view area\n        ml = int(pw * 0.09)\n        mr = int(pw * 0.09)\n        mt = int(ph * 0.02)\n        mb = int(ph * 0.08)\n\n        aw, ah = pw - ml - mr, ph - mt - mb\n        cw = aw / nc * 0.97\n        ch = ah / nr * 0.97\n        gap = min(cw, ch) * 0.015\n        gw = nc * (cw + gap) - gap\n        gh = nr * (ch + gap) - gap\n\n        x0 = self.view.x(0) + ml + (aw - gw) / 2\n        y0 = self.view.y(nr) + mt + (ah - gh) / 2\n\n        g = self.svg.node(self.nodes[\"plot\"], class_=\"heatmap\")\n\n        # Subtitle sits in the margin_top gap between pygal title and grid\n        if self.subtitle_text:\n            svg_text(\n                g,\n                pw / 2 + self.view.x(0),\n                y0 - 30,\n                self.subtitle_text,\n                44,\n                fill=INK_MUTED,\n                weight=\"300\",\n                letter_spacing=1,\n            )\n\n        # Elevated background panel behind heatmap grid\n        pad = 14\n        panel = self.svg.node(g, \"rect\", x=x0 - pad, y=y0 - pad, width=gw + 2 * pad, height=gh + 2 * pad, rx=6, ry=6)\n        panel.set(\"fill\", ELEVATED_BG)\n        panel.set(\"stroke\", INK_MUTED)\n        panel.set(\"stroke-width\", \"0.8\")\n        panel.set(\"stroke-opacity\", \"0.4\")\n\n        # Y-axis title (rotated 90°)\n        if self.y_axis_title:\n            svg_text(g, x0 - int(pw * 0.075), y0 + gh / 2, self.y_axis_title, 46, bold=True, fill=INK, rotation=-90)\n\n        # Row labels — every other to prevent crowding\n        rf = min(30, int(ch * 0.5))\n        for i, lbl in enumerate(self.row_labels):\n            if i % 2 == 0 or i == nr - 1:\n                svg_text(g, x0 - 16, y0 + i * (ch + gap) + ch / 2 + rf * 0.35, lbl, rf, anchor=\"end\", fill=INK_SOFT)\n\n        # Column labels — every 3rd for clean spacing\n        cf = min(30, int(cw * 0.45))\n        for j, lbl in enumerate(self.col_labels):\n            if j % 3 == 0 or j == nc - 1:\n                x = x0 + j * (cw + gap) + cw / 2\n                y = y0 + gh + gap + cf + 10\n                svg_text(g, x, y, lbl, cf, fill=INK_SOFT)\n\n        # X-axis title\n        if self.x_axis_title:\n            svg_text(g, x0 + gw / 2, y0 + gh + int(ph * 0.07), self.x_axis_title, 46, bold=True, fill=INK)\n\n        # Top 3 peak cells for emphasis and annotation\n        cell_values = [\n            (self.matrix_data[i][j], i, j) for i in range(nr) for j in range(nc) if self.matrix_data[i][j] > 0\n        ]\n        cell_values.sort(reverse=True)\n        top_peaks = {(i, j) for _, i, j in cell_values[:3]}\n        peak_cell = (cell_values[0][1], cell_values[0][2]) if cell_values else None\n\n        # Stagger pill vertical positions to prevent overlap when peaks share a row\n        _row_peak_cols: dict = {}\n        for _pi, _pj in top_peaks:\n            _row_peak_cols.setdefault(_pi, []).append(_pj)\n        _pill_v_off: dict = {}\n        _sv = 45.0  # stagger amount in SVG px\n        for _ri, _cs in _row_peak_cols.items():\n            _sorted = sorted(_cs)\n            _n = len(_sorted)\n            if _n == 1:\n                _pill_v_off[(_ri, _sorted[0])] = 0.0\n            elif _n == 2:\n                _pill_v_off[(_ri, _sorted[0])] = -_sv\n                _pill_v_off[(_ri, _sorted[1])] = _sv\n            else:\n                _pill_v_off[(_ri, _sorted[0])] = -_sv\n                _pill_v_off[(_ri, _sorted[1])] = 0.0\n                _pill_v_off[(_ri, _sorted[2])] = _sv\n\n        # Draw heatmap cells\n        for i in range(nr):\n            for j in range(nc):\n                val = self.matrix_data[i][j]\n                cx = x0 + j * (cw + gap)\n                cy = y0 + i * (ch + gap)\n                norm = log_norm(val)\n\n                fill = PAGE_BG if norm < 0 else color_at(norm)\n                stroke = INK_MUTED if norm < 0 else \"none\"\n                sw = \"0.5\"\n\n                # Amber glow halo on absolute peak cell — Imprint amber (#DDCC77)\n                if (i, j) == peak_cell:\n                    glow = self.svg.node(g, \"rect\", x=cx - 4, y=cy - 4, width=cw + 8, height=ch + 8, rx=5, ry=5)\n                    glow.set(\"fill\", \"none\")\n                    glow.set(\"stroke\", \"#DDCC77\")\n                    glow.set(\"stroke-width\", \"5\")\n                    glow.set(\"opacity\", \"0.7\")\n                    stroke = INK\n                    sw = \"2.5\"\n\n                rect = self.svg.node(g, \"rect\", x=cx, y=cy, width=cw, height=ch, rx=3, ry=3)\n                rect.set(\"fill\", fill)\n                rect.set(\"stroke\", stroke)\n                rect.set(\"stroke-width\", sw)\n\n                # Annotate top 3 peaks with a background pill for legibility\n                if (i, j) in top_peaks:\n                    v_off = _pill_v_off.get((i, j), 0.0)\n                    txt = f\"{int(val):,}\"\n                    sz = min(int(ch * 0.36), 30)\n                    ink_color = \"#ffffff\" if norm > 0.45 else INK\n\n                    pill_w = len(txt) * sz * 0.6 + 14\n                    pill_h = sz + 10\n                    pill_cx = cx + cw / 2\n                    pill_cy = cy + ch / 2 + v_off\n                    pill = self.svg.node(\n                        g,\n                        \"rect\",\n                        x=pill_cx - pill_w / 2,\n                        y=pill_cy - pill_h / 2,\n                        width=pill_w,\n                        height=pill_h,\n                        rx=pill_h / 2,\n                        ry=pill_h / 2,\n                    )\n                    pill.set(\"fill\", \"#000000\" if norm > 0.45 else PAGE_BG)\n                    pill.set(\"fill-opacity\", \"0.3\" if norm > 0.45 else \"0.75\")\n\n                    svg_text(g, pill_cx, pill_cy + sz * 0.35, txt, sz, fill=ink_color, bold=True)\n\n        # Smooth gradient colorbar (120-segment approximation)\n        cb_w = int(pw * 0.016)\n        cb_h = int(gh * 0.85)\n        cb_x = x0 + gw + int(pw * 0.025)\n        cb_y = y0 + (gh - cb_h) / 2\n        n_seg = 120\n        seg_h = cb_h / n_seg\n\n        for si in range(n_seg):\n            t = 1 - si / (n_seg - 1)\n            self.svg.node(g, \"rect\", x=cb_x, y=cb_y + si * seg_h, width=cb_w, height=seg_h + 1, fill=color_at(t))\n\n        # Colorbar border\n        border = self.svg.node(g, \"rect\", x=cb_x, y=cb_y, width=cb_w, height=cb_h, rx=3, ry=3)\n        border.set(\"fill\", \"none\")\n        border.set(\"stroke\", INK_SOFT)\n        border.set(\"stroke-width\", \"1.5\")\n\n        # Colorbar ticks (log scale: 0, 1, 10, 100, 1000, ...)\n        max_pow = int(math.log10(self.vmax)) if self.vmax > 0 else 0\n        for tv in [0] + [10**p for p in range(max_pow + 1)]:\n            t = 0 if tv == 0 else math.log10(tv + 1) / log_max\n            ty = cb_y + cb_h * (1 - t)\n            self.svg.node(g, \"line\", x1=cb_x + cb_w, y1=ty, x2=cb_x + cb_w + 10, y2=ty, stroke=INK_SOFT)\n            label = f\"{int(tv):,}\" if tv >= 1000 else str(int(tv))\n            svg_text(g, cb_x + cb_w + 16, ty + 10, label, 30, anchor=\"start\", fill=INK_SOFT)\n\n        # Colorbar title\n        if self.colorbar_title:\n            svg_text(g, cb_x + cb_w / 2, cb_y - 28, self.colorbar_title, 36, bold=True, fill=INK)\n\n    def _compute(self):\n        nr = len(self.matrix_data) if self.matrix_data else 1\n        nc = len(self.matrix_data[0]) if self.matrix_data and self.matrix_data[0] else 1\n        self._box.xmin, self._box.xmax = 0, nc\n        self._box.ymin, self._box.ymax = 0, nr\n\n\n# Data: wind turbine blade root fatigue loading (variable-amplitude spectrum)\nnp.random.seed(42)\n\nn_amp_bins = 20\nn_mean_bins = 20\n\namp_edges = np.linspace(0, 200, n_amp_bins + 1)\nmean_edges = np.linspace(-50, 250, n_mean_bins + 1)\namp_centers = (amp_edges[:-1] + amp_edges[1:]) / 2\nmean_centers = (mean_edges[:-1] + mean_edges[1:]) / 2\n\ncounts = np.zeros((n_amp_bins, n_mean_bins))\nfor i in range(n_amp_bins):\n    for j in range(n_mean_bins):\n        amp, mean_val = amp_centers[i], mean_centers[j]\n        primary = np.exp(-amp / 28) * np.exp(-((mean_val - 100) ** 2) / (2 * 55**2))\n        vibration = 0.5 * np.exp(-amp / 10) * np.exp(-((mean_val - 55) ** 2) / (2 * 20**2))\n        base = 9000 * (primary + vibration) * (1 + 0.2 * np.random.randn())\n        if amp + abs(mean_val - 100) > 220 or base < 2:\n            counts[i][j] = 0\n        else:\n            counts[i][j] = int(round(max(0, base)))\n\n# Flip so high amplitude is at top (fatigue matrix y-axis convention)\nmatrix = counts[::-1].tolist()\nvmax = int(np.max(counts))\n\n# Imprint palette for Style — first series is brand green\nIMPRINT_PALETTE = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=IMPRINT_PALETTE,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=2.5,\n)\n\nchart = RainflowHeatmap(\n    width=2400,\n    height=2400,\n    style=custom_style,\n    title=\"heatmap-rainflow · python · pygal · anyplot.ai\",\n    subtitle_text=\"Wind Turbine Blade Root — Variable Amplitude Fatigue Spectrum\",\n    matrix_data=matrix,\n    row_labels=[f\"{v:.0f}\" for v in amp_centers[::-1]],\n    col_labels=[f\"{v:.0f}\" for v in mean_centers],\n    colormap=IMPRINT_SEQ,\n    vmax=vmax,\n    show_legend=False,\n    margin=80,\n    margin_top=140,\n    margin_bottom=60,\n    show_x_labels=False,\n    show_y_labels=False,\n    x_axis_title=\"Mean Stress (MPa)\",\n    y_axis_title=\"Stress Amplitude (MPa)\",\n    colorbar_title=\"Cycle Count\",\n    explicit_size=True,\n    pretty_print=True,\n)\n\n# Pygal requires at least one series to trigger the rendering pipeline\nchart.add(\"\", [0])\n\nchart.render_to_png(f\"plot-{THEME}.png\")\n\n# Interactive HTML export — leverages pygal's distinctive SVG/JS output\nhtml_content = f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <title>heatmap-rainflow · python · pygal · anyplot.ai</title>\n    <style>\n        body {{ margin: 0; display: flex; justify-content: center;\n               align-items: center; min-height: 100vh; background: {PAGE_BG}; }}\n        .chart {{ max-width: 100%; height: auto; }}\n    </style>\n</head>\n<body>\n    <figure class=\"chart\">{chart.render(is_unicode=True)}</figure>\n</body>\n</html>\"\"\"\n\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(html_content)\n"}