Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9ff6215
Added computing of PSD - Power Spectral Density for spectrum chart
demvlad Apr 30, 2025
3e1a3a1
Added PSD curves at spectrum chart
demvlad Apr 30, 2025
750991a
Removed redundant check TODO code
demvlad Apr 30, 2025
0bdf2f4
Code issues are resolved
demvlad Apr 30, 2025
4bfe62f
PSD caption is edited in index.html
demvlad Apr 30, 2025
9f229c2
Removed non execute condition. The FFT output is allways even value
demvlad May 1, 2025
9637f10
Powers scale computing code refactoring
demvlad May 1, 2025
082512d
Draw the all points of PSD data
demvlad May 1, 2025
a1eeb26
Resolved issue of magnitude computing
demvlad May 1, 2025
f345f2f
Added vertical grid lines
demvlad May 2, 2025
d43fbe0
Added grid with db value captions
demvlad May 3, 2025
43dba39
The PSD Y axises range is alligned by value 10db/Hz
demvlad May 3, 2025
0c6dd89
Added setup points per segment by using vertical slider (Y-scale slider)
demvlad May 3, 2025
7825802
maximal num per segment count value is limited by data samples count
demvlad May 4, 2025
b3bcdc3
The names variable are changed to JS code style
demvlad May 4, 2025
dc19b37
Added the maximal noise frequency label
demvlad May 4, 2025
69dd67e
The 'Max motor noise' label is renamed to 'Max noise'
demvlad May 4, 2025
2f60284
Added mouse marker at the PSD chart
demvlad May 4, 2025
8cb92b2
Zeroes frequency magnitudes removed from curves min-max range
demvlad May 4, 2025
81a4abd
Decreased spectrums charts plot transparent
demvlad May 4, 2025
8350ecf
Code style improvement
demvlad May 4, 2025
4e3ebea
Max noise definition low frequency limit is changed from 100Hz to 50H…
demvlad May 5, 2025
d4e4ef8
Added horizont mouse position marker line at PSD chart
demvlad May 5, 2025
9b7d065
The max noise label is shifted down at PSD chart
demvlad May 5, 2025
15fd548
The PSD chart updates immediately after Y scale sliders change
demvlad May 5, 2025
014a06e
PSD unit label is changed at dBm/Hz
demvlad May 5, 2025
2cde4dd
The minimum PSD value is set as -70dBm
demvlad May 5, 2025
30f7758
Code style improvement
demvlad May 5, 2025
47459e8
Code style improvement
demvlad May 5, 2025
2eef77e
Code style improvement
demvlad May 5, 2025
cab6757
Added checking for missing samples segment data
demvlad May 5, 2025
011b977
Code style improvement
demvlad May 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@ <h4>Workspace</h4>
<option value="1">Freq. vs Throttle</option>
<option value="3">Freq. vs RPM</option>
<option value="2">Error vs Setpoint</option>
<option value="4">Power spectral density</option>
</select>
</div>

