// Radar Simulation — Feature Test: 1–6, 9
//   Feature 1: Initialize display, draw scope boundaries (PPI circle, A scope box)
//   Feature 2: PPI bearing ring with tick marks, degree labels, and outer label ring
//   Feature 3: Replaceable A scope graticule — cycles through 2/5/10/15 mi ranges,
//              5 s hold per range, 0.5 s slide animation between ranges
//   Feature 4: PPI range rings with rotating sweep and P7 persistence
//   Feature 5: Four fake targets with size-based brightness
//   Feature 6: PPI cursor — range ring + bearing line + intersection box (incandescent),
//              auto-animates slowly (range oscillates, bearing rotates) for test
// Press ESC to exit.

#include <glad/glad.h>
#include <GLFW/glfw3.h>

#include <ft2build.h>
#include FT_FREETYPE_H

// GDAL / OGR — for ENC S-57 shoreline and DEM terrain loading
#include <gdal_priv.h>
#include <ogrsf_frmts.h>
#include <cpl_conv.h>

#include <cmath>
#include <vector>
#include <string>
#include <fstream>
#include <sstream>
#include <iostream>
#include <algorithm>
#include <cstring>
#include <cstdio>

// ─── Constants ───────────────────────────────────────────────────────────────

static constexpr float PI          = 3.14159265358979323846f;
static constexpr int   CIRCLE_SEGS = 360;

// Incandescent (warm lamp) — bearing graticule and A scope graticule
static constexpr float INCAN_R = 1.00f;
static constexpr float INCAN_G = 0.78f;
static constexpr float INCAN_B = 0.35f;

// P1 phosphor (green) — A scope
static constexpr float P1_R = 0.00f;
static constexpr float P1_G = 0.90f;
static constexpr float P1_B = 0.20f;

// P7 phosphor active (blue) — PPI scope active display
static constexpr float P7A_R = 0.10f;
static constexpr float P7A_G = 0.45f;
static constexpr float P7A_B = 1.00f;

// P7 phosphor persistence (greenish yellow) — PPI scope trail/fade
static constexpr float P7P_R = 0.35f;
static constexpr float P7P_G = 0.88f;
static constexpr float P7P_B = 0.18f;

// Full printable ASCII rendered into the font atlas: ' '(32) through '~'(126)
static constexpr int GLYPH_FIRST = ' ';
static constexpr int GLYPH_COUNT = 95;

// Feature 3 timing
static constexpr float HOLD_SEC      =  5.0f;  // seconds per range setting
static constexpr float SLIDE_OUT_SEC =  2.0f;  // slide current graticule out (up)
static constexpr float WAIT_SEC      =  0.5f;  // blank pause between graticules
static constexpr float SLIDE_IN_SEC  =  2.0f;  // slide new graticule in (from top)

// Feature 4 — sweep
static constexpr float SWEEP_DEG_PS = 20.0f * 6.0f;  // 20 RPM → 120 °/s
static constexpr float TRAIL_DEG    = 50.0f;          // lit arc behind sweep head (°)
static constexpr int   RING_SEGS    = 300;
static constexpr int   TRAIL_SEGS   = 50;

// Feature 5 — target glow lifetime (s) after sweep passes
static constexpr float TARG_PERSIST = 5.0f;

// ─── Radar location (WGS84, latitude first) ──────────────────────────────────
// Middle of Bellingham Bay, 100-foot platform
static constexpr double RADAR_LAT      = 48.74361448950435;
static constexpr double RADAR_LON      = -122.56466911663048;
static constexpr float  MAX_RANGE_MILES = 6.0f;

// ─── NDC helpers ─────────────────────────────────────────────────────────────

static inline float ndcX(float px, float W) { return  px / W * 2.0f - 1.0f; }
static inline float ndcY(float py, float H) { return  1.0f - py / H * 2.0f; }

// ─── Shader utilities ────────────────────────────────────────────────────────

static std::string readFile(const std::string& path)
{
    std::ifstream f(path);
    if (!f) { std::cerr << "Cannot read shader: " << path << "\n"; return ""; }
    return { std::istreambuf_iterator<char>(f), std::istreambuf_iterator<char>() };
}

static GLuint compileShader(GLenum type, const std::string& src)
{
    GLuint sh = glCreateShader(type);
    const char* s = src.c_str();
    glShaderSource(sh, 1, &s, nullptr);
    glCompileShader(sh);
    GLint ok; glGetShaderiv(sh, GL_COMPILE_STATUS, &ok);
    if (!ok) {
        char log[512]; glGetShaderInfoLog(sh, 512, nullptr, log);
        std::cerr << "Shader compile: " << log << "\n";
    }
    return sh;
}

static GLuint makeProgram(const std::string& vp, const std::string& fp)
{
    GLuint vs = compileShader(GL_VERTEX_SHADER,   readFile(vp));
    GLuint fs = compileShader(GL_FRAGMENT_SHADER, readFile(fp));
    GLuint prog = glCreateProgram();
    glAttachShader(prog, vs); glAttachShader(prog, fs);
    glLinkProgram(prog);
    GLint ok; glGetProgramiv(prog, GL_LINK_STATUS, &ok);
    if (!ok) {
        char log[512]; glGetProgramInfoLog(prog, 512, nullptr, log);
        std::cerr << "Program link: " << log << "\n";
    }
    glDeleteShader(vs); glDeleteShader(fs);
    return prog;
}

// ─── VAO / VBO helpers ───────────────────────────────────────────────────────

// 2-float-per-vertex (NDC positions only)
static void makeLineVAO(GLuint& vao, GLuint& vbo, const std::vector<float>& v)
{
    glGenVertexArrays(1, &vao); glGenBuffers(1, &vbo);
    glBindVertexArray(vao);
    glBindBuffer(GL_ARRAY_BUFFER, vbo);
    glBufferData(GL_ARRAY_BUFFER, v.size() * sizeof(float), v.data(), GL_STATIC_DRAW);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
    glEnableVertexAttribArray(0);
    glBindVertexArray(0);
}

// 4-float-per-vertex (NDC x,y  +  UV u,v)
static void makeTextVAO(GLuint& vao, GLuint& vbo, const std::vector<float>& v)
{
    glGenVertexArrays(1, &vao); glGenBuffers(1, &vbo);
    glBindVertexArray(vao);
    glBindBuffer(GL_ARRAY_BUFFER, vbo);
    glBufferData(GL_ARRAY_BUFFER, v.size() * sizeof(float), v.data(), GL_STATIC_DRAW);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), nullptr);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float),
                          reinterpret_cast<void*>(2 * sizeof(float)));
    glEnableVertexAttribArray(1);
    glBindVertexArray(0);
}

// ─── FreeType font atlas ─────────────────────────────────────────────────────

struct GlyphInfo {
    int atlasX;
    int bitmapW;
    int bitmapH;
    int bearingX;
    int bearingY;
    int advance;
};

struct FontAtlas {
    GLuint    texture = 0;
    int       atlasW  = 0;
    int       atlasH  = 0;
    GlyphInfo glyphs[GLYPH_COUNT]{};
};

static bool buildFontAtlas(FontAtlas& fa, const std::string& fontPath, int sizePx)
{
    FT_Library ft;
    if (FT_Init_FreeType(&ft)) {
        std::cerr << "FreeType init failed\n"; return false;
    }

    FT_Face face;
    if (FT_New_Face(ft, fontPath.c_str(), 0, &face)) {
        std::cerr << "FT_New_Face failed: " << fontPath << "\n";
        FT_Done_FreeType(ft); return false;
    }
    FT_Set_Pixel_Sizes(face, 0, sizePx);

    int totalW = 0, maxH = 0;
    for (int i = 0; i < GLYPH_COUNT; ++i) {
        if (FT_Load_Char(face, GLYPH_FIRST + i, FT_LOAD_RENDER)) continue;
        FT_GlyphSlot g = face->glyph;
        totalW += (int)g->bitmap.width + 2;
        maxH    = std::max(maxH, (int)g->bitmap.rows);
    }

    fa.atlasW = totalW;
    fa.atlasH = maxH;
    std::vector<uint8_t> atlas(fa.atlasW * fa.atlasH, 0);

    int xOff = 0;
    for (int i = 0; i < GLYPH_COUNT; ++i) {
        if (FT_Load_Char(face, GLYPH_FIRST + i, FT_LOAD_RENDER)) continue;
        FT_GlyphSlot g = face->glyph;

        GlyphInfo& gi = fa.glyphs[i];
        gi.atlasX   = xOff;
        gi.bitmapW  = (int)g->bitmap.width;
        gi.bitmapH  = (int)g->bitmap.rows;
        gi.bearingX = g->bitmap_left;
        gi.bearingY = g->bitmap_top;
        gi.advance  = (int)(g->advance.x >> 6);

        for (int row = 0; row < gi.bitmapH; ++row) {
            const uint8_t* src = g->bitmap.buffer + row * std::abs(g->bitmap.pitch);
            uint8_t*       dst = atlas.data() + row * fa.atlasW + xOff;
            std::memcpy(dst, src, gi.bitmapW);
        }
        xOff += gi.bitmapW + 2;
    }

    glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
    glGenTextures(1, &fa.texture);
    glBindTexture(GL_TEXTURE_2D, fa.texture);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RED,
                 fa.atlasW, fa.atlasH, 0,
                 GL_RED, GL_UNSIGNED_BYTE, atlas.data());
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glBindTexture(GL_TEXTURE_2D, 0);
    glPixelStorei(GL_UNPACK_ALIGNMENT, 4);

    FT_Done_Face(face);
    FT_Done_FreeType(ft);
    return true;
}

