From e8ed19d338cff7d2625035d78fc26bd7e2c861aa Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 25 May 2026 10:53:57 -0700 Subject: [PATCH] feat: spectrum-slice (PSD) panel + two-point marker/ruler tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spectrum slice (S): floating panel plotting the time-averaged power spectrum of the current selection (or the visible view when there's no selection). Frequency on X over the region's band, auto-ranged dB on Y, with a peak marker. Backed by ComputePowerSpectrum() in stft.c (mean linear power per bin over the time span). The selection stat panel now biases to the left when this panel is up so the two don't overlap. Marker/ruler tool (M): press-drag-release drops point A and B; the overlay shows crosshairs, a connecting line, and a readout of the ham-useful deltas — Δt, Δf, tone spacing (1/Δt), and drift (Δf/Δt). Marker mode swaps the LMB-drag gesture from box-select to marker drop (Alt/middle still pan); RMB/Esc clear the measurement. Markers reset on new-file load alongside the selection. Both are gated toggles (off by default), wired into the keymap (so they self-document in the About dialog) and the sidebar. Verified headlessly: idle viewport pixel-identical to baseline (AE=0, deterministic); both panels render correctly with sensible numbers. Co-Authored-By: Claude Opus 4.7 --- src/render.c | 208 ++++++++++++++++++++++++++++++++++++++-- src/render.h | 2 + src/spectrogram.c | 43 ++++++++- src/spectrogram_types.h | 17 ++++ src/stft.c | 40 ++++++++ src/stft.h | 8 ++ src/ui.c | 17 ++++ 7 files changed, 325 insertions(+), 10 deletions(-) diff --git a/src/render.c b/src/render.c index 245bf75..53604f3 100644 --- a/src/render.c +++ b/src/render.c @@ -390,14 +390,22 @@ static void DrawStatPanel(Rectangle bounds, Rectangle sel) float selLeft = sel.x, selRight = sel.x + sel.width; float selCenterY = sel.y + sel.height * 0.5f; - // Prefer the right of the selection; fall back to the left, then clamp. - float boxX = selRight + 10; - if (boxX + boxW > bounds.x + bounds.width) { + // Normally prefer the right of the selection, falling back to the left. But + // the spectrum panel lives in the top-right, so when it's up bias the other + // way to avoid stacking the two boxes on top of each other. + float boxX; + if (app.showSpectrum) { boxX = selLeft - boxW - 10; - if (boxX < bounds.x) boxX = bounds.x; - } - if (boxX + boxW > bounds.x + bounds.width) { - boxX = (selLeft > boxW + 20) ? selLeft - boxW - 10 : bounds.x; + if (boxX < bounds.x) { + boxX = selRight + 10; + if (boxX + boxW > bounds.x + bounds.width) boxX = bounds.x; + } + } else { + boxX = selRight + 10; + if (boxX + boxW > bounds.x + bounds.width) { + boxX = selLeft - boxW - 10; + if (boxX < bounds.x) boxX = bounds.x; + } } float boxY = selCenterY - boxH / 2.0f; if (boxY < bounds.y) boxY = bounds.y; @@ -488,7 +496,7 @@ void DrawCursorReadout(Rectangle bounds) { if (!app.loaded || !app.stftComputed || app.signal.samples == NULL) return; if (app.sel.isTimeSelecting || app.sel.isFreqSelecting || app.sel.isDragging || - app.view.isPanning) return; + app.view.isPanning || app.marker.dragging) return; Vector2 m = GetMousePosition(); if (!CheckCollisionPointRec(m, bounds)) return; @@ -530,6 +538,190 @@ void DrawCursorReadout(Rectangle bounds) DrawText(text, (int)bx + 6, (int)by + 4, fontSize, (Color){ 180, 220, 255, 255 }); } +// ============================================================================ +// Marker / delta ruler +// ============================================================================ + +// Map a marker's normalized (t, f) to a screen point inside `bounds`, honoring +// the current zoom. Returns false if it falls outside the visible view. +static bool MarkerToScreen(Rectangle bounds, float t, float f, Vector2* out) +{ + float vw = app.view.end - app.view.start; + float fw = app.view.freqEnd - app.view.freqStart; + if (vw <= 0.0f || fw <= 0.0f) return false; + float tx = (t - app.view.start) / vw; + float fy = (f - app.view.freqStart) / fw; + out->x = bounds.x + tx * bounds.width; + out->y = bounds.y + bounds.height - fy * bounds.height; + return (tx >= -0.05f && tx <= 1.05f && fy >= -0.05f && fy <= 1.05f); +} + +static void DrawMarkerCross(Vector2 p, Color c, const char* tag) +{ + DrawLine((int)p.x - 7, (int)p.y, (int)p.x + 7, (int)p.y, c); + DrawLine((int)p.x, (int)p.y - 7, (int)p.x, (int)p.y + 7, c); + DrawCircleLines((int)p.x, (int)p.y, 4, c); + DrawText(tag, (int)p.x + 7, (int)p.y - 14, 10, c); +} + +// Two-point ruler overlay: crosshairs at A and B, a connecting line, and a +// readout of the time/frequency deltas plus the ham-useful derived rates +// (tone spacing 1/Δt and drift Δf/Δt). +void DrawMarkers(Rectangle bounds) +{ + if (!app.markerMode || !app.marker.active || !app.loaded) return; + + Vector2 a, b; + bool aIn = MarkerToScreen(bounds, app.marker.t0, app.marker.f0, &a); + bool bIn = MarkerToScreen(bounds, app.marker.t1, app.marker.f1, &b); + + BeginScissorMode((int)bounds.x, (int)bounds.y, (int)bounds.width, (int)bounds.height); + DrawLineEx(a, b, 1.0f, Fade(ORANGE, 0.8f)); + if (aIn) DrawMarkerCross(a, ORANGE, "A"); + if (bIn) DrawMarkerCross(b, (Color){ 255, 180, 80, 255 }, "B"); + EndScissorMode(); + + // Compute deltas in real units. + float nyquist = app.signal.sampleRate * 0.5f; + float ta = app.marker.t0 * app.signal.duration, tb = app.marker.t1 * app.signal.duration; + float fa = app.marker.f0 * nyquist, fb = app.marker.f1 * nyquist; + float dt = fabsf(tb - ta); + float df = fabsf(fb - fa); + + char lines[6][64]; + int n = 0; + sprintf(lines[n++], "A: %.3fs %.0fHz", ta, fa); + sprintf(lines[n++], "B: %.3fs %.0fHz", tb, fb); + if (dt >= 1.0f) sprintf(lines[n++], "dt: %.3f s", dt); + else if (dt >= 0.001f) sprintf(lines[n++], "dt: %.1f ms", dt * 1000.0f); + else sprintf(lines[n++], "dt: %.3f ms", dt * 1000.0f); + sprintf(lines[n++], "df: %.1f Hz", df); + if (dt > 1e-6f) sprintf(lines[n++], "1/dt: %.2f Hz", 1.0f / dt); + if (dt > 1e-6f) sprintf(lines[n++], "slope: %.0f Hz/s", (fb - fa) / dt); + + int fontSize = 10; + int maxW = 0; + for (int i = 0; i < n; i++) { int w = MeasureText(lines[i], fontSize); if (w > maxW) maxW = w; } + int boxW = maxW + 16, boxH = n * 14 + 10; + + // Anchor near B (or A if B is off-view), clamped inside bounds. + Vector2 anchor = bIn ? b : a; + float bx = anchor.x + 12, by = anchor.y + 12; + if (bx + boxW > bounds.x + bounds.width) bx = anchor.x - boxW - 12; + if (bx < bounds.x) bx = bounds.x; + if (by + boxH > bounds.y + bounds.height) by = bounds.y + bounds.height - boxH; + if (by < bounds.y) by = bounds.y; + + DrawRectangle((int)bx, (int)by, boxW, boxH, (Color){ 0, 0, 0, 210 }); + DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(ORANGE, 0.7f)); + for (int i = 0; i < n; i++) + DrawText(lines[i], (int)bx + 8, (int)by + 6 + i * 14, fontSize, (Color){ 255, 210, 160, 255 }); +} + +// ============================================================================ +// Spectrum slice (averaged PSD of the selection / view) +// ============================================================================ + +// Floating panel plotting the time-averaged power spectrum over frequency. +// Region: the selection box if one exists, otherwise the visible view. The +// frequency axis spans that band; the curve is power in dB (auto-ranged). +void DrawSpectrumPanel(Rectangle bounds) +{ + if (!app.showSpectrum || !app.loaded || !app.stftComputed) return; + if (app.stft.numSegments <= 0) return; + + bool hasSel = (app.sel.timeStart > 0.001f || app.sel.timeEnd < 0.999f || + app.sel.freqStart > 0.001f || app.sel.freqEnd < 0.999f); + float t0 = hasSel ? app.sel.timeStart : app.view.start; + float t1 = hasSel ? app.sel.timeEnd : app.view.end; + float f0 = hasSel ? app.sel.freqStart : app.view.freqStart; + float f1 = hasSel ? app.sel.freqEnd : app.view.freqEnd; + if (f1 < f0) { float t = f0; f0 = f1; f1 = t; } + + int maxBins = FFT_SIZE_MAX / 2 + 1; + float* power = (float*)malloc(maxBins * sizeof(float)); + if (!power) return; + int nbins = ComputePowerSpectrum(&app.stft, t0, t1, power, maxBins); + if (nbins < 2) { free(power); return; } + + float nyquist = app.signal.sampleRate * 0.5f; + float binHz = nyquist / (float)(nbins - 1); + float freqLow = f0 * nyquist, freqHigh = f1 * nyquist; + int binLo = (int)floorf(freqLow / binHz); + int binHi = (int)ceilf(freqHigh / binHz); + if (binLo < 0) binLo = 0; + if (binHi > nbins - 1) binHi = nbins - 1; + if (binHi <= binLo) { free(power); return; } + + // Auto-range the dB axis over the displayed band. + float maxDb = -200.0f, minDb = 200.0f; + int peakBin = binLo; + for (int b = binLo; b <= binHi; b++) { + float db = 10.0f * log10f(power[b] + 1e-20f); + if (db > maxDb) { maxDb = db; peakBin = b; } + if (db < minDb) minDb = db; + } + if (maxDb - minDb < 6.0f) minDb = maxDb - 6.0f; // avoid a flat line filling the panel + float ceilDb = maxDb + 2.0f, floorDb = minDb - 2.0f; + float rangeDb = ceilDb - floorDb; + + // Panel: top-right of the viewport, semi-transparent. + float scale = GetUIScale(); + float panelW = fminf(360.0f * scale, bounds.width * 0.55f); + float panelH = 170.0f * scale; + Rectangle panel = { bounds.x + bounds.width - panelW - 10.0f * scale, bounds.y + 10.0f * scale, + panelW, panelH }; + DrawRectangleRec(panel, (Color){ 12, 14, 20, 220 }); + DrawRectangleLinesEx(panel, 1, Fade(SKYBLUE, 0.7f)); + + // Plot area inside the panel (leave room for labels). + float padL = 6.0f * scale, padR = 6.0f * scale, padT = 18.0f * scale, padB = 14.0f * scale; + Rectangle plot = { panel.x + padL, panel.y + padT, + panel.width - padL - padR, panel.height - padT - padB }; + + DrawText(hasSel ? "Spectrum (selection)" : "Spectrum (view)", + (int)(panel.x + 6 * scale), (int)(panel.y + 4 * scale), 10, (Color){ 180, 220, 255, 255 }); + + float freqSpan = freqHigh - freqLow; + if (freqSpan < 1e-6f) freqSpan = 1e-6f; + + // The curve: one polyline vertex per pixel column, mapping x -> freq -> bin. + Vector2 prev = { 0 }; + bool havePrev = false; + for (int px = 0; px <= (int)plot.width; px++) { + float fr = freqLow + (px / plot.width) * freqSpan; + int b = (int)(fr / binHz + 0.5f); + if (b < 0) b = 0; + if (b > nbins - 1) b = nbins - 1; + float db = 10.0f * log10f(power[b] + 1e-20f); + float norm = (db - floorDb) / rangeDb; + norm = Clamp(norm, 0.0f, 1.0f); + Vector2 cur = { plot.x + px, plot.y + plot.height - norm * plot.height }; + if (havePrev) DrawLineV(prev, cur, (Color){ 120, 220, 255, 255 }); + prev = cur; + havePrev = true; + } + + // Peak marker + label. + float peakFr = peakBin * binHz; + float peakX = plot.x + ((peakFr - freqLow) / freqSpan) * plot.width; + if (peakX >= plot.x && peakX <= plot.x + plot.width) { + DrawLine((int)peakX, (int)plot.y, (int)peakX, (int)(plot.y + plot.height), Fade(YELLOW, 0.5f)); + } + + // Axis labels: frequency at the ends, peak dB at top-left of the plot. + char lbl[32]; + sprintf(lbl, "%.0f Hz", freqLow); + DrawText(lbl, (int)plot.x, (int)(panel.y + panel.height - 12 * scale), 9, GRAY); + sprintf(lbl, "%.0f Hz", freqHigh); + DrawText(lbl, (int)(plot.x + plot.width - MeasureText(lbl, 9)), + (int)(panel.y + panel.height - 12 * scale), 9, GRAY); + sprintf(lbl, "pk %.0fHz %.0fdB", peakFr, maxDb); + DrawText(lbl, (int)(plot.x + 2), (int)(plot.y + 1), 9, Fade(YELLOW, 0.85f)); + + free(power); +} + // ============================================================================ // Playhead // ============================================================================ diff --git a/src/render.h b/src/render.h index 5874175..ed9c820 100644 --- a/src/render.h +++ b/src/render.h @@ -26,6 +26,8 @@ void DrawLabels(Rectangle bounds); void DrawSelection(Rectangle bounds); void DrawSelectionDrag(Rectangle bounds); void DrawCursorReadout(Rectangle bounds); +void DrawMarkers(Rectangle bounds); +void DrawSpectrumPanel(Rectangle bounds); void DrawPlayhead(Rectangle bounds); #endif // RENDER_H diff --git a/src/spectrogram.c b/src/spectrogram.c index 71fa197..93bea5e 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -128,6 +128,8 @@ void ResetForNewSignal(void) app.sel.isDragging = false; app.sel.isTimeSelecting = false; app.sel.isFreqSelecting = false; + app.marker.active = false; + app.marker.dragging = false; app.isDividing = false; // Stop any playback from the previous signal and rewind the playhead. @@ -155,6 +157,8 @@ static void ActionToggleAbout(void) { app.showAbout = !app.showAbout; } static void ActionToggleFullscreen(void){ ToggleFullscreen(); } static void ActionExport(void) { ExportPNG(&app, app.exportDir); } static void ActionExportWav(void) { ExportSelectionWAV(app.exportDir); } +static void ActionToggleMarker(void) { app.markerMode = !app.markerMode; } +static void ActionToggleSpectrum(void) { app.showSpectrum = !app.showSpectrum; } static void ActionResetView(void) { @@ -179,6 +183,8 @@ static const KeyBinding KEYMAP[] = { { KEY_END, KEYGATE_MODAL | KEYGATE_LOADED,ActionZoomToStart, "End", "zoom to start" }, { KEY_E, KEYGATE_MODAL | KEYGATE_STFT, ActionExport, "E", "export PNG" }, { KEY_W, KEYGATE_MODAL | KEYGATE_STFT, ActionExportWav, "W", "export selection WAV" }, + { KEY_M, KEYGATE_MODAL | KEYGATE_LOADED,ActionToggleMarker, "M", "marker / ruler tool" }, + { KEY_S, KEYGATE_MODAL | KEYGATE_STFT, ActionToggleSpectrum, "S", "spectrum slice (PSD)" }, // Order-sensitive: handled inline (see main loop), listed here for the overlay. { KEY_SPACE, KEYGATE_NONE, NULL, "Space", "play / stop selection" }, { KEY_ESCAPE,KEYGATE_NONE, NULL, "Esc", "clear selection / close dialog" }, @@ -542,6 +548,10 @@ int main(int argc, char* argv[]) app.showAbout = false; } else if (app.showFileBrowser) { app.showFileBrowser = false; + } else if (app.markerMode && app.marker.active) { + // Clear the marker measurement first when the ruler is active. + app.marker.active = false; + app.marker.dragging = false; } else { // Clear selections instead of exiting ClearSelection(); @@ -567,9 +577,10 @@ int main(int argc, char* argv[]) bool mouseNearDivider = mousePos.y >= (dividerScreenY - 5) && mousePos.y <= (dividerScreenY + 5) && mousePos.x >= selBounds.x && mousePos.x <= selBounds.x + selBounds.width; - // Right-click clears selection + // Right-click clears the marker measurement (in marker mode) or the selection. if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && CheckCollisionPointRec(mousePos, selBounds)) { - ClearSelection(); + if (app.markerMode) { app.marker.active = false; app.marker.dragging = false; } + else ClearSelection(); } // Check if click is inside existing selection (for dragging) @@ -614,6 +625,31 @@ int main(int argc, char* argv[]) } else { SetMouseCursor(MOUSE_CURSOR_DEFAULT); // Normal arrow } + + if (app.markerMode) { + // Marker/ruler mode: LMB press drops point A, dragging moves B, + // release finalizes. Alt / middle-drag still pans (handled + // above), so don't drop a marker while panning. + SetMouseCursor(MOUSE_CURSOR_CROSSHAIR); + bool altPan = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) || + IsMouseButtonDown(MOUSE_BUTTON_MIDDLE); + if (!altPan) { + float vt = (mousePos.x - selBounds.x) / selBounds.width; + float vf = 1.0f - (mousePos.y - selBounds.y) / selBounds.height; + float tHere = Clamp(app.view.start + vt * (app.view.end - app.view.start), 0.0f, 1.0f); + float fHere = Clamp(app.view.freqStart + vf * (app.view.freqEnd - app.view.freqStart), 0.0f, 1.0f); + if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + app.marker.t0 = tHere; app.marker.f0 = fHere; + app.marker.t1 = tHere; app.marker.f1 = fHere; + app.marker.dragging = true; + app.marker.active = true; + } + if (app.marker.dragging && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { + app.marker.t1 = tHere; app.marker.f1 = fHere; + } + if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.marker.dragging = false; + } + } else { if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { if (clickInsideSelection) { // Start dragging existing selection @@ -695,6 +731,7 @@ int main(int argc, char* argv[]) app.sel.isFreqSelecting = false; } } + } } // Handle divider drag. Works whether or not the scope is currently shown @@ -964,9 +1001,11 @@ int main(int argc, char* argv[]) if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f)); DrawSelection(viewBounds); DrawSelectionDrag(viewBounds); + DrawMarkers(viewBounds); DrawPlayhead(viewBounds); DrawLabels(viewBounds); if (!UiModalOpen()) DrawCursorReadout(viewBounds); + DrawSpectrumPanel(viewBounds); float maxFreq = (float)app.signal.sampleRate / 2.0f; float freqMin = app.view.freqStart * maxFreq; float freqMax = app.view.freqEnd * maxFreq; diff --git a/src/spectrogram_types.h b/src/spectrogram_types.h index f4c888b..836300d 100644 --- a/src/spectrogram_types.h +++ b/src/spectrogram_types.h @@ -106,6 +106,17 @@ typedef struct { float dragFreqStart; // selection freq start when the move began } Selection; +// Two-point ruler for measuring deltas on the spectrogram (Δt, Δf, baud, drift). +// Both points are 0-1 normalized: t over the whole signal, f as a fraction of +// Nyquist. A press-drag-release drops A at press and B at release; the readout +// stays on screen until cleared, so you can re-drag to re-measure. +typedef struct { + bool active; // a measurement exists (drawn + read out) + bool dragging; // mid-drag, placing point B + float t0, f0; // point A + float t1, f1; // point B +} MarkerTool; + // The visible window into the spectrogram (time + frequency), all 0-1 // normalized, plus the range captured at the start of a pan drag. typedef struct { @@ -132,6 +143,12 @@ typedef struct { // Time + frequency box selection and its drag/move interaction state. Selection sel; + // Two-point ruler tool. markerMode swaps the LMB-drag gesture from + // box-select to dropping markers; showSpectrum toggles the PSD slice panel. + MarkerTool marker; + bool markerMode; + bool showSpectrum; + // Export settings float exportScale; char exportDir[4096]; diff --git a/src/stft.c b/src/stft.c index 09cf3f8..af99910 100644 --- a/src/stft.c +++ b/src/stft.c @@ -366,6 +366,46 @@ static int CompareDouble(const void* a, const void* b) return (da > db) - (da < db); } +int ComputePowerSpectrum(const StftResult* stft, float t0, float t1, + float* power, int maxBins) +{ + if (!stft || stft->numSegments <= 0 || !power || maxBins <= 0) return 0; + + if (t1 < t0) { float tmp = t0; t0 = t1; t1 = tmp; } + t0 = fmaxf(0.0f, t0); t1 = fminf(1.0f, t1); + + int segStart = (int)(t0 * stft->numSegments); + int segEnd = (int)(t1 * stft->numSegments); + if (segStart < 0) segStart = 0; + if (segEnd > stft->numSegments) segEnd = stft->numSegments; + if (segEnd <= segStart) segEnd = (segStart < stft->numSegments) ? segStart + 1 : segStart; + + // Bin count from the first computed segment in range. + int nbins = 0; + for (int s = segStart; s < segEnd; s++) { + if (stft->segments[s].spectrum && stft->segments[s].numBins > 0) { + nbins = stft->segments[s].numBins; break; + } + } + if (nbins < 2) return 0; + if (nbins > maxBins) nbins = maxBins; + + for (int b = 0; b < nbins; b++) power[b] = 0.0f; + int counted = 0; + for (int s = segStart; s < segEnd; s++) { + const StftSegment* seg = &stft->segments[s]; + if (!seg->spectrum || seg->numBins < nbins) continue; + for (int b = 0; b < nbins; b++) { + float a = seg->spectrum[b].amplitude; + power[b] += a * a; + } + counted++; + } + if (counted == 0) return 0; + for (int b = 0; b < nbins; b++) power[b] /= counted; + return nbins; +} + SpectralStats ComputeSpectralStats(const StftResult* stft, float t0, float t1, float f0, float f1) { diff --git a/src/stft.h b/src/stft.h index 659c5c2..d047202 100644 --- a/src/stft.h +++ b/src/stft.h @@ -31,6 +31,14 @@ typedef struct { SpectralStats ComputeSpectralStats(const StftResult* stft, float t0, float t1, float f0, float f1); +// Averaged power spectrum (the "spectrum slice"/PSD curve). Fills the +// caller-provided power[] (length maxBins) with the mean linear power per bin +// (amplitude^2 averaged over the segments in time span [t0,t1], 0-1 +// normalized). Returns the number of bins written, 0 on failure. Convert to dB +// at draw time with 10*log10(power). +int ComputePowerSpectrum(const StftResult* stft, float t0, float t1, + float* power, int maxBins); + // --- FFT-size handling & cache --- void ChangeFFTSize(int newFFT); void SaveToCache(void); diff --git a/src/ui.c b/src/ui.c index 3e01f32..575634e 100644 --- a/src/ui.c +++ b/src/ui.c @@ -426,6 +426,23 @@ void DrawSidebar(void) DrawPanelBox(gridCheck, app.showGrid ? BLUE : DARKGRAY, WHITE); DrawTextScaled("Show Grid", x + 25 * scale, y + 2 * scale, 14, LIGHTGRAY); y += 28 * scale; + // Analysis toggles: marker/ruler tool and spectrum-slice (PSD) panel. + Rectangle markerBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale }; + if (Clicked(markerBtn)) app.markerMode = !app.markerMode; + DrawPanelBox(markerBtn, app.markerMode ? (Color){ 110, 70, 30, 255 } : (Color){ 50, 50, 60, 255 }, + app.markerMode ? ORANGE : GRAY); + DrawTextScaled(app.markerMode ? "Marker Tool: ON (M)" : "Marker Tool (M)", + markerBtn.x + 10 * scale, markerBtn.y + 5 * scale, 13, WHITE); + y += 28 * scale; + + Rectangle specBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale }; + if (Clicked(specBtn)) app.showSpectrum = !app.showSpectrum; + DrawPanelBox(specBtn, app.showSpectrum ? (Color){ 30, 70, 90, 255 } : (Color){ 50, 50, 60, 255 }, + app.showSpectrum ? SKYBLUE : GRAY); + DrawTextScaled(app.showSpectrum ? "Spectrum: ON (S)" : "Spectrum Slice (S)", + specBtn.x + 10 * scale, specBtn.y + 5 * scale, 13, WHITE); + y += 30 * scale; + // File loading DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale; Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };