{"spec_id":"heatmap-chromagram","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nheatmap-chromagram: Music Chromagram (Pitch Class Distribution over Time)\nLibrary: letsplot 4.10.1 | Python 3.13.14\nQuality: 84/100 | Updated: 2026-06-24\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import (\n    LetsPlot,\n    aes,\n    element_blank,\n    element_rect,\n    element_text,\n    geom_raster,\n    ggplot,\n    ggsize,\n    guide_colorbar,\n    labs,\n    layer_tooltips,\n    scale_fill_gradientn,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n)\nfrom lets_plot.export import ggsave\nfrom PIL import Image, ImageDraw\n\n\nLetsPlot.setup_html()\n\n# Theme tokens — Imprint palette, theme-adaptive 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\"\n\n\ndef _fix_canvas_margin(path, bg_color):\n    \"\"\"Replace letsplot's white outer canvas margin with bg_color via edge flood-fill.\"\"\"\n    img = Image.open(path).convert(\"RGB\")\n    w, h = img.size\n    bg_tuple = tuple(int(bg_color[i : i + 2], 16) for i in (1, 3, 5))\n    for corner in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]:\n        if all(c >= 245 for c in img.getpixel(corner)):\n            ImageDraw.floodfill(img, corner, bg_tuple, thresh=10)\n    img.save(path)\n\n\n# Imprint sequential gradient: background → brand green → blue (single-polarity energy)\nSEQ_COLORS = [PAGE_BG, \"#009E73\", \"#4467A3\"]\n\n# Data — synthetic chromagram: C major → G major → A minor → F major\nnp.random.seed(42)\npitch_classes = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"]\nn_pitches = len(pitch_classes)\nn_frames = 200\nframe_duration = 0.05\ntime_seconds = np.arange(n_frames) * frame_duration\n\nchroma = np.random.uniform(0.02, 0.10, (n_pitches, n_frames))\n\nchords = {\n    \"C_major\": [0, 4, 7],  # C, E, G\n    \"G_major\": [7, 11, 2],  # G, B, D\n    \"A_minor\": [9, 0, 4],  # A, C, E\n    \"F_major\": [5, 9, 0],  # F, A, C\n}\n\nsegments = [(0, 50, \"C_major\"), (50, 100, \"G_major\"), (100, 150, \"A_minor\"), (150, 200, \"F_major\")]\n\nfor start, end, chord_name in segments:\n    root, third, fifth = chords[chord_name]\n    chroma[root, start:end] += np.random.uniform(0.7, 0.95, end - start)\n    chroma[third, start:end] += np.random.uniform(0.5, 0.75, end - start)\n    chroma[fifth, start:end] += np.random.uniform(0.55, 0.8, end - start)\n\nkernel = np.ones(5) / 5\nfor i in range(n_pitches):\n    chroma[i] = np.convolve(chroma[i], kernel, mode=\"same\")\n\nchroma = chroma / chroma.max()\n\ntime_grid, pitch_grid = np.meshgrid(time_seconds, np.arange(n_pitches))\ndf = pd.DataFrame(\n    {\n        \"time\": time_grid.ravel(),\n        \"pitch_idx\": pitch_grid.ravel(),\n        \"energy\": np.round(chroma.ravel(), 4),\n        \"pitch_name\": np.repeat(pitch_classes, n_frames),\n    }\n)\n\n# Title with length-aware font size scaling\ntitle = \"heatmap-chromagram · python · letsplot · anyplot.ai\"\ntitle_fontsize = round(16 * (67 / len(title))) if len(title) > 67 else 16\n\n# Plot\nplot = (\n    ggplot(df, aes(x=\"time\", y=\"pitch_idx\", fill=\"energy\"))\n    + geom_raster(\n        tooltips=layer_tooltips()\n        .format(\"@time\", \".2f\")\n        .format(\"@energy\", \".3f\")\n        .line(\"@pitch_name at @time s\")\n        .line(\"Energy: @energy\")\n    )\n    + scale_fill_gradientn(colors=SEQ_COLORS, name=\"Energy\", guide=guide_colorbar(barwidth=14, barheight=170, nbin=256))\n    + scale_x_continuous(\n        name=\"Time (seconds)\", breaks=list(np.arange(0, n_frames * frame_duration + 0.5, 1.0)), expand=[0, 0]\n    )\n    + scale_y_continuous(name=\"Pitch Class\", breaks=list(range(n_pitches)), labels=pitch_classes, expand=[0, 0])\n    + labs(title=title, subtitle=\"Chord progression: C maj → G maj → A min → F maj\")\n    + theme(\n        plot_title=element_text(size=title_fontsize, face=\"bold\", color=INK),\n        plot_subtitle=element_text(size=12, color=INK_SOFT, face=\"italic\"),\n        axis_title_x=element_text(size=12, color=INK),\n        axis_title_y=element_text(size=12, color=INK),\n        axis_text_x=element_text(size=10, color=INK_SOFT),\n        axis_text_y=element_text(size=10, face=\"bold\", color=INK),\n        axis_ticks=element_blank(),\n        axis_line=element_blank(),\n        legend_text=element_text(size=10, color=INK_SOFT),\n        legend_title=element_text(size=12, color=INK),\n        panel_grid=element_blank(),\n        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        plot_margin=[40, 30, 20, 20],\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n    )\n    + ggsize(800, 450)\n)\n\n# Save — scale=4 produces 3200×1800 px from ggsize(800, 450)\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\n_fix_canvas_margin(f\"plot-{THEME}.png\", PAGE_BG)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}