// Append NDC quad vertices for string s, visually centered at screen pixel (cx, cy).
// Only digits '0'–'9' are supported.
static void appendTextQuads(std::vector<float>& verts, const FontAtlas& fa,
                             const std::string& s, float cx, float cy,
                             float W, float H)
{
    if (s.empty()) return;

    float totalAdv = 0.0f;
    int   maxBY    =  0;
    int   minBY    =  0;

    for (char c : s) {
        int i = (int)c - GLYPH_FIRST;
        if (i < 0 || i >= GLYPH_COUNT) continue;
        const GlyphInfo& g = fa.glyphs[i];
        totalAdv += g.advance;
        maxBY = std::max(maxBY, g.bearingY);
        minBY = std::min(minBY, g.bearingY - g.bitmapH);
    }

    float visualCenterAboveBaseline = (maxBY + minBY) * 0.5f;
    float baselineY = cy + visualCenterAboveBaseline;
    float cursorX   = cx - totalAdv * 0.5f;

    for (char c : s) {
        int i = (int)c - GLYPH_FIRST;
        if (i < 0 || i >= GLYPH_COUNT) continue;
        const GlyphInfo& g = fa.glyphs[i];

        float gx0 = cursorX  + g.bearingX;
        float gy0 = baselineY - g.bearingY;
        float gx1 = gx0 + g.bitmapW;
        float gy1 = gy0 + g.bitmapH;

        float u0 = (float) g.atlasX             / fa.atlasW;
        float u1 = (float)(g.atlasX + g.bitmapW)/ fa.atlasW;
        float v0 = 0.0f;
        float v1 = (float) g.bitmapH            / fa.atlasH;

        verts.push_back(ndcX(gx0,W)); verts.push_back(ndcY(gy0,H)); verts.push_back(u0); verts.push_back(v0);
        verts.push_back(ndcX(gx1,W)); verts.push_back(ndcY(gy0,H)); verts.push_back(u1); verts.push_back(v0);
        verts.push_back(ndcX(gx1,W)); verts.push_back(ndcY(gy1,H)); verts.push_back(u1); verts.push_back(v1);
        verts.push_back(ndcX(gx0,W)); verts.push_back(ndcY(gy0,H)); verts.push_back(u0); verts.push_back(v0);
        verts.push_back(ndcX(gx1,W)); verts.push_back(ndcY(gy1,H)); verts.push_back(u1); verts.push_back(v1);
        verts.push_back(ndcX(gx0,W)); verts.push_back(ndcY(gy1,H)); verts.push_back(u0); verts.push_back(v1);

        cursorX += g.advance;
    }
}

// ─── Layout ──────────────────────────────────────────────────────────────────

struct Layout {
    float ppiCX, ppiCY, ppiR;
    float asLeft, asTop, asRight, asBot;
    float brgLeft, brgTop, brgRight, brgBot;     // A scope bearing display window
    float notifyLeft, notifyRight, notifyTop, notifyBot; // "changing graticule" notice
};

static Layout computeLayout(float W, float H, float marginPx)
{
    Layout L{};

    float availW = (W - marginPx) - W * 0.5f;
    float availH = (H - marginPx) - marginPx;
    // The bearing graticule outer ring sits at ppiR * 1.18, so shrink ppiR so
    // that outer ring — not the inner circle — lands at the margin boundary.
    static constexpr float GRAT_SCALE = 1.18f;
    L.ppiR  = std::min(availW, availH) * 0.5f / GRAT_SCALE;
    L.ppiCX = W * 0.5f + availW * 0.5f;
    L.ppiCY = marginPx + availH * 0.5f;

    float asW = (W * 0.5f - 2.0f * marginPx) * 0.65f;
    float asH = H * 0.22f;
    L.asLeft  = marginPx;
    L.asRight = marginPx + asW;
    L.asTop   = (H - asH) * 0.5f;
    L.asBot   = L.asTop + asH;

    // Bearing display window — small box directly below the A scope
    const float brgGap = H * 0.008f;   // gap between A scope bottom and window top
    const float brgH   = H * 0.060f;   // window height (two text lines)
    L.brgLeft  = L.asLeft;
    L.brgRight = L.asRight;
    L.brgTop   = L.asBot + brgGap;
    L.brgBot   = L.brgTop + brgH;

    // Notification window — small box directly above the A scope
    const float notifyGap = H * 0.008f;
    const float notifyH   = H * 0.040f;
    L.notifyLeft  = L.asLeft;
    L.notifyRight = L.asRight;
    L.notifyBot   = L.asTop - notifyGap;
    L.notifyTop   = L.notifyBot - notifyH;

    return L;
}

// ─── Polar coordinate helpers (bearing ° CW from N, range fraction) ──────────

static inline float polarPx(const Layout& L, float bearDeg, float frac)
{ return L.ppiCX + frac * L.ppiR * std::sin(bearDeg * (PI/180.0f)); }

static inline float polarPy(const Layout& L, float bearDeg, float frac)
{ return L.ppiCY - frac * L.ppiR * std::cos(bearDeg * (PI/180.0f)); }

// ─── Feature 1: Scope boundaries ─────────────────────────────────────────────

struct ScopeBounds {
    GLuint prog = 0, vao = 0, vbo = 0;
    int ppiStart = 0, ppiCount = 0;
    int asStart  = 0, asCount  = 0;
};

static ScopeBounds buildScopeBounds(const Layout& L, float W, float H)
{
    ScopeBounds sb{};
    std::vector<float> v;

    // PPI scope circle
    sb.ppiStart = 0;
    sb.ppiCount = CIRCLE_SEGS + 1;
    for (int i = 0; i <= CIRCLE_SEGS; ++i) {
        float a = 2.0f * PI * i / CIRCLE_SEGS;
        v.push_back(ndcX(L.ppiCX + L.ppiR * std::cos(a), W));
        v.push_back(ndcY(L.ppiCY + L.ppiR * std::sin(a), H));
    }

    // A scope bounding box
    sb.asStart = sb.ppiStart + sb.ppiCount;
    sb.asCount = 8;
    auto ln = [&](float x1, float y1, float x2, float y2) {
        v.push_back(ndcX(x1,W)); v.push_back(ndcY(y1,H));
        v.push_back(ndcX(x2,W)); v.push_back(ndcY(y2,H));
    };
    ln(L.asLeft,  L.asTop,  L.asRight, L.asTop);
    ln(L.asRight, L.asTop,  L.asRight, L.asBot);
    ln(L.asRight, L.asBot,  L.asLeft,  L.asBot);
    ln(L.asLeft,  L.asBot,  L.asLeft,  L.asTop);

    makeLineVAO(sb.vao, sb.vbo, v);
    sb.prog = makeProgram("shaders/scope_bounds.vert", "shaders/scope_bounds.frag");
    return sb;
}

// ─── Feature 2: PPI bearing graticule ────────────────────────────────────────

struct BearingGraticule {
    GLuint lineProg = 0, textProg = 0;
    GLuint lineVAO  = 0, lineVBO  = 0;
    GLuint textVAO  = 0, textVBO  = 0;
    int ring1Start  = 0, ring1Count = 0;   // inner ring (at scope edge)
    int tickStart   = 0, tickCount  = 0;   // degree tick marks (outward)
    int ring2Start  = 0, ring2Count = 0;   // outer ring (encircles labels)
    int textVerts   = 0;
    GLuint fontTex  = 0;
};

static BearingGraticule buildBearingGraticule(const Layout& L, const FontAtlas& fa,
                                               float W, float H)
{
    BearingGraticule bg{};
    const float cx = L.ppiCX, cy = L.ppiCY, R = L.ppiR;

    std::vector<float> lineV;

    // Inner ring (scope edge)
    bg.ring1Start = 0;
    bg.ring1Count = CIRCLE_SEGS + 1;
    for (int i = 0; i <= CIRCLE_SEGS; ++i) {
        float a = 2.0f * PI * i / CIRCLE_SEGS;
        lineV.push_back(ndcX(cx + R * std::cos(a), W));
        lineV.push_back(ndcY(cy + R * std::sin(a), H));
    }

    // Tick marks pointing outward from the scope edge
    const float majorLen = 0.055f * R;
    const float minorLen = 0.025f * R;
    bg.tickStart = bg.ring1Start + bg.ring1Count;
    bg.tickCount = 360 * 2;
    for (int b = 0; b < 360; ++b) {
        float brad = b * PI / 180.0f;
        float sb_  = std::sin(brad);
        float cb_  = std::cos(brad);
        float len  = (b % 10 == 0) ? majorLen : minorLen;
        lineV.push_back(ndcX(cx +  R        * sb_, W));
        lineV.push_back(ndcY(cy -  R        * cb_, H));
        lineV.push_back(ndcX(cx + (R + len) * sb_, W));
        lineV.push_back(ndcY(cy - (R + len) * cb_, H));
    }

    // Outer ring (encircles the degree labels)
    const float outerR  = R * 1.18f;
    bg.ring2Start = bg.tickStart + bg.tickCount;
    bg.ring2Count = CIRCLE_SEGS + 1;
    for (int i = 0; i <= CIRCLE_SEGS; ++i) {
        float a = 2.0f * PI * i / CIRCLE_SEGS;
        lineV.push_back(ndcX(cx + outerR * std::cos(a), W));
        lineV.push_back(ndcY(cy + outerR * std::sin(a), H));
    }

    makeLineVAO(bg.lineVAO, bg.lineVBO, lineV);

    // Degree labels (every 10°) between the tick tips and the outer ring
    std::vector<float> textV;
    const float textR = R * 1.09f;
    for (int b = 0; b < 360; b += 10) {
        float brad = b * PI / 180.0f;
        float tx   = cx + textR * std::sin(brad);
        float ty   = cy - textR * std::cos(brad);
        appendTextQuads(textV, fa, std::to_string(b), tx, ty, W, H);
    }
    bg.textVerts = (int)textV.size() / 4;
    bg.fontTex   = fa.texture;

    makeTextVAO(bg.textVAO, bg.textVBO, textV);

    bg.lineProg = makeProgram("shaders/ppi_bearing.vert", "shaders/ppi_bearing.frag");
    bg.textProg = makeProgram("shaders/text.vert",        "shaders/text.frag");
    return bg;
}

// ─── Range configs (shared by Features 3, 4, 5) ──────────────────────────────

struct RangeConfig {
    float maxMiles;
    int   numRings;
    float rings[8];    // explicit ring positions in miles (up to 8)
};

// PPI scope range ring configs (selectable by operator): 2, 4, 6 miles
static const RangeConfig RANGE_CONFIGS[3] = {
    { 2.0f, 2, { 1.0f, 2.0f } },
    { 4.0f, 2, { 2.0f, 4.0f } },
    { 6.0f, 4, { 1.0f, 2.0f, 4.0f, 6.0f } },
};
static constexpr int RANGE_COUNT = 3;

// A scope graticule configs — independent from PPI: 2, 4, 6 miles
static const RangeConfig ASCOPE_RANGE_CONFIGS[3] = {
    { 2.0f, 2, { 1.0f, 2.0f } },
    { 4.0f, 2, { 2.0f, 4.0f } },
    { 6.0f, 4, { 1.0f, 2.0f, 4.0f, 6.0f } },
};
static constexpr int ASCOPE_RANGE_COUNT = 3;

