// render.c - colormaps, spectrogram texture generation, and on-screen drawing
#include "render.h"
#include "stft.h"   // ComputeSpectralStats for the selection panel
#include "utils.h"  // ComputeSignalStats
#include "audio.h"  // PlaySelectedRegion for the selection transport

#include <math.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>

// ===== UI scaling and scaled text =====
// Base resolution for proportional UI scaling.
// GetUIScale() uses logical screen (not framebuffer) dimensions so that
// layout stays based on window size alone. FLAG_WINDOW_HIGHDPI makes
// BeginDrawing() render to the framebuffer at the correct resolution, so
// every 1px drawn in layout coordinates automatically maps to the right
// physical size on any monitor.
float GetUIScale(void)
{
    float scaleX = (float)GetScreenWidth() / BASE_WIDTH;
    float scaleY = (float)GetScreenHeight() / BASE_HEIGHT;
    return (scaleX + scaleY) / 2.0f;
}

// ===== DPI-aware UI font atlas =====
// The font is drawn at a *logical* size (baseSize * GetUIScale()), but the
// FLAG_WINDOW_HIGHDPI framebuffer then scales that to physical pixels by the
// monitor's DPI factor. So the glyphs a user actually sees are sized
// baseSize * GetUIScale() * dpiScale physical pixels. If the atlas is rasterized
// at a fixed size (the old behavior: a single 16px atlas) it gets upscaled on
// HiDPI (soft but acceptable) and *downscaled* on standard DPI, which thins the
// stems into a faint, "hyper-skinny" look. To keep text crisp on every display
// we bake the atlas at that physical size and rebuild it when the effective
// density changes. Call sites are unchanged: DrawTextEx still scales the atlas
// glyph (font.baseSize px) down to the logical draw size, so only the backing
// resolution differs.
static char s_fontPath[1024] = { 0 };
static int  s_fontAtlasBase  = 0;  // pixel size the current atlas is baked at

// Largest base size any DrawTextScaled() call passes; the atlas is sized for
// this so all smaller text only ever downsamples (which is clean) from it.
#define UI_FONT_REF_SIZE 16.0f

void InitUIFont(const char* path)
{
    snprintf(s_fontPath, sizeof(s_fontPath), "%s", path);
    s_fontAtlasBase = 0;
    EnsureUIFont();
}

void EnsureUIFont(void)
{
    if (s_fontPath[0] == 0) return;  // InitUIFont not called yet

    Vector2 dpi = GetWindowScaleDPI();
    float density = GetUIScale() * (dpi.y > 0.0f ? dpi.y : 1.0f);
    if (density < 0.1f) density = 1.0f;

    // Quantize to 4px steps so a live resize drag crosses a rebuild boundary
    // only every ~320px instead of every frame; round up so we never undersample.
    int target = (int)(UI_FONT_REF_SIZE * density + 0.5f);
    target = (target + 3) & ~3;
    if (target < 16)  target = 16;
    if (target > 128) target = 128;  // sanity cap on atlas size
    if (target == s_fontAtlasBase) return;

    Font f = LoadFontEx(s_fontPath, target, 0, 0);
    if (f.texture.id == 0) return;  // keep the existing atlas on failure
    // Mipmaps + trilinear so downsampling to small on-screen sizes stays smooth
    // rather than aliasing the way the default point filter does.
    GenTextureMipmaps(&f.texture);
    SetTextureFilter(f.texture, TEXTURE_FILTER_TRILINEAR);

    if (mainFont.texture.id != 0) UnloadFont(mainFont);
    mainFont = f;
    s_fontAtlasBase = target;
}

void DrawTextScaled(const char* text, float x, float y, float baseSize, Color color)
{
    if (mainFont.texture.id == 0) {
        // Fallback to default if font not loaded
        DrawText(text, (int)x, (int)y, (int)baseSize, color);
        return;
    }
    float scaledSize = baseSize * GetUIScale();
    float spacing = scaledSize * 0.25f;  // 25% of font size for spacing
    DrawTextEx(mainFont, text, (Vector2){ x, y }, scaledSize, spacing, color);
}

float MeasureTextScaled(const char* text, float baseSize)
{
    if (mainFont.texture.id == 0) return MeasureText(text, (int)baseSize);
    float scaledSize = baseSize * GetUIScale();
    float spacing = scaledSize * 0.25f;
    return MeasureTextEx(mainFont, text, scaledSize, spacing).x;
}

// ===== Colormaps =====
// Each colormap maps t in [0,1] to a Color. To add a colormap: add an enum
// value in spectrogram_types.h, write a Cmap* function, and add one row to
// COLORMAPS[] below — the sidebar names and lookups follow automatically.
typedef Color (*ColormapFn)(float t);

static Color CmapGrays(float t)
{
    unsigned char v = (unsigned char)(t * 255);
    return (Color){ v, v, v, 255 };
}

static Color CmapInferno(float t)
{
    float r = 0.0f, g = 0.0f, b = 0.0f;
    if (t < 0.25f) { t = t / 0.25f; r = 0.0f + t * 0.5f; g = 0.0f; b = 0.0f + t * 0.3f; }
    else if (t < 0.5f) { t = (t - 0.25f) / 0.25f; r = 0.5f + t * 0.5f; g = 0.0f + t * 0.3f; b = 0.3f + t * 0.4f; }
    else if (t < 0.75f) { t = (t - 0.5f) / 0.25f; r = 1.0f; g = 0.3f + t * 0.5f; b = 0.7f + t * 0.2f; }
    else { t = (t - 0.75f) / 0.25f; r = 1.0f; g = 0.8f + t * 0.2f; b = 0.9f + t * 0.1f; }
    return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
}

static Color CmapViridis(float t)
{
    float r, g, b;
    if (t < 0.25f) { t = t / 0.25f; r = 0.27f + t * 0.13f; g = 0.00f + t * 0.33f; b = 0.33f + t * 0.27f; }
    else if (t < 0.5f) { t = (t - 0.25f) / 0.25f; r = 0.40f + t * 0.16f; g = 0.33f + t * 0.29f; b = 0.60f - t * 0.20f; }
    else if (t < 0.75f) { t = (t - 0.5f) / 0.25f; r = 0.56f + t * 0.24f; g = 0.62f + t * 0.23f; b = 0.40f - t * 0.20f; }
    else { t = (t - 0.75f) / 0.25f; r = 0.80f + t * 0.17f; g = 0.85f + t * 0.12f; b = 0.20f - t * 0.15f; }
    return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
}

static Color CmapPlasma(float t)
{
    float r = 0.05f + t * 0.9f;
    float g = 0.0f + t * 0.6f + (t > 0.5f ? (t - 0.5f) * 0.4f : 0.0f);
    float b = 0.6f - t * 0.5f;
    return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
}

static Color CmapHot(float t)
{
    float r = Clamp(t * 3.0f, 0.0f, 1.0f);
    float g = Clamp((t - 0.33f) * 3.0f, 0.0f, 1.0f);
    float b = Clamp((t - 0.66f) * 3.0f, 0.0f, 1.0f);
    return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
}

static Color CmapCool(float t)
{
    return (Color){ (unsigned char)(t * 255), (unsigned char)((1.0f - t) * 255), 255, 255 };
}

typedef struct { const char* name; ColormapFn fn; } ColormapDef;

static const ColormapDef COLORMAPS[COLORMAP_COUNT] = {
    [COLORMAP_GRAYS]   = { "Grays",   CmapGrays },
    [COLORMAP_INFERNO] = { "Inferno", CmapInferno },
    [COLORMAP_VIRIDIS] = { "Viridis", CmapViridis },
    [COLORMAP_PLASMA]  = { "Plasma",  CmapPlasma },
    [COLORMAP_HOT]     = { "Hot",     CmapHot },
    [COLORMAP_COOL]    = { "Cool",    CmapCool },
};

Color GetColormapColor(float t, ColormapType type)
{
    if (type < 0 || type >= COLORMAP_COUNT) return GRAY;
    return COLORMAPS[type].fn(Clamp(t, 0.0f, 1.0f));
}

const char* ColormapName(ColormapType type)
{
    if (type < 0 || type >= COLORMAP_COUNT) return "?";
    return COLORMAPS[type].name;
}

void GenerateColormapTexture(void)
{
    if (colormapTexture.id != 0) UnloadTexture(colormapTexture);
    Image img = GenImageColor(256, 1, WHITE);
    Color* pixels = (Color*)img.data;
    for (int i = 0; i < 256; i++) pixels[i] = GetColormapColor(i / 255.0f, app.colormap);
    colormapTexture = LoadTextureFromImage(img);
    UnloadImage(img);
}

// ===== Spectrogram texture =====
// ===== SYNCHROSQUEEZING (energy reassignment) =====
// The expensive part: reassign energy to true frequencies using the derivative
// STFT. Depends only on the STFT data, so the result is cached in
// app.reassignBuffer and reused by ColorizeSpectrogram across dB-floor /
// colormap changes (which don't need to recompute any of this).
static void ComputeSpectrogramReassignment(StftResult* stft)
{
    if (stft->numSegments == 0) return;
    int height = stft->segments[0].numBins;
    int fftSize = (height - 1) * 2;
    float freqPerBin = (float)stft->sampleRate / fftSize;

    // One image column per STFT segment is fine for short files, but a long
    // capture has far more segments than any texture can hold: a 5.7-hour file
    // at 12 kHz yields ~478k segments, i.e. a 478000x1025 RGBA image (~2 GB)
    // that blows past the ~16k GPU texture limit. The upload then fails, the
    // texture id stays 0, and the spectrogram silently renders as nothing.
    //
    // Only the segments currently on screen are rendered, and the width is
    // capped. Building the whole file at once and cropping afterwards would
    // either exceed the texture limit (as above) or, if globally downsampled,
    // permanently throw away the detail that zooming in is supposed to reveal.
    // Restricting to the visible span keeps resolution tied to the zoom level:
    // the further in you go, the fewer segments share a column.
    int segFirst = app.reassignSegFirst;
    int segLast  = app.reassignSegLast;
    if (segFirst < 0) segFirst = 0;
    if (segLast > stft->numSegments) segLast = stft->numSegments;
    if (segLast <= segFirst) { segFirst = 0; segLast = stft->numSegments; }
    int segSpan = segLast - segFirst;

    // Fold multiple segments into each column when the span still exceeds the
    // cap, keeping the per-bin MAX (not a mean) so a short burst lights its
    // column up instead of being averaged into the noise floor — the same
    // reason the waveform scope draws min/max rather than decimated samples.
    int segsPerCol = (segSpan + MAX_SPECTRO_IMAGE_WIDTH - 1) / MAX_SPECTRO_IMAGE_WIDTH;
    if (segsPerCol < 1) segsPerCol = 1;
    int width = (segSpan + segsPerCol - 1) / segsPerCol;
    if (width < 1) width = 1;

    // (Re)allocate the cached accumulation buffer for reassigned energy.
    free(app.reassignBuffer);
    app.reassignBuffer = (float*)calloc((size_t)width * height, sizeof(float));
    if (app.reassignBuffer == NULL) {
        app.reassignWidth = 0;
        app.reassignHeight = 0;
        return;
    }
    app.reassignWidth = width;
    app.reassignHeight = height;
    app.reassignSegsPerCol = segsPerCol;
    float* accumBuffer = app.reassignBuffer;

    // Find max amplitude for normalization (skip NULL segments)
    // Normalize against the visible span only — scanning the whole file would
    // put the cost back on total duration, which is what this range-limited
    // rebuild exists to avoid. It also means the colour scale adapts to what
    // is on screen rather than to a loud burst somewhere else in the capture.
    float maxAmplitude = 0.0001f;
    for (int seg = segFirst; seg < segLast; seg++) {
        if (stft->segments[seg].spectrum == NULL) continue;
        for (int bin = 0; bin < stft->segments[seg].numBins; bin++)
            if (stft->segments[seg].spectrum[bin].amplitude > maxAmplitude)
                maxAmplitude = stft->segments[seg].spectrum[bin].amplitude;
    }

    // Noise threshold: only reassign bins with significant energy
    float noiseThreshold = maxAmplitude * 0.01f;  // 1% of max amplitude

    for (int seg = segFirst; seg < segLast; seg++) {
        // Skip segments that haven't been computed yet (overview/high-res transition)
        if (stft->segments[seg].spectrum == NULL) continue;

        // Column this segment lands in, relative to the start of the range.
        int col = (seg - segFirst) / segsPerCol;
        if (col >= width) col = width - 1;
        if (col < 0) col = 0;

        for (int bin = 0; bin < height; bin++) {
            FrequencyData* V_f = &stft->segments[seg].spectrum[bin];
            FrequencyData* V_fd = &stft->segments[seg].derivativeSpectrum[bin];
            
            float amplitude = V_f->amplitude;
            
            // Skip noise bins
            if (amplitude < noiseThreshold) continue;
            
            // Compute instantaneous frequency using synchrosqueezing formula:
            // ω̂ = bin_freq + Re[V_fd / (i * V_f)]
            // Complex division: (a+bi)/(c+di) = ((ac+bd) + (bc-ad)i) / (c²+d²)
            // We need Re[(a+bi) / (i*(c+di))] = Re[(a+bi) / (-d+ci)] = (ad+bc)/(c²+d²)
            
            float V_f_real = amplitude * cosf(V_f->phase);
            float V_f_imag = amplitude * sinf(V_f->phase);
            float V_fd_real = V_fd->amplitude * cosf(V_fd->phase);
            float V_fd_imag = V_fd->amplitude * sinf(V_fd->phase);
            
            float denom = V_f_real * V_f_real + V_f_imag * V_f_imag;
            
            float trueFreq = V_f->frequency;  // Default to bin frequency
            
            if (denom > 1e-10f) {
                // Re[V_fd / (i * V_f)] = (-V_fd_real * V_f_imag + V_fd_imag * V_f_real) / denom
                // Note the MINUS sign on the first term
                float correction = (-V_fd_real * V_f_imag + V_fd_imag * V_f_real) / denom;
                trueFreq = V_f->frequency + correction;
            }
            
            // Clamp to valid range
            if (trueFreq < 0) trueFreq = 0;
            if (trueFreq >= stft->sampleRate / 2.0f) trueFreq = stft->sampleRate / 2.0f - 1;
            
            // Map to bin coordinate
            float targetBinF = trueFreq / freqPerBin;
            if (targetBinF < 0) targetBinF = 0;
            if (targetBinF >= height) targetBinF = height - 0.001f;
            
            // Bilinear splatting to neighboring bins
            int bin0 = (int)targetBinF;
            int bin1 = bin0 + 1;
            if (bin1 >= height) bin1 = height - 1;
            
            float frac = targetBinF - bin0;
            int idx0 = (height - 1 - bin0) * width + col;
            int idx1 = (height - 1 - bin1) * width + col;
            
            // Within a column the bilinear splat accumulates as before. Across
            // segments folded into one column take the max, so a brief loud
            // burst isn't diluted by its quiet neighbours.
            float e0 = amplitude * (1 - frac);
            float e1 = amplitude * frac;
            if (segsPerCol == 1) {
                accumBuffer[idx0] += e0;
                accumBuffer[idx1] += e1;
            } else {
                if (e0 > accumBuffer[idx0]) accumBuffer[idx0] = e0;
                if (e1 > accumBuffer[idx1]) accumBuffer[idx1] = e1;
            }
        }
    }
}

