Spectrogram Time-Frequency Heatmap — Highcharts

A spectrogram displaying time-frequency representation of a signal as a heatmap. It shows how the frequency content of a signal changes over time, with color intensity representing the amplitude or power at each time-frequency point. Essential for analyzing non-stationary signals where frequency characteristics vary, revealing patterns invisible in time-domain or frequency-domain views alone.

Spectrogram Time-Frequency Heatmap rendered with Highcharts

Renders

JavaScript source (Highcharts)

// anyplot.ai
// spectrogram-basic: Spectrogram Time-Frequency Heatmap
// Library: highcharts 12.6.0 | JavaScript 22.23.2
// Quality: 89/100 | Created: 2026-09-09
//# anyplot-orientation: landscape

const t = window.ANYPLOT_TOKENS;

// --- Data: linear chirp test tone, computed via a short-time Fourier -------
// transform. The core Highcharts bundle has no heatmap/colorAxis module, so
// the time-frequency grid is drawn cell-by-cell with the SVG renderer.
const SAMPLE_RATE = 4000; // Hz
const DURATION = 2.0; // seconds
const N_SAMPLES = Math.round(SAMPLE_RATE * DURATION);
const F_START = 150; // Hz — sweep start
const F_END = 1600; // Hz — sweep end

// Deterministic LCG — the browser has no seeded RNG.
let seed = 42;
function rand() {
  seed = (seed * 1103515245 + 12345) & 0x7fffffff;
  return seed / 0x7fffffff;
}

// Linear chirp: instantaneous frequency rises smoothly from F_START to
// F_END across the signal duration, like a swept-sine calibration tone
// used to test audio equipment or room acoustics.
const signal = new Float64Array(N_SAMPLES);
for (let n = 0; n < N_SAMPLES; n++) {
  const time = n / SAMPLE_RATE;
  const sweepRate = (F_END - F_START) / DURATION;
  const phase = 2 * Math.PI * (F_START * time + (sweepRate * time * time) / 2);
  signal[n] = Math.sin(phase) + 0.02 * (rand() * 2 - 1);
}

// --- Short-time Fourier transform -------------------------------------------
const WINDOW_LEN = 112; // samples per analysis frame
const HOP = 123; // samples between frame starts
const N_COLS = Math.floor((N_SAMPLES - WINDOW_LEN) / HOP) + 1; // time frames
const N_ROWS = 46; // frequency bins kept (0 Hz .. Nyquist-ish)
const FREQ_STEP = SAMPLE_RATE / WINDOW_LEN; // Hz per bin

const hann = new Float64Array(WINDOW_LEN);
for (let n = 0; n < WINDOW_LEN; n++) {
  hann[n] = 0.5 - 0.5 * Math.cos((2 * Math.PI * n) / (WINDOW_LEN - 1));
}

// MAGNITUDE[col][row] — magnitude spectrum per frame, via a direct DFT
// (the grid is small enough that a full DFT is cheap and needs no FFT lib).
const MAGNITUDE = Array.from({ length: N_COLS }, () => new Float64Array(N_ROWS));
let maxMagnitude = 0;
for (let col = 0; col < N_COLS; col++) {
  const start = col * HOP;
  for (let row = 0; row < N_ROWS; row++) {
    let real = 0;
    let imag = 0;
    for (let n = 0; n < WINDOW_LEN; n++) {
      const sample = signal[start + n] * hann[n];
      const angle = (-2 * Math.PI * row * n) / WINDOW_LEN;
      real += sample * Math.cos(angle);
      imag += sample * Math.sin(angle);
    }
    const magnitude = Math.sqrt(real * real + imag * imag);
    MAGNITUDE[col][row] = magnitude;
    if (magnitude > maxMagnitude) maxMagnitude = magnitude;
  }
}

// Power relative to peak, in dB, clamped to a fixed dynamic range.
const DB_FLOOR = -60;
const POWER_DB = MAGNITUDE.map((frame) => frame.map((magnitude) => Math.max(20 * Math.log10(magnitude / maxMagnitude + 1e-12), DB_FLOOR)));

// --- Color: imprint_seq — single-polarity data (power level, always <= 0dB) --
function hexToRgb(hex) {
  return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
}
const SEQ_LO = hexToRgb(t.seq[0]); // #009E73 — quiet
const SEQ_HI = hexToRgb(t.seq[1]); // #4467A3 — loud

