{"spec_id":"spectrogram-basic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// spectrogram-basic: Spectrogram Time-Frequency Heatmap\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-09\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: linear chirp test tone, computed via a short-time Fourier -------\n// transform. The core Highcharts bundle has no heatmap/colorAxis module, so\n// the time-frequency grid is drawn cell-by-cell with the SVG renderer.\nconst SAMPLE_RATE = 4000; // Hz\nconst DURATION = 2.0; // seconds\nconst N_SAMPLES = Math.round(SAMPLE_RATE * DURATION);\nconst F_START = 150; // Hz — sweep start\nconst F_END = 1600; // Hz — sweep end\n\n// Deterministic LCG — the browser has no seeded RNG.\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\n\n// Linear chirp: instantaneous frequency rises smoothly from F_START to\n// F_END across the signal duration, like a swept-sine calibration tone\n// used to test audio equipment or room acoustics.\nconst signal = new Float64Array(N_SAMPLES);\nfor (let n = 0; n < N_SAMPLES; n++) {\n  const time = n / SAMPLE_RATE;\n  const sweepRate = (F_END - F_START) / DURATION;\n  const phase = 2 * Math.PI * (F_START * time + (sweepRate * time * time) / 2);\n  signal[n] = Math.sin(phase) + 0.02 * (rand() * 2 - 1);\n}\n\n// --- Short-time Fourier transform -------------------------------------------\nconst WINDOW_LEN = 112; // samples per analysis frame\nconst HOP = 123; // samples between frame starts\nconst N_COLS = Math.floor((N_SAMPLES - WINDOW_LEN) / HOP) + 1; // time frames\nconst N_ROWS = 46; // frequency bins kept (0 Hz .. Nyquist-ish)\nconst FREQ_STEP = SAMPLE_RATE / WINDOW_LEN; // Hz per bin\n\nconst hann = new Float64Array(WINDOW_LEN);\nfor (let n = 0; n < WINDOW_LEN; n++) {\n  hann[n] = 0.5 - 0.5 * Math.cos((2 * Math.PI * n) / (WINDOW_LEN - 1));\n}\n\n// MAGNITUDE[col][row] — magnitude spectrum per frame, via a direct DFT\n// (the grid is small enough that a full DFT is cheap and needs no FFT lib).\nconst MAGNITUDE = Array.from({ length: N_COLS }, () => new Float64Array(N_ROWS));\nlet maxMagnitude = 0;\nfor (let col = 0; col < N_COLS; col++) {\n  const start = col * HOP;\n  for (let row = 0; row < N_ROWS; row++) {\n    let real = 0;\n    let imag = 0;\n    for (let n = 0; n < WINDOW_LEN; n++) {\n      const sample = signal[start + n] * hann[n];\n      const angle = (-2 * Math.PI * row * n) / WINDOW_LEN;\n      real += sample * Math.cos(angle);\n      imag += sample * Math.sin(angle);\n    }\n    const magnitude = Math.sqrt(real * real + imag * imag);\n    MAGNITUDE[col][row] = magnitude;\n    if (magnitude > maxMagnitude) maxMagnitude = magnitude;\n  }\n}\n\n// Power relative to peak, in dB, clamped to a fixed dynamic range.\nconst DB_FLOOR = -60;\nconst POWER_DB = MAGNITUDE.map((frame) => frame.map((magnitude) => Math.max(20 * Math.log10(magnitude / maxMagnitude + 1e-12), DB_FLOOR)));\n\n// --- Color: imprint_seq — single-polarity data (power level, always <= 0dB) --\nfunction hexToRgb(hex) {\n  return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];\n}\nconst SEQ_LO = hexToRgb(t.seq[0]); // #009E73 — quiet\nconst SEQ_HI = hexToRgb(t.seq[1]); // #4467A3 — loud\n\nfunction lerp(a, b, f) {\n  return a + (b - a) * f;\n}\nfunction powerFill(db) {\n  const f = Math.min(Math.max((db - DB_FLOOR) / (0 - DB_FLOOR), 0), 1);\n  const red = Math.round(lerp(SEQ_LO[0], SEQ_HI[0], f));\n  const green = Math.round(lerp(SEQ_LO[1], SEQ_HI[1], f));\n  const blue = Math.round(lerp(SEQ_LO[2], SEQ_HI[2], f));\n  return `rgb(${red},${green},${blue})`;\n}\n\n// --- Title (fontsize scaled off the 67-char baseline) ----------------------\nconst TITLE_TEXT = 'Swept-Sine Test Tone · spectrogram-basic · javascript · highcharts · anyplot.ai';\nconst TITLE_FS = Math.max(Math.round(22 * Math.min(1, 67 / TITLE_TEXT.length)), 14);\n\n// Fixed chart geometry (landscape canvas, harness-guaranteed 1600x900 CSS px)\n// — a single source of truth for the margin, the grid, and the invisible\n// hover layer below, so everything lines up without a runtime resync.\nconst CHART_MARGIN = [130, 200, 90, 90]; // [top, right, bottom, left]\nconst CELL_W = (window.ANYPLOT_SIZE.width - CHART_MARGIN[1] - CHART_MARGIN[3]) / N_COLS;\nconst CELL_H = (window.ANYPLOT_SIZE.height - CHART_MARGIN[0] - CHART_MARGIN[2]) / N_ROWS;\n\nfunction frameTime(col) {\n  return (col * HOP + WINDOW_LEN / 2) / SAMPLE_RATE;\n}\nfunction binFreq(row) {\n  return row * FREQ_STEP;\n}\n\nconst drawn = [];\nfunction clearDrawn() {\n  drawn.forEach((el) => {\n    try {\n      el.destroy();\n    } catch (_err) {\n      // already removed\n    }\n  });\n  drawn.length = 0;\n}\n\nfunction drawAll() {\n  const chart = this;\n  clearDrawn();\n  const r = chart.renderer;\n\n  const cellW = chart.plotWidth / N_COLS;\n  const cellH = chart.plotHeight / N_ROWS;\n\n  // Grid cells — frequency increases upward (row 0 = 0 Hz at the bottom),\n  // time increases rightward, matching the invisible scatter axes below.\n  for (let col = 0; col < N_COLS; col++) {\n    for (let row = 0; row < N_ROWS; row++) {\n      const x = chart.plotLeft + col * cellW;\n      const y = chart.plotTop + (N_ROWS - 1 - row) * cellH;\n      drawn.push(\n        r\n          .rect(x - 0.5, y - 0.5, cellW + 1, cellH + 1, 0)\n          .attr({ fill: powerFill(POWER_DB[col][row]), stroke: 'none', zIndex: 2 })\n          .add()\n      );\n    }\n  }\n\n  // Time labels (x-axis) — roughly 8 evenly spaced frames.\n  const timeLabelStride = Math.round(N_COLS / 8);\n  for (let col = 0; col < N_COLS; col += timeLabelStride) {\n    const cx = chart.plotLeft + (col + 0.5) * cellW;\n    drawn.push(\n      r\n        .text(frameTime(col).toFixed(2), cx, chart.plotTop + chart.plotHeight + 24)\n        .attr({ align: 'center', zIndex: 2 })\n        .css({ color: t.inkSoft, fontSize: '14px' })\n        .add()\n    );\n  }\n  drawn.push(\n    r\n      .text('Time (s)', chart.plotLeft + chart.plotWidth / 2, chart.plotTop + chart.plotHeight + 58)\n      .attr({ align: 'center', zIndex: 2 })\n      .css({ color: t.inkSoft, fontSize: '16px' })\n      .add()\n  );\n\n  // Frequency labels (y-axis) — roughly 6 evenly spaced bins.\n  const freqLabelStride = Math.round((N_ROWS - 1) / 5);\n  for (let row = 0; row < N_ROWS; row += freqLabelStride) {\n    const cy = chart.plotTop + (N_ROWS - 1 - row) * cellH + cellH / 2 + 5;\n    drawn.push(\n      r\n        .text(Math.round(binFreq(row)).toLocaleString(), chart.plotLeft - 14, cy)\n        .attr({ align: 'right', zIndex: 2 })\n        .css({ color: t.inkSoft, fontSize: '14px' })\n        .add()\n    );\n  }\n  drawn.push(\n    r\n      .text('Frequency (Hz)', chart.plotLeft - 60, chart.plotTop - 20)\n      .attr({ align: 'left', zIndex: 2 })\n      .css({ color: t.inkSoft, fontSize: '16px' })\n      .add()\n  );\n\n  // Vertical colorbar in the freed right margin.\n  const barLeft = chart.plotLeft + chart.plotWidth + 55;\n  const barTop = chart.plotTop + 10;\n  const barWidth = 26;\n  const barHeight = chart.plotHeight - 20;\n  const segments = 50;\n  const segH = barHeight / segments;\n\n  for (let i = 0; i < segments; i++) {\n    const db = 0 - ((0 - DB_FLOOR) * i) / (segments - 1);\n    drawn.push(\n      r\n        .rect(barLeft, barTop + i * segH, barWidth, segH + 0.5)\n        .attr({ fill: powerFill(db), zIndex: 2 })\n        .add()\n    );\n  }\n  drawn.push(\n    r\n      .rect(barLeft, barTop, barWidth, barHeight)\n      .attr({ fill: 'none', stroke: t.inkSoft, 'stroke-width': 1, zIndex: 2 })\n      .add()\n  );\n  // Endpoints plus two evenly spaced intermediate stops (-20 dB, -40 dB) so\n  // readers can estimate values along the gradient, not just the extremes.\n  [\n    [0, 0],\n    [DB_FLOOR / 3, 1 / 3],\n    [(2 * DB_FLOOR) / 3, 2 / 3],\n    [DB_FLOOR, 1],\n  ].forEach(([db, frac]) => {\n    drawn.push(\n      r\n        .text(`${db} dB`, barLeft + barWidth + 10, barTop + frac * barHeight + 5)\n        .attr({ align: 'left', zIndex: 2 })\n        .css({ color: t.inkSoft, fontSize: '13px' })\n        .add()\n    );\n  });\n  drawn.push(\n    r\n      .text('Power', barLeft, barTop - 16)\n      .attr({ align: 'left', zIndex: 2 })\n      .css({ color: t.inkSoft, fontSize: '14px', fontWeight: '500' })\n      .add()\n  );\n}\n\n// Invisible scatter layer aligned to each drawn cell so hovering exposes a\n// real Highcharts tooltip — the core bundle has no heatmap/colorAxis module,\n// but a matched-axis scatter series recovers native hover interactivity\n// without disturbing the hand-drawn grid above it.\nconst cellPoints = [];\nfor (let col = 0; col < N_COLS; col++) {\n  for (let row = 0; row < N_ROWS; row++) {\n    cellPoints.push({ x: col, y: row, timeS: frameTime(col), freqHz: binFreq(row), db: POWER_DB[col][row] });\n  }\n}\n\nHighcharts.chart('container', {\n  chart: {\n    backgroundColor: 'transparent',\n    animation: false,\n    style: { fontFamily: 'inherit' },\n    margin: CHART_MARGIN,\n    events: { load: drawAll, redraw: drawAll },\n  },\n  credits: { enabled: false },\n  title: {\n    text: TITLE_TEXT,\n    style: { color: t.ink, fontSize: TITLE_FS + 'px', fontWeight: '600' },\n  },\n  subtitle: {\n    text: `Linear chirp ${F_START} Hz → ${F_END} Hz over ${DURATION} s, sampled at ${SAMPLE_RATE.toLocaleString()} Hz`,\n    style: { color: t.inkSoft, fontSize: '14px' },\n  },\n  xAxis: { visible: false, min: -0.5, max: N_COLS - 0.5 },\n  yAxis: { visible: false, min: -0.5, max: N_ROWS - 0.5 },\n  legend: { enabled: false },\n  tooltip: {\n    enabled: true,\n    backgroundColor: t.elevatedBg,\n    borderColor: t.inkSoft,\n    borderRadius: 6,\n    style: { color: t.ink, fontSize: '13px' },\n    formatter: function () {\n      const p = this.point;\n      return `<b>${p.timeS.toFixed(2)} s · ${Math.round(p.freqHz).toLocaleString()} Hz</b><br/>${p.db.toFixed(1)} dB`;\n    },\n  },\n  plotOptions: {\n    series: { animation: false },\n    scatter: {\n      enableMouseTracking: true,\n      stickyTracking: false,\n      marker: {\n        enabled: true,\n        symbol: 'circle',\n        radius: Math.max(Math.min(CELL_W, CELL_H) / 2, 3),\n        fillColor: 'rgba(0,0,0,0.001)',\n        lineWidth: 0,\n        states: { hover: { enabled: false } },\n      },\n    },\n  },\n  series: [\n    {\n      type: 'scatter',\n      name: 'Power',\n      data: cellPoints,\n    },\n  ],\n});\n"}