// Map the cached reassignment buffer to colors using the current dB floor/
// ceiling and colormap. Cheap — safe to call every frame the dB slider moves
// or when the colormap changes (no synchrosqueezing recompute).
// CPU half of colorization: map the cached reassignment buffer into an RGBA
// Image. No GL — safe to call with no window / no X server (headless render).
static void ColorizeIntoImage(Image* image)
{
    if (app.reassignBuffer == NULL) return;
    int width = app.reassignWidth;
    int height = app.reassignHeight;
    float* accumBuffer = app.reassignBuffer;

    UnloadImage(*image);  // release previous image (NULL-safe on first call)
    *image = GenImageColor(width, height, BLACK);
    Color* pixels = (Color*)image->data;

    // Guard the range: if the floor reaches/exceeds the ceiling the division
    // would otherwise go to ~0 (everything clamps white) or negative (the
    // colors invert). A >=1 dB range degrades gracefully to a hard threshold.
    float range = app.amplitudeCeilingDb - app.amplitudeFloorDb;
    if (range < 1.0f) range = 1.0f;

    for (int i = 0; i < width * height; i++) {
        if (accumBuffer[i] > 0.0001f) {
            float db = AmplitudeToDecibels(accumBuffer[i]);
            float normalized = (db - app.amplitudeFloorDb) / range;
            normalized = Clamp(normalized, 0.0f, 1.0f);
            pixels[i] = GetColormapColor(normalized, app.colormap);
        }
    }
}

void ColorizeSpectrogram(Image* image, Texture2D* texture)
{
    ColorizeIntoImage(image);
    if (image->data == NULL) return;

    if (texture->id != 0) UnloadTexture(*texture);
    *texture = LoadTextureFromImage(*image);
    SetTextureFilter(*texture, TEXTURE_FILTER_BILINEAR);
}

// Headless spectrogram bitmap: reassignment + colorize straight into an Image,
// with no texture upload. This is the GL-free equivalent of
// GenerateSpectrogramTexture, used by the `--render` path so a PNG can be
// produced with no window and no X server at all.
void BuildSpectrogramImageCPU(StftResult* stft, Image* image)
{
    if (stft->numSegments == 0) return;
    ComputeSpectrogramReassignment(stft);
    ColorizeIntoImage(image);
}

// Recompute the reassignment (STFT changed) and rebuild the texture.
void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture)
{
    if (stft->numSegments == 0) return;
    ComputeSpectrogramReassignment(stft);
    ColorizeSpectrogram(image, texture);
}

// Compute auto-adjusted amplitude floor/ceiling from STFT data

// ===== Grid, labels, selection, playhead =====
// Axis tick spacing. Shared by the grid and the labels so a gridline always
// lands exactly on a labelled value: the grid used to draw a fixed number of
// even divisions of the viewport rectangle, which meant its lines sat at
// arbitrary times/frequencies and slid around under pan and zoom instead of
// marking anything.

// Frequency tick step (Hz) for a visible span.
static int FreqTickSpacing(float freqRange)
{
    if (freqRange < 20)    return 5;
    if (freqRange < 50)    return 10;
    if (freqRange < 200)   return 50;
    if (freqRange < 1000)  return 100;
    if (freqRange < 5000)  return 200;
    if (freqRange < 20000) return 1000;
    if (freqRange < 50000) return 5000;
    return 10000;
}

// Coarser step for labels, so text stays readable where ticks are dense.
static int FreqLabelSpacing(int tickSpacing)
{
    if (tickSpacing <= 10)   return 10;
    if (tickSpacing <= 50)   return 50;
    if (tickSpacing <= 200)  return 200;
    if (tickSpacing <= 1000) return 1000;
    if (tickSpacing <= 5000) return 5000;
    return 10000;
}

// Time tick step (seconds) for a visible span: a 1-2-5 ladder, which keeps the
// values human-readable at every zoom (0.1, 0.2, 0.5, 1, 2, 5, ...).
static double TimeTickSpacing(double spanSec, int targetDivisions)
{
    if (spanSec <= 0.0 || targetDivisions <= 0) return 1.0;
    double raw = spanSec / targetDivisions;
    double mag = pow(10.0, floor(log10(raw)));
    double n   = raw / mag;
    double step;
    if (n <= 1.0)      step = 1.0;
    else if (n <= 2.0) step = 2.0;
    else if (n <= 5.0) step = 5.0;
    else               step = 10.0;
    return step * mag;
}

// Grid drawn at real axis values rather than even divisions of the rectangle,
// so every line marks a round time/frequency and stays locked to the labels
// while panning and zooming. numCellsX/Y are a *target* density; the actual
// spacing snaps to the same ladders the labels use.
void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color)
{
    if (bounds.width <= 0 || bounds.height <= 0) return;

    // --- Vertical lines: time ---
    double duration = app.signal.duration;
    double viewSpan = (double)(app.view.end - app.view.start);
    if (duration > 0.0 && viewSpan > 0.0) {
        double spanSec  = viewSpan * duration;
        double startSec = (double)app.view.start * duration;
        double endSec   = startSec + spanSec;
        double step     = TimeTickSpacing(spanSec, numCellsX);

        double first = ceil(startSec / step) * step;
        for (double t = first; t <= endSec; t += step) {
            float frac = (float)((t - startSec) / spanSec);
            float x = bounds.x + frac * bounds.width;
            if (x < bounds.x || x > bounds.x + bounds.width) continue;
            DrawLineV((Vector2){ x, bounds.y },
                      (Vector2){ x, bounds.y + bounds.height }, color);
        }
    }

    // --- Horizontal lines: frequency ---
    float maxFreq  = EffectiveMaxFreqHz();
    float freqMin  = app.view.freqStart * maxFreq;
    float freqMax  = app.view.freqEnd * maxFreq;
    float freqRange = freqMax - freqMin;
    if (freqRange > 0.0f) {
        // Match the labels' own step so lines and text coincide, but fall back
        // to the finer tick step when that would leave too few lines to read as
        // a grid.
        int tick  = FreqTickSpacing(freqRange);
        int step  = FreqLabelSpacing(tick);
        if (freqRange / (float)step < (float)numCellsY * 0.5f) step = tick;
        if (step <= 0) step = 1;

        int first = ((int)(freqMin / step)) * step;
        if (first < freqMin) first += step;
        for (int hz = first; hz <= freqMax; hz += step) {
            float t = (hz - freqMin) / freqRange;
            float y = bounds.y + bounds.height - t * bounds.height;
            if (y < bounds.y || y > bounds.y + bounds.height) continue;
            DrawLineV((Vector2){ bounds.x, y },
                      (Vector2){ bounds.x + bounds.width, y }, color);
        }
    }
}

void DrawLabels(Rectangle bounds)
{
    int baseFontSize = 12;
    Color textColor = LIGHTGRAY;

    // Time labels. Precision tracks the zoom: the span across two adjacent
    // labels decides how many decimals are meaningful. Without this a deep
    // zoom prints the same "%.1fs" value in every slot, which reads as a
    // frozen axis even though the view is moving.
    // Placed on round values from the same 1-2-5 ladder the grid uses, not at
    // fixed fractions of the viewport: labels at i/10 of the view read as
    // arbitrary numbers ("3.47s") and never line up with a gridline.
    double viewSpanSec = (double)(app.view.end - app.view.start) * app.signal.duration;
    double startSec    = (double)app.view.start * app.signal.duration;
    double endSec      = startSec + viewSpanSec;
    double labelStepSec = TimeTickSpacing(viewSpanSec, 10);

    int decimals;
    if (labelStepSec >= 1.0)        decimals = 1;
    else if (labelStepSec >= 0.1)   decimals = 2;
    else if (labelStepSec >= 0.01)  decimals = 3;
    else                            decimals = 4;

    double firstLabel = ceil(startSec / labelStepSec) * labelStepSec;
    for (double ts = firstLabel; ts <= endSec && viewSpanSec > 0.0; ts += labelStepSec) {
        float x = bounds.x + (float)((ts - startSec) / viewSpanSec) * bounds.width;
        char label[32];
        // Past a minute the m:ss form stays readable only while the step is
        // coarse; zoomed in we need the fractional seconds inside the minute.
        if (ts >= 60.0) {
            int mins = (int)(ts / 60.0);
            double secs = ts - mins * 60.0;
            if (labelStepSec >= 1.0) sprintf(label, "%d:%02d", mins, (int)secs);
            else sprintf(label, "%d:%0*.*f", mins, decimals + 3, decimals, secs);
        } else {
            sprintf(label, "%.*fs", decimals, ts);
        }
        // Centre on the tick, then keep the first/last inside the viewport.
        float lw = MeasureTextScaled(label, baseFontSize);
        float lx = x - lw * 0.5f;
        if (lx < bounds.x) lx = bounds.x;
        if (lx + lw > bounds.x + bounds.width) lx = bounds.x + bounds.width - lw;
        DrawTextScaled(label, lx, bounds.y + bounds.height + 5, baseFontSize, textColor);
    }

    // Frequency labels adapted to current zoom level. Honors the display crop:
    // 1.0 of the view range is the user's chosen max, not raw Nyquist.
    float maxFreq = EffectiveMaxFreqHz();
    float freqMin = app.view.freqStart * maxFreq;
    float freqMax = app.view.freqEnd * maxFreq;

    // Same ladders the grid uses (see FreqTickSpacing), so a gridline and its
    // label are always the same value.
    float freqRange = freqMax - freqMin;
    int tickSpacing  = FreqTickSpacing(freqRange);
    int labelSpacing = FreqLabelSpacing(tickSpacing);

    // Round freqMin up to nearest tick spacing (smallest multiple >= freqMin)
    int firstTick = ((int)(freqMin / tickSpacing)) * tickSpacing;
    if (firstTick < freqMin) firstTick += tickSpacing;
    for (int hz = firstTick; hz <= freqMax; hz += tickSpacing) {
        float t = (hz - freqMin) / freqRange;
        float y = bounds.y + bounds.height - t * bounds.height;
        Color tickColor = (hz % 1000 == 0) ? GRAY : Fade(GRAY, 0.4f);
        DrawLineV((Vector2){ bounds.x - 5, y }, (Vector2){ bounds.x, y }, tickColor);
    }

    // Draw labels at the coarser spacing.
    //
    // Right-aligned against the axis and compacted ("2.4k", not "2400Hz"): the
    // old form was drawn at a fixed bounds.x-70 offset while the gutter is only
    // freqLabelWidth wide, so long labels overflowed left into the icon rail.
    // Measuring each label and aligning its right edge to the axis keeps every
    // label inside the gutter no matter how wide the value is. The unit is
    // stated once in the axis banner rather than repeated on every tick.
    // Pick ONE format for the whole axis rather than per-label, so the column
    // doesn't mix "3k" with "2.3k" and read as ragged. The decision is made
    // from the label spacing: if every label lands on a whole kHz they all get
    // "%dk", otherwise they all carry the same single decimal.
    bool useKilo = (freqMax >= 1000.0f);
    bool wholeKilo = useKilo && (labelSpacing % 1000 == 0);

    for (int hz = firstTick; hz <= freqMax; hz += labelSpacing) {
        float t = (hz - freqMin) / freqRange;
        float y = bounds.y + bounds.height - t * bounds.height;
        char label[32];
        if (!useKilo)       sprintf(label, "%d", hz);
        else if (wholeKilo) sprintf(label, "%dk", hz / 1000);
        else                sprintf(label, "%.1fk", hz / 1000.0f);
        float lw = MeasureTextScaled(label, baseFontSize);
        DrawTextScaled(label, bounds.x - 8 - lw, y - 5, baseFontSize, textColor);
    }
}

// Build the selection-box readout: time-domain stats (whole-band waveform in
// the time span) plus frequency-domain stats for the boxed band. Returns the
// number of lines written. Reads the global app state (sel / signal / stft).
static int BuildSelectionStatLines(char lines[][128], int maxLines)
{
    int startSample = (int)(app.sel.timeStart * app.signal.numSamples);
    int endSample   = (int)(app.sel.timeEnd * app.signal.numSamples);
    SignalStats t = ComputeSignalStats(&app.signal, startSample, endSample);
    if (t.durationSec <= 0.0f || app.signal.samples == NULL) return 0;

    int n = 0;
    if (n < maxLines) sprintf(lines[n++], "Duration: %.3fs", t.durationSec);
    if (n < maxLines) sprintf(lines[n++], "Energy: %.2f", t.energy);
    if (n < maxLines) sprintf(lines[n++], "Peak: %.3f", t.peakAmplitude);
    if (n < maxLines) sprintf(lines[n++], "RMS: %.3f", t.rmsAmplitude);
    if (n < maxLines) sprintf(lines[n++], "PAPR: %.1f dB", t.paprDb);

    if (app.stftComputed) {
        SpectralStats s = ComputeSpectralStats(&app.stft, app.sel.timeStart,
                              app.sel.timeEnd, app.sel.freqStart, app.sel.freqEnd);
        if (s.valid) {
            if (n < maxLines) sprintf(lines[n++], "Peak f: %.0f Hz", s.peakFreqHz);
            if (n < maxLines) sprintf(lines[n++], "Center: %.0f Hz", s.centroidHz);
            if (n < maxLines) sprintf(lines[n++], "Occ BW: %.0f Hz", s.bandwidthHz);
            if (n < maxLines) sprintf(lines[n++], "SNR: %.1f dB", s.snrDb);
        }
    }
    return n;
}

