{"spec_id":"heatmap-chromagram","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// heatmap-chromagram: Music Chromagram (Pitch Class Distribution over Time)\n// Library: highcharts 12.6.0 | JavaScript 22.22.3\n// Quality: 92/100 | Created: 2026-06-24\n\nconst t = window.ANYPLOT_TOKENS;\n\n// Chromatic pitch class labels (C at bottom index 0, B at top index 11)\nconst PITCH_CLASSES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];\n\n// Chord progression: C major → G major → A minor → F major\nconst CHORD_TONES = [\n  [0, 4, 7],   // C major: C, E, G\n  [7, 11, 2],  // G major: G, B, D\n  [9, 0, 4],   // A minor: A, C, E\n  [5, 9, 0],   // F major: F, A, C\n];\nconst FRAMES_PER_CHORD = 20;\nconst NUM_FRAMES = FRAMES_PER_CHORD * CHORD_TONES.length; // 80\nconst DURATION = 8.0; // seconds\n\n// Deterministic LCG PRNG\nlet _seed = 42;\nfunction rand() {\n  _seed = (_seed * 1664525 + 1013904223) >>> 0;\n  return _seed / 4294967296;\n}\n\n// Generate chroma[pitch][frame] — chord tones get high energy, others get noise\nconst chroma = Array.from({ length: 12 }, () => new Array(NUM_FRAMES).fill(0));\nfor (let f = 0; f < NUM_FRAMES; f++) {\n  const ci = Math.floor(f / FRAMES_PER_CHORD);\n  const tones = CHORD_TONES[ci];\n  const phi = f % FRAMES_PER_CHORD;\n  // Attack / decay envelope at chord boundaries\n  const env = phi < 2 ? 0.65 + 0.175 * phi : phi > 17 ? 1.0 - 0.15 * (phi - 17) : 1.0;\n  for (let p = 0; p < 12; p++) {\n    chroma[p][f] = tones.includes(p)\n      ? Math.min(1.0, (0.60 + rand() * 0.35) * env)\n      : 0.03 + rand() * 0.09;\n  }\n}\n\n// imprint_seq: t.seq[0] (#009E73, brand green) → t.seq[1] (#4467A3, blue)\nfunction h2r(hex) {\n  return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];\n}\nfunction seqColor(v) {\n  const [r1, g1, b1] = h2r(t.seq[0]);\n  const [r2, g2, b2] = h2r(t.seq[1]);\n  return '#' + [r1 + (r2 - r1) * v, g1 + (g2 - g1) * v, b1 + (b2 - b1) * v]\n    .map(c => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0'))\n    .join('');\n}\n\nconst TITLE = 'heatmap-chromagram · javascript · highcharts · anyplot.ai';\n\n// Dummy scatter data — invisible points force Y-axis to render all 12 category labels\nconst dummyData = PITCH_CLASSES.map((_, i) => ({ x: DURATION / 2, y: i }));\n\nconst chart = Highcharts.chart('container', {\n  chart: {\n    backgroundColor: 'transparent',\n    animation: false,\n    style: { fontFamily: 'inherit' },\n    margin: [60, 115, 65, 68],\n  },\n  credits: { enabled: false },\n  title: {\n    text: TITLE,\n    style: { color: t.ink, fontSize: '22px', fontWeight: '600' },\n  },\n  xAxis: {\n    min: 0,\n    max: DURATION,\n    startOnTick: false,\n    endOnTick: false,\n    tickInterval: 1,\n    title: { text: 'Time (s)', style: { color: t.inkSoft, fontSize: '16px' } },\n    lineColor: t.inkSoft,\n    lineWidth: 1,\n    tickColor: t.inkSoft,\n    tickLength: 5,\n    gridLineWidth: 0,\n    labels: { style: { color: t.inkSoft, fontSize: '14px' } },\n  },\n  yAxis: {\n    categories: PITCH_CLASSES,\n    reversed: false,  // category 0 (C) at bottom\n    title: { text: 'Pitch Class', style: { color: t.inkSoft, fontSize: '16px' } },\n    lineColor: t.inkSoft,\n    lineWidth: 1,\n    tickColor: t.inkSoft,\n    tickLength: 5,\n    gridLineWidth: 0,\n    labels: { style: { color: t.inkSoft, fontSize: '14px' } },\n  },\n  legend: { enabled: false },\n  // Invisible scatter series — required to make Highcharts render the axes\n  series: [{\n    type: 'scatter',\n    data: dummyData,\n    showInLegend: false,\n    enableMouseTracking: false,\n    color: 'transparent',\n    marker: { radius: 0, enabled: false },\n    states: { hover: { enabled: false }, inactive: { opacity: 1 } },\n  }],\n  plotOptions: { series: { animation: false } },\n  tooltip: { enabled: false },\n});\n\nconst ren = chart.renderer;\nconst { plotLeft: px, plotTop: py, plotWidth: pw, plotHeight: ph } = chart;\nconst cellW = pw / NUM_FRAMES;\nconst cellH = ph / 12;\n\n// Draw heatmap cells into seriesGroup so they render behind axis labels\n// p=0 (C) at bottom (large y), p=11 (B) at top (small y) — matches category reversed:false\nconst cellsGroup = ren.g('chroma-cells').add(chart.seriesGroup);\nfor (let f = 0; f < NUM_FRAMES; f++) {\n  for (let p = 0; p < 12; p++) {\n    ren.rect(px + f * cellW, py + (11 - p) * cellH, cellW + 0.5, cellH + 0.5)\n      .attr({ fill: seqColor(chroma[p][f]), 'stroke-width': 0 })\n      .add(cellsGroup);\n  }\n}\n\n// Subtle border around the heatmap area (top + right lines; left and bottom drawn by axes)\nren.path(['M', px, py, 'L', px + pw, py, 'L', px + pw, py + ph])\n  .attr({ stroke: t.inkSoft, 'stroke-width': 0.75 })\n  .add();\n\n// Colorbar (right side, outside plot area → root SVG)\nconst cbX = px + pw + 28;\nconst cbW = 16;\nconst CB_STEPS = 80;\nfor (let i = 0; i < CB_STEPS; i++) {\n  const v = i / (CB_STEPS - 1);\n  // i=0 (v=0, green, low energy) → bottom; i=CB_STEPS-1 (v=1, blue, high energy) → top\n  ren.rect(cbX, py + (1 - (i + 1) / CB_STEPS) * ph, cbW, ph / CB_STEPS + 0.5)\n    .attr({ fill: seqColor(v), 'stroke-width': 0 })\n    .add();\n}\nren.rect(cbX, py, cbW, ph)\n  .attr({ fill: 'none', stroke: t.inkSoft, 'stroke-width': 0.5 })\n  .add();\n\n// Colorbar labels\nconst ls = { color: t.inkSoft, fontSize: '14px' };\nconst lx = cbX + cbW + 5;\nren.text('1.0', lx, py + 5).css(ls).attr({ align: 'left' }).add();\nren.text('0.5', lx, py + ph / 2 + 4).css(ls).attr({ align: 'left' }).add();\nren.text('0.0', lx, py + ph + 3).css(ls).attr({ align: 'left' }).add();\nren.text('Energy', cbX + cbW / 2, py - 8)\n  .css({ color: t.inkSoft, fontSize: '14px' })\n  .attr({ align: 'center' })\n  .add();\n\n// Chord section dividers at t=2, 4, 6 s\n[2, 4, 6].forEach(tb => {\n  const xDiv = px + (tb / DURATION) * pw;\n  ren.path(['M', xDiv, py, 'L', xDiv, py + ph])\n    .attr({ stroke: t.inkSoft, 'stroke-width': 1, dashstyle: 'ShortDash', opacity: 0.65 })\n    .add();\n});\n\n// Chord name annotations just above the heatmap area\nconst CHORD_LABELS = ['C maj', 'G maj', 'Am', 'F maj'];\nCHORD_LABELS.forEach((label, i) => {\n  const midT = (i + 0.5) * (DURATION / CHORD_TONES.length);\n  const xMid = px + (midT / DURATION) * pw;\n  ren.text(label, xMid, py - 8)\n    .css({ color: t.ink, fontSize: '13px', fontWeight: '600' })\n    .attr({ align: 'center' })\n    .add();\n});\n"}