Skip to content

Commit 95ba68b

Browse files
feat(pygal): implement heatmap-chromagram (#4960)
## Implementation: `heatmap-chromagram` - pygal Implements the **pygal** version of `heatmap-chromagram`. **File:** `plots/heatmap-chromagram/implementations/pygal.py` **Parent Issue:** #4564 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23219179032)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 412ea05 commit 95ba68b

2 files changed

Lines changed: 613 additions & 0 deletions

File tree

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
""" pyplots.ai
2+
heatmap-chromagram: Music Chromagram (Pitch Class Distribution over Time)
3+
Library: pygal 3.1.0 | Python 3.14.3
4+
Quality: 91/100 | Updated: 2026-03-23
5+
"""
6+
7+
import sys
8+
9+
import numpy as np
10+
11+
12+
# Temporarily remove current directory from path to avoid name collision
13+
_cwd = sys.path[0] if sys.path[0] else "."
14+
if _cwd in sys.path:
15+
sys.path.remove(_cwd)
16+
17+
from pygal.graph.graph import Graph # noqa: E402
18+
from pygal.style import Style # noqa: E402
19+
20+
21+
# Restore path
22+
sys.path.insert(0, _cwd)
23+
24+
25+
class ChromagramHeatmap(Graph):
26+
"""Custom chromagram heatmap extending pygal's Graph base class."""
27+
28+
_series_margin = 0
29+
30+
def __init__(self, *args, **kwargs):
31+
self.chroma_data = kwargs.pop("chroma_data", [])
32+
self.pitch_labels = kwargs.pop("pitch_labels", [])
33+
self.time_labels = kwargs.pop("time_labels", [])
34+
self.colormap = kwargs.pop("colormap", [])
35+
self.x_axis_title = kwargs.pop("x_axis_title", "")
36+
self.y_axis_title = kwargs.pop("y_axis_title", "")
37+
self.chord_regions = kwargs.pop("chord_regions", [])
38+
self.vmin = kwargs.pop("vmin", 0.0)
39+
self.vmax = kwargs.pop("vmax", 1.0)
40+
super().__init__(*args, **kwargs)
41+
42+
def _interpolate_color(self, value):
43+
"""Interpolate color from sequential colormap based on value."""
44+
normalized = max(0, min(1, (value - self.vmin) / (self.vmax - self.vmin)))
45+
pos = normalized * (len(self.colormap) - 1)
46+
idx1 = int(pos)
47+
idx2 = min(idx1 + 1, len(self.colormap) - 1)
48+
frac = pos - idx1
49+
50+
c1, c2 = self.colormap[idx1], self.colormap[idx2]
51+
r = int(int(c1[1:3], 16) + (int(c2[1:3], 16) - int(c1[1:3], 16)) * frac)
52+
g = int(int(c1[3:5], 16) + (int(c2[3:5], 16) - int(c1[3:5], 16)) * frac)
53+
b = int(int(c1[5:7], 16) + (int(c2[5:7], 16) - int(c1[5:7], 16)) * frac)
54+
return f"#{r:02x}{g:02x}{b:02x}"
55+
56+
def _plot(self):
57+
"""Draw the chromagram heatmap with chord region annotations."""
58+
if not self.chroma_data:
59+
return
60+
61+
n_rows = len(self.chroma_data)
62+
n_cols = len(self.chroma_data[0])
63+
64+
plot_width = self.view.width
65+
plot_height = self.view.height
66+
67+
# Tighter margins for better canvas utilization
68+
margin_left = 245
69+
margin_bottom = 180
70+
margin_top = 80
71+
margin_right = 300
72+
73+
avail_w = plot_width - margin_left - margin_right
74+
avail_h = plot_height - margin_bottom - margin_top
75+
76+
cell_w = avail_w / n_cols
77+
cell_h = avail_h / n_rows
78+
grid_w = n_cols * cell_w
79+
grid_h = n_rows * cell_h
80+
81+
x0 = self.view.x(0) + margin_left
82+
y0 = self.view.y(n_rows) + margin_top
83+
84+
plot_node = self.nodes["plot"]
85+
hmap = self.svg.node(plot_node, class_="chromagram-heatmap")
86+
87+
# --- Chord region labels and separators (Data Storytelling) ---
88+
region_font = 38
89+
region_colors = ["#e8e8e8", "#f4f4f4", "#e8e8e8", "#f4f4f4"]
90+
for idx, region in enumerate(self.chord_regions):
91+
rx = x0 + region["start"] * cell_w
92+
rw = (region["end"] - region["start"]) * cell_w
93+
94+
# Subtle alternating background band behind heatmap
95+
self.svg.node(
96+
hmap,
97+
"rect",
98+
x=rx,
99+
y=y0,
100+
width=rw,
101+
height=grid_h,
102+
fill=region_colors[idx % len(region_colors)],
103+
opacity="0.15",
104+
)
105+
106+
# Chord label above the heatmap
107+
label_x = rx + rw / 2
108+
label_y = y0 - 20
109+
t = self.svg.node(hmap, "text", x=label_x, y=label_y)
110+
t.set("text-anchor", "middle")
111+
t.set("fill", "#555555")
112+
t.set("style", f"font-size:{region_font}px;font-weight:bold;font-family:sans-serif;font-style:italic")
113+
t.text = region["label"]
114+
115+
# Vertical separator line at chord boundary (except first)
116+
if region["start"] > 0:
117+
sep_x = rx
118+
self.svg.node(
119+
hmap, "line", x1=sep_x, y1=y0 - 5, x2=sep_x, y2=y0 + grid_h + 5, stroke="#999999", fill="none"
120+
)
121+
self.svg.node(hmap, "line", x1=sep_x, y1=y0 - 5, x2=sep_x, y2=y0 + grid_h + 5).set(
122+
"style", "stroke:#999999;stroke-width:2;stroke-dasharray:8,6"
123+
)
124+
125+
# --- Subtitle: harmonic progression pattern ---
126+
subtitle_x = x0 + grid_w / 2
127+
subtitle_y = y0 + grid_h + 165
128+
st = self.svg.node(hmap, "text", x=subtitle_x, y=subtitle_y)
129+
st.set("text-anchor", "middle")
130+
st.set("fill", "#777777")
131+
st.set("style", "font-size:34px;font-family:sans-serif;font-style:italic")
132+
st.text = "Chord progression: I \u2013 V \u2013 vi \u2013 IV (C major key)"
133+
134+
# --- Heatmap cells ---
135+
for i in range(n_rows):
136+
row_group = self.svg.node(hmap, "g", class_=f"pitch-row-{i}")
137+
for j in range(n_cols):
138+
value = self.chroma_data[i][j]
139+
color = self._interpolate_color(value)
140+
x = x0 + j * cell_w
141+
y = y0 + i * cell_h
142+
143+
rect = self.svg.node(
144+
row_group, "rect", x=x, y=y, width=cell_w + 0.5, height=cell_h + 0.5, fill=color, stroke="none"
145+
)
146+
# Native pygal tooltip via <title> element
147+
title_el = self.svg.node(rect, "title")
148+
title_el.text = f"{self.pitch_labels[i]} @ {self.time_labels[j]}s — Energy: {value:.3f}"
149+
150+
# --- Thin horizontal separators between pitch rows ---
151+
for i in range(1, n_rows):
152+
sep_y = y0 + i * cell_h
153+
self.svg.node(
154+
hmap, "line", x1=x0, y1=sep_y, x2=x0 + grid_w, y2=sep_y, stroke="#ffffff", fill="none", opacity="0.3"
155+
).set("style", "stroke-width:1.5")
156+
157+
# --- Heatmap border ---
158+
self.svg.node(hmap, "rect", x=x0, y=y0, width=grid_w, height=grid_h, fill="none", stroke="#333333")
159+
160+
# --- Y-axis: pitch class labels ---
161+
row_font = min(44, int(cell_h * 0.6))
162+
for i, label in enumerate(self.pitch_labels):
163+
y = y0 + i * cell_h + cell_h / 2
164+
t = self.svg.node(hmap, "text", x=x0 - 18, y=y + row_font * 0.35)
165+
t.set("text-anchor", "end")
166+
t.set("fill", "#333333")
167+
t.set("style", f"font-size:{row_font}px;font-weight:600;font-family:sans-serif")
168+
t.text = label
169+
170+
# --- Y-axis title (rotated) ---
171+
if self.y_axis_title:
172+
yt_x = x0 - 200
173+
yt_y = y0 + grid_h / 2
174+
t = self.svg.node(hmap, "text", x=yt_x, y=yt_y)
175+
t.set("text-anchor", "middle")
176+
t.set("fill", "#333333")
177+
t.set("style", "font-size:48px;font-weight:bold;font-family:sans-serif")
178+
t.set("transform", f"rotate(-90, {yt_x}, {yt_y})")
179+
t.text = self.y_axis_title
180+
181+
# --- X-axis: time labels ---
182+
col_font = 36
183+
step = max(1, n_cols // 10)
184+
for j in range(0, n_cols, step):
185+
x = x0 + j * cell_w + cell_w / 2
186+
y = y0 + grid_h + col_font + 15
187+
t = self.svg.node(hmap, "text", x=x, y=y)
188+
t.set("text-anchor", "middle")
189+
t.set("fill", "#333333")
190+
t.set("style", f"font-size:{col_font}px;font-family:sans-serif")
191+
t.text = self.time_labels[j]
192+
193+
# --- X-axis title ---
194+
if self.x_axis_title:
195+
xt_x = x0 + grid_w / 2
196+
xt_y = y0 + grid_h + 130
197+
t = self.svg.node(hmap, "text", x=xt_x, y=xt_y)
198+
t.set("text-anchor", "middle")
199+
t.set("fill", "#333333")
200+
t.set("style", "font-size:48px;font-weight:bold;font-family:sans-serif")
201+
t.text = self.x_axis_title
202+
203+
# --- Colorbar ---
204+
cb_w = 50
205+
cb_h = grid_h * 0.85
206+
cb_x = x0 + grid_w + 50
207+
cb_y = y0 + (grid_h - cb_h) / 2
208+
209+
n_seg = 60
210+
seg_h = cb_h / n_seg
211+
for s in range(n_seg):
212+
sv = self.vmax - (self.vmax - self.vmin) * s / (n_seg - 1)
213+
self.svg.node(
214+
hmap, "rect", x=cb_x, y=cb_y + s * seg_h, width=cb_w, height=seg_h + 1, fill=self._interpolate_color(sv)
215+
)
216+
217+
# Colorbar border
218+
self.svg.node(hmap, "rect", x=cb_x, y=cb_y, width=cb_w, height=cb_h, fill="none", stroke="#333333")
219+
220+
# Colorbar tick labels
221+
cb_font = 36
222+
for frac, txt in [
223+
(0.0, f"{self.vmax:.1f}"),
224+
(0.5, f"{(self.vmax + self.vmin) / 2:.1f}"),
225+
(1.0, f"{self.vmin:.1f}"),
226+
]:
227+
ty = cb_y + frac * cb_h + cb_font * 0.35
228+
t = self.svg.node(hmap, "text", x=cb_x + cb_w + 15, y=ty)
229+
t.set("fill", "#333333")
230+
t.set("style", f"font-size:{cb_font}px;font-family:sans-serif")
231+
t.text = txt
232+
233+
# Colorbar title
234+
t = self.svg.node(hmap, "text", x=cb_x + cb_w / 2, y=cb_y - 25)
235+
t.set("text-anchor", "middle")
236+
t.set("fill", "#333333")
237+
t.set("style", "font-size:40px;font-weight:bold;font-family:sans-serif")
238+
t.text = "Energy"
239+
240+
def _compute(self):
241+
"""Compute bounding box for the graph view."""
242+
n_cols = len(self.chroma_data[0]) if self.chroma_data else 1
243+
n_rows = len(self.chroma_data) if self.chroma_data else 1
244+
self._box.xmin = 0
245+
self._box.xmax = n_cols
246+
self._box.ymin = 0
247+
self._box.ymax = n_rows
248+
249+
250+
# --- Data: C major -> G major -> Am -> F major chord progression ---
251+
np.random.seed(42)
252+
253+
pitch_classes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
254+
n_pitches = len(pitch_classes)
255+
n_frames = 80
256+
frame_duration = 0.1
257+
time_positions = [f"{i * frame_duration:.1f}" for i in range(n_frames)]
258+
259+
chroma = np.random.uniform(0.02, 0.12, (n_pitches, n_frames))
260+
261+
# C major (C, E, G) - frames 0-19
262+
for f in range(0, 20):
263+
chroma[0, f] += np.random.uniform(0.7, 0.95)
264+
chroma[4, f] += np.random.uniform(0.5, 0.75)
265+
chroma[7, f] += np.random.uniform(0.55, 0.8)
266+
267+
# G major (G, B, D) - frames 20-39
268+
for f in range(20, 40):
269+
chroma[7, f] += np.random.uniform(0.7, 0.95)
270+
chroma[11, f] += np.random.uniform(0.5, 0.75)
271+
chroma[2, f] += np.random.uniform(0.55, 0.8)
272+
273+
# A minor (A, C, E) - frames 40-59
274+
for f in range(40, 60):
275+
chroma[9, f] += np.random.uniform(0.7, 0.95)
276+
chroma[0, f] += np.random.uniform(0.5, 0.75)
277+
chroma[4, f] += np.random.uniform(0.55, 0.8)
278+
279+
# F major (F, A, C) - frames 60-79
280+
for f in range(60, 80):
281+
chroma[5, f] += np.random.uniform(0.7, 0.95)
282+
chroma[9, f] += np.random.uniform(0.5, 0.75)
283+
chroma[0, f] += np.random.uniform(0.55, 0.8)
284+
285+
# Smooth transitions
286+
for f in range(n_frames - 1):
287+
chroma[:, f + 1] = 0.3 * chroma[:, f] + 0.7 * chroma[:, f + 1]
288+
289+
chroma = np.clip(chroma, 0, 1)
290+
291+
# Reverse so C is at bottom, B at top
292+
chroma_display = chroma[::-1]
293+
pitch_labels_display = pitch_classes[::-1]
294+
295+
# Chord region definitions for storytelling annotations
296+
chord_regions = [
297+
{"start": 0, "end": 20, "label": "C major"},
298+
{"start": 20, "end": 40, "label": "G major"},
299+
{"start": 40, "end": 60, "label": "A minor"},
300+
{"start": 60, "end": 80, "label": "F major"},
301+
]
302+
303+
# pygal Style configuration
304+
custom_style = Style(
305+
background="white",
306+
plot_background="white",
307+
foreground="#333333",
308+
foreground_strong="#333333",
309+
foreground_subtle="#999999",
310+
colors=("#306998",),
311+
title_font_size=64,
312+
legend_font_size=40,
313+
label_font_size=40,
314+
value_font_size=36,
315+
font_family="sans-serif",
316+
)
317+
318+
# Inferno-inspired sequential colormap
319+
sequential_colormap = [
320+
"#000004",
321+
"#1b0c41",
322+
"#4a0c6b",
323+
"#781c6d",
324+
"#a52c60",
325+
"#cf4446",
326+
"#ed6925",
327+
"#fb9b06",
328+
"#f7d13d",
329+
"#fcffa4",
330+
]
331+
332+
# Create chart using pygal Graph extension pattern
333+
chart = ChromagramHeatmap(
334+
width=4800,
335+
height=2700,
336+
style=custom_style,
337+
title="heatmap-chromagram · pygal · pyplots.ai",
338+
chroma_data=chroma_display.tolist(),
339+
pitch_labels=pitch_labels_display,
340+
time_labels=time_positions,
341+
colormap=sequential_colormap,
342+
chord_regions=chord_regions,
343+
show_legend=False,
344+
margin=80,
345+
margin_top=160,
346+
margin_bottom=60,
347+
show_x_labels=False,
348+
show_y_labels=False,
349+
x_axis_title="Time (seconds)",
350+
y_axis_title="Pitch Class",
351+
vmin=0.0,
352+
vmax=1.0,
353+
)
354+
355+
# Add series to trigger pygal rendering pipeline
356+
chart.add("chromagram", [0])
357+
358+
# Render outputs using pygal's native methods
359+
chart.render_to_file("plot.svg")
360+
chart.render_to_png("plot.png")
361+
362+
html_content = f"""<!DOCTYPE html>
363+
<html>
364+
<head>
365+
<meta charset="utf-8">
366+
<title>heatmap-chromagram - pygal</title>
367+
<style>
368+
body {{ margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #f5f5f5; }}
369+
.chart {{ max-width: 100%; height: auto; }}
370+
</style>
371+
</head>
372+
<body>
373+
<figure class="chart">
374+
{chart.render(is_unicode=True)}
375+
</figure>
376+
</body>
377+
</html>
378+
"""
379+
380+
with open("plot.html", "w", encoding="utf-8") as f:
381+
f.write(html_content)

0 commit comments

Comments
 (0)