// Draw the selection readout box beside the (already-normalized, screen-space)
// selection rect `sel`, placed to its right or left and clamped to `bounds`.
static void DrawStatPanel(Rectangle bounds, Rectangle sel)
{
    char lines[12][128];
    int lineCount = BuildSelectionStatLines(lines, 12);
    if (lineCount == 0) return;

    int fontSize = 10;
    int maxTextW = 0;
    for (int i = 0; i < lineCount; i++) {
        int w = MeasureText(lines[i], fontSize);
        if (w > maxTextW) maxTextW = w;
    }
    int boxW = maxTextW + 20;
    int boxH = lineCount * 14 + 12;

    float selLeft = sel.x, selRight = sel.x + sel.width;
    float selCenterY = sel.y + sel.height * 0.5f;

    // 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 = 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;
    if (boxY + boxH > bounds.y + bounds.height) boxY = bounds.y + bounds.height - boxH;

    DrawRectangle((int)boxX, (int)boxY, boxW, boxH, (Color){ 0, 0, 0, 200 });
    DrawRectangleLines((int)boxX, (int)boxY, boxW, boxH, Fade(YELLOW, 0.6f));
    for (int i = 0; i < lineCount; i++) {
        DrawText(lines[i], (int)boxX + 10, (int)boxY + 8 + i * 14, fontSize, LIGHTGRAY);
    }
}


// ===== Selection transport bar =====
// Play controls attached to the selection box, so the common actions are where
// the user is already looking instead of over on the rail. Appears only when a
// region is selected.
//
// Geometry is split from drawing (SelectionTransportRect / TransportButtonRect)
// because input handling runs long before the draw pass, and a click here must
// be claimed before the pan/select handlers see it.

// Number of buttons: rewind-to-start, play/pause, stop, loop.
#define TRANSPORT_BUTTONS 4

Rectangle SelectionTransportRect(Rectangle bounds)
{
    bool hasSelection = (app.sel.timeStart > 0.001f || app.sel.timeEnd < 0.999f ||
                         app.sel.freqStart > 0.001f || app.sel.freqEnd < 0.999f);
    if (!app.loaded || !hasSelection || app.sel.isTimeSelecting || app.sel.isDragging)
        return (Rectangle){ 0, 0, 0, 0 };

    float scale = GetUIScale();
    float bw = 26.0f * scale;                       // per-button
    float w  = bw * TRANSPORT_BUTTONS + 8.0f * scale;
    float h  = 24.0f * scale;

    float viewWidth = app.view.end - app.view.start;
    if (viewWidth <= 0.0f) return (Rectangle){ 0, 0, 0, 0 };
    float x0 = bounds.x + ((app.sel.timeStart - app.view.start) / viewWidth) * bounds.width;
    float x1 = bounds.x + ((app.sel.timeEnd   - app.view.start) / viewWidth) * bounds.width;

    float freqWidth = app.view.freqEnd - app.view.freqStart;
    float yTop = bounds.y + bounds.height -
                 ((app.sel.freqEnd - app.view.freqStart) / freqWidth) * bounds.height;

    // Centred on the selection, just above its top edge; flipped below when
    // there isn't room, and clamped so it stays inside the viewport.
    float cx = (fmaxf(x0, bounds.x) + fminf(x1, bounds.x + bounds.width)) * 0.5f;
    float bx = cx - w * 0.5f;
    float by = yTop - h - 6.0f * scale;
    if (by < bounds.y + 2.0f) by = yTop + 6.0f * scale;
    if (by + h > bounds.y + bounds.height) by = bounds.y + bounds.height - h - 2.0f;
    if (bx < bounds.x) bx = bounds.x;
    if (bx + w > bounds.x + bounds.width) bx = bounds.x + bounds.width - w;

    // Off-screen selection: nothing to attach to.
    if (x1 < bounds.x || x0 > bounds.x + bounds.width) return (Rectangle){ 0, 0, 0, 0 };
    return (Rectangle){ bx, by, w, h };
}

static Rectangle TransportButtonRect(Rectangle bar, int i)
{
    float scale = GetUIScale();
    float bw = 26.0f * scale;
    return (Rectangle){ bar.x + 4.0f * scale + i * bw, bar.y + 2.0f * scale,
                        bw, bar.height - 4.0f * scale };
}

bool TransportCapturesMouse(Rectangle bounds)
{
    Rectangle bar = SelectionTransportRect(bounds);
    if (bar.width <= 0.0f) return false;
    return CheckCollisionPointRec(GetMousePosition(), bar);
}

// Act on a click. Separate from drawing so it runs during the input phase.
void UpdateSelectionTransport(Rectangle bounds)
{
    Rectangle bar = SelectionTransportRect(bounds);
    if (bar.width <= 0.0f) return;
    if (!IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) return;

    Vector2 m = GetMousePosition();
    for (int i = 0; i < TRANSPORT_BUTTONS; i++) {
        if (!CheckCollisionPointRec(m, TransportButtonRect(bar, i))) continue;
        switch (i) {
            case 0:  // rewind to the start of the selection
                app.playFromT = 0.0f;
                app.playheadT = 0.0f;
                if (app.isPlaying) {
                    StopSound(AudioPlaybackSound);
                    PlaySelectedRegion();
                    app.playbackFinished = false;
                }
                break;
            case 1:  // play / pause
                if (app.isPlaying) {
                    // Pause: remember where we are so play resumes from here.
                    float stopAbs = app.playSelStart +
                                    app.playheadT * (app.playSelEnd - app.playSelStart);
                    float selSpan = app.sel.timeEnd - app.sel.timeStart;
                    app.playFromT = (selSpan > 1e-6f)
                                  ? Clamp((stopAbs - app.sel.timeStart) / selSpan, 0.0f, 0.999f)
                                  : 0.0f;
                    StopSound(AudioPlaybackSound);
                    app.isPlaying = false;
                    app.playbackFinished = false;
                } else {
                    PlaySelectedRegion();
                    app.isPlaying = true;
                    app.playbackFinished = false;
                }
                break;
            case 2:  // stop: halt and rewind
                if (app.isPlaying) StopSound(AudioPlaybackSound);
                app.isPlaying = false;
                app.playbackFinished = false;
                app.playFromT = 0.0f;
                app.playheadT = 0.0f;
                break;
            case 3:
                app.loopPlayback = !app.loopPlayback;
                break;
        }
        break;
    }
}

void DrawSelectionTransport(Rectangle bounds)
{
    Rectangle bar = SelectionTransportRect(bounds);
    if (bar.width <= 0.0f) return;

    float scale = GetUIScale();
    Vector2 m = GetMousePosition();

    DrawRectangleRec(bar, (Color){ 26, 26, 32, 235 });
    DrawRectangleLinesEx(bar, 1, (Color){ 110, 110, 130, 255 });

    for (int i = 0; i < TRANSPORT_BUTTONS; i++) {
        Rectangle b = TransportButtonRect(bar, i);
        bool over = CheckCollisionPointRec(m, b);
        bool on   = (i == 3) ? app.loopPlayback : false;
        if (over) DrawRectangleRec(b, (Color){ 60, 60, 76, 255 });
        if (on)   DrawRectangleRec(b, (Color){ 50, 80, 60, 255 });

        Color c = on ? (Color){ 150, 230, 170, 255 } : (Color){ 210, 210, 220, 255 };
        float cx = b.x + b.width * 0.5f, cy = b.y + b.height * 0.5f;
        float s = b.height * 0.26f;

        switch (i) {
            case 0:  // |< rewind to start
                // Bar on the left, triangle pointing INTO it. raylib wants
                // counter-clockwise vertices; the previous winding put the apex
                // to the right of the base, so the triangle never rendered and
                // this read as a lone vertical bar.
                DrawRectangleRec((Rectangle){ cx - s, cy - s, 2.0f * scale, s * 2 }, c);
                DrawTriangle((Vector2){ cx - s + 3 * scale, cy },
                             (Vector2){ cx + s, cy + s },
                             (Vector2){ cx + s, cy - s }, c);
                break;
            case 1:  // play / pause
                if (app.isPlaying) {
                    DrawRectangleRec((Rectangle){ cx - s, cy - s, s * 0.7f, s * 2 }, c);
                    DrawRectangleRec((Rectangle){ cx + s * 0.3f, cy - s, s * 0.7f, s * 2 }, c);
                } else {
                    DrawTriangle((Vector2){ cx - s * 0.7f, cy - s },
                                 (Vector2){ cx - s * 0.7f, cy + s },
                                 (Vector2){ cx + s, cy }, c);
                }
                break;
            case 2:  // stop
                DrawRectangleRec((Rectangle){ cx - s * 0.85f, cy - s * 0.85f,
                                              s * 1.7f, s * 1.7f }, c);
                break;
            case 3:  // loop
                DrawRectangleLinesEx((Rectangle){ cx - s, cy - s * 0.7f, s * 2, s * 1.4f },
                                     1.4f, c);
                DrawTriangle((Vector2){ cx + s * 0.2f, cy - s * 1.3f },
                             (Vector2){ cx + s * 0.2f, cy - s * 0.1f },
                             (Vector2){ cx + s * 1.1f, cy - s * 0.7f }, c);
                break;
        }
    }
}

void DrawSelection(Rectangle bounds)
{
    // Only draw if selection is not full range AND not currently dragging
    bool hasSelection = (app.sel.timeStart > 0.001f || app.sel.timeEnd < 0.999f ||
                         app.sel.freqStart > 0.001f || app.sel.freqEnd < 0.999f);
    if (!hasSelection || app.sel.isTimeSelecting) return;  // Don't draw overlay while dragging
    
    Color overlayColor = Fade(BLACK, 0.25f);  // Lighter overlay
    
    // Convert signal coordinates to viewport coordinates
    float viewWidth = app.view.end - app.view.start;
    float freqWidth = app.view.freqEnd - app.view.freqStart;
    
    float selStartX = bounds.x + ((app.sel.timeStart - app.view.start) / viewWidth) * bounds.width;
    float selEndX = bounds.x + ((app.sel.timeEnd - app.view.start) / viewWidth) * bounds.width;
    float selStartY = bounds.y + bounds.height - ((app.sel.freqEnd - app.view.freqStart) / freqWidth) * bounds.height;
    float selEndY = bounds.y + bounds.height - ((app.sel.freqStart - app.view.freqStart) / freqWidth) * bounds.height;
    
    // Clamp to viewport bounds
    selStartX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selStartX));
    selEndX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selEndX));
    selStartY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selStartY));
    selEndY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selEndY));
    
    // Draw overlay outside the selection box
    DrawRectangle(bounds.x, bounds.y, selStartX - bounds.x, bounds.height, overlayColor);
    DrawRectangle(selEndX, bounds.y, bounds.x + bounds.width - selEndX, bounds.height, overlayColor);
    DrawRectangle(selStartX, bounds.y, selEndX - selStartX, selStartY - bounds.y, overlayColor);
    DrawRectangle(selStartX, selEndY, selEndX - selStartX, bounds.y + bounds.height - selEndY, overlayColor);
    
    // Draw selection box border
    DrawRectangleLinesEx((Rectangle){ selStartX, selStartY, selEndX - selStartX, selEndY - selStartY }, 2, YELLOW);

    // Readout box beside the selection.
    DrawStatPanel(bounds, (Rectangle){ fminf(selStartX, selEndX), fminf(selStartY, selEndY),
                                       fabsf(selEndX - selStartX), fabsf(selEndY - selStartY) });
}

void DrawSelectionDrag(Rectangle bounds)
{
    // Draw bounding box while dragging (no overlay)
    if ((!app.sel.isTimeSelecting && !app.sel.isFreqSelecting && !app.sel.isDragging) || 
        (app.sel.isDragging && !IsMouseButtonDown(MOUSE_LEFT_BUTTON))) return;
    
    // Convert signal coordinates to viewport coordinates
    float viewWidth = app.view.end - app.view.start;
    float freqWidth = app.view.freqEnd - app.view.freqStart;
    
    float selStartX = bounds.x + ((app.sel.timeStart - app.view.start) / viewWidth) * bounds.width;
    float selEndX = bounds.x + ((app.sel.timeEnd - app.view.start) / viewWidth) * bounds.width;
    float selStartY = bounds.y + bounds.height - ((app.sel.freqEnd - app.view.freqStart) / freqWidth) * bounds.height;
    float selEndY = bounds.y + bounds.height - ((app.sel.freqStart - app.view.freqStart) / freqWidth) * bounds.height;
    
    // Clamp to viewport bounds
    selStartX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selStartX));
    selEndX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selEndX));
    selStartY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selStartY));
    selEndY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selEndY));
    
    // Normalize coordinates for drawing
    float x = selStartX < selEndX ? selStartX : selEndX;
    float w = fabsf(selEndX - selStartX);
    float y = selStartY < selEndY ? selStartY : selEndY;
    float h = fabsf(selEndY - selStartY);
    
    DrawRectangleLinesEx((Rectangle){ x, y, w, h }, 2, YELLOW);

    // Live readout box while dragging.
    DrawStatPanel(bounds, (Rectangle){ x, y, w, h });
}

