{"spec_id":"piano-roll-midi","library":"echarts","language":"javascript","code":"// anyplot.ai\n// piano-roll-midi: MIDI Piano Roll Visualization\n// Library: echarts 5.5.1 | JavaScript 22.22.3\n// Quality: 92/100 | Created: 2026-06-03\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: C major composition — melody + triads, 4 measures (16 beats) ---\n// [start_beat, duration_beats, midi_pitch, velocity]\nconst notes = [\n  // Melody (measures 1–2): ascending scale then descent\n  [0.0, 0.5, 72, 88],  [0.5, 0.5, 74, 78],\n  [1.0, 0.5, 76, 96],  [1.5, 0.5, 74, 74],\n  [2.0, 1.0, 72, 102], [3.0, 0.5, 71, 68],\n  [3.5, 0.5, 69, 62],\n  [4.0, 0.5, 67, 84],  [4.5, 0.5, 69, 78],\n  [5.0, 0.5, 71, 91],  [5.5, 0.5, 72, 97],\n  [6.0, 1.0, 74, 104], [7.0, 1.0, 72, 85],\n  // Block chords (measure 3): C major then D minor\n  [8.0, 2.0,  60, 78],  [8.0, 2.0,  64, 73],  [8.0, 2.0,  67, 68],\n  [10.0, 2.0, 62, 80],  [10.0, 2.0, 65, 75],  [10.0, 2.0, 69, 70],\n  // Final cadence (measure 4)\n  [12.0, 0.5, 72, 112], [12.5, 0.5, 71, 94],\n  [13.0, 0.5, 69, 84],  [13.5, 0.5, 67, 72],\n  [14.0, 2.0, 60, 104],\n];\n\nconst TOTAL_BEATS = 16;\nconst allPitches = notes.map(n => n[2]);\nconst minPitch = Math.min(...allPitches) - 1;  // 59 = B3\nconst maxPitch = Math.max(...allPitches) + 1;  // 77 = F5\n\nconst NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];\n\nfunction noteName(midi) {\n  return NOTE_NAMES[midi % 12] + (Math.floor(midi / 12) - 1);\n}\n\nfunction isBlackKey(midi) {\n  return [1, 3, 6, 8, 10].includes(midi % 12);\n}\n\nfunction hexRgb(hex) {\n  return [\n    parseInt(hex.slice(1, 3), 16),\n    parseInt(hex.slice(3, 5), 16),\n    parseInt(hex.slice(5, 7), 16),\n  ];\n}\n\n// Imprint sequential colormap: low velocity → t.seq[0] (green), high → t.seq[1] (blue)\nfunction velColor(vel) {\n  const r = Math.min(vel / 127, 1);\n  const c0 = hexRgb(t.seq[0]);\n  const c1 = hexRgb(t.seq[1]);\n  return `rgb(${Math.round(c0[0] + r * (c1[0] - c0[0]))},${Math.round(c0[1] + r * (c1[1] - c0[1]))},${Math.round(c0[2] + r * (c1[2] - c0[2]))})`;\n}\n\nconst isDark = window.ANYPLOT_THEME === 'dark';\n// Subtle shading for black-key rows (mirrors DAW piano keyboard layout)\nconst blackKeyBg = isDark ? 'rgba(240,239,232,0.07)' : 'rgba(26,26,23,0.08)';\n\n// Black-key pitches in the display range\nconst blackKeyPitches = [];\nfor (let p = minPitch; p <= maxPitch; p++) {\n  if (isBlackKey(p)) blackKeyPitches.push([p]);\n}\n\n// Render a shaded band covering the full time range for a black key row\nfunction renderBgItem(params, api) {\n  const pitch = api.value(0);\n  const tl = api.coord([0, pitch + 0.5]);\n  const br = api.coord([TOTAL_BEATS, pitch - 0.5]);\n  return {\n    type: 'rect',\n    shape: { x: tl[0], y: tl[1], width: br[0] - tl[0], height: br[1] - tl[1] },\n    style: { fill: blackKeyBg },\n    z2: 0,\n  };\n}\n\n// Render each MIDI note as a horizontal bar colored by velocity\nfunction renderNoteItem(params, api) {\n  const start = api.value(0);\n  const dur   = api.value(1);\n  const pitch = api.value(2);\n  const vel   = api.value(3);\n  const pad   = 0.28;  // vertical padding: bar occupies 72% of the row height\n  const tl = api.coord([start, pitch + pad]);\n  const br = api.coord([start + dur, pitch - pad]);\n  return {\n    type: 'rect',\n    shape: {\n      x: tl[0] + 1,\n      y: tl[1],\n      width: Math.max(br[0] - tl[0] - 2, 3),\n      height: br[1] - tl[1],\n    },\n    style: {\n      fill: velColor(vel),\n      stroke: isDark ? 'rgba(0,0,0,0.3)' : 'rgba(255,255,255,0.4)',\n      lineWidth: 1,\n    },\n    z2: 2,\n  };\n}\n\n// --- Init -------------------------------------------------------------------\nconst chart = echarts.init(document.getElementById('container'));\n\n// --- Option -----------------------------------------------------------------\nchart.setOption({\n  animation: false,\n  color: t.palette,\n  backgroundColor: 'transparent',\n\n  title: {\n    text: 'piano-roll-midi · javascript · echarts · anyplot.ai',\n    left: 'center',\n    top: 16,\n    textStyle: { color: t.ink, fontSize: 22, fontWeight: 'bold' },\n  },\n\n  grid: { left: 95, right: 115, top: 80, bottom: 75 },\n\n  xAxis: {\n    type: 'value',\n    min: 0,\n    max: TOTAL_BEATS,\n    interval: 4,\n    minorTick: { show: true, splitNumber: 4 },\n    name: 'Measure',\n    nameLocation: 'middle',\n    nameGap: 48,\n    nameTextStyle: { color: t.inkSoft, fontSize: 16 },\n    axisLabel: {\n      color: t.inkSoft,\n      fontSize: 13,\n      // Label each measure start: M1 … M5\n      formatter: val => val <= TOTAL_BEATS ? `M${val / 4 + 1}` : '',\n    },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    // Strong lines at measure boundaries\n    splitLine: {\n      show: true,\n      lineStyle: { color: t.grid, width: 1.5 },\n    },\n    // Lighter lines at each beat\n    minorSplitLine: {\n      show: true,\n      lineStyle: { color: t.grid, width: 0.7 },\n    },\n  },\n\n  yAxis: {\n    type: 'value',\n    min: minPitch,\n    max: maxPitch,\n    interval: 1,\n    name: 'Pitch',\n    nameLocation: 'middle',\n    nameGap: 72,\n    nameTextStyle: { color: t.inkSoft, fontSize: 16 },\n    axisLabel: {\n      color: t.inkSoft,\n      fontSize: 11,\n      // Show note name only for white keys; leave black-key rows unlabeled\n      formatter: val => {\n        const m = Math.round(val);\n        return isBlackKey(m) ? '' : noteName(m);\n      },\n    },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    splitLine: {\n      show: true,\n      lineStyle: { color: t.grid, width: 0.5 },\n    },\n  },\n\n  // Velocity color ramp legend (positioned in the right margin)\n  graphic: [\n    {\n      type: 'text',\n      right: 18, top: 86,\n      style: { text: 'Velocity', fill: t.inkSoft, fontSize: 13, fontWeight: 'bold' },\n    },\n    {\n      type: 'text',\n      right: 26, top: 108,\n      style: { text: 'ff', fill: t.inkSoft, fontSize: 11 },\n    },\n    {\n      type: 'rect',\n      right: 20, top: 126,\n      shape: { width: 14, height: 120 },\n      style: {\n        fill: {\n          type: 'linear', x: 0, y: 0, x2: 0, y2: 1,\n          colorStops: [\n            { offset: 0, color: t.seq[1] },  // blue at top = loud\n            { offset: 1, color: t.seq[0] },  // green at bottom = soft\n          ],\n        },\n      },\n    },\n    {\n      type: 'text',\n      right: 26, top: 252,\n      style: { text: 'pp', fill: t.inkSoft, fontSize: 11 },\n    },\n  ],\n\n  series: [\n    // Black-key row shading (background layer)\n    {\n      type: 'custom',\n      renderItem: renderBgItem,\n      data: blackKeyPitches,\n      encode: { x: 0, y: 0 },\n      silent: true,\n      z: 0,\n    },\n    // MIDI note bars (foreground layer)\n    {\n      type: 'custom',\n      renderItem: renderNoteItem,\n      data: notes,\n      encode: { x: [0], y: 2 },\n      z: 2,\n    },\n  ],\n});\n"}