function lerp(a, b, f) {
  return a + (b - a) * f;
}
function powerFill(db) {
  const f = Math.min(Math.max((db - DB_FLOOR) / (0 - DB_FLOOR), 0), 1);
  const red = Math.round(lerp(SEQ_LO[0], SEQ_HI[0], f));
  const green = Math.round(lerp(SEQ_LO[1], SEQ_HI[1], f));
  const blue = Math.round(lerp(SEQ_LO[2], SEQ_HI[2], f));
  return `rgb(${red},${green},${blue})`;
}

// --- Title (fontsize scaled off the 67-char baseline) ----------------------
const TITLE_TEXT = 'Swept-Sine Test Tone · spectrogram-basic · javascript · highcharts · anyplot.ai';
const TITLE_FS = Math.max(Math.round(22 * Math.min(1, 67 / TITLE_TEXT.length)), 14);

// Fixed chart geometry (landscape canvas, harness-guaranteed 1600x900 CSS px)
// — a single source of truth for the margin, the grid, and the invisible
// hover layer below, so everything lines up without a runtime resync.
const CHART_MARGIN = [130, 200, 90, 90]; // [top, right, bottom, left]
const CELL_W = (window.ANYPLOT_SIZE.width - CHART_MARGIN[1] - CHART_MARGIN[3]) / N_COLS;
const CELL_H = (window.ANYPLOT_SIZE.height - CHART_MARGIN[0] - CHART_MARGIN[2]) / N_ROWS;

function frameTime(col) {
  return (col * HOP + WINDOW_LEN / 2) / SAMPLE_RATE;
}
function binFreq(row) {
  return row * FREQ_STEP;
}

const drawn = [];
function clearDrawn() {
  drawn.forEach((el) => {
    try {
      el.destroy();
    } catch (_err) {
      // already removed
    }
  });
  drawn.length = 0;
}

function drawAll() {
  const chart = this;
  clearDrawn();
  const r = chart.renderer;

  const cellW = chart.plotWidth / N_COLS;
  const cellH = chart.plotHeight / N_ROWS;

  // Grid cells — frequency increases upward (row 0 = 0 Hz at the bottom),
  // time increases rightward, matching the invisible scatter axes below.
  for (let col = 0; col < N_COLS; col++) {
    for (let row = 0; row < N_ROWS; row++) {
      const x = chart.plotLeft + col * cellW;
      const y = chart.plotTop + (N_ROWS - 1 - row) * cellH;
      drawn.push(
        r
          .rect(x - 0.5, y - 0.5, cellW + 1, cellH + 1, 0)
          .attr({ fill: powerFill(POWER_DB[col][row]), stroke: 'none', zIndex: 2 })
          .add()
      );
    }
  }

  // Time labels (x-axis) — roughly 8 evenly spaced frames.
  const timeLabelStride = Math.round(N_COLS / 8);
  for (let col = 0; col < N_COLS; col += timeLabelStride) {
    const cx = chart.plotLeft + (col + 0.5) * cellW;
    drawn.push(
      r
        .text(frameTime(col).toFixed(2), cx, chart.plotTop + chart.plotHeight + 24)
        .attr({ align: 'center', zIndex: 2 })
        .css({ color: t.inkSoft, fontSize: '14px' })
        .add()
    );
  }
  drawn.push(
    r
      .text('Time (s)', chart.plotLeft + chart.plotWidth / 2, chart.plotTop + chart.plotHeight + 58)
      .attr({ align: 'center', zIndex: 2 })
      .css({ color: t.inkSoft, fontSize: '16px' })
      .add()
  );

  // Frequency labels (y-axis) — roughly 6 evenly spaced bins.
  const freqLabelStride = Math.round((N_ROWS - 1) / 5);
  for (let row = 0; row < N_ROWS; row += freqLabelStride) {
    const cy = chart.plotTop + (N_ROWS - 1 - row) * cellH + cellH / 2 + 5;
    drawn.push(
      r
        .text(Math.round(binFreq(row)).toLocaleString(), chart.plotLeft - 14, cy)
        .attr({ align: 'right', zIndex: 2 })
        .css({ color: t.inkSoft, fontSize: '14px' })
        .add()
    );
  }
  drawn.push(
    r
      .text('Frequency (Hz)', chart.plotLeft - 60, chart.plotTop - 20)
      .attr({ align: 'left', zIndex: 2 })
      .css({ color: t.inkSoft, fontSize: '16px' })
      .add()
  );

  // Vertical colorbar in the freed right margin.
  const barLeft = chart.plotLeft + chart.plotWidth + 55;
  const barTop = chart.plotTop + 10;
  const barWidth = 26;
  const barHeight = chart.plotHeight - 20;
  const segments = 50;
  const segH = barHeight / segments;

  for (let i = 0; i < segments; i++) {
    const db = 0 - ((0 - DB_FLOOR) * i) / (segments - 1);
    drawn.push(
      r
        .rect(barLeft, barTop + i * segH, barWidth, segH + 0.5)
        .attr({ fill: powerFill(db), zIndex: 2 })
        .add()
    );
  }
  drawn.push(
    r
      .rect(barLeft, barTop, barWidth, barHeight)
      .attr({ fill: 'none', stroke: t.inkSoft, 'stroke-width': 1, zIndex: 2 })
      .add()
  );
  // Endpoints plus two evenly spaced intermediate stops (-20 dB, -40 dB) so
  // readers can estimate values along the gradient, not just the extremes.
  [
    [0, 0],
    [DB_FLOOR / 3, 1 / 3],
    [(2 * DB_FLOOR) / 3, 2 / 3],
    [DB_FLOOR, 1],
  ].forEach(([db, frac]) => {
    drawn.push(
      r
        .text(`${db} dB`, barLeft + barWidth + 10, barTop + frac * barHeight + 5)
        .attr({ align: 'left', zIndex: 2 })
        .css({ color: t.inkSoft, fontSize: '13px' })
        .add()
    );
  });
  drawn.push(
    r
      .text('Power', barLeft, barTop - 16)
      .attr({ align: 'left', zIndex: 2 })
      .css({ color: t.inkSoft, fontSize: '14px', fontWeight: '500' })
      .add()
  );
}