// Floating time/frequency/level tag that follows the cursor over the
// spectrogram (suppressed while selecting/panning, which have their own
// readout). The level is the STFT magnitude at that bin, in dB.
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 || app.marker.dragging) return;
    Vector2 m = GetMousePosition();
    if (!CheckCollisionPointRec(m, bounds)) return;

    float tFrac = app.view.start + ((m.x - bounds.x) / bounds.width) * (app.view.end - app.view.start);
    float fFrac = app.view.freqStart + (1.0f - (m.y - bounds.y) / bounds.height) * (app.view.freqEnd - app.view.freqStart);
    float timeSec = tFrac * app.signal.duration;
    // Map cursor freq through the cropped axis (so 100% of view = chosen max,
    // not raw Nyquist), but use the true Nyquist for STFT bin spacing — the
    // bins themselves still cover the full signal regardless of the crop.
    float displayMax = EffectiveMaxFreqHz();
    float dataNyquist = app.signal.sampleRate * 0.5f;
    float freqHz = fFrac * displayMax;

    // Sample the STFT level around this (time, freq).
    //
    // Averaged over a neighbourhood in DATA space (+/-CURSOR_AVG_SEGS segments,
    // +/-CURSOR_AVG_BINS bins) rather than reading one bin. A single bin of an
    // OFDM burst swings ~30 dB between adjacent subcarriers and symbols, so a
    // one-bin readout reports where the cursor happened to land rather than the
    // level of the signal under it. This is a quick reference number, so it is
    // deliberately weighted toward a stable reading over a local one.
    //
    // Wider in frequency than in time: subcarriers are the noisy axis, while
    // widening time risks averaging across symbol/frame boundaries. At 12 kHz
    // with a 2048-point FFT the window is ~76 Hz x ~300 ms — under a third of
    // the narrowest mLink channel, so it stays inside one signal.
    //
    // Deliberately not a window of *screen* pixels: one pixel covers hundreds
    // of segments zoomed out and a fraction of a bin zoomed in, so that would
    // mean something different at every zoom level.
    //
    // The mean is taken over POWER (amplitude^2) and converted afterwards.
    // Averaging dB values would be a geometric mean of power, which biases low
    // and understates a burst sitting next to quiet bins.
    char level[32] = "--";
    if (app.stft.numSegments > 0) {
        int seg = (int)(tFrac * app.stft.numSegments);
        if (seg < 0) seg = 0;
        if (seg >= app.stft.numSegments) seg = app.stft.numSegments - 1;
        const StftSegment* s = &app.stft.segments[seg];
        if (s->spectrum && s->numBins > 1) {
            float binHz = dataNyquist / (float)(s->numBins - 1);
            int bin = (int)(freqHz / binHz + 0.5f);
            if (bin < 0) bin = 0;
            if (bin >= s->numBins) bin = s->numBins - 1;

            double sumPow = 0.0;
            int n = 0;
            for (int ds = -CURSOR_AVG_SEGS; ds <= CURSOR_AVG_SEGS; ds++) {
                int si = seg + ds;
                if (si < 0 || si >= app.stft.numSegments) continue;
                const StftSegment* ss = &app.stft.segments[si];
                if (!ss->spectrum) continue;          // not yet filled in
                for (int db = -CURSOR_AVG_BINS; db <= CURSOR_AVG_BINS; db++) {
                    int bi = bin + db;
                    if (bi < 0 || bi >= ss->numBins) continue;
                    float a = ss->spectrum[bi].amplitude;
                    sumPow += (double)a * (double)a;
                    n++;
                }
            }
            if (n > 0) {
                float rms = (float)sqrt(sumPow / n);
                sprintf(level, "%.1f dB", AmplitudeToDecibels(rms));
            }
        }
    }

    char text[80];
    sprintf(text, "%.3fs  %.0f Hz  %s", timeSec, freqHz, level);
    int fontSize = 10;
    int tw = MeasureText(text, fontSize);
    int boxW = tw + 12, boxH = fontSize + 8;

    // Offset up-right of the cursor; flip to keep it inside the viewport.
    float bx = m.x + 14, by = m.y - boxH - 6;
    if (bx + boxW > bounds.x + bounds.width) bx = m.x - boxW - 14;
    if (by < bounds.y) by = m.y + 14;

    DrawRectangle((int)bx, (int)by, boxW, boxH, (Color){ 0, 0, 0, 200 });
    DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(SKYBLUE, 0.6f));
    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. Marker positions are normalized inside
    // the displayed view, so they scale with the crop just like the freq
    // axis labels do — what the user sees IS what they measure.
    float nyquist = EffectiveMaxFreqHz();
    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;
    // The spectrum slice always spans the complete frequency range. Honoring a
    // partial frequency selection only crops the displayed bins and skews the
    // auto-ranged dB axis + peak pick (inaccurate bins), so we ignore the
    // selection's freq bounds here. The selection rectangle is still drawn
    // normally by DrawSelection(); we just don't filter the slice by frequency.
    float f0 = 0.0f;
    float f1 = 1.0f;

    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);
}

// ============================================================================
// mLnL annotation overlay (schema v2)
//
// Layer order (per the spec; deepest first):
//   1. tx_frame — PRIMARY: filled translucent box + outline + per-frame label,
//      each frame drawn in its own frequency lane (announce / bulk / emergency)
//   2. assertion_passed / assertion_failed — thin outlined rectangles
//   3. control / channel_up / channel_down / impairment / gain — vertical lines
// Each pass also records the topmost hover hit so the tooltip drawn last
// reflects the visually-frontmost annotation under the cursor.
// ============================================================================

// Default per-node palette used when an event omits the `color` field.
static Color NodeColor(unsigned int node)
{
    static const Color palette[] = {
        { 120, 220, 255, 255 },  // sky
        { 255, 180,  80, 255 },  // amber
        { 160, 255, 140, 255 },  // mint
        { 255, 130, 200, 255 },  // pink
        { 200, 160, 255, 255 },  // lavender
        { 255, 230, 110, 255 },  // pale gold
    };
    return palette[node % (sizeof(palette) / sizeof(palette[0]))];
}

// Resolve an event's display color: use the producer-supplied `color` field
// if present, otherwise fall back to a per-kind default (tx_burst uses node
// palette; assertions use spec defaults; controls/etc. get a neutral hint).
static Color EventColor(const MlnlEvent* e)
{
    if (e->has_color) return (Color){ e->colorR, e->colorG, e->colorB, 255 };
    switch (e->kind) {
        case MLNL_KIND_TX_FRAME:
        case MLNL_KIND_TX_BURST:
            return NodeColor(e->has_node ? e->node : 0);
        case MLNL_KIND_ASSERTION_PASSED: return (Color){ 60, 179, 113, 255 };  // #3CB371
        case MLNL_KIND_ASSERTION_FAILED: return (Color){ 214,  40,  40, 255 }; // #D62828
        case MLNL_KIND_CONTROL:          return (Color){ 255, 220, 120, 255 };
        default:                         return (Color){ 200, 200, 220, 255 };
    }
}

// Map (t_s, freq_hz) to screen, honoring zoom. duration_s and nyquist_hz are
// the signal's extents.
static Vector2 AnnoToScreen(Rectangle bounds, double t_s, double f_hz,
                            double duration_s, double nyquist_hz)
{
    double tFrac = (duration_s > 0.0) ? (t_s / duration_s) : 0.0;
    double fFrac = (nyquist_hz > 0.0) ? (f_hz / nyquist_hz) : 0.0;
    double vw = app.view.end - app.view.start;
    double fw = app.view.freqEnd - app.view.freqStart;
    Vector2 p;
    p.x = bounds.x + (float)((tFrac - app.view.start) / vw) * bounds.width;
    p.y = bounds.y + bounds.height - (float)((fFrac - app.view.freqStart) / fw) * bounds.height;
    return p;
}

// Project the event's time+frequency band into a screen rectangle, clipped to
// the viewport. Events with no freq fields span the full frequency axis.
// Returns false if the result is entirely outside the visible area.
// Can this event collide with anything at all? Only things that actually
// occupy the air can: a transmission has both a real time extent and a
// frequency band. Point markers (control, gain_change, assertions — zero
// duration, no band) are log annotations about the run, not signals, so a
// marker landing inside a burst is not interference and must not be reported
// as one. Treating a missing band as "whole spectrum" (which is how EventRect
// *draws* it) made every such marker collide with whatever it sat inside, and
// accounted for 38% of the reported collisions on a real capture.
static bool EventCanCollide(const MlnlEvent* e)
{
    return e->has_freq && e->t_end > e->t_start;
}

// True iff two events occupy the same time AND frequency space — i.e. they
// genuinely collide on the air, rather than merely looking stacked because the
// view is zoomed out. Computed from the event data, never from screen rects,
// so the answer doesn't change with zoom.
static bool EventsCollide(const MlnlEvent* a, const MlnlEvent* b)
{
    if (!EventCanCollide(a) || !EventCanCollide(b)) return false;
    if (a->t_start >= b->t_end || b->t_start >= a->t_end) return false;
    if (a->f_lo_hz >= b->f_hi_hz || b->f_lo_hz >= a->f_hi_hz) return false;
    return true;
}

// Recompute which events collide with at least one other. O(n log n)-ish: the
// list is walked in time order and each event only compared against those still
// overlapping it, so a few thousand annotations cost microseconds. Cached in
// app.collisionFlags and only rebuilt when the annotation set changes.
void ComputeCollisions(void)
{
    int n = app.annotations.eventCount;
    free(app.collisionFlags);
    app.collisionFlags = NULL;
    app.collisionCount = 0;
    app.collisionRegionCount = 0;
    if (n <= 0) return;

    app.collisionFlags = (unsigned char*)calloc(n, 1);
    if (!app.collisionFlags) return;

    // Index sorted by start time, so the inner loop can stop early.
    int* order = (int*)malloc((size_t)n * sizeof(int));
    if (!order) return;
    for (int i = 0; i < n; i++) order[i] = i;
    for (int i = 1; i < n; i++) {  // insertion sort: input is near-sorted already
        int key = order[i];
        double kt = app.annotations.events[key].t_start;
        int j = i - 1;
        while (j >= 0 && app.annotations.events[order[j]].t_start > kt) {
            order[j + 1] = order[j];
            j--;
        }
        order[j + 1] = key;
    }

    for (int i = 0; i < n; i++) {
        const MlnlEvent* a = &app.annotations.events[order[i]];
        for (int j = i + 1; j < n; j++) {
            const MlnlEvent* b = &app.annotations.events[order[j]];
            if (b->t_start >= a->t_end) break;  // sorted: nothing later can overlap
            if (EventsCollide(a, b)) {
                app.collisionFlags[order[i]] = 1;
                app.collisionFlags[order[j]] = 1;
            }
        }
    }

    for (int i = 0; i < n; i++) if (app.collisionFlags[i]) app.collisionCount++;

    // Merge colliding events into contiguous time regions, so the overlay can
    // draw one band per pile-up instead of one per event (which would smear
    // into a solid wall when zoomed out on a long capture).
    double regionEnd = -1.0;
    for (int i = 0; i < n; i++) {
        int idx = order[i];
        if (!app.collisionFlags[idx]) continue;
        const MlnlEvent* e = &app.annotations.events[idx];
        if (e->t_start > regionEnd) {
            if (app.collisionRegionCount < MAX_COLLISION_REGIONS) {
                CollisionRegion* r = &app.collisionRegions[app.collisionRegionCount];
                r->t0 = e->t_start;
                r->t1 = e->t_end;
                // Only banded events reach here (EventCanCollide requires
                // has_freq), so f_lo/f_hi are always meaningful.
                r->f_lo = e->f_lo_hz;
                r->f_hi = e->f_hi_hz;
                r->count = 1;
                app.collisionRegionCount++;
            }
            regionEnd = e->t_end;
        } else {
            if (app.collisionRegionCount > 0) {
                CollisionRegion* r = &app.collisionRegions[app.collisionRegionCount - 1];
                if (e->t_end > r->t1) r->t1 = e->t_end;
                // Union the bands: a region spanning a wideband frame and a
                // narrow one has to cover both or the marker would sit off the
                // signal it is pointing at.
                if (e->f_lo_hz < r->f_lo) r->f_lo = e->f_lo_hz;
                if (e->f_hi_hz > r->f_hi) r->f_hi = e->f_hi_hz;
                r->count++;
            }
            if (e->t_end > regionEnd) regionEnd = e->t_end;
        }
    }

    free(order);
}

static bool EventRect(Rectangle bounds, const MlnlEvent* e,
                      double duration_s, double nyquist_hz, Rectangle* out)
{
    double f0 = e->has_freq ? e->f_lo_hz : 0.0;
    double f1 = e->has_freq ? e->f_hi_hz : nyquist_hz;
    Vector2 lo = AnnoToScreen(bounds, e->t_start, f0, duration_s, nyquist_hz);
    Vector2 hi = AnnoToScreen(bounds, e->t_end,   f1, duration_s, nyquist_hz);
    float x = fminf(lo.x, hi.x);
    float y = fminf(lo.y, hi.y);
    float w = fabsf(hi.x - lo.x);
    float h = fabsf(hi.y - lo.y);
    if (x + w < bounds.x || x > bounds.x + bounds.width) return false;
    float x0 = fmaxf(x, bounds.x);
    float x1 = fminf(x + w, bounds.x + bounds.width);
    float y0 = fmaxf(y, bounds.y);
    float y1 = fminf(y + h, bounds.y + bounds.height);
    if (x1 <= x0 || y1 <= y0) return false;
    *out = (Rectangle){ x0, y0, x1 - x0, y1 - y0 };
    return true;
}

// Build the tooltip lines for an event. Fields are listed in priority order
// (kind banner, note, time range, freq band, then kind-specific extras).
// Format a tx_frame's intent->air latency compactly: "+160ms", "+4.0s".
// sched_offset_ms = air_time - modem intent time (how late the burst hit the
// air vs. when it was rendered); see mlnl_chunk_spec.md §4.2.
static void FormatSchedOffset(double ms, char* out, int cap)
{
    const char* sign = (ms < 0) ? "-" : "+";
    double a = fabs(ms);
    if (a >= 1000.0) snprintf(out, cap, "%s%.1fs", sign, a / 1000.0);
    else             snprintf(out, cap, "%s%.0fms", sign, a);
}

static int BuildEventLines(const MlnlEvent* e, char lines[][96], int maxLines)
{
    int n = 0;
    if (n < maxLines) snprintf(lines[n++], 96, "%s", e->kindStr);
    if (e->has_note && n < maxLines) snprintf(lines[n++], 96, "%s", e->note);

    double dt = e->t_end - e->t_start;
    if (dt > 0.0) {
        if (n < maxLines) snprintf(lines[n++], 96, "%.3f - %.3f s", e->t_start, e->t_end);
        if (n < maxLines) snprintf(lines[n++], 96, "dur: %.3f s", dt);
    } else {
        if (n < maxLines) snprintf(lines[n++], 96, "t: %.3f s", e->t_start);
    }
    if (e->has_freq && n < maxLines) snprintf(lines[n++], 96, "%.0f - %.0f Hz", e->f_lo_hz, e->f_hi_hz);

    // tx_frame specifics: frame name, daemon channel, position in the PTT, rate.
    if (e->has_frame && n < maxLines) snprintf(lines[n++], 96, "frame: %s", e->frame);
    if (e->has_ch && n < maxLines)    snprintf(lines[n++], 96, "ch: %s", e->ch);
    if (e->has_seqn && e->nFrames > 1 && n < maxLines)
        snprintf(lines[n++], 96, "frame %d of %d", e->seq + 1, e->nFrames);
    if (e->has_rate && n < maxLines)  snprintf(lines[n++], 96, "rate: %s", e->rate);
    // Intent->air latency: how late this burst hit the air vs. the modem's
    // render time. Boxes are already air-anchored upstream, so this is purely
    // informational (see mlnl_chunk_spec.md §4.2).
    if (e->has_sched_offset && n < maxLines) {
        char so[24];
        FormatSchedOffset(e->sched_offset_ms, so, sizeof(so));
        snprintf(lines[n++], 96, "sched offset: %s", so);
    }

    if (e->has_node && n < maxLines) {
        // For tx_burst prefer "node N <LABEL> #id" so the operator can map back
        // to the harness log even when note is identical between siblings.
        if (e->has_label && e->has_id)
            snprintf(lines[n++], 96, "node %u  %s  #%u", e->node, e->label, e->id);
        else if (e->has_id)
            snprintf(lines[n++], 96, "node %u  #%u", e->node, e->id);
        else
            snprintf(lines[n++], 96, "node: %u", e->node);
    } else if (e->has_id && n < maxLines) {
        snprintf(lines[n++], 96, "#%u", e->id);
    }
    if (e->has_command && n < maxLines) snprintf(lines[n++], 96, "cmd: %s", e->command);
    if (e->has_name && n < maxLines)    snprintf(lines[n++], 96, "%.90s", e->name);
    if (e->has_reason && n < maxLines)  snprintf(lines[n++], 96, "reason: %.80s", e->reason);
    if (e->has_slack && n < maxLines)   snprintf(lines[n++], 96, "slack: %.2fs", e->slack_s);
    if (e->has_stats) {
        if (n < maxLines) snprintf(lines[n++], 96, "peak: %.3f", e->peak);
        if (n < maxLines) snprintf(lines[n++], 96, "rms: %.3f", e->rms);
        if (n < maxLines) snprintf(lines[n++], 96, "PAPR: %.1f dB", e->papr_db);
    }
    return n;
}

