{"spec_id":"heatmap-chromagram","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nheatmap-chromagram: Music Chromagram (Pitch Class Distribution over Time)\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-24\n\"\"\"\n\nimport os\nimport sys\n\nimport numpy as np\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\nIMPRINT_PALETTE = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\n# Imprint sequential colormap: #009E73 → #4467A3 (20 stops)\n_N = 20\n_c0, _c1 = (0, 158, 115), (68, 103, 163)\nSEQ_CMAP = [\n    \"#{:02X}{:02X}{:02X}\".format(\n        round(_c0[0] + (_c1[0] - _c0[0]) * i / (_N - 1)),\n        round(_c0[1] + (_c1[1] - _c0[1]) * i / (_N - 1)),\n        round(_c0[2] + (_c1[2] - _c0[2]) * i / (_N - 1)),\n    )\n    for i in range(_N)\n]\n\n# Pop cwd from path so this file (pygal.py) doesn't shadow the pygal package\n_p0 = sys.path.pop(0) if sys.path else \"\"\nfrom pygal.graph.graph import Graph\nfrom pygal.style import Style\n\n\nsys.path.insert(0, _p0)\n\n\ndef _lerp_color(value, vmin=0.0, vmax=1.0):\n    t = max(0.0, min(1.0, (value - vmin) / (vmax - vmin))) * (len(SEQ_CMAP) - 1)\n    i, j = int(t), min(int(t) + 1, len(SEQ_CMAP) - 1)\n    f = t - i\n    c0, c1 = SEQ_CMAP[i], SEQ_CMAP[j]\n    r = int(int(c0[1:3], 16) + (int(c1[1:3], 16) - int(c0[1:3], 16)) * f)\n    g = int(int(c0[3:5], 16) + (int(c1[3:5], 16) - int(c0[3:5], 16)) * f)\n    b = int(int(c0[5:7], 16) + (int(c1[5:7], 16) - int(c0[5:7], 16)) * f)\n    return f\"#{r:02x}{g:02x}{b:02x}\"\n\n\ndef _render_heatmap(chart):\n    if not chart.chroma_data:\n        return\n\n    n_rows, n_cols = len(chart.chroma_data), len(chart.chroma_data[0])\n    m_l, m_b, m_t, m_r = 245, 180, 80, 300\n    cw = (chart.view.width - m_l - m_r) / n_cols\n    ch = (chart.view.height - m_b - m_t) / n_rows\n    gw, gh = n_cols * cw, n_rows * ch\n    x0 = chart.view.x(0) + m_l\n    y0 = chart.view.y(n_rows) + m_t\n\n    node = chart.svg.node(chart.nodes[\"plot\"], class_=\"chroma-heatmap\")\n\n    # Chord region background bands — opacity raised to be clearly visible (DE-01)\n    for k, reg in enumerate(chart.chord_regions):\n        rx = x0 + reg[\"start\"] * cw\n        rw = (reg[\"end\"] - reg[\"start\"]) * cw\n        chart.svg.node(node, \"rect\", x=rx, y=y0, width=rw, height=gh, fill=chart.ink, opacity=[\"0.12\", \"0.17\"][k % 2])\n        t = chart.svg.node(node, \"text\", x=rx + rw / 2, y=y0 - 20)\n        t.set(\"text-anchor\", \"middle\")\n        t.set(\"fill\", chart.ink_muted)\n        t.set(\"style\", \"font-size:42px;font-weight:bold;font-family:sans-serif;font-style:italic\")\n        t.text = reg[\"label\"]\n        if reg[\"start\"] > 0:\n            chart.svg.node(\n                node, \"line\", x1=rx, y1=y0 - 5, x2=rx, y2=y0 + gh + 5, stroke=chart.ink_muted, fill=\"none\"\n            ).set(\"style\", \"stroke-width:2;stroke-dasharray:8,6\")\n\n    # Subtitle below x-axis\n    st = chart.svg.node(node, \"text\", x=x0 + gw / 2, y=y0 + gh + 165)\n    st.set(\"text-anchor\", \"middle\")\n    st.set(\"fill\", chart.ink_muted)\n    st.set(\"style\", \"font-size:40px;font-family:sans-serif;font-style:italic\")\n    st.text = \"Chord progression: I – V – vi – IV (C major key)\"\n\n    # Heatmap cells with SVG tooltip titles (pygal interactive feature)\n    for i in range(n_rows):\n        rg = chart.svg.node(node, \"g\", class_=f\"row-{i}\")\n        for j in range(n_cols):\n            v = chart.chroma_data[i][j]\n            rect = chart.svg.node(\n                rg,\n                \"rect\",\n                x=x0 + j * cw,\n                y=y0 + i * ch,\n                width=cw + 0.5,\n                height=ch + 0.5,\n                fill=_lerp_color(v),\n                stroke=\"none\",\n            )\n            chart.svg.node(rect, \"title\").text = f\"{chart.pitch_labels[i]} @ {chart.time_labels[j]}s — Energy: {v:.3f}\"\n\n    # Thin horizontal pitch-row separators\n    for i in range(1, n_rows):\n        chart.svg.node(\n            node,\n            \"line\",\n            x1=x0,\n            y1=y0 + i * ch,\n            x2=x0 + gw,\n            y2=y0 + i * ch,\n            stroke=\"#ffffff\",\n            fill=\"none\",\n            opacity=\"0.3\",\n        ).set(\"style\", \"stroke-width:1.5\")\n\n    # Heatmap border — INK_MUTED at 0.5 opacity to unify fine chrome (DE-02)\n    chart.svg.node(node, \"rect\", x=x0, y=y0, width=gw, height=gh, fill=\"none\", stroke=chart.ink_muted, opacity=\"0.5\")\n\n    # Y-axis: pitch class labels\n    row_font = min(44, int(ch * 0.6))\n    for i, label in enumerate(chart.pitch_labels):\n        t = chart.svg.node(node, \"text\", x=x0 - 18, y=y0 + i * ch + ch / 2 + row_font * 0.35)\n        t.set(\"text-anchor\", \"end\")\n        t.set(\"fill\", chart.ink)\n        t.set(\"style\", f\"font-size:{row_font}px;font-weight:600;font-family:sans-serif\")\n        t.text = label\n\n    # Y-axis title (rotated)\n    yt_x, yt_y = x0 - 200, y0 + gh / 2\n    t = chart.svg.node(node, \"text\", x=yt_x, y=yt_y)\n    t.set(\"text-anchor\", \"middle\")\n    t.set(\"fill\", chart.ink)\n    t.set(\"style\", \"font-size:48px;font-weight:bold;font-family:sans-serif\")\n    t.set(\"transform\", f\"rotate(-90, {yt_x}, {yt_y})\")\n    t.text = \"Pitch Class\"\n\n    # X-axis: time tick labels\n    step = max(1, n_cols // 10)\n    for j in range(0, n_cols, step):\n        t = chart.svg.node(node, \"text\", x=x0 + j * cw + cw / 2, y=y0 + gh + 51)\n        t.set(\"text-anchor\", \"middle\")\n        t.set(\"fill\", chart.ink)\n        t.set(\"style\", \"font-size:36px;font-family:sans-serif\")\n        t.text = chart.time_labels[j]\n\n    # X-axis title\n    t = chart.svg.node(node, \"text\", x=x0 + gw / 2, y=y0 + gh + 130)\n    t.set(\"text-anchor\", \"middle\")\n    t.set(\"fill\", chart.ink)\n    t.set(\"style\", \"font-size:48px;font-weight:bold;font-family:sans-serif\")\n    t.text = \"Time (seconds)\"\n\n    # Colorbar gradient (high=blue at top, low=green at bottom)\n    cb_w, n_seg = 50, 60\n    cb_h = gh * 0.85\n    cb_x, cb_y = x0 + gw + 50, y0 + (gh - cb_h) / 2\n    seg_h = cb_h / n_seg\n    for s in range(n_seg):\n        chart.svg.node(\n            node,\n            \"rect\",\n            x=cb_x,\n            y=cb_y + s * seg_h,\n            width=cb_w,\n            height=seg_h + 1,\n            fill=_lerp_color(1.0 - s / (n_seg - 1)),\n        )\n\n    chart.svg.node(\n        node, \"rect\", x=cb_x, y=cb_y, width=cb_w, height=cb_h, fill=\"none\", stroke=chart.ink_muted, opacity=\"0.5\"\n    )\n\n    cb_font = 36\n    for frac, txt in [(0.0, \"1.0\"), (0.5, \"0.5\"), (1.0, \"0.0\")]:\n        t = chart.svg.node(node, \"text\", x=cb_x + cb_w + 15, y=cb_y + frac * cb_h + cb_font * 0.35)\n        t.set(\"fill\", chart.ink)\n        t.set(\"style\", f\"font-size:{cb_font}px;font-family:sans-serif\")\n        t.text = txt\n\n    t = chart.svg.node(node, \"text\", x=cb_x + cb_w / 2, y=cb_y - 25)\n    t.set(\"text-anchor\", \"middle\")\n    t.set(\"fill\", chart.ink)\n    t.set(\"style\", \"font-size:40px;font-weight:bold;font-family:sans-serif\")\n    t.text = \"Energy\"\n\n\nclass ChromagramHeatmap(Graph):\n    _series_margin = 0\n\n    def __init__(self, chroma_data, pitch_labels, time_labels, chord_regions, ink, ink_muted, **kwargs):\n        self.chroma_data = chroma_data\n        self.pitch_labels = pitch_labels\n        self.time_labels = time_labels\n        self.chord_regions = chord_regions\n        self.ink = ink\n        self.ink_muted = ink_muted\n        super().__init__(**kwargs)\n\n    def _compute(self):\n        nc = len(self.chroma_data[0]) if self.chroma_data else 1\n        nr = len(self.chroma_data) if self.chroma_data else 1\n        self._box.xmin, self._box.xmax = 0, nc\n        self._box.ymin, self._box.ymax = 0, nr\n\n    def _plot(self):\n        _render_heatmap(self)\n\n\n# Data: C major → G major → A minor → F major chord progression\nnp.random.seed(42)\npitch_classes = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"]\nn_pitches, n_frames = len(pitch_classes), 80\ntime_positions = [f\"{i * 0.1:.1f}\" for i in range(n_frames)]\n\nchroma = np.random.uniform(0.02, 0.12, (n_pitches, n_frames))\nfor f in range(0, 20):\n    chroma[0, f] += np.random.uniform(0.7, 0.95)\n    chroma[4, f] += np.random.uniform(0.5, 0.75)\n    chroma[7, f] += np.random.uniform(0.55, 0.8)\nfor f in range(20, 40):\n    chroma[7, f] += np.random.uniform(0.7, 0.95)\n    chroma[11, f] += np.random.uniform(0.5, 0.75)\n    chroma[2, f] += np.random.uniform(0.55, 0.8)\nfor f in range(40, 60):\n    chroma[9, f] += np.random.uniform(0.7, 0.95)\n    chroma[0, f] += np.random.uniform(0.5, 0.75)\n    chroma[4, f] += np.random.uniform(0.55, 0.8)\nfor f in range(60, 80):\n    chroma[5, f] += np.random.uniform(0.7, 0.95)\n    chroma[9, f] += np.random.uniform(0.5, 0.75)\n    chroma[0, f] += np.random.uniform(0.55, 0.8)\nfor f in range(n_frames - 1):\n    chroma[:, f + 1] = 0.3 * chroma[:, f] + 0.7 * chroma[:, f + 1]\nchroma = np.clip(chroma, 0, 1)\n\nchroma_display = chroma[::-1]\npitch_labels_display = pitch_classes[::-1]\nchord_regions = [\n    {\"start\": 0, \"end\": 20, \"label\": \"C major\"},\n    {\"start\": 20, \"end\": 40, \"label\": \"G major\"},\n    {\"start\": 40, \"end\": 60, \"label\": \"A minor\"},\n    {\"start\": 60, \"end\": 80, \"label\": \"F major\"},\n]\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    font_family=\"sans-serif\",\n)\n\nchart = ChromagramHeatmap(\n    chroma_data=chroma_display.tolist(),\n    pitch_labels=pitch_labels_display,\n    time_labels=time_positions,\n    chord_regions=chord_regions,\n    ink=INK,\n    ink_muted=INK_MUTED,\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=\"heatmap-chromagram · python · pygal · anyplot.ai\",\n    show_legend=False,\n    margin=80,\n    margin_top=160,\n    margin_bottom=60,\n    show_x_labels=False,\n    show_y_labels=False,\n)\nchart.add(\"chromagram\", [0])\n\nchart.render_to_file(f\"plot-{THEME}.svg\")\nchart.render_to_png(f\"plot-{THEME}.png\")\n\nsvg_content = chart.render(is_unicode=True)\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(\n        \"<!DOCTYPE html>\\n<html>\\n<head>\\n\"\n        '    <meta charset=\"utf-8\">\\n'\n        \"    <title>heatmap-chromagram - python - pygal - anyplot.ai</title>\\n\"\n        \"    <style>\\n\"\n        f\"        body {{ margin: 0; display: flex; justify-content: center;\"\n        f\" align-items: center; min-height: 100vh; background: {PAGE_BG}; }}\\n\"\n        \"        .chart { max-width: 100%; height: auto; }\\n\"\n        \"    </style>\\n\"\n        \"</head>\\n<body>\\n\"\n        '    <figure class=\"chart\">\\n' + svg_content + \"\\n    </figure>\\n</body>\\n</html>\\n\"\n    )\n"}