// ─── Feature 3: A scope replaceable graticule ────────────────────────────────

struct AScopeGraticule {
    GLuint lineVAO = 0, lineVBO = 0;
    GLuint textVAO = 0, textVBO = 0;
    int    lineCount = 0;
    int    textVerts = 0;
};

struct AScopeGratProg {
    GLuint line = 0;
    GLuint text = 0;
};

static AScopeGratProg buildAScopeGratPrograms()
{
    return {
        makeProgram("shaders/ascope_graticule.vert",
                    "shaders/ascope_graticule.frag"),
        makeProgram("shaders/ascope_graticule_text.vert",
                    "shaders/ascope_graticule_text.frag")
    };
}

static AScopeGraticule buildAScopeGraticule(
    const Layout& L, const FontAtlas& fa, const RangeConfig& rc,
    float W, float H)
{
    AScopeGraticule ag{};

    const float asH  = L.asBot - L.asTop;
    const float pad  = 4.0f;
    const float gx0  = L.asLeft  + pad;
    const float gx1  = L.asRight - pad;
    const float gy0  = L.asTop   + pad;           // top of signal area
    const float gy1  = L.asTop   + asH * 0.80f;  // baseline
    const float sigW = gx1 - gx0;
    const float sigH = gy1 - gy0;

    std::vector<float> lineV;
    auto ln = [&](float x1, float y1, float x2, float y2) {
        lineV.push_back(ndcX(x1,W)); lineV.push_back(ndcY(y1,H));
        lineV.push_back(ndcX(x2,W)); lineV.push_back(ndcY(y2,H));
    };

    // Outer frame
    ln(L.asLeft,  L.asTop,  L.asRight, L.asTop);
    ln(L.asRight, L.asTop,  L.asRight, L.asBot);
    ln(L.asRight, L.asBot,  L.asLeft,  L.asBot);
    ln(L.asLeft,  L.asBot,  L.asLeft,  L.asTop);

    // Baseline and top amplitude reference
    ln(gx0, gy1, gx1, gy1);
    ln(gx0, gy0, gx1, gy0);

    // Horizontal amplitude guides at 25 %, 50 %, 75 %
    for (int i = 1; i <= 3; ++i)
        ln(gx0, gy0 + sigH * i * 0.25f, gx1, gy0 + sigH * i * 0.25f);

    // Vertical tick marks at each explicit ring position
    for (int ri = 0; ri < rc.numRings; ++ri) {
        float xMaj = gx0 + (rc.rings[ri] / rc.maxMiles) * sigW;
        ln(xMaj, gy1, xMaj, gy0);
    }

    ag.lineCount = (int)lineV.size() / 2;
    makeLineVAO(ag.lineVAO, ag.lineVBO, lineV);

    // Range labels at each ring tick
    std::vector<float> textV;
    const float labelY = L.asTop + asH * 0.90f;
    for (int ri = 0; ri < rc.numRings; ++ri) {
        float x       = gx0 + (rc.rings[ri] / rc.maxMiles) * sigW;
        int   labelMi = (int)std::round(rc.rings[ri]);
        appendTextQuads(textV, fa, std::to_string(labelMi), x, labelY, W, H);
    }
    ag.textVerts = (int)textV.size() / 4;
    makeTextVAO(ag.textVAO, ag.textVBO, textV);

    return ag;
}

static void drawAScopeGraticule(
    const AScopeGratProg& prog, const AScopeGraticule& ag,
    float yOffNDC, const FontAtlas& fa, float W, float H, const Layout& L)
{
    glEnable(GL_SCISSOR_TEST);
    glScissor((GLint) L.asLeft,
              (GLint)(H - L.asBot),
              (GLint)(L.asRight - L.asLeft),
              (GLint)(L.asBot   - L.asTop));

    glUseProgram(prog.line);
    glUniform3f(glGetUniformLocation(prog.line, "uColor"), INCAN_R, INCAN_G, INCAN_B);
    glUniform1f(glGetUniformLocation(prog.line, "uYOffset"), yOffNDC);
    glBindVertexArray(ag.lineVAO);
    glDrawArrays(GL_LINES, 0, ag.lineCount);

    if (ag.textVerts > 0) {
        glUseProgram(prog.text);
        glUniform3f(glGetUniformLocation(prog.text, "uColor"), INCAN_R, INCAN_G, INCAN_B);
        glUniform1f(glGetUniformLocation(prog.text, "uYOffset"), yOffNDC);
        glUniform1i(glGetUniformLocation(prog.text, "uTexture"), 0);
        glActiveTexture(GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, fa.texture);
        glBindVertexArray(ag.textVAO);
        glDrawArrays(GL_TRIANGLES, 0, ag.textVerts);
        glBindTexture(GL_TEXTURE_2D, 0);
    }

    glBindVertexArray(0);
    glDisable(GL_SCISSOR_TEST);
}

// ─── Feature 4: PPI range rings ──────────────────────────────────────────────

// 5 floats/vertex: NDC x, y, r, g, b
static void buildRingVerts(std::vector<float>& v,
                            const Layout& L, int rangeIdx,
                            float sweepAngle, float W, float H)
{
    const RangeConfig& rc = RANGE_CONFIGS[rangeIdx];
    const int nr = rc.numRings;

    auto push = [&](float px, float py, float r, float g, float b) {
        v.push_back(ndcX(px,W)); v.push_back(ndcY(py,H));
        v.push_back(r); v.push_back(g); v.push_back(b);
    };

    // Full persistence rings (dim greenish yellow) at explicit ring positions
    for (int ri = 0; ri < nr; ++ri) {
        float frac = rc.rings[ri] / rc.maxMiles;
        for (int i = 0; i <= RING_SEGS; ++i) {
            float a = 2.0f * PI * i / RING_SEGS;
            push(L.ppiCX + frac*L.ppiR*std::cos(a),
                 L.ppiCY + frac*L.ppiR*std::sin(a),
                 P7P_R*0.45f, P7P_G*0.45f, P7P_B*0.45f);
        }
    }

    // Sweep trail arcs at each ring
    for (int ri = 0; ri < nr; ++ri) {
        float frac = rc.rings[ri] / rc.maxMiles;
        for (int i = 0; i <= TRAIL_SEGS; ++i) {
            float t   = 1.0f - (float)i / TRAIL_SEGS;
            float ang = sweepAngle - (float)i * TRAIL_DEG / TRAIL_SEGS;
            float r   = P7A_R * t + P7P_R * 0.45f * (1.0f - t);
            float g2  = P7A_G * t + P7P_G * 0.45f * (1.0f - t);
            float b   = P7A_B * t + P7P_B * 0.45f * (1.0f - t);
            push(polarPx(L,ang,frac), polarPy(L,ang,frac), r, g2, b);
        }
    }

    // Sweep line (centre → edge) in active blue
    push(L.ppiCX, L.ppiCY, P7A_R, P7A_G, P7A_B);
    push(polarPx(L,sweepAngle,1.0f), polarPy(L,sweepAngle,1.0f),
         P7A_R, P7A_G, P7A_B);
}

// Unsigned angular separation between two headings (°)
static float angDiff(float a, float b)
{
    float d = std::fmod(std::fabs(a - b), 360.0f);
    return d > 180.0f ? 360.0f - d : d;
}

struct RingLayer {
    GLuint prog     = 0, vao     = 0, vbo     = 0;
    GLuint textProg = 0, textVAO = 0, textVBO = 0;
    GLuint fontTex  = 0;
    float  labelLastActT = -999.0f;  // glfwGetTime() when sweep last passed 180°
};

static RingLayer buildRingLayer(const FontAtlas& fa)
{
    RingLayer rl{};
    glGenVertexArrays(1, &rl.vao); glGenBuffers(1, &rl.vbo);
    glBindVertexArray(rl.vao);
    glBindBuffer(GL_ARRAY_BUFFER, rl.vbo);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 5*sizeof(float), nullptr);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 5*sizeof(float),
                          reinterpret_cast<void*>(2*sizeof(float)));
    glEnableVertexAttribArray(1);
    glBindVertexArray(0);
    rl.prog = makeProgram("shaders/ppi_range_rings.vert",
                          "shaders/ppi_range_rings.frag");

    // Text layer for range labels
    glGenVertexArrays(1, &rl.textVAO); glGenBuffers(1, &rl.textVBO);
    glBindVertexArray(rl.textVAO);
    glBindBuffer(GL_ARRAY_BUFFER, rl.textVBO);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4*sizeof(float), nullptr);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4*sizeof(float),
                          reinterpret_cast<void*>(2*sizeof(float)));
    glEnableVertexAttribArray(1);
    glBindVertexArray(0);
    rl.textProg = makeProgram("shaders/text.vert", "shaders/text.frag");
    rl.fontTex  = fa.texture;
    return rl;
}