// Draw a label inside (or just above) `box` in `color`, scissor-clipped to
// the box's horizontal extent so long notes don't bleed over neighbors.
// topInside=true places the label inside the top of the box (used for tx_bursts
// so the box outline still reads clearly above); false places it just above,
// falling back to inside if the box is at the top of the viewport.
// Label slots already claimed this frame, so overlapping boxes don't stack
// their text into an unreadable smear. Each entry is the screen-space extent
// of a drawn label; a candidate that would collide with one is dropped and
// surfaced on hover instead (the hover stack reports every box under the
// cursor, so nothing is lost — it just isn't painted on top of its neighbour).
#define MAX_LABEL_SLOTS 256
static Rectangle g_labelSlots[MAX_LABEL_SLOTS];
static int g_labelSlotCount = 0;

static void ResetLabelSlots(void) { g_labelSlotCount = 0; }

static bool ClaimLabelSlot(Rectangle r)
{
    for (int i = 0; i < g_labelSlotCount; i++) {
        // Pure AABB overlap. Labels are single-line and left-aligned, so any
        // intersection at all means one would be drawn over the other.
        if (r.x < g_labelSlots[i].x + g_labelSlots[i].width &&
            r.x + r.width > g_labelSlots[i].x &&
            r.y < g_labelSlots[i].y + g_labelSlots[i].height &&
            r.y + r.height > g_labelSlots[i].y)
            return false;
    }
    if (g_labelSlotCount < MAX_LABEL_SLOTS) g_labelSlots[g_labelSlotCount++] = r;
    return true;
}

static void DrawBoxLabel(Rectangle box, const char* text, Color color, bool topInside)
{
    if (!text || !*text || box.width < 18.0f) return;
    float scale = GetUIScale();
    float fs = 10.0f;
    float lineH = fs * scale + 2;
    int x = (int)box.x + 3;
    int y = topInside ? (int)box.y + 2 : (int)(box.y - lineH);
    if (y < 0) y = (int)box.y + 2;

    // Clip the claim to the box, matching what the scissor actually paints —
    // otherwise a long label reserves space it never draws into and needlessly
    // suppresses its neighbours.
    float drawW = MeasureTextScaled(text, fs);
    if (drawW > box.width - 4) drawW = box.width - 4;
    if (!ClaimLabelSlot((Rectangle){ (float)x, (float)y, drawW, lineH })) return;

    BeginScissorMode(x, y, (int)box.width - 4, (int)lineH);
    DrawTextScaled(text, x, y, fs, color);
    EndScissorMode();
}

static void DrawTooltip(Rectangle bounds, Vector2 anchor,
                        char lines[][96], int n, Color border)
{
    if (n <= 0) return;
    float scale = GetUIScale();
    float fontSize = 11.0f;
    float lineH = fontSize * scale + 4;
    float padX = 10 * scale;
    float padY = 6 * scale;

    float maxW = 0;
    for (int i = 0; i < n; i++) {
        float w = MeasureTextScaled(lines[i], fontSize);
        if (w > maxW) maxW = w;
    }
    int boxW = (int)(maxW + padX * 2);
    int boxH = (int)(n * lineH + padY * 2);
    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, 220 });
    DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(border, 0.8f));
    for (int i = 0; i < n; i++)
        DrawTextScaled(lines[i], bx + padX, by + padY + i * lineH, fontSize, LIGHTGRAY);
}

// One-line summary of an event, for the stacked-hover list. Leads with the
// fields that actually distinguish co-located transmissions (node, frame, ch);
// the full per-field breakdown stays in the single-event tooltip.
static void BuildEventSummary(const MlnlEvent* e, char* out, int cap)
{
    char who[32] = "";
    if (e->has_node) snprintf(who, sizeof(who), "node %u  ", e->node);

    const char* what = e->has_frame   ? e->frame :
                       e->has_command ? e->command :
                       e->has_label   ? e->label :
                       e->has_name    ? e->name : e->kindStr;

    char pos[24] = "";
    if (e->has_seqn && e->nFrames > 1)
        snprintf(pos, sizeof(pos), "  %d/%d", e->seq + 1, e->nFrames);

    char chs[24] = "";
    if (e->has_ch) snprintf(chs, sizeof(chs), "  [%s]", e->ch);

    snprintf(out, cap, "%s%.28s%s%s", who, what, pos, chs);
}

// Tooltip listing every annotation the cursor is inside, one row per event.
// Rows are drawn topmost-first (reverse of draw order) so the box visually on
// top is the first thing read. Each row gets its event's own colour swatch,
// which is what lets a row be matched back to a specific box on screen.
static void DrawHoverStackTooltip(Rectangle bounds, Vector2 anchor, int total)
{
    int n = app.hoverStackCount;
    if (n <= 0) return;

    float scale = GetUIScale();
    float fontSize = 11.0f;
    float lineH = fontSize * scale + 4;
    float padX = 10 * scale;
    float padY = 6 * scale;
    float swatch = fontSize * scale * 0.7f;
    float swatchGap = swatch + 6 * scale;

    char hdr[64];
    if (total > n) snprintf(hdr, sizeof(hdr), "%d overlapping  (+%d more)", total, total - n);
    else           snprintf(hdr, sizeof(hdr), "%d overlapping", total);

    char rows[MAX_HOVER_STACK][96];
    for (int i = 0; i < n; i++) {
        // Reverse: hoverStack is topmost-last, the list reads topmost-first.
        const MlnlEvent* e = &app.annotations.events[app.hoverStack[n - 1 - i]];
        BuildEventSummary(e, rows[i], 96);
    }

    float maxW = MeasureTextScaled(hdr, fontSize);
    for (int i = 0; i < n; i++) {
        float w = MeasureTextScaled(rows[i], fontSize) + swatchGap;
        if (w > maxW) maxW = w;
    }

    int totalRows = n + 1;  // header + rows
    int boxW = (int)(maxW + padX * 2);
    int boxH = (int)(totalRows * lineH + padY * 2);
    // Sit above the cursor, horizontally centred on it: the boxes being
    // described are under the pointer, so anything drawn below or beside it
    // covers the very thing the user is pointing at. Flips below only when
    // there isn't room above.
    float bx = anchor.x - boxW * 0.5f;
    float by = anchor.y - boxH - 14;
    if (by < bounds.y) by = anchor.y + 18;
    if (bx < bounds.x) bx = bounds.x;
    if (bx + boxW > bounds.x + bounds.width) bx = bounds.x + bounds.width - boxW;
    if (by + boxH > bounds.y + bounds.height) by = bounds.y + bounds.height - boxH;

    DrawRectangle((int)bx, (int)by, boxW, boxH, (Color){ 0, 0, 0, 235 });
    DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(GRAY, 0.9f));

    float y = by + padY;
    DrawTextScaled(hdr, bx + padX, y, fontSize, (Color){ 255, 255, 255, 255 });
    y += lineH;

    for (int i = 0; i < n; i++) {
        const MlnlEvent* e = &app.annotations.events[app.hoverStack[n - 1 - i]];
        Color c = EventColor(e);
        DrawRectangle((int)(bx + padX), (int)(y + 3 * scale),
                      (int)swatch, (int)swatch, c);
        DrawTextScaled(rows[i], bx + padX + swatchGap, y, fontSize, LIGHTGRAY);
        y += lineH;
    }
}

static bool IsPointEvent(const MlnlEvent* e)
{
    if (e->t_end > e->t_start) return false;  // has a range -> draw as rect
    switch (e->kind) {
        case MLNL_KIND_CONTROL:
        case MLNL_KIND_CHANNEL_UP:
        case MLNL_KIND_CHANNEL_DOWN:
        case MLNL_KIND_IMPAIRMENT_FIRE:
        case MLNL_KIND_GAIN_CHANGE:
        case MLNL_KIND_UNKNOWN:
            return true;
        default:
            return true;  // zero-width assertion etc. also render as vertical line
    }
}

// True iff the given event is currently emphasized (selected, or hovered in
// the timeline lane). Spectrogram-cursor hover is intentionally NOT emphasized
// — otherwise simply moving the mouse over the viewport would flicker the
// overlays. Emphasis is reserved for explicit indication via the timeline.
static bool AnnotationEmphasized(int eventIndex)
{
    return (app.selectedAnnotation == eventIndex) ||
           (app.hoveredTimelineEvent == eventIndex);
}

static unsigned char AlphaForEvent(int eventIndex, float fillMultiplier)
{
    float op = AnnotationEmphasized(eventIndex)
                 ? app.annotationOpacityHover
                 : app.annotationOpacityBase;
    return (unsigned char)Clamp(op * fillMultiplier, 0.0f, 255.0f);
}

// Compose a tx_frame's in-box label per spec §5: the frame name, its LDPC rate
// (bulk frames only), then its position in the PTT ("seq of n"). e.g.
// "BULK 3/4  3/6" or "PRESENCE/SEED". Falls back to the producer note.
static void FormatTxFrameLabel(const MlnlEvent* e, char* out, int cap)
{
    // Sized for the worst case "  %d/%d" with two full-width ints (2 + 11 + 1
    // + 11 + NUL = 26) so -Wformat-truncation stays quiet at -O2.
    char pos[28] = { 0 };
    if (e->has_seqn && e->nFrames > 1)
        snprintf(pos, sizeof(pos), "  %d/%d", e->seq + 1, e->nFrames);

    const char* base = e->has_frame ? e->frame : (e->has_note ? e->note : "tx_frame");
    if (e->has_frame && e->has_rate)
        snprintf(out, cap, "%s %s%s", base, e->rate, pos);
    else
        snprintf(out, cap, "%s%s", base, pos);
}