Expand Down
4 changes: 4 additions & 0 deletions src/graph_spectrum.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ export function FlightLogAnalyser(flightLog, canvas, analyserCanvas) {
fftData = GraphSpectrumCalc.dataLoadPidErrorVsSetpoint();
break;

case SPECTRUM_TYPE.PSD_VS_FREQUENCY:
fftData = GraphSpectrumCalc.dataLoadFrequencyPSD();
break;

case SPECTRUM_TYPE.FREQUENCY:
default:
fftData = GraphSpectrumCalc.dataLoadFrequency();
Expand Down
113 changes: 111 additions & 2 deletions src/graph_spectrum_calc.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,29 @@ GraphSpectrumCalc.dataLoadFrequency = function() {
return fftData;
};

GraphSpectrumCalc.dataLoadFrequencyPSD = function() {
const points_per_segment = 512,
overlap_count = 256;
const flightSamples = this._getFlightSamplesFreq(false);
const psd = this._psd(flightSamples.samples, this._blackBoxRate, points_per_segment, overlap_count);
let min = 1e6,
max = -1e6;
for (const value of psd) {
min = Math.min(value, min);
max = Math.max(value, max);
}

const psdData = {
fieldIndex : this._dataBuffer.fieldIndex,
fieldName : this._dataBuffer.fieldName,
psdLength : psd.length,
psdOutput : psd,
blackBoxRate : this._blackBoxRate,
minimum: min,
maximum: max,
};
return psdData;
};

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add safeguards & make Welch parameters configurable

dataLoadFrequencyPSD uses fixed values (points_per_segment = 512, overlap_count = 256) and proceeds even when the captured sample-count is smaller than points_per_segment.
If samples.length < 512, _fft_segmented returns an empty array, which later causes segmentsCount === 0 and a division-by-zero in _psd (lines 556-572).

Consider:

-const points_per_segment = 512,
-      overlap_count = 256;
+// Pick reasonable defaults, but clamp them to the available data
+const MIN_SEGMENT = 64;          // avoid pathological tiny segments
+let points_per_segment = 512;
+let overlap_count      = 256;
+
+// Shrink segment size when we have fewer samples than the default
+if (flightSamples.samples.length < points_per_segment) {
+  points_per_segment = Math.max(
+    MIN_SEGMENT,
+    1 << Math.floor(Math.log2(flightSamples.samples.length))
+  );
+  overlap_count = Math.floor(points_per_segment / 2);
+}
+
+// Bail out early when we still do not have enough data
+if (flightSamples.samples.length < points_per_segment) {
+  return {
+    error: 'Not enough data for PSD calculation',
+    fieldName: this._dataBuffer.fieldName
+  };
+}

Exposing points_per_segment & overlap_count through the UI (or at least constants in userSettings) will also address the feedback about “too small number of points per segment / too large overlap”.

Committable suggestion skipped: line range outside the PR's diff.

GraphSpectrumCalc._dataLoadFrequencyVsX = function(vsFieldNames, minValue = Infinity, maxValue = -Infinity) {

Expand Down Expand Up @@ -283,7 +306,7 @@ GraphSpectrumCalc._getFlightChunks = function() {
return allChunks;
};

GraphSpectrumCalc._getFlightSamplesFreq = function() {
GraphSpectrumCalc._getFlightSamplesFreq = function(scaled = true) {

const allChunks = this._getFlightChunks();

Expand All @@ -293,7 +316,11 @@ GraphSpectrumCalc._getFlightSamplesFreq = function() {
let samplesCount = 0;
for (const chunk of allChunks) {
for (const frame of chunk.frames) {
samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]));
if (scaled) {
samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]));
} else {
samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid lookupRaw when no curve is present

When scaled === false, the code falls back to raw frame values (good).
However, when scaled === true it unconditionally calls this._dataBuffer.curve.lookupRaw(...). If curve is null (e.g., for already-scaled fields), this will throw.

-if (scaled) {
-  samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]));
+if (scaled && this._dataBuffer.curve) {
+  samples[samplesCount] = this._dataBuffer.curve.lookupRaw(
+    frame[this._dataBuffer.fieldIndex]
+  );
 } else {
   samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
 }

Preventing the null-dereference keeps existing FFT paths stable.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (scaled) {
samples[samplesCount] = (this._dataBuffer.curve.lookupRaw(frame[this._dataBuffer.fieldIndex]));
} else {
samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
}
if (scaled && this._dataBuffer.curve) {
samples[samplesCount] = this._dataBuffer.curve.lookupRaw(
frame[this._dataBuffer.fieldIndex]
);
} else {
samples[samplesCount] = frame[this._dataBuffer.fieldIndex];
}

samplesCount++;
}
}
Expand Down Expand Up @@ -485,3 +512,85 @@ GraphSpectrumCalc._normalizeFft = function(fftOutput, fftLength) {

return fftData;
};

