{"spec_id":"piano-roll-midi","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// piano-roll-midi: MIDI Piano Roll Visualization\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-08-20\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: an 8-measure phrase (4/4, quarter = 1 beat) ----------------------\n// Sustained chord pad (soft, low register) + a scale-wise melody on top.\nconst BEATS_PER_MEASURE = 4;\nconst MEASURES = 8;\nconst TOTAL_BEATS = BEATS_PER_MEASURE * MEASURES;\n\nconst chordPad = [\n  { start: 0, notes: [48, 52, 55] }, // C major  (C3 E3 G3)\n  { start: 8, notes: [43, 47, 50] }, // G major  (G2 B2 D3)\n  { start: 16, notes: [45, 48, 52] }, // A minor (A2 C3 E3)\n  { start: 24, notes: [41, 45, 48] }, // F major (F2 A2 C3)\n];\n\nconst melodyPitches = [\n  67, 69, 71, 72, 74, 72, 71, 69, 67, 65, 64, 62, 60, 62, 64, 65, 67, 69, 71,\n  72, 74, 76, 74, 72, 71, 69, 67, 65, 64, 62, 60, 59,\n];\nconst melodyDurations = [0.5, 0.5, 1, 2]; // repeats every 4-beat measure\n\nconst notes = [];\nchordPad.forEach((chord) => {\n  chord.notes.forEach((pitch) => {\n    notes.push({ start: chord.start, duration: 8, pitch, velocity: 48 });\n  });\n});\nlet cursor = 0;\nmelodyPitches.forEach((pitch, i) => {\n  const duration = melodyDurations[i % melodyDurations.length];\n  const velocity = Math.round(40 + 70 * Math.sin((Math.PI * i) / (melodyPitches.length - 1)));\n  notes.push({ start: cursor, duration, pitch, velocity });\n  cursor += duration;\n});\n\nconst minPitch = Math.min(...notes.map((n) => n.pitch));\nconst maxPitch = Math.max(...notes.map((n) => n.pitch));\n\n// --- Helpers -----------------------------------------------------------------\nconst NOTE_NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\nconst BLACK_KEY_CLASSES = [1, 3, 6, 8, 10];\n\nfunction noteName(pitch) {\n  const octave = Math.floor(pitch / 12) - 1;\n  return NOTE_NAMES[pitch % 12] + octave;\n}\n\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction lerpColor(hexA, hexB, frac) {\n  const a = hexToRgb(hexA);\n  const b = hexToRgb(hexB);\n  const c = a.map((v, i) => Math.round(v + (b[i] - v) * frac));\n  return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;\n}\n\nconst [inkR, inkG, inkB] = hexToRgb(t.ink);\nconst blackKeyRowFill = `rgba(${inkR}, ${inkG}, ${inkB}, 0.05)`;\n\n// velocity → imprint_div: soft (low) is blue, loud (high) is red\nfunction velocityColor(velocity) {\n  const frac = velocity / 127;\n  return frac < 0.5\n    ? lerpColor(t.div[2], t.div[1], frac / 0.5)\n    : lerpColor(t.div[1], t.div[0], (frac - 0.5) / 0.5);\n}\n\n// --- Chart ---------------------------------------------------------------\nHighcharts.chart(\"container\", {\n  chart: {\n    type: \"scatter\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    events: {\n      load: function () {\n        const chart = this;\n        const xAxis = chart.xAxis[0];\n        const yAxis = chart.yAxis[0];\n        const rowHalf = 0.4;\n\n        // Notes as rectangles positioned by real time/pitch coordinates\n        notes.forEach((note) => {\n          const x1 = xAxis.toPixels(note.start, false);\n          const x2 = xAxis.toPixels(note.start + note.duration, false);\n          const y1 = yAxis.toPixels(note.pitch - rowHalf, false);\n          const y2 = yAxis.toPixels(note.pitch + rowHalf, false);\n          chart.renderer\n            .rect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1), 2)\n            .attr({\n              fill: velocityColor(note.velocity),\n              stroke: t.pageBg,\n              \"stroke-width\": 1,\n              zIndex: 5,\n            })\n            .add();\n        });\n\n        // Compact velocity legend (top-right, inside the plot area)\n        const legendW = 160;\n        const legendX = xAxis.left + xAxis.width - legendW - 20;\n        const legendY = chart.plotTop + 8;\n        const steps = 24;\n        for (let i = 0; i < steps; i++) {\n          chart.renderer\n            .rect(legendX + (i * legendW) / steps, legendY, legendW / steps + 1, 10)\n            .attr({ fill: velocityColor((i / (steps - 1)) * 127), zIndex: 5 })\n            .add();\n        }\n        chart.renderer\n          .text(\"Velocity: soft\", legendX, legendY - 4)\n          .css({ color: t.inkSoft, fontSize: \"12px\" })\n          .add();\n        chart.renderer\n          .text(\"loud\", legendX + legendW, legendY - 4)\n          .attr({ align: \"right\" })\n          .css({ color: t.inkSoft, fontSize: \"12px\" })\n          .add();\n      },\n    },\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: \"piano-roll-midi · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  xAxis: {\n    title: { text: \"Beat\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n    min: -0.5,\n    max: TOTAL_BEATS + 0.5,\n    tickInterval: 1,\n    gridLineWidth: 1,\n    gridLineColor: t.grid,\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    labels: {\n      style: { color: t.inkSoft, fontSize: \"13px\" },\n      formatter: function () {\n        return this.value % BEATS_PER_MEASURE === 0 ? String(this.value) : \"\";\n      },\n    },\n    plotLines: Array.from({ length: MEASURES + 1 }, (_, m) => ({\n      value: m * BEATS_PER_MEASURE,\n      color: t.inkSoft,\n      width: m % 2 === 0 ? 2 : 1,\n      zIndex: 3,\n    })),\n  },\n  yAxis: {\n    title: { text: \"Pitch\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n    min: minPitch - 1,\n    max: maxPitch + 1,\n    tickInterval: 1,\n    gridLineWidth: 0,\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    labels: {\n      style: { color: t.inkSoft, fontSize: \"13px\" },\n      formatter: function () {\n        return BLACK_KEY_CLASSES.includes(this.value % 12) ? \"\" : noteName(this.value);\n      },\n    },\n    plotBands: Array.from(\n      { length: maxPitch - minPitch + 3 },\n      (_, i) => minPitch - 1 + i,\n    ).map((pitch) => ({\n      from: pitch - 0.5,\n      to: pitch + 0.5,\n      color: BLACK_KEY_CLASSES.includes(((pitch % 12) + 12) % 12) ? blackKeyRowFill : \"transparent\",\n      zIndex: 1,\n    })),\n  },\n  legend: { enabled: false },\n  plotOptions: { series: { animation: false } },\n  // Invisible anchor series: Highcharts only renders axis lines/labels/grid\n  // for cartesian charts that carry data — the notes themselves are drawn as\n  // custom rectangles (see load handler) since no core series type supports\n  // floating time-ranged bars, so this anchor exists purely to establish the\n  // real axis scale that those rectangles are positioned against.\n  series: [\n    {\n      type: \"scatter\",\n      data: notes.map((n) => [n.start + n.duration / 2, n.pitch]),\n      marker: { enabled: false },\n      enableMouseTracking: false,\n      showInLegend: false,\n    },\n  ],\n});\n"}