void DrawAnnotations(Rectangle bounds)
{
    if (!app.loaded || !app.annotations.loaded) return;
    if (!app.showAnnotations) {
        app.hoveredEvent = -1;
        app.hoverStackCount = 0;
        return;
    }
    if (app.signal.duration <= 0.0f) return;

    app.hoveredEvent = -1;
    ResetLabelSlots();

    double duration = app.signal.duration;
    // Annotation freq mapping uses the DISPLAYED top-of-axis: events with
    // f_lo/f_hi above the crop simply get clipped at the top of the visible
    // area, the same way pan/zoom already handles off-screen events.
    double nyquist = EffectiveMaxFreqHz();
    Vector2 m = GetMousePosition();
    bool mouseInBounds = CheckCollisionPointRec(m, bounds);
    bool suppressHover = app.sel.isTimeSelecting || app.sel.isFreqSelecting ||
                         app.sel.isDragging || app.view.isPanning || app.marker.dragging ||
                         app.markerMode ||
                         // The transport bar floats over the spectrogram; an
                         // annotation tooltip firing underneath it would cover
                         // the buttons being reached for.
                         TransportCapturesMouse(bounds);

    int hoverEvent = -1;
    // hover prefers later layers (assertions over bursts, point markers over both),
    // which matches the spec's draw-order rationale: things on top win the click.
    // hoverEvent stays "the winner" for everything that wants a single event
    // (emphasis, selection); the stack below additionally keeps the losers so
    // the tooltip can report what the winner is covering up.
    int stack[MAX_HOVER_STACK];
    int stackCount = 0;      // capped at MAX_HOVER_STACK
    int stackTotal = 0;      // uncapped, so the tooltip can say "+N more"

    // ---- Layer 1: tx_burst (wash + outline) ----
    // tx_burst uses the full 200 fill multiplier; emphasis comes from a higher
    // per-event opacity, NOT a different multiplier.
    for (int i = 0; i < app.annotations.eventCount; i++) {
        const MlnlEvent* e = &app.annotations.events[i];
        if (e->kind != MLNL_KIND_TX_BURST) continue;
        if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
        Rectangle r;
        if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
        Color c = EventColor(e);
        unsigned char strokeA = AlphaForEvent(i, 255.0f);
        unsigned char fillA   = AlphaForEvent(i, 200.0f);
        Color stroke = (Color){ c.r, c.g, c.b, strokeA };
        DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
        DrawRectangleLinesEx(r, 1.5f, stroke);
        DrawBoxLabel(r, e->has_note ? e->note : e->kindStr, stroke, /*topInside=*/true);

        if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r)) {
            hoverEvent = i;
            stackTotal++;
            if (stackCount < MAX_HOVER_STACK) stack[stackCount++] = i;
        }
    }

    // ---- Layer 1b: tx_frame (PRIMARY per-frame fill + outline + label) ----
    // The daemon's own per-frame self-report. Each frame carries its exact band
    // (f_lo/f_hi resolved from its `ch`), so one transmission renders as a
    // readable sequence of boxes laid out across the announce / bulk lanes.
    for (int i = 0; i < app.annotations.eventCount; i++) {
        const MlnlEvent* e = &app.annotations.events[i];
        if (e->kind != MLNL_KIND_TX_FRAME) continue;
        if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
        Rectangle r;
        if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
        Color c = EventColor(e);
        unsigned char strokeA = AlphaForEvent(i, 255.0f);
        unsigned char fillA   = AlphaForEvent(i, 200.0f);
        Color stroke = (Color){ c.r, c.g, c.b, strokeA };
        DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
        DrawRectangleLinesEx(r, 1.5f, stroke);
        // Sized to hold the longest base (note[96]) + rate + position without
        // truncation; DrawBoxLabel scissor-clips it to the box width anyway.
        char lbl[160];
        FormatTxFrameLabel(e, lbl, sizeof(lbl));
        DrawBoxLabel(r, lbl, stroke, /*topInside=*/true);

        if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r)) {
            hoverEvent = i;
            stackTotal++;
            if (stackCount < MAX_HOVER_STACK) stack[stackCount++] = i;
        }
    }

    // ---- Layer 2: assertion_passed / assertion_failed (outline-dominant; a
    // very light wash so failed regions still tint red without blanking the
    // signal underneath) ----
    for (int i = 0; i < app.annotations.eventCount; i++) {
        const MlnlEvent* e = &app.annotations.events[i];
        if (e->kind != MLNL_KIND_ASSERTION_PASSED && e->kind != MLNL_KIND_ASSERTION_FAILED)
            continue;
        if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
        if (e->t_end <= e->t_start) continue;
        Rectangle r;
        if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
        Color c = EventColor(e);
        unsigned char strokeA = AlphaForEvent(i, 255.0f);
        unsigned char fillA   = AlphaForEvent(i, 100.0f);
        Color stroke = (Color){ c.r, c.g, c.b, strokeA };
        DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
        DrawRectangleLinesEx(r, 1.0f, stroke);
        DrawBoxLabel(r, e->has_note ? e->note : (e->has_name ? e->name : e->kindStr),
                     stroke, /*topInside=*/false);

        if (!suppressHover && mouseInBounds && CheckCollisionPointRec(m, r)) {
            hoverEvent = i;
            stackTotal++;
            if (stackCount < MAX_HOVER_STACK) stack[stackCount++] = i;
        }
    }

    // ---- Layer 3: point markers (vertical lines for zero-width events) ----
    float labelStaggerY = 0.0f;
    for (int i = 0; i < app.annotations.eventCount; i++) {
        const MlnlEvent* e = &app.annotations.events[i];
        if (!IsPointEvent(e)) continue;
        if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;

        double tFrac = e->t_start / duration;
        if (tFrac < app.view.start || tFrac > app.view.end) continue;
        float x = bounds.x + (float)((tFrac - app.view.start) /
                                     (app.view.end - app.view.start)) * bounds.width;

        Color c = EventColor(e);
        unsigned char strokeA = AlphaForEvent(i, 255.0f);
        Color stroke = (Color){ c.r, c.g, c.b, strokeA };
        DrawLine((int)x, (int)bounds.y, (int)x, (int)(bounds.y + bounds.height), Fade(stroke, 0.6f));
        DrawCircleLines((int)x, (int)bounds.y + 6, 3, stroke);

        const char* lbl = e->has_note ? e->note :
                          e->has_command ? e->command :
                          e->has_name ? e->name : e->kindStr;
        float lblScale = GetUIScale();
        float lblFs = 10.0f;
        float lblLineH = lblFs * lblScale + 2;
        int labelY = (int)(bounds.y + 14 * lblScale + labelStaggerY);
        int labelMaxW = (int)(bounds.x + bounds.width) - ((int)x + 4);
        if (labelMaxW > 8) {
            BeginScissorMode((int)x + 4, labelY, labelMaxW, (int)lblLineH);
            DrawTextScaled(lbl, (int)x + 4, labelY, lblFs, stroke);
            EndScissorMode();
        }
        labelStaggerY = (labelStaggerY > 24.0f * lblScale) ? 0.0f : labelStaggerY + lblLineH;

        if (!suppressHover && mouseInBounds &&
            m.x >= x - 4 && m.x <= x + 4 &&
            m.y >= bounds.y && m.y <= bounds.y + bounds.height) {
            hoverEvent = i;
            stackTotal++;
            if (stackCount < MAX_HOVER_STACK) stack[stackCount++] = i;
        }
    }
    app.hoveredEvent = hoverEvent;

    app.hoverStackCount = stackCount;
    for (int i = 0; i < stackCount; i++) app.hoverStack[i] = stack[i];

    // ---- Collision overlay ----
    // Marks where transmissions genuinely overlap in time AND frequency. Drawn
    // as a band per merged region rather than per event: at a wide zoom a
    // multi-hour capture has thousands of collisions, and one mark each would
    // paint the view solid red and say nothing.
    // Each marker is confined to the frequency band the collision actually
    // occupies (padded, so a narrow overlap is still findable) rather than
    // spanning the full axis — a full-height bar hides the signal it is
    // pointing at and says nothing about *where* the interference sits.
    if (app.showCollisions && app.collisionRegionCount > 0 && duration > 0.0) {
        for (int i = 0; i < app.collisionRegionCount; i++) {
            const CollisionRegion* cr = &app.collisionRegions[i];
            double fLo = cr->f_lo - COLLISION_BAND_PAD_HZ;
            double fHi = cr->f_hi + COLLISION_BAND_PAD_HZ;
            if (fLo < 0.0) fLo = 0.0;

            Vector2 lo = AnnoToScreen(bounds, cr->t0, fLo, duration, nyquist);
            Vector2 hi = AnnoToScreen(bounds, cr->t1, fHi, duration, nyquist);

            float x0 = fminf(lo.x, hi.x), x1 = fmaxf(lo.x, hi.x);
            float y0 = fminf(lo.y, hi.y), y1 = fmaxf(lo.y, hi.y);
            if (x1 < bounds.x || x0 > bounds.x + bounds.width) continue;
            if (y1 < bounds.y || y0 > bounds.y + bounds.height) continue;

            // Keep a very narrow region visible, then clip to the viewport.
            if (x1 < x0 + 2.0f) x1 = x0 + 2.0f;
            if (y1 < y0 + 2.0f) y1 = y0 + 2.0f;
            if (x0 < bounds.x) x0 = bounds.x;
            if (y0 < bounds.y) y0 = bounds.y;
            if (x1 > bounds.x + bounds.width)  x1 = bounds.x + bounds.width;
            if (y1 > bounds.y + bounds.height) y1 = bounds.y + bounds.height;
            if (x1 <= x0 || y1 <= y0) continue;

            // Deeper pile-ups read as more opaque, so density stays legible
            // where individual regions are too narrow to separate.
            int a = 40 + cr->count * 10;
            if (a > 120) a = 120;
            Rectangle r = { x0, y0, x1 - x0, y1 - y0 };
            DrawRectangleRec(r, (Color){ 255, 60, 60, (unsigned char)a });
            DrawRectangleLinesEx(r, 1.0f, (Color){ 255, 100, 100, 220 });
        }
    }

    // ---- Tooltip ---- Drawn last so it floats above the collision overlay:
    // the red bands are a background cue, the tooltip is what's being read.
    // Timeline hover takes priority over spectrogram hover (the lane is the
    // active surface when you're hovering it).
    int tipFor = (app.hoveredTimelineEvent >= 0) ? app.hoveredTimelineEvent : hoverEvent;

    if (app.hoverStackCount > 1 && app.hoveredTimelineEvent < 0) {
        // Several boxes under the cursor: one line each beats full detail for
        // one, since the question being asked is "who else is in here?".
        DrawHoverStackTooltip(bounds, m, stackTotal);
    } else if (tipFor >= 0) {
        const MlnlEvent* e = &app.annotations.events[tipFor];
        char lines[12][96];
        int n = BuildEventLines(e, lines, 12);
        DrawTooltip(bounds, m, lines, n, EventColor(e));
    }

    if (app.annotations.truncated) {
        const char* msg = "mLnL: truncated";
        float fs = 10.0f;
        float w = MeasureTextScaled(msg, fs);
        float h = fs * GetUIScale() + 6;
        DrawRectangle((int)(bounds.x + bounds.width - w - 14), (int)(bounds.y + 4),
                      (int)(w + 10), (int)h, (Color){ 60, 0, 0, 200 });
        DrawTextScaled(msg, bounds.x + bounds.width - w - 9, bounds.y + 7,
                       fs, (Color){ 255, 200, 200, 255 });
    }
}

// ============================================================================
// Headless (no-GL) annotation overlay — drawn straight into an Image
// ============================================================================
//
// The interactive DrawAnnotations() above issues GL draw calls and depends on
// view/zoom + hover state. The headless `--render` path has none of that: it
// exports the full-resolution spectrogram bitmap (full time span, full Nyquist
// axis), so events map directly with no view transform. These helpers mirror
// the same geometry (EventRect/EventColor/IsPointEvent) but emit CPU ImageDraw*
// calls, honoring the kind filter (annotationKindEnabled) and the resting
// overlay opacity (annotationOpacityBase). There is no hover/selection, so the
// resting alpha governs every overlay.

// Load a TTF as a Font usable by ImageDrawTextEx with NO GL context: glyph
// images + recs are populated, but no atlas texture is uploaded (ImageTextEx
// draws from font.glyphs[i].image, not font.texture). Returns a font with
// glyphCount==0 on failure; the caller treats that as "skip labels".
Font LoadFontCPU(const char* path, int baseSize)
{
    Font font = { 0 };
    font.baseSize = baseSize;
    int dataSize = 0;
    unsigned char* fileData = LoadFileData(path, &dataSize);
    if (fileData == NULL) return font;
    font.glyphs = LoadFontData(fileData, dataSize, baseSize, NULL, 95, FONT_DEFAULT, &font.glyphCount);
    UnloadFileData(fileData);
    if (font.glyphs == NULL) { font.glyphCount = 0; return font; }
    // Fills font.recs (glyph source rects). We discard the packed atlas image:
    // ImageTextEx reads the per-glyph images, not an atlas texture.
    Image atlas = GenImageFontAtlas(font.glyphs, &font.recs, font.glyphCount, baseSize, 4, 0);
    UnloadImage(atlas);
    // Sentinel non-zero texture id: MeasureTextEx/ImageText* bail when it's 0
    // ("security check"), but the image-drawing path reads font.glyphs[i].image,
    // not the texture. We never upload or unload it (caller frees glyphs+recs).
    font.texture = (Texture2D){ .id = 1 };
    return font;
}

// Overlay alpha for the headless render. annotationOpacityBase is the overall
// overlay strength (0..1); `frac` weights an element within that (1.0 = a solid
// outline, lower = a translucent wash). Fills are kept light so the spectrogram
// reads through the (often full-span) annotation boxes; outlines carry the
// boundary. There is no hover/selection here, so this single knob governs all.
static unsigned char HeadlessAlpha(float frac)
{
    return (unsigned char)Clamp(app.annotationOpacityBase * frac * 255.0f, 0.0f, 255.0f);
}

// Brighten an event color toward white so labels stay legible at full alpha,
// even when the box fill shares the same hue.
static Color LabelColor(Color c)
{
    return (Color){ (unsigned char)(c.r + (255 - c.r) * 0.55f),
                    (unsigned char)(c.g + (255 - c.g) * 0.55f),
                    (unsigned char)(c.b + (255 - c.b) * 0.55f), 255 };
}

static void ImageBoxLabel(Image* img, Font font, Rectangle box, const char* text,
                          Color color, int fontSize)
{
    if (font.glyphCount == 0 || !text || !*text || box.width < 18.0f) return;
    Vector2 ts = MeasureTextEx(font, text, (float)fontSize, 1.0f);
    // Dark backing strip so labels stay legible over a bright spectrogram.
    ImageDrawRectangleRec(img, (Rectangle){ box.x + 1, box.y + 1, ts.x + 4, ts.y + 2 },
                          (Color){ 0, 0, 0, 165 });
    ImageDrawTextEx(img, font, text, (Vector2){ box.x + 3, box.y + 2 },
                    (float)fontSize, 1.0f, color);
}

// Draw the mLnL overlay onto a full-resolution spectrogram Image (no GL).

// ===== Minimap =====
// A whole-file thumbnail with the current view drawn on it, for navigating a
// long capture without zooming out and back in.
//
// The thumbnail is built ONCE into a texture and reused every frame. Rebuilding
// it per frame would mean re-reducing the entire STFT (hundreds of thousands of
// segments on a long file) for a 256x64 image — the single most wasteful thing
// this UI could do. It is invalidated only when its content actually changes:
// a new file, a colormap change, or the annotation/collision overlays being
// toggled. Panning and zooming just move the viewport rectangle drawn on top,
// which costs nothing.

void InvalidateMinimap(void) { app.minimapValid[0] = app.minimapValid[1] = false; }

// Peak amplitude over one thumbnail cell. Strided rather than exhaustive: the
// output is a 256x64 blob, so sampling every Nth segment and bin gives the same
// picture for a fraction of the reads. Without this, reducing a multi-hour
// capture means ~1 G amplitude reads per rebuild, which is a visible hitch every
// time an overlay is toggled. The stride is chosen so each cell takes a bounded
// number of samples no matter how long the file is.
#define MINIMAP_CELL_SAMPLES 8
static float MinimapCellPeak(int s0, int s1, int b0, int b1)
{
    int sStride = (s1 - s0) / MINIMAP_CELL_SAMPLES;
    if (sStride < 1) sStride = 1;
    int bStride = (b1 - b0) / MINIMAP_CELL_SAMPLES;
    if (bStride < 1) bStride = 1;

    float m = 0.0f;
    for (int s = s0; s < s1; s += sStride) {
        const StftSegment* seg = &app.stft.segments[s];
        if (!seg->spectrum) continue;             // not yet filled in
        for (int b = b0; b < b1; b += bStride) {
            float a = seg->spectrum[b].amplitude;
            if (a > m) m = a;
        }
    }
    return m;
}