static void renderRingLayer(RingLayer& rl, const Layout& L,
                             int rangeIdx, float sweepAngle, float curTime,
                             float W, float H, const FontAtlas& fa)
{
    std::vector<float> v;
    buildRingVerts(v, L, rangeIdx, sweepAngle, W, H);

    glBindBuffer(GL_ARRAY_BUFFER, rl.vbo);
    glBufferData(GL_ARRAY_BUFFER, (GLsizeiptr)(v.size()*sizeof(float)),
                 v.data(), GL_DYNAMIC_DRAW);

    glUseProgram(rl.prog);
    glBindVertexArray(rl.vao);

    const RangeConfig& rcDraw = RANGE_CONFIGS[rangeIdx];
    const int nr = rcDraw.numRings;
    int off = 0;
    for (int ri = 0; ri < nr; ++ri) {
        glDrawArrays(GL_LINE_STRIP, off, RING_SEGS+1);
        off += RING_SEGS+1;
    }
    for (int ri = 0; ri < nr; ++ri) {
        glDrawArrays(GL_LINE_STRIP, off, TRAIL_SEGS+1);
        off += TRAIL_SEGS+1;
    }
    glDrawArrays(GL_LINES, off, 2);
    glBindVertexArray(0);

    // Range labels — placed just below (south of) each ring.
    // Sweep activates blue at 180°; color fades to P7P persistence at same
    // rate as targets (TARG_PERSIST seconds).
    static constexpr float LABEL_THRESH    = 3.5f;
    static constexpr float LABEL_OFFSET_PX = 10.0f;

    if (angDiff(sweepAngle, 180.0f) < LABEL_THRESH)
        rl.labelLastActT = curTime;

    float ldt  = curTime - rl.labelLastActT;
    float lfade = (rl.labelLastActT < 0.0f) ? 0.0f
                 : std::max(0.0f, 1.0f - ldt / TARG_PERSIST);
    bool  lact  = angDiff(sweepAngle, 180.0f) < LABEL_THRESH;

    float lr, lg, lb;
    if (lact) { lr=P7A_R; lg=P7A_G; lb=P7A_B; }
    else      { lr=P7P_R * lfade; lg=P7P_G * lfade; lb=P7P_B * lfade; }

    if (lr + lg + lb > 0.01f) {
        std::vector<float> tv;
        for (int ri = 0; ri < nr; ++ri) {
            float frac = rcDraw.rings[ri] / rcDraw.maxMiles;
            float px   = L.ppiCX;
            float py   = L.ppiCY + frac * L.ppiR + LABEL_OFFSET_PX;
            int   lmi  = (int)std::round(rcDraw.rings[ri]);
            appendTextQuads(tv, fa, std::to_string(lmi), px, py, W, H);
        }
        if (!tv.empty()) {
            glBindBuffer(GL_ARRAY_BUFFER, rl.textVBO);
            glBufferData(GL_ARRAY_BUFFER, (GLsizeiptr)(tv.size()*sizeof(float)),
                         tv.data(), GL_DYNAMIC_DRAW);
            glUseProgram(rl.textProg);
            glBindVertexArray(rl.textVAO);
            glUniform3f(glGetUniformLocation(rl.textProg, "uColor"), lr, lg, lb);
            glUniform1i(glGetUniformLocation(rl.textProg, "uTexture"), 0);
            glActiveTexture(GL_TEXTURE0);
            glBindTexture(GL_TEXTURE_2D, rl.fontTex);
            glDrawArrays(GL_TRIANGLES, 0, (GLsizei)(tv.size() / 4));
            glBindTexture(GL_TEXTURE_2D, 0);
            glBindVertexArray(0);
        }
    }
}

// ─── Feature 5: Fake targets ──────────────────────────────────────────────────
//
// Apparent radar cross-section is computed from vessel dimensions and
// aspect angle: apparent = length*|sin(aspect)| + beam*|cos(aspect)|
// where aspect = bearing_from_radar_to_target - target_heading_true
//
// Signal strength tiers (apparent size in feet):
//   >= 100 ft  : full bright   (large ship)
//    50-100 ft : full bright   (yacht)
//    10-50  ft : bright        (sailboat)
//    < 10   ft : medium        (kayak/rowboat)

struct FakeTarget {
    float bearingDeg;    // bearing from radar to target (° true, CW from N)
    float rangeMiles;    // range from radar
    float lengthFt;      // vessel length
    float beamFt;        // vessel beam (width)
    float headingDeg;    // vessel heading (° true)
    mutable float lastActT;  // glfwGetTime() when sweep last lit this target
};

// Radar cross-section: effective width presented toward the radar
static float apparentFt(const FakeTarget& t)
{
    float aspect = t.bearingDeg - t.headingDeg;
    float rad    = aspect * (PI / 180.0f);
    return t.lengthFt * std::fabs(std::sin(rad))
         + t.beamFt   * std::fabs(std::cos(rad));
}

// Blob radius and bloom radius (in pixels) from apparent size
static void sizeFromApparent(float apparent, float ppiR,
                              float& coreRad, float& bloomRad, float& bright)
{
    if (apparent >= 100.0f) {
        coreRad  = ppiR * 0.028f;
        bloomRad = 0.0f;
        bright   = 1.00f;
    } else if (apparent >= 50.0f) {
        coreRad  = ppiR * 0.022f;
        bloomRad = 0.0f;
        bright   = 1.00f;
    } else if (apparent >= 10.0f) {
        coreRad  = ppiR * 0.015f;
        bloomRad = 0.0f;
        bright   = 0.90f;
    } else {
        coreRad  = ppiR * 0.010f;
        bloomRad = 0.0f;
        bright   = 0.75f;
    }
}

// ─── A scope signal trace ─────────────────────────────────────────────────────
// Draws P1-green target spikes on the A scope at the current bearing.
// Activated when the PPI sweep passes the A scope bearing; fades like PPI targets.

struct AScopeTrace {
    GLuint vao = 0, vbo = 0, prog = 0;
    float  lastActT[4];
};

static AScopeTrace buildAScopeTrace()
{
    AScopeTrace at{};
    for (int i = 0; i < 4; ++i) at.lastActT[i] = -999.0f;
    glGenVertexArrays(1, &at.vao); glGenBuffers(1, &at.vbo);
    glBindVertexArray(at.vao);
    glBindBuffer(GL_ARRAY_BUFFER, at.vbo);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2*sizeof(float), nullptr);
    glEnableVertexAttribArray(0);
    glBindVertexArray(0);
    at.prog = makeProgram("shaders/scope_bounds.vert", "shaders/scope_bounds.frag");
    return at;
}

// Renders target return spikes on the A scope clipped to the A scope box.
// The A scope antenna is pointed at a fixed bearing (ascopeBearingDeg),
// independent of the PPI sweep.  Every radar pulse illuminates that bearing,
// so returns are continuous — brightness is steady, no sweep dependency.
static void renderAScopeTrace(AScopeTrace& at, const Layout& L,
                               FakeTarget* tgts, int nTgts,
                               float maxRangeMi, float ascopeBearingDeg,
                               float W, float H)
{
    static const float THRESH = 3.5f;
    static const float PAD    = 4.0f;
    static const float HALF_W = 3.0f;  // spike half-width in pixels

    const float asH  = L.asBot  - L.asTop;
    const float gx0  = L.asLeft  + PAD;
    const float gx1  = L.asRight - PAD;
    const float gy1  = L.asTop   + asH * 0.80f;  // baseline
    const float sigW = gx1 - gx0;
    const float sigH = (L.asTop + PAD) - gy1;    // negative: upward from baseline

    glEnable(GL_SCISSOR_TEST);
    glScissor((GLint)L.asLeft, (GLint)(H - L.asBot),
              (GLint)(L.asRight - L.asLeft), (GLint)(L.asBot - L.asTop));
    glUseProgram(at.prog);
    glBindVertexArray(at.vao);
    const GLint locCol = glGetUniformLocation(at.prog, "uColor");

    for (int i = 0; i < nTgts; ++i) {
        const FakeTarget& t = tgts[i];

        // Only show targets whose bearing falls within the A scope beam
        if (angDiff(ascopeBearingDeg, t.bearingDeg) >= THRESH) continue;

        float rangeFrac = t.rangeMiles / maxRangeMi;
        if (rangeFrac > 1.0f) continue;

        float coreRad, bloomRad, baseBright;
        sizeFromApparent(apparentFt(t), L.ppiR, coreRad, bloomRad, baseBright);

        float height = sigH * baseBright;  // upward (sigH is negative)

        float sx  = gx0 + rangeFrac * sigW;
        float sy0 = gy1;
        float sy1 = gy1 + height;

        float verts[6] = {
            ndcX(sx - HALF_W, W), ndcY(sy0, H),
            ndcX(sx,          W), ndcY(sy1, H),
            ndcX(sx + HALF_W, W), ndcY(sy0, H)
        };
        glBindBuffer(GL_ARRAY_BUFFER, at.vbo);
        glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_DYNAMIC_DRAW);
        glUniform3f(locCol, P1_R * baseBright, P1_G * baseBright, P1_B * baseBright);
        glDrawArrays(GL_TRIANGLES, 0, 3);
    }

    glBindVertexArray(0);
    glDisable(GL_SCISSOR_TEST);
}

// Build one textured quad (x,y,u,v = 4 floats/vertex) centred at (px,py)
static void genBlob(std::vector<float>& v,
                    float px, float py, float radiusPx, float W, float H)
{
    float x0=ndcX(px-radiusPx,W), y0=ndcY(py-radiusPx,H);
    float x1=ndcX(px+radiusPx,W), y1=ndcY(py+radiusPx,H);
    v.push_back(x0); v.push_back(y0); v.push_back(-1.f); v.push_back(-1.f);
    v.push_back(x1); v.push_back(y0); v.push_back( 1.f); v.push_back(-1.f);
    v.push_back(x1); v.push_back(y1); v.push_back( 1.f); v.push_back( 1.f);
    v.push_back(x0); v.push_back(y0); v.push_back(-1.f); v.push_back(-1.f);
    v.push_back(x1); v.push_back(y1); v.push_back( 1.f); v.push_back( 1.f);
    v.push_back(x0); v.push_back(y1); v.push_back(-1.f); v.push_back( 1.f);
}

struct TargetLayer {
    GLuint prog = 0, vao = 0, vbo = 0;
};

static TargetLayer buildTargetLayer()
{
    TargetLayer tl{};
    glGenVertexArrays(1, &tl.vao); glGenBuffers(1, &tl.vbo);
    glBindVertexArray(tl.vao);
    glBindBuffer(GL_ARRAY_BUFFER, tl.vbo);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4*sizeof(float), nullptr);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4*sizeof(float),
                          reinterpret_cast<void*>(2*sizeof(float)));
    glEnableVertexAttribArray(1);
    glBindVertexArray(0);
    tl.prog = makeProgram("shaders/ppi_targets.vert","shaders/ppi_targets.frag");
    return tl;
}