/**
* Compute PSD for data samples by Welch method follow Python code
*/
GraphSpectrumCalc._psd = function(samples, fs, n_per_seg, n_overlap, scaling = 'density') {
// Compute FFT for samples segments
const fftOutput = this._fft_segmented(samples, n_per_seg, n_overlap);

const dataCount = fftOutput[0].length;
const segmentsCount = fftOutput.length;
const psdOutput = new Float64Array(dataCount);

// Compute power scale coef
let scale = 1;
if (scaling == 'density') {
if (userSettings.analyserHanning) {
const window = Array(n_per_seg).fill(1);
this._hanningWindow(window, n_per_seg);
let skSum = 0;
for (const value of window) {
skSum += value ** 2;
}
scale = 1 / (fs * skSum);
} else {
scale = 1 / n_per_seg;
}
} else if (scaling == 'spectrum') {
if (userSettings.analyserHanning) {
const window = Array(n_per_seg).fill(1);
this._hanningWindow(window, n_per_seg);
let sum = 0;
for (const value of window) {
sum += value;
}
scale = 1 / sum ** 2;
} else {
scale = 1 / n_per_seg ** 2;
}
}

// Compute average for scaled power
for (let i = 0; i < dataCount; i++) {
psdOutput[i] = 0.0;
for (let j = 0; j < segmentsCount; j++) {
let p = scale * fftOutput[j][i] ** 2;
if (dataCount % 2) {
p *= 2;
} else if (i != dataCount - 1) {
p *= 2;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we perform same action for both if cases?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first condition is removed. The fft output has even size only for our fft transform procedure

psdOutput[i] += p;
}

const min_avg = 1e-5; // limit min value for -50db
let avg = psdOutput[i] / segmentsCount;
avg = Math.max(avg, min_avg);
psdOutput[i] = 10 * Math.log10(avg);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Incorrect power doubling logic & potential divide-by-zero

  1. segmentsCount may be 0 (see previous comment) – dividing by it (line 569) will yield Infinity / NaN.
    Guard against empty fftOutput.

  2. Doubling of the one-sided spectrum should exclude the DC component (i === 0) and, for even n_per_seg, the Nyquist bin (i === dataCount/2).
    Current checks (dataCount % 2 … / i != dataCount - 1) are wrong for a real-input FFT.

-  for (let i = 0; i < dataCount; i++) {
+  if (segmentsCount === 0) return psdOutput.fill(NaN);     // early-exit guard
+
+  const nyquistIdx = (dataCount % 2 === 0) ? dataCount / 2 : null;
+  for (let i = 0; i < dataCount; i++) {
     ...
-      if (dataCount % 2) {
-        p *= 2;
-      } else if (i != dataCount - 1) {
-        p *= 2;
-      }
+      // Double all bins except DC and Nyquist (if present)
+      if (i !== 0 && i !== nyquistIdx) {
+        p *= 2;
+      }
     ...
   }

This follows the same rule used by SciPy’s signal.welch.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (let i = 0; i < dataCount; i++) {
psdOutput[i] = 0.0;
for (let j = 0; j < segmentsCount; j++) {
let p = scale * fftOutput[j][i] ** 2;
if (dataCount % 2) {
p *= 2;
} else if (i != dataCount - 1) {
p *= 2;
}
psdOutput[i] += p;
}
const min_avg = 1e-5; // limit min value for -50db
let avg = psdOutput[i] / segmentsCount;
avg = Math.max(avg, min_avg);
psdOutput[i] = 10 * Math.log10(avg);
}
// early-exit guard against divide-by-zero
if (segmentsCount === 0) return psdOutput.fill(NaN);
const nyquistIdx = (dataCount % 2 === 0) ? dataCount / 2 : null;
for (let i = 0; i < dataCount; i++) {
psdOutput[i] = 0.0;
for (let j = 0; j < segmentsCount; j++) {
let p = scale * fftOutput[j][i] ** 2;
// Double all bins except DC and Nyquist (if present)
if (i !== 0 && i !== nyquistIdx) {
p *= 2;
}
psdOutput[i] += p;
}
const min_avg = 1e-5; // limit min value for -50 dB
let avg = psdOutput[i] / segmentsCount;
avg = Math.max(avg, min_avg);
psdOutput[i] = 10 * Math.log10(avg);
}


return psdOutput;
};


/**
* Compute FFT for samples segments by lenghts as n_per_seg with n_overlap overlap points count
*/
GraphSpectrumCalc._fft_segmented = function(samples, n_per_seg, n_overlap) {
const samplesCount = samples.length;
let output = [];
for (let i = 0; i < samplesCount - n_per_seg; i += n_per_seg - n_overlap) {
const fftInput = samples.slice(i, i + n_per_seg);

if (userSettings.analyserHanning) {
this._hanningWindow(fftInput, n_per_seg);
}

const fftOutput = this._fft(fftInput);
output.push(fftOutput.slice(0, n_per_seg));
}

return output;
};
60 changes: 58 additions & 2 deletions src/graph_spectrum_plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const SPECTRUM_TYPE = {
FREQ_VS_THROTTLE: 1,
PIDERROR_VS_SETPOINT: 2,
FREQ_VS_RPM: 3,
PSD_VS_FREQUENCY: 4,
};

export const SPECTRUM_OVERDRAW_TYPE = {
Expand Down Expand Up @@ -171,6 +172,10 @@ GraphSpectrumPlot._drawGraph = function (canvasCtx) {
case SPECTRUM_TYPE.PIDERROR_VS_SETPOINT:
this._drawPidErrorVsSetpointGraph(canvasCtx);
break;

case SPECTRUM_TYPE.PSD_VS_FREQUENCY:
this._drawFrequencyPSDGraph(canvasCtx);
break;
}
};

Expand Down Expand Up @@ -294,7 +299,7 @@ GraphSpectrumPlot._drawFrequencyGraph = function (canvasCtx) {
this._fftData.fieldName,
WIDTH - 4,
HEIGHT - 6,
"right"
"right",
);
this._drawHorizontalGridLines(
canvasCtx,
Expand All @@ -304,7 +309,58 @@ GraphSpectrumPlot._drawFrequencyGraph = function (canvasCtx) {
WIDTH,
HEIGHT,
MARGIN,
"Hz"
"Hz",
);
};

GraphSpectrumPlot._drawFrequencyPSDGraph = function (canvasCtx) {
const HEIGHT = canvasCtx.canvas.height - MARGIN;
const WIDTH = canvasCtx.canvas.width;
const LEFT = canvasCtx.canvas.left;
const TOP = canvasCtx.canvas.top;

const PLOTTED_BLACKBOX_RATE = this._fftData.blackBoxRate / this._zoomX;

canvasCtx.save();
canvasCtx.translate(LEFT, TOP);
this._drawGradientBackground(canvasCtx, WIDTH, HEIGHT);

const pointsCount = this._fftData.psdLength;
const scaleX = 2 * WIDTH / PLOTTED_BLACKBOX_RATE * this._zoomX;
canvasCtx.beginPath();
canvasCtx.lineWidth = 1;
canvasCtx.strokeStyle = "orange";

canvasCtx.moveTo(0, 0);
const a1 = Math.abs(this._fftData.minimum),
a2 = Math.abs(this._fftData.maximum),
limit = Math.max(a1, a2);
const scaleY = HEIGHT / 2 / limit;
for (let pointNum = 0; pointNum < pointsCount; pointNum += 2) {
const freq = PLOTTED_BLACKBOX_RATE / 2 * pointNum / pointsCount;
const y = HEIGHT / 2 - this._fftData.psdOutput[pointNum] * scaleY;
canvasCtx.lineTo(freq * scaleX, y);
}
canvasCtx.stroke();

canvasCtx.restore();

this._drawAxisLabel(
canvasCtx,
this._fftData.fieldName,
WIDTH - 4,
HEIGHT - 6,
"right",
);
this._drawHorizontalGridLines(
canvasCtx,
PLOTTED_BLACKBOX_RATE / 2,
LEFT,
TOP,
WIDTH,
HEIGHT,
MARGIN,
"Hz",
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

PSD graph drawing implementation needs validation and improved mouse interaction.

The basic implementation looks good, but there are a few improvements that could be made:

  1. Missing validation for this._fftData.psdLength, this._fftData.psdOutput, this._fftData.minimum, and this._fftData.maximum
  2. Using pointNum += 2 without a comment explaining why only every other point is plotted
  3. No support for showing imported spectrums (unlike the frequency graph)
  4. No support for mouse position tracking in _drawMousePosition function for this spectrum type

I recommend adding data validation to prevent errors when properties are missing:

GraphSpectrumPlot._drawFrequencyPSDGraph = function (canvasCtx) {
  const HEIGHT = canvasCtx.canvas.height - MARGIN;
  const WIDTH = canvasCtx.canvas.width;
  const LEFT = canvasCtx.canvas.left;
  const TOP = canvasCtx.canvas.top;

  const PLOTTED_BLACKBOX_RATE = this._fftData.blackBoxRate / this._zoomX;

  canvasCtx.save();
  canvasCtx.translate(LEFT, TOP);
  this._drawGradientBackground(canvasCtx, WIDTH, HEIGHT);

+ // Validate required properties
+ if (!this._fftData.psdLength || !this._fftData.psdOutput || 
+     this._fftData.minimum === undefined || this._fftData.maximum === undefined) {
+   console.error('PSD data is missing required properties');
+   canvasCtx.restore();
+   return;
+ }
+
  const pointsCount = this._fftData.psdLength;
  const scaleX = 2 * WIDTH / PLOTTED_BLACKBOX_RATE * this._zoomX;
  canvasCtx.beginPath();
  canvasCtx.lineWidth = 1;
  canvasCtx.strokeStyle = "orange";

  canvasCtx.moveTo(0, 0);
  const a1 = Math.abs(this._fftData.minimum),
        a2 = Math.abs(this._fftData.maximum),
        limit = Math.max(a1, a2);
  const scaleY = HEIGHT / 2 / limit;
+ // Draw PSD curve - only plot every other point for performance optimization
  for (let pointNum = 0; pointNum < pointsCount; pointNum += 2) {
    const freq = PLOTTED_BLACKBOX_RATE / 2 * pointNum / pointsCount;
    const y = HEIGHT / 2 - this._fftData.psdOutput[pointNum] * scaleY;
    canvasCtx.lineTo(freq * scaleX, y);
  }
  canvasCtx.stroke();

Also, mouse position tracking should be updated in the _drawMousePosition function (around line 1404):

  // X axis
  if (
    this._spectrumType === SPECTRUM_TYPE.FREQUENCY ||
    this._spectrumType === SPECTRUM_TYPE.FREQ_VS_THROTTLE ||
-   this._spectrumType === SPECTRUM_TYPE.FREQ_VS_RPM
+   this._spectrumType === SPECTRUM_TYPE.FREQ_VS_RPM ||
+   this._spectrumType === SPECTRUM_TYPE.PSD_VS_FREQUENCY
  ) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
GraphSpectrumPlot._drawFrequencyPSDGraph = function (canvasCtx) {
const HEIGHT = canvasCtx.canvas.height - MARGIN;
const WIDTH = canvasCtx.canvas.width;
const LEFT = canvasCtx.canvas.left;
const TOP = canvasCtx.canvas.top;
const PLOTTED_BLACKBOX_RATE = this._fftData.blackBoxRate / this._zoomX;
canvasCtx.save();
canvasCtx.translate(LEFT, TOP);
this._drawGradientBackground(canvasCtx, WIDTH, HEIGHT);
const pointsCount = this._fftData.psdLength;
const scaleX = 2 * WIDTH / PLOTTED_BLACKBOX_RATE * this._zoomX;
canvasCtx.beginPath();
canvasCtx.lineWidth = 1;
canvasCtx.strokeStyle = "orange";
canvasCtx.moveTo(0, 0);
const a1 = Math.abs(this._fftData.minimum),
a2 = Math.abs(this._fftData.maximum),
limit = Math.max(a1, a2);
const scaleY = HEIGHT / 2 / limit;
for (let pointNum = 0; pointNum < pointsCount; pointNum += 2) {
const freq = PLOTTED_BLACKBOX_RATE / 2 * pointNum / pointsCount;
const y = HEIGHT / 2 - this._fftData.psdOutput[pointNum] * scaleY;
canvasCtx.lineTo(freq * scaleX, y);
}
canvasCtx.stroke();
canvasCtx.restore();
this._drawAxisLabel(
canvasCtx,
this._fftData.fieldName,
WIDTH - 4,
HEIGHT - 6,
"right",
);
this._drawHorizontalGridLines(
canvasCtx,
PLOTTED_BLACKBOX_RATE / 2,
LEFT,
TOP,
WIDTH,
HEIGHT,
MARGIN,
"Hz",
);
GraphSpectrumPlot._drawFrequencyPSDGraph = function (canvasCtx) {
const HEIGHT = canvasCtx.canvas.height - MARGIN;
const WIDTH = canvasCtx.canvas.width;
const LEFT = canvasCtx.canvas.left;
const TOP = canvasCtx.canvas.top;
const PLOTTED_BLACKBOX_RATE = this._fftData.blackBoxRate / this._zoomX;
canvasCtx.save();
canvasCtx.translate(LEFT, TOP);
this._drawGradientBackground(canvasCtx, WIDTH, HEIGHT);
// Validate required properties
if (!this._fftData.psdLength ||
!this._fftData.psdOutput ||
this._fftData.minimum === undefined ||
this._fftData.maximum === undefined) {
console.error('PSD data is missing required properties');
canvasCtx.restore();
return;
}
const pointsCount = this._fftData.psdLength;
const scaleX = 2 * WIDTH / PLOTTED_BLACKBOX_RATE * this._zoomX;
canvasCtx.beginPath();
canvasCtx.lineWidth = 1;
canvasCtx.strokeStyle = "orange";
canvasCtx.moveTo(0, 0);
const a1 = Math.abs(this._fftData.minimum),
a2 = Math.abs(this._fftData.maximum),
limit = Math.max(a1, a2);
const scaleY = HEIGHT / 2 / limit;
// Draw PSD curve - only plot every other point for performance optimization
for (let pointNum = 0; pointNum < pointsCount; pointNum += 2) {
const freq = PLOTTED_BLACKBOX_RATE / 2 * pointNum / pointsCount;
const y = HEIGHT / 2 - this._fftData.psdOutput[pointNum] * scaleY;
canvasCtx.lineTo(freq * scaleX, y);
}
canvasCtx.stroke();
canvasCtx.restore();
this._drawAxisLabel(
canvasCtx,
this._fftData.fieldName,
WIDTH - 4,
HEIGHT - 6,
"right",
);
this._drawHorizontalGridLines(
canvasCtx,
PLOTTED_BLACKBOX_RATE / 2,
LEFT,
TOP,
WIDTH,
HEIGHT,
MARGIN,
"Hz",
);
};

};

Expand Down