static void BuildMinimapTexture(int slot)
{
    int TW = slot ? MINIMAP_TEX_W_LG : TW;
    int TH = slot ? MINIMAP_TEX_H_LG : TH;

    if (app.minimapTexture[slot].id != 0) UnloadTexture(app.minimapTexture[slot]);
    app.minimapTexture[slot] = (Texture2D){ 0 };
    app.minimapValid[slot] = true;
    if (!app.stftComputed || app.stft.numSegments <= 0) return;

    Image img = GenImageColor(TW, TH, (Color){ 12, 12, 16, 255 });
    Color* px = (Color*)img.data;

    int nSeg = app.stft.numSegments;
    int nBin = app.stft.segments[0].numBins;

    // Peak-reduce the STFT straight into the thumbnail: for each output pixel,
    // take the max amplitude over the segments and bins it covers. Max (not
    // mean) so a short burst still shows as a mark instead of averaging away.
    float peak = 0.0001f;
    for (int x = 0; x < TW; x++) {
        int s0 = (int)((long long)x * nSeg / TW);
        int s1 = (int)((long long)(x + 1) * nSeg / TW);
        if (s1 <= s0) s1 = s0 + 1;
        if (s1 > nSeg) s1 = nSeg;

        for (int y = 0; y < TH; y++) {
            // Row 0 is the top of the image = highest frequency.
            int b0 = (int)((long long)(TH - 1 - y) * nBin / TH);
            int b1 = (int)((long long)(TH - y) * nBin / TH);
            if (b1 <= b0) b1 = b0 + 1;
            if (b1 > nBin) b1 = nBin;

            float m = MinimapCellPeak(s0, s1, b0, b1);
            if (m > peak) peak = m;
        }
    }

    // Second pass: colourize against the peak found above. Two passes keeps the
    // scale honest without needing a separate float buffer for a 16k-pixel image.
    for (int x = 0; x < TW; x++) {
        int s0 = (int)((long long)x * nSeg / TW);
        int s1 = (int)((long long)(x + 1) * nSeg / TW);
        if (s1 <= s0) s1 = s0 + 1;
        if (s1 > nSeg) s1 = nSeg;
        for (int y = 0; y < TH; y++) {
            int b0 = (int)((long long)(TH - 1 - y) * nBin / TH);
            int b1 = (int)((long long)(TH - y) * nBin / TH);
            if (b1 <= b0) b1 = b0 + 1;
            if (b1 > nBin) b1 = nBin;
            float m = MinimapCellPeak(s0, s1, b0, b1);
            float db = AmplitudeToDecibels(m / peak);
            float t = Clamp((db + 60.0f) / 60.0f, 0.0f, 1.0f);
            px[y * TW + x] = (m > 0.0f) ? GetColormapColor(t, app.colormap)
                                                   : (Color){ 12, 12, 16, 255 };
        }
    }

    // Bake the overlays in, so toggling them is what forces a rebuild rather
    // than costing anything per frame.
    double dur = app.signal.duration;
    if (dur > 0.0 && app.annotations.loaded) {
        if (app.showAnnotations) {
            for (int i = 0; i < app.annotations.eventCount; i++) {
                const MlnlEvent* e = &app.annotations.events[i];
                if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
                int x0 = (int)(e->t_start / dur * TW);
                int x1 = (int)(e->t_end   / dur * TW);
                if (x1 <= x0) x1 = x0 + 1;
                if (x0 < 0) x0 = 0;
                if (x1 > TW) x1 = TW;
                // A thin tick along the bottom edge: density, not detail.
                for (int x = x0; x < x1; x++)
                    for (int y = TH - 3; y < TH; y++)
                        px[y * TW + x] = (Color){ 120, 200, 255, 255 };
            }
        }
        if (app.showCollisions) {
            for (int i = 0; i < app.collisionRegionCount; i++) {
                const CollisionRegion* cr = &app.collisionRegions[i];
                int x0 = (int)(cr->t0 / dur * TW);
                int x1 = (int)(cr->t1 / dur * TW);
                if (x1 <= x0) x1 = x0 + 1;
                if (x0 < 0) x0 = 0;
                if (x1 > TW) x1 = TW;
                for (int x = x0; x < x1; x++)
                    for (int y = 0; y < 3; y++)
                        px[y * TW + x] = (Color){ 255, 70, 70, 255 };
            }
        }
    }

    app.minimapTexture[slot] = LoadTextureFromImage(img);
    SetTextureFilter(app.minimapTexture[slot], TEXTURE_FILTER_BILINEAR);
    UnloadImage(img);
}

// Where the minimap sits, without drawing it. Input handling runs hundreds of
// lines before the draw pass, and it needs this rect to know whether a click
// belongs to the minimap or to the spectrogram underneath — otherwise the pan
// handler claims the press first and both end up writing app.view in the same
// frame, which reads as the drag jumping around.
Rectangle MinimapBounds(void)
{
    if (!app.showMinimap || !app.loaded || !app.stftComputed)
        return (Rectangle){ 0, 0, 0, 0 };
    float scale = GetUIScale();
    float mul = app.minimapLarge ? 2.0f : 1.0f;
    float w = MINIMAP_DRAW_W * mul * scale;
    float h = MINIMAP_DRAW_H * mul * scale;
    float m = MINIMAP_MARGIN * scale;
    // Anchored to the top-right: growing extends left and down, so the corner
    // it is pinned to never moves.
    return (Rectangle){ GetScreenWidth() - w - m, MENUBAR_HEIGHT * scale + m, w, h };
}

// The corner grab square, in screen space. Zero-sized when the minimap is
// hidden. Shared by the draw pass and the input phase so they can't disagree
// about where it is.
Rectangle MinimapHandleRect(void)
{
    Rectangle b = MinimapBounds();
    if (b.width <= 0.0f) return (Rectangle){ 0, 0, 0, 0 };
    float hs = MINIMAP_HANDLE * GetUIScale();
    return (Rectangle){ b.x, b.y + b.height - hs, hs, hs };
}

// True when the cursor is over the minimap (or a drag that started there is
// still live), so the spectrogram ignores the click.
bool MinimapCapturesMouse(void)
{
    if (app.minimapDragging) return true;
    Rectangle b = MinimapBounds();
    if (b.width <= 0.0f) return false;
    return CheckCollisionPointRec(GetMousePosition(), b);
}

Rectangle DrawMinimap(void)
{
    Rectangle box = MinimapBounds();
    if (box.width <= 0.0f) return (Rectangle){ 0, 0, 0, 0 };

    int slot = app.minimapLarge ? 1 : 0;
    float scale = GetUIScale();

    if (!app.minimapValid[slot]) BuildMinimapTexture(slot);

    DrawRectangleRec(box, (Color){ 12, 12, 16, 235 });
    if (app.minimapTexture[slot].id != 0) {
        DrawTexturePro(app.minimapTexture[slot],
                       (Rectangle){ 0, 0, (float)app.minimapTexture[slot].width,
                                    (float)app.minimapTexture[slot].height },
                       box, (Vector2){ 0, 0 }, 0.0f, (Color){ 255, 255, 255, 230 });
    }
    DrawRectangleLinesEx(box, 1, (Color){ 110, 110, 130, 255 });

    // Current view, as a rectangle over the thumbnail. Kept at least a couple
    // of pixels wide so a deep zoom still shows a visible marker.
    float vx0 = box.x + app.view.start * box.width;
    float vx1 = box.x + app.view.end   * box.width;
    if (vx1 - vx0 < 2.0f) vx1 = vx0 + 2.0f;
    Rectangle vr = { vx0, box.y, vx1 - vx0, box.height };
    DrawRectangleRec(vr, (Color){ 255, 255, 255, 40 });
    DrawRectangleLinesEx(vr, 1, (Color){ 255, 255, 255, 200 });

    // Bottom-left corner handle: click to switch size. Not a drag-resize —
    // two fixed sizes means two cached textures and no rebuild on resize.
    float hs = MINIMAP_HANDLE * scale;
    Rectangle handle = MinimapHandleRect();
    bool overHandle = CheckCollisionPointRec(GetMousePosition(), handle);
    DrawRectangleRec(handle, overHandle ? (Color){ 90, 90, 110, 240 }
                                        : (Color){ 50, 50, 62, 220 });
    // Diagonal grip lines, drawn corner-in so it reads as a resize affordance.
    for (int i = 1; i <= 2; i++) {
        float o = hs * (0.28f * i);
        DrawLineEx((Vector2){ handle.x + 2, handle.y + handle.height - o },
                   (Vector2){ handle.x + o, handle.y + handle.height - 2 },
                   1.2f, (Color){ 190, 190, 205, 255 });
    }
    // The click itself is handled during the input phase (MinimapHandleRect +
    // the scrub block in the main loop), which runs before the pan handlers.
    // Acting on it here would be too late: the scrub would already have claimed
    // the press and the view would jump while the size changed.
    return box;
}

void DrawAnnotationsToImage(Image* img, Font font)
{
    if (!app.annotations.loaded || !app.showAnnotations) return;
    if (app.signal.duration <= 0.0f || img->data == NULL) return;

    Rectangle bounds = { 0, 0, (float)img->width, (float)img->height };
    double duration = app.signal.duration;
    double nyquist  = EffectiveMaxFreqHz();
    int fontSize = (int)Clamp((float)img->height / 45.0f, 11.0f, 28.0f);

    // ---- Layer 1: ranged fills (tx_burst, tx_frame; then assertions) ----
    for (int i = 0; i < app.annotations.eventCount; i++) {
        const MlnlEvent* e = &app.annotations.events[i];
        if (e->kind != MLNL_KIND_TX_BURST && e->kind != MLNL_KIND_TX_FRAME) continue;
        if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
        Rectangle r;
        if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
        Color c = EventColor(e);
        // Outline-only. Translucent fills alpha-stack to opaque wherever frames
        // overlap (a PTT is many adjacent/overlapping frames), which buries the
        // signal — exactly the energy these boxes annotate. The border + label
        // mark each frame while its transmission still reads underneath.
        ImageDrawRectangleLines(img, r, 2, (Color){ c.r, c.g, c.b, HeadlessAlpha(1.0f) });
        char lbl[160];
        if (e->kind == MLNL_KIND_TX_FRAME) FormatTxFrameLabel(e, lbl, sizeof(lbl));
        else snprintf(lbl, sizeof(lbl), "%s", e->has_note ? e->note : e->kindStr);
        ImageBoxLabel(img, font, r, lbl, LabelColor(c), fontSize);
    }
    for (int i = 0; i < app.annotations.eventCount; i++) {
        const MlnlEvent* e = &app.annotations.events[i];
        if (e->kind != MLNL_KIND_ASSERTION_PASSED && e->kind != MLNL_KIND_ASSERTION_FAILED) continue;
        if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
        if (e->t_end <= e->t_start) continue;
        Rectangle r;
        if (!EventRect(bounds, e, duration, nyquist, &r)) continue;
        Color c = EventColor(e);
        // Outline-only: assertions are often full-band and overlap, so a fill
        // would stack to opaque and bury the signal. The 2px border marks the
        // region; passed/failed read from its color.
        ImageDrawRectangleLines(img, r, 2, (Color){ c.r, c.g, c.b, HeadlessAlpha(1.0f) });
        ImageBoxLabel(img, font, r, e->has_note ? e->note : (e->has_name ? e->name : e->kindStr),
                      LabelColor(c), fontSize);
    }

    // ---- Layer 2: point / vertical-line events (control, channel_*, etc.) ----
    for (int i = 0; i < app.annotations.eventCount; i++) {
        const MlnlEvent* e = &app.annotations.events[i];
        if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
        if (!IsPointEvent(e)) continue;
        Vector2 p = AnnoToScreen(bounds, e->t_start, 0.0, duration, nyquist);
        if (p.x < bounds.x || p.x > bounds.x + bounds.width) continue;
        Color c = EventColor(e);
        Color line = (Color){ c.r, c.g, c.b, HeadlessAlpha(1.0f) };
        ImageDrawLineEx(img, (Vector2){ p.x, bounds.y }, (Vector2){ p.x, bounds.y + bounds.height },
                        2, line);
        ImageBoxLabel(img, font, (Rectangle){ p.x + 2, bounds.y, 200, 20 },
                      e->has_note ? e->note : e->kindStr, line, fontSize);
    }
}

// ============================================================================
// Annotation overlay on the waveform scope (time-axis only)
// ============================================================================
//
// The scope has no frequency axis so annotations render as full-height time
// bands (tx_burst + assertion) or vertical lines (point events). No hit-test:
// hover/select still happens via the timeline lane, this is purely a visual
// echo so the user can correlate the spectrogram and waveform views.
//
// Reuses AlphaForEvent / EventColor / IsPointEvent so the spectrogram and
// scope move together — selecting an event in the timeline lights it on both.