// Invisible scatter layer aligned to each drawn cell so hovering exposes a
// real Highcharts tooltip — the core bundle has no heatmap/colorAxis module,
// but a matched-axis scatter series recovers native hover interactivity
// without disturbing the hand-drawn grid above it.
const cellPoints = [];
for (let col = 0; col < N_COLS; col++) {
  for (let row = 0; row < N_ROWS; row++) {
    cellPoints.push({ x: col, y: row, timeS: frameTime(col), freqHz: binFreq(row), db: POWER_DB[col][row] });
  }
}

Highcharts.chart('container', {
  chart: {
    backgroundColor: 'transparent',
    animation: false,
    style: { fontFamily: 'inherit' },
    margin: CHART_MARGIN,
    events: { load: drawAll, redraw: drawAll },
  },
  credits: { enabled: false },
  title: {
    text: TITLE_TEXT,
    style: { color: t.ink, fontSize: TITLE_FS + 'px', fontWeight: '600' },
  },
  subtitle: {
    text: `Linear chirp ${F_START} Hz → ${F_END} Hz over ${DURATION} s, sampled at ${SAMPLE_RATE.toLocaleString()} Hz`,
    style: { color: t.inkSoft, fontSize: '14px' },
  },
  xAxis: { visible: false, min: -0.5, max: N_COLS - 0.5 },
  yAxis: { visible: false, min: -0.5, max: N_ROWS - 0.5 },
  legend: { enabled: false },
  tooltip: {
    enabled: true,
    backgroundColor: t.elevatedBg,
    borderColor: t.inkSoft,
    borderRadius: 6,
    style: { color: t.ink, fontSize: '13px' },
    formatter: function () {
      const p = this.point;
      return `<b>${p.timeS.toFixed(2)} s · ${Math.round(p.freqHz).toLocaleString()} Hz</b><br/>${p.db.toFixed(1)} dB`;
    },
  },
  plotOptions: {
    series: { animation: false },
    scatter: {
      enableMouseTracking: true,
      stickyTracking: false,
      marker: {
        enabled: true,
        symbol: 'circle',
        radius: Math.max(Math.min(CELL_W, CELL_H) / 2, 3),
        fillColor: 'rgba(0,0,0,0.001)',
        lineWidth: 0,
        states: { hover: { enabled: false } },
      },
    },
  },
  series: [
    {
      type: 'scatter',
      name: 'Power',
      data: cellPoints,
    },
  ],
});

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/spectrogram-basic/highcharts/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "spectrogram-basic",
  "language": "javascript",
  "library": "highcharts",
  "page": "https://anyplot.ai/spectrogram-basic/javascript/highcharts",
  "hub": "https://anyplot.ai/spectrogram-basic",
  "code_json": "https://api.anyplot.ai/specs/spectrogram-basic/highcharts/code",
  "spec_json": "https://api.anyplot.ai/specs/spectrogram-basic",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/spectrogram-basic/javascript/highcharts/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/spectrogram-basic/javascript/highcharts/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/spectrogram-basic/javascript/highcharts/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/spectrogram-basic/javascript/highcharts/plot-dark.html",
  "quality_score": 89.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Spectrogram Time-Frequency Heatmap on anyplot.ai.

Other implementations