{"spec_id":"spectrogram-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// spectrogram-basic: Spectrogram Time-Frequency Heatmap\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-09\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Signal: machinery vibration during a speed ramp-up --------------------\nconst sampleRate = 1000; // Hz\nconst duration = 3.0; // seconds\nconst numSamples = Math.round(sampleRate * duration);\n\nconst startFreq = 20; // rotational frequency at t=0 (Hz)\nconst endFreq = 150; // rotational frequency at t=duration (Hz)\nconst chirpRate = (endFreq - startFreq) / duration;\n\n// Tiny fixed-seed LCG for reproducible noise (the browser has no seeded RNG).\nlet lcgState = 42;\nfunction nextRandom() {\n  lcgState = (lcgState * 1103515245 + 12345) & 0x7fffffff;\n  return lcgState / 0x7fffffff;\n}\n\nconst signal = new Float64Array(numSamples);\nfor (let n = 0; n < numSamples; n++) {\n  const time = n / sampleRate;\n  // Linear chirp: instantaneous frequency ramps startFreq -> endFreq.\n  const phase = 2 * Math.PI * (startFreq * time + (chirpRate * time * time) / 2);\n  const fundamental = Math.sin(phase);\n  const bearingHarmonic = 0.5 * Math.sin(2 * phase); // fault harmonic at 2x\n  const noise = 0.15 * (nextRandom() * 2 - 1);\n  signal[n] = fundamental + bearingHarmonic + noise;\n}\n\n// --- Short-time Fourier transform (windowed DFT) ----------------------------\nconst windowSize = 200;\nconst hopSize = 20;\nconst maxFreqHz = 350;\nconst freqBinWidth = sampleRate / windowSize; // Hz per bin\nconst numFreqBins = Math.floor(maxFreqHz / freqBinWidth) + 1;\nconst numTimeBins = Math.floor((numSamples - windowSize) / hopSize) + 1;\n// Axis bounds match the bin grid exactly (bin edges tile [0, xAxisMax] /\n// [0, yAxisMax] with no gap) rather than the nominal signal duration/maxFreqHz.\nconst xAxisMax = (numTimeBins * hopSize) / sampleRate;\nconst yAxisMax = numFreqBins * freqBinWidth;\n\nconst hannWindow = new Float64Array(windowSize);\nfor (let n = 0; n < windowSize; n++) {\n  hannWindow[n] = 0.5 - 0.5 * Math.cos((2 * Math.PI * n) / (windowSize - 1));\n}\n\n// Precomputed twiddle factors avoid millions of Math.cos/sin calls below.\nconst twiddleCos = new Float64Array(windowSize);\nconst twiddleSin = new Float64Array(windowSize);\nfor (let m = 0; m < windowSize; m++) {\n  const angle = (-2 * Math.PI * m) / windowSize;\n  twiddleCos[m] = Math.cos(angle);\n  twiddleSin[m] = Math.sin(angle);\n}\n\nconst powerDb = []; // powerDb[timeBin][freqBin]\nlet maxDb = -Infinity;\nfor (let ti = 0; ti < numTimeBins; ti++) {\n  const start = ti * hopSize;\n  const row = new Float64Array(numFreqBins);\n  for (let k = 0; k < numFreqBins; k++) {\n    let re = 0;\n    let im = 0;\n    for (let n = 0; n < windowSize; n++) {\n      const sample = signal[start + n] * hannWindow[n];\n      const idx = (k * n) % windowSize;\n      re += sample * twiddleCos[idx];\n      im += sample * twiddleSin[idx];\n    }\n    const power = (re * re + im * im) / windowSize;\n    const db = 10 * Math.log10(power + 1e-12);\n    row[k] = db;\n    if (db > maxDb) maxDb = db;\n  }\n  powerDb.push(row);\n}\n\n// Narrowed from a wider range so the fainter 2x bearing-fault harmonic reads\n// more clearly against the noise floor (the noise floor sits well below this\n// range and gets pushed toward the darker end of the gradient).\nconst dynamicRangeDb = 45; // display range below the loudest bin\n\nfunction mixHex(hexA, hexB, ratio) {\n  const a = parseInt(hexA.slice(1), 16);\n  const b = parseInt(hexB.slice(1), 16);\n  const ar = (a >> 16) & 255;\n  const ag = (a >> 8) & 255;\n  const ab = a & 255;\n  const br = (b >> 16) & 255;\n  const bg = (b >> 8) & 255;\n  const bb = b & 255;\n  const r = Math.round(ar + (br - ar) * ratio);\n  const g = Math.round(ag + (bg - ag) * ratio);\n  const bl = Math.round(ab + (bb - ab) * ratio);\n  return `rgb(${r}, ${g}, ${bl})`;\n}\n\nfunction colorForDb(db) {\n  const relative = Math.max(-dynamicRangeDb, Math.min(0, db - maxDb));\n  const value = (relative + dynamicRangeDb) / dynamicRangeDb; // 0..1\n  return mixHex(t.seq[0], t.seq[1], value);\n}\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Heatmap + colorbar plugin ----------------------------------------------\n// Chart.js has no native heatmap type; this plugin fills the cartesian chart\n// area with one rect per time/frequency bin, using the scales' own pixel\n// mapping so cells tile exactly with no gaps or overlap.\nconst colorbarWidth = 22;\nconst spectrogramPlugin = {\n  id: \"spectrogramHeatmap\",\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const { x, y } = scales;\n    ctx.save();\n    for (let ti = 0; ti < numTimeBins; ti++) {\n      const tStart = (ti * hopSize) / sampleRate;\n      const tEnd = tStart + hopSize / sampleRate;\n      const px0 = x.getPixelForValue(tStart);\n      const px1 = x.getPixelForValue(tEnd);\n      const row = powerDb[ti];\n      for (let fi = 0; fi < numFreqBins; fi++) {\n        const fStart = fi * freqBinWidth;\n        const fEnd = fStart + freqBinWidth;\n        const py0 = y.getPixelForValue(fEnd);\n        const py1 = y.getPixelForValue(fStart);\n        ctx.fillStyle = colorForDb(row[fi]);\n        ctx.fillRect(px0, py0, px1 - px0 + 1, py1 - py0 + 1);\n      }\n    }\n    ctx.restore();\n  },\n  afterDraw(chart) {\n    const { ctx, chartArea } = chart;\n    const barX = chartArea.right + 34;\n    const barTop = chartArea.top;\n    const barHeight = chartArea.bottom - chartArea.top;\n\n    const gradient = ctx.createLinearGradient(0, barTop + barHeight, 0, barTop);\n    gradient.addColorStop(0, t.seq[0]);\n    gradient.addColorStop(1, t.seq[1]);\n\n    ctx.save();\n    ctx.fillStyle = gradient;\n    ctx.fillRect(barX, barTop, colorbarWidth, barHeight);\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 1;\n    ctx.strokeRect(barX, barTop, colorbarWidth, barHeight);\n\n    ctx.fillStyle = t.inkSoft;\n    ctx.font = \"13px sans-serif\";\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    const tickCount = 4;\n    for (let i = 0; i <= tickCount; i++) {\n      const ratio = i / tickCount;\n      const value = Math.round(maxDb - dynamicRangeDb * (1 - ratio));\n      const tickY = barTop + barHeight * (1 - ratio);\n      ctx.fillText(`${value} dB`, barX + colorbarWidth + 8, tickY);\n    }\n\n    ctx.translate(barX + colorbarWidth + 66, barTop + barHeight / 2);\n    ctx.rotate(Math.PI / 2);\n    ctx.textAlign = \"center\";\n    ctx.fillStyle = t.ink;\n    ctx.font = \"14px sans-serif\";\n    ctx.fillText(\"Power (dB)\", 0, 0);\n    ctx.restore();\n  },\n};\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets: [] },\n  plugins: [spectrogramPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { right: 145 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"spectrogram-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n      },\n      legend: { display: false },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: 0,\n        max: xAxisMax,\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { display: false },\n        border: { color: t.inkSoft },\n        title: { display: true, text: \"Time (s)\", color: t.ink, font: { size: 16 } },\n      },\n      y: {\n        type: \"linear\",\n        min: 0,\n        max: yAxisMax,\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { display: false },\n        border: { color: t.inkSoft },\n        title: { display: true, text: \"Frequency (Hz)\", color: t.ink, font: { size: 16 } },\n      },\n    },\n  },\n});\n"}