void DrawAnnotationsOnScope(Rectangle bounds)
{
    if (!app.loaded || !app.annotations.loaded) return;
    if (!app.showAnnotations) return;
    if (app.signal.duration <= 0.0f) return;
    if (bounds.width < 4 || bounds.height < 4) return;

    double duration = app.signal.duration;
    double viewW = app.view.end - app.view.start;
    if (viewW <= 0.0) return;

    // Map a time fraction (0..1) to a screen x within the scope bounds.
    #define SC_X(tFrac) (bounds.x + (float)(((tFrac) - app.view.start) / viewW) * bounds.width)

    // ---- Layer 1: tx_burst (full-height band) ----
    for (int i = 0; i < app.annotations.eventCount; i++) {
        const MlnlEvent* e = &app.annotations.events[i];
        if (e->kind != MLNL_KIND_TX_BURST) continue;
        if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
        double t0f = e->t_start / duration, t1f = e->t_end / duration;
        if (t1f < app.view.start || t0f > app.view.end) continue;
        float x0 = SC_X(t0f), x1 = SC_X(t1f);
        if (x0 < bounds.x) x0 = bounds.x;
        if (x1 > bounds.x + bounds.width) x1 = bounds.x + bounds.width;
        if (x1 - x0 < 1) continue;

        Color c = EventColor(e);
        unsigned char fillA   = AlphaForEvent(i, 200.0f);
        unsigned char strokeA = AlphaForEvent(i, 255.0f);
        DrawRectangle((int)x0, (int)bounds.y, (int)(x1 - x0), (int)bounds.height,
                      (Color){ c.r, c.g, c.b, fillA });
        // Hairlines top & bottom so the band reads as deliberate framing
        // rather than tinted background, even at low alpha.
        DrawLine((int)x0, (int)bounds.y, (int)x1, (int)bounds.y,
                 (Color){ c.r, c.g, c.b, strokeA });
        DrawLine((int)x0, (int)(bounds.y + bounds.height - 1),
                 (int)x1, (int)(bounds.y + bounds.height - 1),
                 (Color){ c.r, c.g, c.b, strokeA });
    }

    // ---- Layer 1b: tx_frame (full-height band) ----
    for (int i = 0; i < app.annotations.eventCount; i++) {
        const MlnlEvent* e = &app.annotations.events[i];
        if (e->kind != MLNL_KIND_TX_FRAME) continue;
        if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
        double t0f = e->t_start / duration, t1f = e->t_end / duration;
        if (t1f < app.view.start || t0f > app.view.end) continue;
        float x0 = SC_X(t0f), x1 = SC_X(t1f);
        if (x0 < bounds.x) x0 = bounds.x;
        if (x1 > bounds.x + bounds.width) x1 = bounds.x + bounds.width;
        if (x1 - x0 < 1) continue;

        Color c = EventColor(e);
        unsigned char fillA   = AlphaForEvent(i, 200.0f);
        unsigned char strokeA = AlphaForEvent(i, 255.0f);
        DrawRectangle((int)x0, (int)bounds.y, (int)(x1 - x0), (int)bounds.height,
                      (Color){ c.r, c.g, c.b, fillA });
        DrawLine((int)x0, (int)bounds.y, (int)x1, (int)bounds.y,
                 (Color){ c.r, c.g, c.b, strokeA });
        DrawLine((int)x0, (int)(bounds.y + bounds.height - 1),
                 (int)x1, (int)(bounds.y + bounds.height - 1),
                 (Color){ c.r, c.g, c.b, strokeA });
    }

    // ---- Layer 2: assertion outlines (full-height) ----
    for (int i = 0; i < app.annotations.eventCount; i++) {
        const MlnlEvent* e = &app.annotations.events[i];
        if (e->kind != MLNL_KIND_ASSERTION_PASSED && e->kind != MLNL_KIND_ASSERTION_FAILED)
            continue;
        if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
        if (e->t_end <= e->t_start) continue;
        double t0f = e->t_start / duration, t1f = e->t_end / duration;
        if (t1f < app.view.start || t0f > app.view.end) continue;
        float x0 = SC_X(t0f), x1 = SC_X(t1f);
        if (x0 < bounds.x) x0 = bounds.x;
        if (x1 > bounds.x + bounds.width) x1 = bounds.x + bounds.width;
        if (x1 - x0 < 1) continue;

        Color c = EventColor(e);
        unsigned char fillA   = AlphaForEvent(i, 100.0f);
        unsigned char strokeA = AlphaForEvent(i, 255.0f);
        Rectangle r = { x0, bounds.y, x1 - x0, bounds.height };
        DrawRectangleRec(r, (Color){ c.r, c.g, c.b, fillA });
        DrawRectangleLinesEx(r, 1, (Color){ c.r, c.g, c.b, strokeA });
    }

    // ---- Layer 3: point events as vertical lines ----
    for (int i = 0; i < app.annotations.eventCount; i++) {
        const MlnlEvent* e = &app.annotations.events[i];
        if (!IsPointEvent(e)) continue;
        if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
        double t0f = e->t_start / duration;
        if (t0f < app.view.start || t0f > app.view.end) continue;
        float x = SC_X(t0f);
        Color c = EventColor(e);
        unsigned char strokeA = AlphaForEvent(i, 255.0f);
        DrawLine((int)x, (int)bounds.y, (int)x, (int)(bounds.y + bounds.height),
                 Fade((Color){ c.r, c.g, c.b, strokeA }, 0.6f));
    }

    // ---- Hover hit-test + tooltip ----
    // The scope only carries a time axis, so a hit is purely horizontal: the
    // mouse x falls inside an event's band (range events) or near its line
    // (point events). Narrowest match wins so a short event inside a wide band
    // is still reachable. Pops the same detail tooltip as the spectrogram, so
    // the sched-offset / metadata is available from either view.
    Vector2 m = GetMousePosition();
    bool suppress = app.sel.isDragging || app.sel.isTimeSelecting || app.sel.isFreqSelecting ||
                    app.view.isPanning || app.marker.dragging;
    if (!suppress && CheckCollisionPointRec(m, bounds)) {
        int hover = -1;
        double bestW = 1e18;
        for (int i = 0; i < app.annotations.eventCount; i++) {
            const MlnlEvent* e = &app.annotations.events[i];
            if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
            if (IsPointEvent(e)) {
                double t0f = e->t_start / duration;
                if (t0f < app.view.start || t0f > app.view.end) continue;
                float x = SC_X(t0f);
                if (m.x >= x - 4 && m.x <= x + 4) { bestW = 0.0; hover = i; }
            } else if (e->kind == MLNL_KIND_TX_BURST || e->kind == MLNL_KIND_TX_FRAME ||
                       e->kind == MLNL_KIND_ASSERTION_PASSED || e->kind == MLNL_KIND_ASSERTION_FAILED) {
                double t0f = e->t_start / duration, t1f = e->t_end / duration;
                if (t1f < app.view.start || t0f > app.view.end) continue;
                float x0 = SC_X(t0f), x1 = SC_X(t1f);
                if (m.x >= x0 && m.x <= x1) {
                    double w = t1f - t0f;
                    if (w <= bestW) { bestW = w; hover = i; }
                }
            }
        }
        if (hover >= 0) {
            const MlnlEvent* e = &app.annotations.events[hover];
            char lines[12][96];
            int n = BuildEventLines(e, lines, 12);
            DrawTooltip(bounds, m, lines, n, EventColor(e));
        }
    }
    #undef SC_X
}

// ============================================================================
// Timeline lane
// ============================================================================

// Count enabled-and-present annotation kinds. Mirrors the helper in
// spectrogram.c — kept local because static functions don't cross translation
// units; if it grows beyond two callers move it into a shared module.
static int CountVisibleAnnotationKindsLocal(void)
{
    int n = 0;
    for (int k = 0; k < MLNL_KIND_MAX; k++)
        if (app.annotations.kindPresent[k] && app.annotationKindEnabled[k]) n++;
    return n;
}

// Sort items by event duration descending: widest first, narrowest last.
// We draw in that order so the narrowest event ends up on top, and our
// hit-test (which keeps the *last* hit) naturally picks the narrowest event
// under the cursor — exactly what the user asked for so big chunks can't
// wash out short events sitting inside them.
typedef struct { int idx; double dur; } TlSortItem;
static int CmpTlSortDesc(const void* a, const void* b)
{
    double da = ((const TlSortItem*)a)->dur, db = ((const TlSortItem*)b)->dur;
    if (da > db) return -1;
    if (da < db) return 1;
    return 0;
}

// Row index (0..numRows-1) for a kind in the expanded timeline. -1 if hidden.
static int TimelineRowForKind(int kind)
{
    if (kind < 0 || kind >= MLNL_KIND_MAX) return -1;
    if (!app.annotations.kindPresent[kind] || !app.annotationKindEnabled[kind]) return -1;
    int r = 0;
    for (int k = 0; k < kind; k++)
        if (app.annotations.kindPresent[k] && app.annotationKindEnabled[k]) r++;
    return r;
}

void DrawTimeline(Rectangle lane)
{
    if (!app.annotations.loaded || !app.showAnnotations) return;
    if (app.annotations.eventCount == 0) return;
    if (lane.width < 8 || lane.height < 4) return;

    // Background. Slightly different from the spectrogram so the lane reads
    // as a separate UI surface rather than blending into the viewport.
    DrawRectangleRec(lane, (Color){ 8, 10, 14, 235 });
    DrawRectangleLinesEx(lane, 1, Fade(GRAY, 0.35f));

    double duration = app.signal.duration;
    if (duration <= 0) return;

    // Reserve a narrow chevron strip on the right for the expand/collapse toggle.
    float chevW = 14.0f * GetUIScale();
    Rectangle chev = { lane.x + lane.width - chevW - 2, lane.y, chevW, lane.height };
    Rectangle plot = { lane.x + 2, lane.y + 1, lane.width - chevW - 6, lane.height - 2 };

    Vector2 m = GetMousePosition();
    bool mouseInLane = CheckCollisionPointRec(m, lane);
    bool mouseInPlot = CheckCollisionPointRec(m, plot);
    bool mouseInChev = CheckCollisionPointRec(m, chev);

    app.hoveredTimelineEvent = -1;

    // Build a list of visible event indices (those not filtered by the
    // per-kind checkboxes) sorted by duration descending so we draw widest
    // first / narrowest last (last hit wins on hover).
    int eventCount = app.annotations.eventCount;
    TlSortItem* items = (TlSortItem*)malloc((size_t)eventCount * sizeof(TlSortItem));
    if (!items) return;
    int nVisible = 0;
    for (int i = 0; i < eventCount; i++) {
        const MlnlEvent* e = &app.annotations.events[i];
        if (e->kind < MLNL_KIND_MAX && !app.annotationKindEnabled[e->kind]) continue;
        items[nVisible].idx = i;
        items[nVisible].dur = e->t_end - e->t_start;
        nVisible++;
    }
    qsort(items, nVisible, sizeof(TlSortItem), CmpTlSortDesc);

    // Helper: time (seconds) -> screen X within plot.
    //
    // Mapped through the VISIBLE view, not the whole file. It used to divide by
    // `duration` alone, so the lane always showed the entire capture while the
    // spectrogram directly above it showed a zoomed window — the two were only
    // ever aligned at full zoom-out, and an event's tick sat nowhere near the
    // burst it described.
    double tlSpan = (double)(app.view.end - app.view.start);
    if (tlSpan < 1e-9) tlSpan = 1e-9;
    #define TL_X(t) (plot.x + (float)((((t) / duration) - app.view.start) / tlSpan) * plot.width)

    // Events outside the visible window now project off-plot, so clip the lane
    // to its own rect rather than letting them smear across the UI.
    BeginScissorMode((int)plot.x, (int)plot.y, (int)plot.width, (int)plot.height);

    if (!app.timelineExpanded) {
        // Single mixed strip. Each event is a colored rect spanning [t0,t1]
        // (point events get a 2px tick). Narrowest-on-top is implicit in the
        // sort order.
        for (int k = 0; k < nVisible; k++) {
            int i = items[k].idx;
            const MlnlEvent* e = &app.annotations.events[i];
            Color c = EventColor(e);
            float x0 = TL_X(e->t_start);
            float x1 = TL_X(e->t_end);
            float w = x1 - x0;
            if (w < 2.0f) w = 2.0f;  // visibility minimum for narrow events
            Rectangle r = { x0, plot.y, w, plot.height };
            if (app.selectedAnnotation == i)
                DrawRectangleLinesEx((Rectangle){r.x-1, r.y-1, r.width+2, r.height+2}, 1, WHITE);
            DrawRectangleRec(r, c);
            if (mouseInPlot && CheckCollisionPointRec(m, r))
                app.hoveredTimelineEvent = i;
        }
    } else {
        // One row per enabled kind. Draw row labels + separators first, then
        // events on top (still narrowest-last).
        int numRows = CountVisibleAnnotationKindsLocal();
        if (numRows < 1) numRows = 1;
        float rowH = plot.height / (float)numRows;

        for (int kind = 0, row = 0; kind < MLNL_KIND_MAX; kind++) {
            if (!app.annotations.kindPresent[kind] || !app.annotationKindEnabled[kind]) continue;
            float ry = plot.y + row * rowH;
            if (row > 0)
                DrawLine((int)plot.x, (int)ry, (int)(plot.x + plot.width), (int)ry, Fade(GRAY, 0.2f));
            DrawTextScaled(MlnlKindName((MlnlKind)kind), plot.x + 4, ry + 1, 9,
                           Fade(LIGHTGRAY, 0.7f));
            row++;
        }

        for (int k = 0; k < nVisible; k++) {
            int i = items[k].idx;
            const MlnlEvent* e = &app.annotations.events[i];
            int row = TimelineRowForKind(e->kind);
            if (row < 0) continue;
            Color c = EventColor(e);
            float x0 = TL_X(e->t_start);
            float x1 = TL_X(e->t_end);
            float w = x1 - x0;
            if (w < 2.0f) w = 2.0f;
            float ry = plot.y + row * rowH + 1;
            float rh = rowH - 2;
            Rectangle r = { x0, ry, w, rh };
            if (app.selectedAnnotation == i)
                DrawRectangleLinesEx((Rectangle){r.x-1, r.y-1, r.width+2, r.height+2}, 1, WHITE);
            DrawRectangleRec(r, c);
            if (mouseInPlot && CheckCollisionPointRec(m, r))
                app.hoveredTimelineEvent = i;
        }
    }
    free(items);
    #undef TL_X

    // Chevron toggle (separate hit area from the plot so it always responds).
    if (mouseInChev) DrawRectangleRec(chev, Fade(GRAY, 0.25f));
    float chevFs = 10.0f;
    DrawTextScaled(app.timelineExpanded ? "v" : ">",
                   chev.x + 3, chev.y + chev.height * 0.5f - chevFs * GetUIScale() * 0.5f,
                   chevFs, LIGHTGRAY);
    if (mouseInChev && IsMouseButtonPressed(MOUSE_LEFT_BUTTON))
        app.timelineExpanded = !app.timelineExpanded;

    EndScissorMode();

    // Click handling. In collapsed mode the lane is small + low-resolution,
    // so any click in the plot expands rather than risking an accidental
    // selection. Selection is an "expanded mode" gesture.
    if (mouseInPlot && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
        if (!app.timelineExpanded) {
            app.timelineExpanded = true;
        } else if (app.hoveredTimelineEvent >= 0) {
            app.selectedAnnotation = (app.selectedAnnotation == app.hoveredTimelineEvent)
                                       ? -1 : app.hoveredTimelineEvent;
        } else {
            app.selectedAnnotation = -1;
        }
    }

    // Subtle hint when hovering a collapsed lane (helps the user discover
    // the expand affordance the first time they see annotations).
    if (!app.timelineExpanded && mouseInLane) {
        int kinds = CountVisibleAnnotationKindsLocal();
        if (kinds > 0) {
            char hint[48];
            snprintf(hint, sizeof(hint), "%d kinds — click to expand", kinds);
            float hintFs = 9.0f;
            float w = MeasureTextScaled(hint, hintFs);
            DrawTextScaled(hint, plot.x + plot.width - w - 4, plot.y - 1,
                           hintFs, Fade(LIGHTGRAY, 0.85f));
        }
    }
}

// ============================================================================
// Playhead
// ============================================================================

// Screen X of the playhead within `bounds`, or a negative value when there is
// nothing to draw. Shared with the scrub hit-test so the marker and its grab
// region can't disagree about where it is.
float PlayheadScreenX(Rectangle bounds)
{
    if (!app.loaded || app.playheadT < 0.0f || app.playheadT > 1.0f) return -1.0f;
    if (app.playSelEnd <= app.playSelStart) return -1.0f;

    // Against the snapshot of the playing region, not the live selection.
    float timePos = app.playSelStart + app.playheadT * (app.playSelEnd - app.playSelStart);
    float viewWidth = app.view.end - app.view.start;
    if (viewWidth <= 0.0f) return -1.0f;
    float t = (timePos - app.view.start) / viewWidth;
    return bounds.x + t * bounds.width;
}

void DrawPlayhead(Rectangle bounds)
{
    // Drawn while stopped too, so the marker stays put and can be dragged to
    // replay a subrange without redoing the selection.
    float x = PlayheadScreenX(bounds);
    if (x < 0.0f) return;
    if (x < bounds.x - 4 || x > bounds.x + bounds.width + 4) return;
    x = fmaxf(bounds.x, fminf(bounds.x + bounds.width, x));

    Color c = app.isPlaying ? RED : (Color){ 255, 120, 120, 255 };
    DrawLine(x, bounds.y, x, bounds.y + bounds.height, c);
    DrawRectangle(x - 2, bounds.y, 4, bounds.height, (Color){ 255, 0, 0, 60 });

    // Grab tab at the top, so the marker reads as draggable when stopped.
    if (!app.isPlaying) {
        DrawTriangle((Vector2){ x - 5, bounds.y },
                     (Vector2){ x + 5, bounds.y },
                     (Vector2){ x, bounds.y + 8 }, c);
    }
}
