{"spec_id":"heatmap-chromagram","library":"d3","language":"javascript","code":"// anyplot.ai\n// heatmap-chromagram: Music Chromagram (Pitch Class Distribution over Time)\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 89/100 | Created: 2026-06-24\n//# anyplot-orientation: square\n\nconst tok = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// Pitch classes: B at top, C at bottom (conventional chromagram layout)\nconst pitchClasses = [\"B\", \"A#\", \"A\", \"G#\", \"G\", \"F#\", \"F\", \"E\", \"D#\", \"D\", \"C#\", \"C\"];\n\nconst N_FRAMES = 80;\nconst DURATION = 8; // seconds\n\n// Fixed-seed LCG — browser provides no seeded RNG\nlet _seed = 42;\nfunction rand() {\n  _seed = (Math.imul(1664525, _seed) + 1013904223) | 0;\n  return (_seed >>> 0) / 4294967296;\n}\n\n// Base energies per pitch class for each chord in the I–V–vi–IV progression\nconst CHORDS = {\n  \"C major\": { C: 0.92, \"C#\": 0.07, D: 0.10, \"D#\": 0.06, E: 0.88, F: 0.12, \"F#\": 0.07, G: 0.90, \"G#\": 0.07, A: 0.15, \"A#\": 0.08, B: 0.12 },\n  \"G major\": { C: 0.15, \"C#\": 0.07, D: 0.88, \"D#\": 0.06, E: 0.12, F: 0.08, \"F#\": 0.10, G: 0.92, \"G#\": 0.08, A: 0.12, \"A#\": 0.07, B: 0.90 },\n  \"A minor\": { C: 0.88, \"C#\": 0.07, D: 0.12, \"D#\": 0.06, E: 0.90, F: 0.10, \"F#\": 0.08, G: 0.15, \"G#\": 0.08, A: 0.92, \"A#\": 0.07, B: 0.10 },\n  \"F major\": { C: 0.88, \"C#\": 0.07, D: 0.10, \"D#\": 0.08, E: 0.12, F: 0.92, \"F#\": 0.08, G: 0.12, \"G#\": 0.07, A: 0.88, \"A#\": 0.15, B: 0.08 },\n};\n\n// I–V–vi–IV chord sections (2 s each)\nconst CHORD_SECTIONS = [\n  { label: \"C maj\", start: 0, end: 2 },\n  { label: \"G maj\", start: 2, end: 4 },\n  { label: \"A min\", start: 4, end: 6 },\n  { label: \"F maj\", start: 6, end: 8 },\n];\n\nconst CHORD_SEQ = [\"C major\", \"C major\", \"G major\", \"G major\", \"A minor\", \"A minor\", \"F major\", \"F major\"];\nconst N_SECTIONS = CHORD_SEQ.length;\n\n// Generate 80 × 12 = 960 chroma data points\nconst data = [];\nfor (let ti = 0; ti < N_FRAMES; ti++) {\n  const si = Math.floor((ti / N_FRAMES) * N_SECTIONS);\n  const chord = CHORDS[CHORD_SEQ[si]];\n  const t_sec = (ti / N_FRAMES) * DURATION;\n  for (const pc of pitchClasses) {\n    const base = chord[pc] !== undefined ? chord[pc] : 0.07;\n    const energy = Math.min(1, Math.max(0, base + (rand() - 0.5) * 0.12));\n    data.push({ time: t_sec, pitch: pc, energy });\n  }\n}\n\nconst margin = { top: 115, right: 150, bottom: 70, left: 70 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\nconst timeValues = Array.from(new Set(data.map(d => d.time))).sort((a, b) => a - b);\n\nconst xScale = d3.scaleBand().domain(timeValues).range([0, iw]).padding(0);\nconst yScale = d3.scaleBand().domain(pitchClasses).range([0, ih]).padding(0);\nconst cmap = d3.scaleSequential(d3.interpolateRgbBasis(tok.seq)).domain([0, 1]);\n\n// Heatmap cells — nested binding via d3.groups(data, pitch class)\nconst byPitch = d3.groups(data, d => d.pitch);\ng.selectAll(\"g.row\")\n  .data(byPitch)\n  .join(\"g\")\n  .attr(\"class\", \"row\")\n  .each(function([pc, frames]) {\n    d3.select(this).selectAll(\"rect\")\n      .data(frames)\n      .join(\"rect\")\n      .attr(\"x\", d => xScale(d.time))\n      .attr(\"y\", yScale(pc))\n      .attr(\"width\", xScale.bandwidth() + 0.5)\n      .attr(\"height\", yScale.bandwidth() + 0.5)\n      .attr(\"fill\", d => cmap(d.energy));\n  });\n\n// X axis — one tick per second\nconst xTickVals = timeValues.filter((_, i) => i % 10 === 0);\nconst xAxis = g.append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(xScale).tickValues(xTickVals).tickFormat(d => `${Math.round(d)}s`));\nxAxis.selectAll(\"text\").attr(\"fill\", tok.inkSoft).style(\"font-size\", \"14px\");\nxAxis.selectAll(\"line\").attr(\"stroke\", tok.inkSoft);\nxAxis.select(\".domain\").remove(); // open-face axis; cells define the boundary\n\n// Y axis — all 12 pitch classes\nconst yAxis = g.append(\"g\").call(d3.axisLeft(yScale));\nyAxis.selectAll(\"text\").attr(\"fill\", tok.inkSoft).style(\"font-size\", \"14px\");\nyAxis.selectAll(\"line\").attr(\"stroke\", tok.inkSoft);\nyAxis.select(\".domain\").remove();\n\n// Chord section boundary lines at t = 2, 4, 6 s\ng.selectAll(\"line.chord-boundary\")\n  .data([2, 4, 6])\n  .join(\"line\")\n  .attr(\"class\", \"chord-boundary\")\n  .attr(\"x1\", d => xScale(d))\n  .attr(\"x2\", d => xScale(d))\n  .attr(\"y1\", -8)\n  .attr(\"y2\", ih)\n  .attr(\"stroke\", tok.ink)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"5,3\")\n  .attr(\"opacity\", 0.6);\n\n// Chord section labels — centered above each 2 s segment\ng.selectAll(\"text.chord-label\")\n  .data(CHORD_SECTIONS)\n  .join(\"text\")\n  .attr(\"class\", \"chord-label\")\n  .attr(\"x\", d => iw * (d.start + d.end) / 2 / DURATION)\n  .attr(\"y\", -14)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", tok.ink)\n  .style(\"font-size\", \"13px\")\n  .style(\"font-weight\", \"500\")\n  .text(d => d.label);\n\n// Axis labels\ng.append(\"text\")\n  .attr(\"x\", iw / 2).attr(\"y\", ih + 55)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", tok.ink).style(\"font-size\", \"16px\")\n  .text(\"Time (s)\");\n\nsvg.append(\"text\")\n  .attr(\"transform\", `translate(22, ${margin.top + ih / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", tok.ink).style(\"font-size\", \"16px\")\n  .text(\"Pitch Class\");\n\n// Colorbar — smooth gradient as 200 stacked SVG rects\nconst CB_WIDTH = 22;\nconst CB_X = iw + 40;\nconst CB_STEPS = 200;\nconst cbCellH = ih / CB_STEPS;\n\ng.selectAll(\"rect.cb\")\n  .data(d3.range(CB_STEPS))\n  .join(\"rect\")\n  .attr(\"class\", \"cb\")\n  .attr(\"x\", CB_X)\n  .attr(\"y\", (_, i) => i * cbCellH)\n  .attr(\"width\", CB_WIDTH)\n  .attr(\"height\", cbCellH + 0.5)\n  .attr(\"fill\", (_, i) => cmap(1 - i / (CB_STEPS - 1))); // top = 1.0, bottom = 0.0\n\n// Colorbar axis (energy 1.0 → 0.0)\nconst cbAxisScale = d3.scaleLinear().domain([1, 0]).range([0, ih]);\nconst cbAxis = g.append(\"g\")\n  .attr(\"transform\", `translate(${CB_X + CB_WIDTH}, 0)`)\n  .call(d3.axisRight(cbAxisScale).ticks(5).tickFormat(d3.format(\".1f\")));\ncbAxis.selectAll(\"text\").attr(\"fill\", tok.inkSoft).style(\"font-size\", \"13px\");\ncbAxis.selectAll(\"line\").attr(\"stroke\", tok.inkSoft);\ncbAxis.select(\".domain\").attr(\"stroke\", tok.inkSoft);\n\n// Colorbar label\ng.append(\"text\")\n  .attr(\"x\", CB_X + CB_WIDTH / 2).attr(\"y\", -14)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", tok.ink).style(\"font-size\", \"13px\")\n  .text(\"Energy\");\n\n// Title\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 58)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", tok.ink).style(\"font-size\", \"22px\").style(\"font-weight\", \"600\")\n  .text(\"heatmap-chromagram · javascript · d3 · anyplot.ai\");\n"}