static void renderTargets(TargetLayer& tl,
                           FakeTarget* tgts, int nTgts,
                           const Layout& L, float maxRangeMi, float ppiR,
                           float sweepAngle, float curTime, float W, float H)
{
    static const float THRESH = 3.5f;  // sweep activation threshold (°)

    glUseProgram(tl.prog);
    glBindVertexArray(tl.vao);

    const GLint locCol  = glGetUniformLocation(tl.prog, "uColor");
    const GLint locFall = glGetUniformLocation(tl.prog, "uFalloff");

    for (int i = 0; i < nTgts; ++i) {
        FakeTarget& t = tgts[i];

        if (angDiff(sweepAngle, t.bearingDeg) < THRESH)
            t.lastActT = curTime;

        float rangeFrac = t.rangeMiles / maxRangeMi;
        if (rangeFrac > 1.0f) continue;  // outside current range setting

        float px = polarPx(L, t.bearingDeg, rangeFrac);
        float py = polarPy(L, t.bearingDeg, rangeFrac);

        float dt     = curTime - t.lastActT;
        float fade   = (dt < 0.0f) ? 0.0f : std::max(0.0f, 1.0f - dt/TARG_PERSIST);
        bool  active = angDiff(sweepAngle, t.bearingDeg) < THRESH;

        float coreRad, bloomRad, baseBright;
        sizeFromApparent(apparentFt(t), ppiR, coreRad, bloomRad, baseBright);

        float bright = active ? baseBright : baseBright * fade;
        if (bright < 0.02f) continue;

        float cr, cg, cb;
        if (active) { cr=P7A_R; cg=P7A_G; cb=P7A_B; }
        else        { cr=P7P_R; cg=P7P_G; cb=P7P_B; }

        // Core blob
        {
            std::vector<float> bv;
            genBlob(bv, px, py, coreRad, W, H);
            glBindBuffer(GL_ARRAY_BUFFER, tl.vbo);
            glBufferData(GL_ARRAY_BUFFER, (GLsizeiptr)(bv.size()*sizeof(float)),
                         bv.data(), GL_DYNAMIC_DRAW);
            glUniform3f(locCol, cr*bright, cg*bright, cb*bright);
            glUniform1f(locFall, 4.5f);
            glDrawArrays(GL_TRIANGLES, 0, 6);
        }
    }
    glBindVertexArray(0);
}

// ─── Feature 6: PPI cursor ───────────────────────────────────────────────────
//
// Cursor has three graphical elements (all incandescent):
//   • Range ring  — full circle at the cursor range fraction
//   • Bearing line — radial line from centre to scope edge at cursor bearing
//   • Intersection box — small square centred where the two cross

struct CursorLayer {
    GLuint prog = 0, vao = 0, vbo = 0;
};

static CursorLayer buildCursorLayer()
{
    CursorLayer cl{};
    glGenVertexArrays(1, &cl.vao); glGenBuffers(1, &cl.vbo);
    glBindVertexArray(cl.vao);
    glBindBuffer(GL_ARRAY_BUFFER, cl.vbo);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2*sizeof(float), nullptr);
    glEnableVertexAttribArray(0);
    glBindVertexArray(0);
    cl.prog = makeProgram("shaders/ppi_cursor.vert", "shaders/ppi_cursor.frag");
    return cl;
}

static void renderCursor(CursorLayer& cl, const Layout& L,
                          float rangeFrac, float bearingDeg, float W, float H)
{
    std::vector<float> v;

    // ── Range ring (circle at cursor range) ────────────────────────────────
    const float rPx = rangeFrac * L.ppiR;
    const int   ringStart = 0;
    for (int i = 0; i <= CIRCLE_SEGS; ++i) {
        float a = 2.0f * PI * i / CIRCLE_SEGS;
        v.push_back(ndcX(L.ppiCX + rPx * std::cos(a), W));
        v.push_back(ndcY(L.ppiCY + rPx * std::sin(a), H));
    }

    // ── Bearing line (centre → scope edge) ────────────────────────────────
    const int lineStart = ringStart + CIRCLE_SEGS + 1;
    v.push_back(ndcX(L.ppiCX, W));
    v.push_back(ndcY(L.ppiCY, H));
    v.push_back(ndcX(polarPx(L, bearingDeg, 1.0f), W));
    v.push_back(ndcY(polarPy(L, bearingDeg, 1.0f), H));

    // ── Intersection box ───────────────────────────────────────────────────
    const float ix      = polarPx(L, bearingDeg, rangeFrac);
    const float iy      = polarPy(L, bearingDeg, rangeFrac);
    const float half    = L.ppiR * 0.035f;
    const int   boxStart = lineStart + 2;
    // 5 vertices to close the square loop
    v.push_back(ndcX(ix - half, W)); v.push_back(ndcY(iy - half, H));
    v.push_back(ndcX(ix + half, W)); v.push_back(ndcY(iy - half, H));
    v.push_back(ndcX(ix + half, W)); v.push_back(ndcY(iy + half, H));
    v.push_back(ndcX(ix - half, W)); v.push_back(ndcY(iy + half, H));
    v.push_back(ndcX(ix - half, W)); v.push_back(ndcY(iy - half, H));

    glBindBuffer(GL_ARRAY_BUFFER, cl.vbo);
    glBufferData(GL_ARRAY_BUFFER, (GLsizeiptr)(v.size() * sizeof(float)),
                 v.data(), GL_DYNAMIC_DRAW);

    glUseProgram(cl.prog);
    glUniform3f(glGetUniformLocation(cl.prog, "uColor"), INCAN_R, INCAN_G, INCAN_B);
    glBindVertexArray(cl.vao);

    glDrawArrays(GL_LINE_STRIP, ringStart, CIRCLE_SEGS + 1);  // range ring
    glDrawArrays(GL_LINES,      lineStart, 2);                 // bearing line
    glDrawArrays(GL_LINE_STRIP, boxStart,  5);                 // intersection box

    glBindVertexArray(0);
}

// ─── A scope bearing display window ──────────────────────────────────────────
//
// Small box below the A scope showing the current A scope bearing setting
// as "BRG XXX" in incandescent colour.
// Reuses scope_bounds shader (uColor, no offset) for the box outline
// and text/text shaders for the label.

struct BearingDisplay {
    GLuint boxVAO = 0, boxVBO = 0;
    int    boxCount = 0;
    GLuint textVAO = 0, textVBO = 0;
};

static BearingDisplay buildBearingDisplay(const Layout& L, float W, float H)
{
    BearingDisplay bd{};

    // Static box outline (4 line segments)
    std::vector<float> v;
    auto ln = [&](float x1, float y1, float x2, float y2) {
        v.push_back(ndcX(x1,W)); v.push_back(ndcY(y1,H));
        v.push_back(ndcX(x2,W)); v.push_back(ndcY(y2,H));
    };
    ln(L.brgLeft,  L.brgTop,  L.brgRight, L.brgTop);
    ln(L.brgRight, L.brgTop,  L.brgRight, L.brgBot);
    ln(L.brgRight, L.brgBot,  L.brgLeft,  L.brgBot);
    ln(L.brgLeft,  L.brgBot,  L.brgLeft,  L.brgTop);
    bd.boxCount = (int)v.size() / 2;
    makeLineVAO(bd.boxVAO, bd.boxVBO, v);

    // Dynamic text VAO (rebuilt each frame with current bearing value)
    glGenVertexArrays(1, &bd.textVAO); glGenBuffers(1, &bd.textVBO);
    glBindVertexArray(bd.textVAO);
    glBindBuffer(GL_ARRAY_BUFFER, bd.textVBO);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4*sizeof(float), nullptr);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4*sizeof(float),
                          reinterpret_cast<void*>(2*sizeof(float)));
    glEnableVertexAttribArray(1);
    glBindVertexArray(0);

    return bd;
}

static void renderBearingDisplay(BearingDisplay& bd, const Layout& L,
                                  GLuint boxProg, GLuint textProg,
                                  const FontAtlas& fa, float bearingDeg,
                                  float maxRangeMi, float W, float H)
{
    // Box outline
    glUseProgram(boxProg);
    glUniform3f(glGetUniformLocation(boxProg, "uColor"),
                INCAN_R, INCAN_G, INCAN_B);
    glBindVertexArray(bd.boxVAO);
    glDrawArrays(GL_LINES, 0, bd.boxCount);
    glBindVertexArray(0);

    // Two lines: "BRG 000" and "RNG  15"
    int iBear = static_cast<int>(bearingDeg) % 360;
    if (iBear < 0) iBear += 360;
    int iRng  = static_cast<int>(maxRangeMi);
    char brgBuf[12], rngBuf[12];
    std::snprintf(brgBuf, sizeof(brgBuf), "BRG %03d", iBear);
    std::snprintf(rngBuf, sizeof(rngBuf), "RNG  %2d", iRng);

    const float cx   = (L.brgLeft + L.brgRight) * 0.5f;
    const float brgH = L.brgBot - L.brgTop;
    const float cy1  = L.brgTop + brgH * 0.30f;   // upper line
    const float cy2  = L.brgTop + brgH * 0.70f;   // lower line

    std::vector<float> tv;
    appendTextQuads(tv, fa, std::string(brgBuf), cx, cy1, W, H);
    appendTextQuads(tv, fa, std::string(rngBuf), cx, cy2, W, H);
    if (tv.empty()) return;

    glBindBuffer(GL_ARRAY_BUFFER, bd.textVBO);
    glBufferData(GL_ARRAY_BUFFER, (GLsizeiptr)(tv.size()*sizeof(float)),
                 tv.data(), GL_DYNAMIC_DRAW);
    glUseProgram(textProg);
    glUniform3f(glGetUniformLocation(textProg, "uColor"),
                INCAN_R, INCAN_G, INCAN_B);
    glUniform1i(glGetUniformLocation(textProg, "uTexture"), 0);
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, fa.texture);
    glBindVertexArray(bd.textVAO);
    glDrawArrays(GL_TRIANGLES, 0, static_cast<GLsizei>(tv.size() / 4));
    glBindTexture(GL_TEXTURE_2D, 0);
    glBindVertexArray(0);
}

// ─── New Update 2: "Operator Manually Changing Range Graticule" window ────────
//
// Appears above the A scope only while the graticule is animating (any phase
// other than HOLD).

struct GratNotifyWindow {
    GLuint boxVAO = 0, boxVBO = 0;
    int    boxCount = 0;
    GLuint textVAO = 0, textVBO = 0;
};

static GratNotifyWindow buildGratNotifyWindow(const Layout& L, float W, float H)
{
    GratNotifyWindow gn{};

    std::vector<float> v;
    auto ln = [&](float x1, float y1, float x2, float y2) {
        v.push_back(ndcX(x1,W)); v.push_back(ndcY(y1,H));
        v.push_back(ndcX(x2,W)); v.push_back(ndcY(y2,H));
    };
    ln(L.notifyLeft,  L.notifyTop,  L.notifyRight, L.notifyTop);
    ln(L.notifyRight, L.notifyTop,  L.notifyRight, L.notifyBot);
    ln(L.notifyRight, L.notifyBot,  L.notifyLeft,  L.notifyBot);
    ln(L.notifyLeft,  L.notifyBot,  L.notifyLeft,  L.notifyTop);
    gn.boxCount = (int)v.size() / 2;
    makeLineVAO(gn.boxVAO, gn.boxVBO, v);

    glGenVertexArrays(1, &gn.textVAO); glGenBuffers(1, &gn.textVBO);
    glBindVertexArray(gn.textVAO);
    glBindBuffer(GL_ARRAY_BUFFER, gn.textVBO);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4*sizeof(float), nullptr);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4*sizeof(float),
                          reinterpret_cast<void*>(2*sizeof(float)));
    glEnableVertexAttribArray(1);
    glBindVertexArray(0);

    return gn;
}

static void renderGratNotifyWindow(GratNotifyWindow& gn, const Layout& L,
                                    GLuint boxProg, GLuint textProg,
                                    const FontAtlas& fa, float W, float H)
{
    // Box outline
    glUseProgram(boxProg);
    glUniform3f(glGetUniformLocation(boxProg, "uColor"),
                INCAN_R, INCAN_G, INCAN_B);
    glBindVertexArray(gn.boxVAO);
    glDrawArrays(GL_LINES, 0, gn.boxCount);
    glBindVertexArray(0);

    // Text centred in box, clipped to box bounds
    const float cx = (L.notifyLeft + L.notifyRight) * 0.5f;
    const float cy = (L.notifyTop  + L.notifyBot)  * 0.5f;

    std::vector<float> tv;
    appendTextQuads(tv, fa, "Operator Manually Changing Range Graticule",
                   cx, cy, W, H);
    if (tv.empty()) return;

    glEnable(GL_SCISSOR_TEST);
    glScissor((GLint) L.notifyLeft,
              (GLint)(H - L.notifyBot),
              (GLint)(L.notifyRight - L.notifyLeft),
              (GLint)(L.notifyBot   - L.notifyTop));

    glBindBuffer(GL_ARRAY_BUFFER, gn.textVBO);
    glBufferData(GL_ARRAY_BUFFER, (GLsizeiptr)(tv.size()*sizeof(float)),
                 tv.data(), GL_DYNAMIC_DRAW);
    glUseProgram(textProg);
    glUniform3f(glGetUniformLocation(textProg, "uColor"),
                INCAN_R, INCAN_G, INCAN_B);
    glUniform1i(glGetUniformLocation(textProg, "uTexture"), 0);
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, fa.texture);
    glBindVertexArray(gn.textVAO);
    glDrawArrays(GL_TRIANGLES, 0, static_cast<GLsizei>(tv.size() / 4));
    glBindTexture(GL_TEXTURE_2D, 0);
    glBindVertexArray(0);

    glDisable(GL_SCISSOR_TEST);
}

// ─── Feature 9: Shoreline & Terrain ──────────────────────────────────────────
//
// Two data sources are combined into one GL_POINTS buffer:
//
//   1. NOAA ENC S-57 (COALNE layer) — precise nautical coastline.
//      Line-string vertices are densified with intermediate points every
//      ~40 m so the coastline appears continuous on-screen.
//
//   2. USGS 3DEP 1/3-arc-second DEM (GeoTIFF) — terrain elevation grid.
//      All land pixels (elevation > 0 m) within the 15-mile radius are
//      sampled at ~150 m intervals and added as terrain-return points.
//      (Full line-of-sight shadow calculation is deferred to a later
//      feature iteration.)
//
// The vertex shader holds the geometry fixed; sweep-based phosphor fade is
// computed entirely on the GPU from a small set of uniforms each frame.

// Convert WGS84 lat/lon to radar-local bearing (° CW from N) and range (miles).
// Uses flat-earth approximation — accurate to <0.01% at 15-mile range.
static void latLonToRadar(double lat, double lon,
                           float& bearDeg, float& rangeMi)
{
    constexpr double MI_PER_DEG_LAT = 69.11;
    double cosLat  = std::cos(RADAR_LAT * M_PI / 180.0);
    double dy      = (lat - RADAR_LAT) * MI_PER_DEG_LAT;          // + = north
    double dx      = (lon - RADAR_LON) * MI_PER_DEG_LAT * cosLat; // + = east
    rangeMi        = static_cast<float>(std::sqrt(dx*dx + dy*dy));
    bearDeg        = static_cast<float>(std::atan2(dx, dy) * 180.0 / M_PI);
    if (bearDeg < 0.0f) bearDeg += 360.0f;
}

// Vertex: (bearingDeg, rangeMiles)
struct ShorelineVert { float bearDeg; float rangeMi; };

// ── ENC coastline loading ─────────────────────────────────────────────────────

// Recursively extract line-string geometry points, densifying at stepMiles.
static void appendENCGeomPts(OGRGeometry* geom, float maxRange,
                              float stepMiles,
                              std::vector<ShorelineVert>& pts)
{
    if (!geom) return;
    OGRwkbGeometryType gtype = wkbFlatten(geom->getGeometryType());

    if (gtype == wkbLineString || gtype == wkbLinearRing) {
        OGRLineString* ls = geom->toLineString();
        int n = ls->getNumPoints();
        if (n < 1) return;

        constexpr double MI_PER_DEG_LAT = 69.11;
        double cosLat = std::cos(RADAR_LAT * M_PI / 180.0);

        for (int i = 0; i < n - 1; ++i) {
            double lon0 = ls->getX(i),  lat0 = ls->getY(i);
            double lon1 = ls->getX(i+1),lat1 = ls->getY(i+1);

            // Segment length in miles
            double dlat = (lat1 - lat0) * MI_PER_DEG_LAT;
            double dlon = (lon1 - lon0) * MI_PER_DEG_LAT * cosLat;
            float  segMi = static_cast<float>(std::sqrt(dlat*dlat + dlon*dlon));

            int nSteps = std::max(1, static_cast<int>(segMi / stepMiles));
            for (int j = 0; j <= nSteps; ++j) {
                double t   = static_cast<double>(j) / nSteps;
                double lat = lat0 + t * (lat1 - lat0);
                double lon = lon0 + t * (lon1 - lon0);
                float bear, range;
                latLonToRadar(lat, lon, bear, range);
                if (range <= maxRange)
                    pts.push_back({bear, range});
            }
        }
        // Last point
        {
            float bear, range;
            latLonToRadar(ls->getY(n-1), ls->getX(n-1), bear, range);
            if (range <= maxRange) pts.push_back({bear, range});
        }
    }
    else if (gtype == wkbPolygon) {
        OGRPolygon*    poly = geom->toPolygon();
        OGRLinearRing* ring = poly->getExteriorRing();
        if (ring) appendENCGeomPts(ring, maxRange, stepMiles, pts);
    }
    else if (gtype == wkbMultiLineString || gtype == wkbMultiPolygon ||
             gtype == wkbGeometryCollection) {
        OGRGeometryCollection* gc = geom->toGeometryCollection();
        for (int i = 0; i < gc->getNumGeometries(); ++i)
            appendENCGeomPts(gc->getGeometryRef(i), maxRange, stepMiles, pts);
    }
}

// Load COALNE coastline from an S-57 ENC file.
// Points are densified at ~40 m intervals so they appear continuous.
static std::vector<ShorelineVert> loadENCCoastline(const std::string& encPath)
{
    GDALAllRegister();
    GDALDataset* ds = static_cast<GDALDataset*>(
        GDALOpenEx(encPath.c_str(), GDAL_OF_VECTOR | GDAL_OF_READONLY,
                   nullptr, nullptr, nullptr));
    if (!ds) {
        std::cerr << "Feature 9: cannot open ENC: " << encPath << "\n";
        return {};
    }

    std::vector<ShorelineVert> pts;

    // COALNE = coastline (primary); SLCONS = shoreline construction (piers etc.)
    const char* layerNames[] = { "COALNE", "SLCONS" };
    for (const char* lname : layerNames) {
        OGRLayer* layer = ds->GetLayerByName(lname);
        if (!layer) continue;

        OGRFeature* feat;
        layer->ResetReading();
        while ((feat = layer->GetNextFeature()) != nullptr) {
            OGRGeometry* geom = feat->GetGeometryRef();
            // densify: one point every ~0.025 miles (~40 m)
            appendENCGeomPts(geom, MAX_RANGE_MILES, 0.025f, pts);
            OGRFeature::DestroyFeature(feat);
        }
    }

    GDALClose(ds);
    std::cerr << "Feature 9 ENC: " << pts.size() << " coastline points\n";
    return pts;
}

// ── DEM terrain loading with line-of-sight (radar shadow) ────────────────────
//
// For each azimuth (0.05° steps), march outward from the radar. Track the
// maximum elevation angle seen so far. A terrain point is visible — and
// generates a radar return — only when its elevation angle equals or exceeds
// the current maximum. Everything behind that horizon is in radar shadow.
// This correctly shows the faces of hills toward the radar and hides valleys
// and reverse slopes behind them.

static constexpr float RADAR_HEIGHT_M = 30.48f;   // 100-foot platform

static std::vector<ShorelineVert> loadDEMTerrainWithLOS(const std::string& demPath)
{
    GDALAllRegister();
    GDALDataset* ds = static_cast<GDALDataset*>(
        GDALOpen(demPath.c_str(), GA_ReadOnly));
    if (!ds) {
        std::cerr << "Feature 9: cannot open DEM: " << demPath << "\n";
        return {};
    }

    GDALRasterBand* band = ds->GetRasterBand(1);
    if (!band) {
        std::cerr << "Feature 9: DEM has no band 1\n";
        GDALClose(ds); return {};
    }

    double gt[6];
    ds->GetGeoTransform(gt);
    int rasterW = ds->GetRasterXSize();
    int rasterH = ds->GetRasterYSize();

    // Bounding box with 2% margin
    constexpr double M_PER_DEG_LAT = 111320.0;
    double cosLat    = std::cos(RADAR_LAT * M_PI / 180.0);
    double mPerDegLon = M_PER_DEG_LAT * cosLat;
    double maxRangeM  = MAX_RANGE_MILES * 1609.344;
    double dLat = maxRangeM / M_PER_DEG_LAT * 1.02;
    double dLon = maxRangeM / mPerDegLon     * 1.02;

    int px0 = std::max(0,        static_cast<int>((RADAR_LON - dLon - gt[0]) / gt[1]));
    int px1 = std::min(rasterW-1,static_cast<int>((RADAR_LON + dLon - gt[0]) / gt[1]) + 1);
    int py0 = std::max(0,        static_cast<int>((RADAR_LAT + dLat - gt[3]) / gt[5]));
    int py1 = std::min(rasterH-1,static_cast<int>((RADAR_LAT - dLat - gt[3]) / gt[5]) + 1);
    int readW = px1 - px0 + 1;
    int readH = py1 - py0 + 1;
    if (readW <= 0 || readH <= 0) {
        std::cerr << "Feature 9: DEM bounding box empty\n";
        GDALClose(ds); return {};
    }

    // Bulk-read elevation tile into memory (float32, metres)
    std::vector<float> elevGrid(readW * readH, 0.0f);
    if (band->RasterIO(GF_Read, px0, py0, readW, readH,
                       elevGrid.data(), readW, readH, GDT_Float32, 0, 0) != CE_None) {
        std::cerr << "Feature 9: DEM RasterIO failed\n";
        GDALClose(ds); return {};
    }
    GDALClose(ds);

    // ── Ray-march LOS ─────────────────────────────────────────────────────────
    // 7200 rays (0.05° apart) × 20 m steps out to maxRange
    const int   NUM_AZIMUTHS = 7200;
    const float STEP_M       = 20.0f;
    const int   NUM_STEPS    = static_cast<int>(maxRangeM / STEP_M) + 1;

    std::vector<ShorelineVert> pts;
    pts.reserve(60000);

    for (int ai = 0; ai < NUM_AZIMUTHS; ++ai) {
        float bearDeg = ai * 360.0f / NUM_AZIMUTHS;
        float bearRad = bearDeg * static_cast<float>(M_PI) / 180.0f;
        float sinB    = std::sin(bearRad);   // east component
        float cosB    = std::cos(bearRad);   // north component

        float maxElevAngle = -static_cast<float>(M_PI) / 2.0f;  // horizon starts at -90°

        for (int step = 1; step < NUM_STEPS; ++step) {
            float rangeM  = step * STEP_M;
            float rangeMi = rangeM / 1609.344f;

            // Geographic position of this ray step
            double lat = RADAR_LAT + cosB * rangeM / M_PER_DEG_LAT;
            double lon = RADAR_LON + sinB * rangeM / mPerDegLon;

            // DEM pixel lookup (nearest-neighbour)
            int pxg = static_cast<int>((lon - gt[0]) / gt[1]);
            int pyg = static_cast<int>((lat - gt[3]) / gt[5]);
            int pxl = pxg - px0;
            int pyl = pyg - py0;
            if (pxl < 0 || pxl >= readW || pyl < 0 || pyl >= readH) continue;

            float elev = elevGrid[pyl * readW + pxl];

            // Elevation angle from radar platform to this terrain point
            float elevAngle = std::atan2(elev - RADAR_HEIGHT_M, rangeM);

            if (elevAngle >= maxElevAngle) {
                if (elev > 0.0f)          // land (not ocean)
                    pts.push_back({bearDeg, rangeMi});
                maxElevAngle = elevAngle; // raise the horizon
            }
        }
    }

    std::cerr << "Feature 9 DEM LOS: " << pts.size() << " visible terrain points\n";
    return pts;
}

// ── ShorelineLayer GL object ──────────────────────────────────────────────────

struct ShorelineLayer {
    GLuint prog  = 0;
    GLuint vao   = 0;
    GLuint vbo   = 0;
    int    count = 0;   // number of GL_POINTS vertices
};

static ShorelineLayer buildShorelineLayer()
{
    const std::string encPath = MAP_DIR "/US5WA45M.000";
    const std::string demPath = MAP_DIR "/n48_w123_1arc_v3.tif";

    auto encPts = loadENCCoastline(encPath);
    auto demPts = loadDEMTerrainWithLOS(demPath);

    // Pack both into a single interleaved buffer: (bearDeg, rangeMi) per vertex
    std::vector<float> verts;
    verts.reserve((encPts.size() + demPts.size()) * 2);
    for (auto& p : encPts) { verts.push_back(p.bearDeg); verts.push_back(p.rangeMi); }
    for (auto& p : demPts) { verts.push_back(p.bearDeg); verts.push_back(p.rangeMi); }

    ShorelineLayer sl{};
    sl.count = static_cast<int>(verts.size() / 2);

    glGenVertexArrays(1, &sl.vao);
    glGenBuffers(1, &sl.vbo);
    glBindVertexArray(sl.vao);
    glBindBuffer(GL_ARRAY_BUFFER, sl.vbo);
    glBufferData(GL_ARRAY_BUFFER,
                 static_cast<GLsizeiptr>(verts.size() * sizeof(float)),
                 verts.data(), GL_STATIC_DRAW);
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
    glEnableVertexAttribArray(0);
    glBindVertexArray(0);

    sl.prog = makeProgram("shaders/ppi_shoreline.vert", "shaders/ppi_shoreline.frag");
    std::cerr << "Feature 9: " << sl.count << " total shoreline+terrain points\n";
    return sl;
}

static void renderShorelineLayer(const ShorelineLayer& sl, const Layout& L,
                                  int rangeIdx, float sweepAngle, float W, float H)
{
    if (sl.count == 0 || sl.prog == 0) return;

    const float maxRange = RANGE_CONFIGS[rangeIdx].maxMiles;

    // NDC centre and per-axis NDC radius
    float cx = L.ppiCX / W * 2.0f - 1.0f;
    float cy = 1.0f - L.ppiCY / H * 2.0f;
    float rx = L.ppiR  / W * 2.0f;
    float ry = L.ppiR  / H * 2.0f;

    glUseProgram(sl.prog);
    glUniform2f(glGetUniformLocation(sl.prog, "uCenter"),     cx, cy);
    glUniform1f(glGetUniformLocation(sl.prog, "uPpiRx"),      rx);
    glUniform1f(glGetUniformLocation(sl.prog, "uPpiRy"),      ry);
    glUniform1f(glGetUniformLocation(sl.prog, "uMaxRange"),   maxRange);
    glUniform1f(glGetUniformLocation(sl.prog, "uSweepAngle"), sweepAngle);
    glUniform1f(glGetUniformLocation(sl.prog, "uSweepDegPS"), SWEEP_DEG_PS);
    glUniform1f(glGetUniformLocation(sl.prog, "uPersist"),    TARG_PERSIST);
    glUniform3f(glGetUniformLocation(sl.prog, "uActiveColor"),
                P7A_R, P7A_G, P7A_B);
    glUniform3f(glGetUniformLocation(sl.prog, "uPersistColor"),
                P7P_R, P7P_G, P7P_B);

    glEnable(GL_PROGRAM_POINT_SIZE);
    glBindVertexArray(sl.vao);
    glDrawArrays(GL_POINTS, 0, sl.count);
    glBindVertexArray(0);
    glDisable(GL_PROGRAM_POINT_SIZE);
}

// ─── Key callback ─────────────────────────────────────────────────────────────

static void onKey(GLFWwindow* win, int key, int /*scan*/, int action, int /*mods*/)
{
    if (key == GLFW_KEY_ESCAPE && action == GLFW_PRESS)
        glfwSetWindowShouldClose(win, GLFW_TRUE);
}

// ─── main ─────────────────────────────────────────────────────────────────────

int main()
{
    if (!glfwInit()) { std::cerr << "GLFW init failed\n"; return 1; }

    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

    GLFWmonitor*       mon  = glfwGetPrimaryMonitor();
    const GLFWvidmode* mode = glfwGetVideoMode(mon);

    GLFWwindow* win = glfwCreateWindow(
        mode->width, mode->height,
        "Radar Test — Features 1-6",
        nullptr, nullptr);
    if (!win) { std::cerr << "Window create failed\n"; glfwTerminate(); return 1; }

    glfwMakeContextCurrent(win);
    glfwSetKeyCallback(win, onKey);

    if (!gladLoadGLLoader(reinterpret_cast<GLADloadproc>(glfwGetProcAddress))) {
        std::cerr << "GLAD init failed\n"; return 1;
    }

    int fbW, fbH;
    glfwGetFramebufferSize(win, &fbW, &fbH);
    glViewport(0, 0, fbW, fbH);
    const float W = static_cast<float>(fbW);
    const float H = static_cast<float>(fbH);

    int mmW, mmH;
    glfwGetMonitorPhysicalSize(mon, &mmW, &mmH);
    const float dpiX   = static_cast<float>(mode->width) / (static_cast<float>(mmW) / 25.4f);
    const float margin = 1.0f * dpiX;

    const Layout layout = computeLayout(W, H, margin);

    // Font atlas (digits only, ~1.8 % of screen height)
    FontAtlas fa{};
    const std::string fontPath =
        "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf";
    const int fontSizePx = std::max(12, static_cast<int>(H * 0.018f));
    if (!buildFontAtlas(fa, fontPath, fontSizePx)) {
        glfwTerminate(); return 1;
    }

    // Feature 1 — scope boundaries
    ScopeBounds sb = buildScopeBounds(layout, W, H);

    // Feature 2 — PPI bearing graticule
    BearingGraticule bg = buildBearingGraticule(layout, fa, W, H);

    // Feature 3 — A scope replaceable graticules (uses its own 4-step config)
    AScopeGratProg agProg = buildAScopeGratPrograms();
    AScopeGraticule graticules[ASCOPE_RANGE_COUNT];
    for (int i = 0; i < ASCOPE_RANGE_COUNT; ++i)
        graticules[i] = buildAScopeGraticule(layout, fa, ASCOPE_RANGE_CONFIGS[i], W, H);

    // Feature 4 — PPI range rings
    RingLayer rl = buildRingLayer(fa);

    // Feature 5 — Four fake targets per CLAUDE.md spec:
    //   1.  5 mi N,  100 ft / 20 ft, heading S at 1 kt  (head-on)   → apparent ~20 ft
    //   2.  5 mi S,   20 ft /  5 ft, heading S at 20 kt (stern-on)  → apparent ~5 ft
    //   3.  6 mi E,   30 ft / 10 ft, heading N at 30 kt (full side) → apparent ~30 ft
    //   4.  6 mi W,  100 ft / 25 ft, heading S at 5 kt  (full side) → apparent ~100 ft
    FakeTarget targets[4] = {
        {   0.0f,  5.0f, 100.0f,  20.0f, 180.0f, -999.0f },  // N  – head-on,   ~20 ft apparent
        { 180.0f,  5.0f,  20.0f,   5.0f, 180.0f, -999.0f },  // S  – stern-on,  ~5 ft apparent
        {  90.0f,  6.0f,  30.0f,  10.0f,   0.0f, -999.0f },  // E  – full side, ~30 ft apparent
        { 270.0f,  6.0f, 100.0f,  25.0f, 180.0f, -999.0f },  // W  – full side, ~100 ft apparent
    };
    TargetLayer tl = buildTargetLayer();

    // A scope trace
    AScopeTrace at = buildAScopeTrace();

    // Feature 6 — PPI cursor
    CursorLayer cl = buildCursorLayer();

    // Feature 9 — Shoreline + terrain (loads ENC + DEM at startup)
    ShorelineLayer sl = buildShorelineLayer();

    // Bearing display window (new suggestion #3)
    // Reuse scope_bounds program for the box, text program for label
    BearingDisplay bd = buildBearingDisplay(layout, W, H);

    // New Update 2 — graticule change notification window
    GratNotifyWindow gn = buildGratNotifyWindow(layout, W, H);

    const float ppiR = layout.ppiR;

    // NDC height of A scope box — used for slide animation
    const float scopeNDCH = (layout.asBot - layout.asTop) * 2.0f / H;

    // Feature 3 animation state — 4 phases per range cycle
    // PPI and A scope cycle independently but share the same phase timer
    enum class GratPhase { HOLD, SLIDE_OUT, WAIT, SLIDE_IN };
    int       curRange        = 0;
    int       nextRange       = 1;
    int       curAScopeRange  = 0;
    int       nextAScopeRange = 1;
    GratPhase gratPhase  = GratPhase::HOLD;
    float     phaseTimer = 0.0f;

    // A scope bearing (° true, CW from N).
    // New suggestion #4: temporarily locked to targets[0] bearing (north, 0°).
    // Will be driven by Control 7 once hardware is available.
    float ascopeBearingDeg = targets[0].bearingDeg;  // 0.0° — north

    float sweepAngle = 0.0f;
    float prevTime   = static_cast<float>(glfwGetTime());

    glEnable(GL_BLEND);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

    while (!glfwWindowShouldClose(win)) {
        const float now = static_cast<float>(glfwGetTime());
        const float dt  = now - prevTime;
        prevTime = now;

        // ── Advance sweep (Features 4 & 5) ───────────────────────────────────
        sweepAngle = std::fmod(sweepAngle + SWEEP_DEG_PS * dt, 360.0f);

        // ── Advance Feature 3 animation ───────────────────────────────────────
        phaseTimer += dt;
        switch (gratPhase) {
            case GratPhase::HOLD:
                if (phaseTimer >= HOLD_SEC) {
                    nextRange       = (curRange       + 1) % RANGE_COUNT;
                    nextAScopeRange = (curAScopeRange + 1) % ASCOPE_RANGE_COUNT;
                    gratPhase  = GratPhase::SLIDE_OUT;
                    phaseTimer = 0.0f;
                }
                break;
            case GratPhase::SLIDE_OUT:
                if (phaseTimer >= SLIDE_OUT_SEC) {
                    gratPhase  = GratPhase::WAIT;
                    phaseTimer = 0.0f;
                }
                break;
            case GratPhase::WAIT:
                if (phaseTimer >= WAIT_SEC) {
                    curRange       = nextRange;
                    curAScopeRange = nextAScopeRange;
                    gratPhase  = GratPhase::SLIDE_IN;
                    phaseTimer = 0.0f;
                }
                break;
            case GratPhase::SLIDE_IN:
                if (phaseTimer >= SLIDE_IN_SEC) {
                    gratPhase  = GratPhase::HOLD;
                    phaseTimer = 0.0f;
                }
                break;
        }

        glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);

        // ── Feature 1: scope outlines ─────────────────────────────────────────
        glUseProgram(sb.prog);
        const GLint sbCol = glGetUniformLocation(sb.prog, "uColor");
        glBindVertexArray(sb.vao);

        glUniform3f(sbCol, P7A_R * 0.4f, P7A_G * 0.4f, P7A_B * 0.4f);
        glDrawArrays(GL_LINE_STRIP, sb.ppiStart, sb.ppiCount);

        glUniform3f(sbCol, P1_R, P1_G, P1_B);
        glDrawArrays(GL_LINES, sb.asStart, sb.asCount);

        glBindVertexArray(0);

        // ── Feature 2: PPI bearing rings + ticks ─────────────────────────────
        glUseProgram(bg.lineProg);
        glUniform3f(glGetUniformLocation(bg.lineProg, "uColor"),
                    INCAN_R, INCAN_G, INCAN_B);
        glBindVertexArray(bg.lineVAO);
        glDrawArrays(GL_LINE_STRIP, bg.ring1Start, bg.ring1Count);
        glDrawArrays(GL_LINES,      bg.tickStart,  bg.tickCount);
        glDrawArrays(GL_LINE_STRIP, bg.ring2Start, bg.ring2Count);
        glBindVertexArray(0);

        glUseProgram(bg.textProg);
        glUniform3f(glGetUniformLocation(bg.textProg, "uColor"),
                    INCAN_R, INCAN_G, INCAN_B);
        glUniform1i(glGetUniformLocation(bg.textProg, "uTexture"), 0);
        glActiveTexture(GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, bg.fontTex);
        glBindVertexArray(bg.textVAO);
        glDrawArrays(GL_TRIANGLES, 0, bg.textVerts);
        glBindVertexArray(0);
        glBindTexture(GL_TEXTURE_2D, 0);

        // ── Feature 3: A scope graticule (4-phase slide animation) ──────────
        switch (gratPhase) {
            case GratPhase::HOLD:
                drawAScopeGraticule(agProg, graticules[curAScopeRange],
                                    0.0f, fa, W, H, layout);
                break;
            case GratPhase::SLIDE_OUT: {
                float t = std::min(phaseTimer / SLIDE_OUT_SEC, 1.0f);
                drawAScopeGraticule(agProg, graticules[curAScopeRange],
                                    t * scopeNDCH, fa, W, H, layout);
                break;
            }
            case GratPhase::WAIT:
                // blank — no graticule drawn
                break;
            case GratPhase::SLIDE_IN: {
                float t = std::min(phaseTimer / SLIDE_IN_SEC, 1.0f);
                drawAScopeGraticule(agProg, graticules[curAScopeRange],
                                    (1.0f - t) * scopeNDCH, fa, W, H, layout);
                break;
            }
        }

        // ── New Update 2: notification window above A scope ───────────────────
        if (gratPhase != GratPhase::HOLD)
            renderGratNotifyWindow(gn, layout, sb.prog, bg.textProg, fa, W, H);

        // ── A scope signal trace ──────────────────────────────────────────────
        renderAScopeTrace(at, layout, targets, 4,
                          ASCOPE_RANGE_CONFIGS[curAScopeRange].maxMiles, ascopeBearingDeg,
                          W, H);

        // ── Feature 4: PPI range rings ────────────────────────────────────────
        renderRingLayer(rl, layout, curRange, sweepAngle, now, W, H, fa);

        // ── Feature 5: Active targets + persistence ───────────────────────────
        renderTargets(tl, targets, 4, layout,
                      RANGE_CONFIGS[curRange].maxMiles, ppiR,
                      sweepAngle, now, W, H);

        // ── Feature 9: Shoreline + terrain ───────────────────────────────────
        renderShorelineLayer(sl, layout, curRange, sweepAngle, W, H);

        // ── Feature 6: PPI cursor locked to targets[2] (east, 6 mi) ──────────
        // New suggestion #5: cursor follows one target; not free-roaming.
        // Only draw when the target is within the current range setting.
        {
            const float cursorRangeFrac  = targets[2].rangeMiles
                                           / RANGE_CONFIGS[curRange].maxMiles;
            const float cursorBearingDeg = targets[2].bearingDeg;
            if (cursorRangeFrac <= 1.0f)
                renderCursor(cl, layout, cursorRangeFrac, cursorBearingDeg, W, H);
        }

        // ── Bearing display window (new suggestion #3) ────────────────────────
        renderBearingDisplay(bd, layout,
                             sb.prog, bg.textProg,
                             fa, ascopeBearingDeg,
                             ASCOPE_RANGE_CONFIGS[curAScopeRange].maxMiles, W, H);

        glfwSwapBuffers(win);
        glfwPollEvents();
    }

    // ── Cleanup ───────────────────────────────────────────────────────────────
    glDeleteVertexArrays(1, &sb.vao);     glDeleteBuffers(1, &sb.vbo);
    glDeleteVertexArrays(1, &bg.lineVAO); glDeleteBuffers(1, &bg.lineVBO);
    glDeleteVertexArrays(1, &bg.textVAO); glDeleteBuffers(1, &bg.textVBO);
    glDeleteTextures(1, &fa.texture);
    glDeleteProgram(sb.prog);
    glDeleteProgram(bg.lineProg);
    glDeleteProgram(bg.textProg);

    for (int i = 0; i < ASCOPE_RANGE_COUNT; ++i) {
        glDeleteVertexArrays(1, &graticules[i].lineVAO);
        glDeleteBuffers(1,      &graticules[i].lineVBO);
        glDeleteVertexArrays(1, &graticules[i].textVAO);
        glDeleteBuffers(1,      &graticules[i].textVBO);
    }
    glDeleteProgram(agProg.line);
    glDeleteProgram(agProg.text);
    glDeleteVertexArrays(1, &rl.vao);     glDeleteBuffers(1, &rl.vbo);
    glDeleteVertexArrays(1, &rl.textVAO); glDeleteBuffers(1, &rl.textVBO);
    glDeleteProgram(rl.prog);
    glDeleteProgram(rl.textProg);
    glDeleteVertexArrays(1, &tl.vao);  glDeleteBuffers(1, &tl.vbo);
    glDeleteProgram(tl.prog);
    glDeleteVertexArrays(1, &at.vao);  glDeleteBuffers(1, &at.vbo);
    glDeleteProgram(at.prog);
    glDeleteVertexArrays(1, &cl.vao);  glDeleteBuffers(1, &cl.vbo);
    glDeleteProgram(cl.prog);
    glDeleteVertexArrays(1, &bd.boxVAO);  glDeleteBuffers(1, &bd.boxVBO);
    glDeleteVertexArrays(1, &bd.textVAO); glDeleteBuffers(1, &bd.textVBO);
    glDeleteVertexArrays(1, &gn.boxVAO);  glDeleteBuffers(1, &gn.boxVBO);
    glDeleteVertexArrays(1, &gn.textVAO); glDeleteBuffers(1, &gn.textVBO);
    glDeleteVertexArrays(1, &sl.vao);     glDeleteBuffers(1, &sl.vbo);
    glDeleteProgram(sl.prog);

    glfwDestroyWindow(win);
    glfwTerminate();
    return 0;
}
