// Cache for the full dxstats API response, so we can reload on the fly if the user changes the value of their continent
// in the select box
let dxStatsData = null;
// Kp forecast chart
let kpChart = null;
// Cache for ionosonde data from the API
let ionosondeData = null;
// Ionosonde foF2/MUF chart
let ionosondeChart = null;

// Load solar conditions
function loadSolarConditions() {
    $.getJSON('/api/v2/solar', function (jsonData) {

        // HF

        const hfConditionClass = {'Good': 'bg-success-subtle', 'Fair': 'bg-warning-subtle', 'Poor': 'bg-danger-subtle'};

        if (jsonData.hf_conditions) {
            $('#hamqsl-section').show();
            Object.entries(jsonData.hf_conditions).forEach(function ([key, condition]) {
                const cell = $('#hf-conditions-' + key);
                cell.text(condition);
                cell.addClass(hfConditionClass[condition]);
            });
        }

        // VHF

        if (jsonData.vhf_conditions) {
            Object.entries(jsonData.vhf_conditions).forEach(function ([key, condition]) {
                const cell = $('#vhf-conditions-' + key);
                cell.text(condition);
                let vhfClass;
                if (condition === 'Band Closed') {
                    vhfClass = 'bg-danger-subtle';
                } else if (condition.includes('High')) {
                    vhfClass = 'bg-warning-subtle';
                } else {
                    vhfClass = 'bg-success-subtle';
                }
                cell.addClass(vhfClass);
            });
        }
        if (jsonData.aurora_latitude !== null && jsonData.aurora_latitude !== undefined) {
            $('#vhf-conditions-aurora-lat').text(jsonData.aurora_latitude + '°');
        }

        // Solar Weather

        const swFields = {
            'sfi': 'sw-sfi',
            'sunspots': 'sw-sunspots',
            'band_conditions_desc': 'sw-solar-flux-desc',
            'k_index': 'sw-k-index',
            'a_index': 'sw-a-index',
            'geomag_field': 'sw-geomag-field',
            'geomag_storm_scale': 'sw-geomag-storm-scale',
            'geomag_storm_desc': 'sw-geomag-storm-desc',
            'geomag_noise': 'sw-geomag-noise',
            'xray': 'sw-xray',
            'radio_blackout_scale': 'sw-radio-blackout-scale',
            'xray_desc': 'sw-xray-desc',
            'proton_flux': 'sw-proton-flux',
            'solar_storm_scale': 'sw-solar-storm-scale',
            'proton_flux_desc': 'sw-proton-desc',
            'electron_flux': 'sw-electron-flux',
            'electron_flux_desc': 'sw-electron-desc',
        };
        Object.entries(swFields).forEach(function ([field, id]) {
            const val = jsonData[field];
            if (val !== null && val !== undefined) {
                $('#' + id).text(val);
            }
        });

        // Solar Weather - colouring

        function applySwClass(valsId, descId, cls) {
            $('#' + valsId).addClass(cls);
            $('#' + descId).addClass(cls);
        }

        const sfi = jsonData.sfi;
        if (sfi !== null && sfi !== undefined) {
            applySwClass('sw-solar-flux-vals', 'sw-solar-flux-desc',
                sfi > 120 ? 'bg-success-subtle' : sfi > 90 ? 'bg-warning-subtle' : 'bg-danger-subtle');
        }

        const kIndex = jsonData.k_index;
        if (kIndex !== null && kIndex !== undefined) {
            applySwClass('sw-geomag-vals', 'sw-geomag-desc',
                kIndex < 5 ? 'bg-success-subtle' : kIndex < 6 ? 'bg-warning-subtle' : 'bg-danger-subtle');
        }

        const xRay = jsonData.xray;
        if (xRay) {
            const letter = xRay[0].toUpperCase();
            const xRayClass = (letter === 'X') ? 'bg-danger-subtle'
                : (letter === 'M') ? 'bg-warning-subtle'
                    : 'bg-success-subtle';
            applySwClass('sw-xray-vals', 'sw-xray-desc', xRayClass);
        }

        const protonFlux = jsonData.proton_flux;
        if (protonFlux !== null && protonFlux !== undefined) {
            applySwClass('sw-proton-vals', 'sw-proton-desc',
                protonFlux <= 100 ? 'bg-success-subtle' : protonFlux <= 10000 ? 'bg-warning-subtle' : 'bg-danger-subtle');
        }

        const electronFlux = jsonData.electron_flux;
        if (electronFlux !== null && electronFlux !== undefined) {
            applySwClass('sw-electron-vals', 'sw-electron-desc',
                electronFlux <= 100 ? 'bg-success-subtle' : electronFlux <= 1000 ? 'bg-warning-subtle' : 'bg-danger-subtle');
        }

        // Ionosonde

        if (jsonData.ionosonde_data && Object.keys(jsonData.ionosonde_data).length > 0) {
            $('#ionosonde-section').show();
            ionosondeData = jsonData.ionosonde_data;
            populateIonosondeDropdown(ionosondeData);
            renderIonosondeData();
        }

        // Forecast

        if (jsonData.k_index_forecast) {
            $('#noaa-section').show();
        }
        renderKIndexForecast(jsonData.k_index_forecast);
        renderSolarStormForecast(jsonData.solar_storm_forecast);
        renderBlackoutForecast(jsonData.blackout_forecast_r1r2, jsonData.blackout_forecast_r3_or_greater);
    });
}

// Render the K-index forecast as a Chart.js bar chart, one bar per 3-hour UTC period
function renderKIndexForecast(data) {
    if (!data) return;

    const entries = Object.entries(data)
        .map(([tsStr, kp]) => ({ts: parseFloat(tsStr), kp}))
        .sort((a, b) => a.ts - b.ts);
    if (entries.length === 0) return;

    // Use a simple integer index axis: ticks at 0, 1, 2, ..., N (period boundaries) and bars
    // centred at 0.5, 1.5, ..., N-0.5 (midpoints). This guarantees tick marks fall exactly on
    // bar edges regardless of how Chart.js rounds large timestamp values.
    // "axisMin = 0" is the left/top edge of bar 0; "axisMax = N" is the right/bottom edge of bar N-1.
    const N = entries.length;
    const periodSecs = 3 * 3600;

    // Inherit colours from Bootstrap CSS variables so that dark mode inherently works. We want bar colours that are not
    // quite as saturated as the Bootstrap success/warning/danger colours but not as desaturated as the "subtle"
    // versions, so use tinycolor to apply some transparency.
    const style = getComputedStyle(document.documentElement);
    const withAlpha = hex => tinycolor(hex).setAlpha(0.8).toRgbString();
    const colors = entries.map(e =>
        e.kp < 4.5 ? withAlpha(style.getPropertyValue('--bs-success').trim())
            : e.kp < 5.5 ? withAlpha(style.getPropertyValue('--bs-warning').trim())
                : withAlpha(style.getPropertyValue('--bs-danger').trim())
    );
    const textColor = style.getPropertyValue('--bs-body-color').trim() || '#666';
    const gridColor = style.getPropertyValue('--bs-border-color').trim() || 'rgba(128,128,128,0.3)';

    if (kpChart) {
        kpChart.destroy();
    }

    const isMobile = window.innerWidth < 768;
    const kpAxisTicks = {
        stepSize: 1,
        color: textColor,
        // Include geomagnetic storm levels (Gx) alongside the Kp index
        callback: v => v > 4 ? `(G${v - 4}) ${v}` : String(v),
    };
    const kpAxis = {
        min: 0,
        max: 9,
        title: {display: true, text: 'Kp', color: textColor},
        ticks: kpAxisTicks,
        grid: {color: gridColor},
    };
    // Linear scale using integer indices. Ticks at 0..N (period boundary indices);
    // the callback converts each integer index back to a UTC time string.
    // On mobile the time axis is vertical, so reverse it to keep time running top-to-bottom.
    const timeAxis = {
        type: 'linear',
        min: 0,
        max: N,
        offset: false,
        reverse: isMobile,
        title: {display: true, text: 'Time (UTC)', color: textColor},
        ticks: {
            stepSize: 1,
            color: textColor,
            maxRotation: 45,
            minRotation: 0,
            callback(value) {
                if (!Number.isInteger(value) || value < 0 || value > N) return null;
                const ts = value < N ? entries[value].ts : entries[N - 1].ts + periodSecs;
                const dt = new Date(ts * 1000);
                const h = dt.getUTCHours(), m = dt.getUTCMinutes();
                const timeStr = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
                if (h === 0 && m === 0) {
                    return [timeStr, dt.toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'})];
                }
                return timeStr;
            },
        },
        grid: {color: gridColor, offset: false},
    };

    // Draw a "now" line at the current time position
    const nowLinePlugin = {
        id: 'nowLine',
        afterDraw(chart) {
            const nowTs = Date.now() / 1000;
            // Find which bar (if any) the current time falls in and compute a fractional index
            const firstTs = entries[0].ts;
            const lastTs = entries[N - 1].ts + periodSecs;
            if (nowTs < firstTs || nowTs > lastTs) return;
            const fracIndex = (nowTs - firstTs) / periodSecs;

            const {ctx, chartArea} = chart;
            const scale = isMobile ? chart.scales.y : chart.scales.x;
            const pos = scale.getPixelForValue(fracIndex);

            ctx.save();
            ctx.strokeStyle = textColor;
            ctx.lineWidth = 2;
            ctx.setLineDash([5, 4]);
            ctx.beginPath();
            if (isMobile) {
                ctx.moveTo(chartArea.left, pos);
                ctx.lineTo(chartArea.right, pos);
            } else {
                ctx.moveTo(pos, chartArea.top);
                ctx.lineTo(pos, chartArea.bottom);
            }
            ctx.stroke();
            ctx.setLineDash([]);
            ctx.fillStyle = textColor;
            ctx.font = '11px sans-serif';
            if (isMobile) {
                ctx.textAlign = 'right';
                ctx.textBaseline = 'bottom';
                ctx.fillText('Now', chartArea.right, pos - 3);
            } else {
                ctx.textAlign = 'left';
                ctx.textBaseline = 'top';
                ctx.fillText('  Now', pos, chartArea.top + 3);
            }
            ctx.restore();
        }
    };

    // Bars centred at i+0.5 (midpoint between tick i and tick i+1) so each bar spans
    // exactly from tick i to tick i+1 with barPercentage/categoryPercentage = 1.0.
    const chartData = isMobile
        ? entries.map((e, i) => ({x: e.kp, y: i + 0.5}))
        : entries.map((e, i) => ({x: i + 0.5, y: e.kp}));

    kpChart = new Chart(document.getElementById('forecast-kp-chart'), {
        type: 'bar',
        data: {
            datasets: [{
                data: chartData,
                backgroundColor: colors,
                hoverBackgroundColor: colors,
                borderWidth: 0,
                barPercentage: 1.0,
                categoryPercentage: 1.0,
            }]
        },
        options: {
            responsive: true,
            // Swap axes on mobile, and change the aspect ratio
            aspectRatio: isMobile ? 0.4 : 3,
            indexAxis: isMobile ? 'y' : 'x',
            plugins: {
                legend: {
                    display: false
                },
                tooltip: {
                    enabled: false
                }
            },
            scales: {
                x: isMobile ? kpAxis : timeAxis,
                y: isMobile ? timeAxis : kpAxis,
            }
        },
        plugins: [nowLinePlugin],
    });
}

// Render the solar storm forecast table
function renderSolarStormForecast(data) {
    if (!data) return;

    const entries = Object.entries(data)
        .map(([tsStr, pct]) => ({ts: parseFloat(tsStr), pct}))
        .sort((a, b) => a.ts - b.ts);

    // Header
    const headRow = $('#forecast-solar-storm-head').empty().append('<th></th>');
    entries.forEach(({ts}) => {
        const label = new Date(ts * 1000)
            .toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'});
        headRow.append(`<th>${label}</th>`);
    });

    // Single data row: "S1 or greater" label + one cell per date
    const tr = $('<tr>').append('<td>S1 or greater</td>');
    entries.forEach(({pct}) => {
        const td = $('<td>').text(pct + '%');
        td.addClass(pct < 50 ? 'bg-success-subtle' : pct < 75 ? 'bg-warning-subtle' : 'bg-danger-subtle');
        tr.append(td);
    });
    $('#forecast-solar-storm-tbody').empty().append(tr);
}

// Render the radio blackout forecast table
function renderBlackoutForecast(r1r2Data, r3Data) {
    if (!r1r2Data && !r3Data) return;

    const tsSet = new Set([
        ...Object.keys(r1r2Data || {}),
        ...Object.keys(r3Data || {})
    ]);
    const entries = [...tsSet]
        .map(tsStr => ({
            ts: parseFloat(tsStr),
            r1r2: r1r2Data ? r1r2Data[tsStr] : undefined,
            r3: r3Data ? r3Data[tsStr] : undefined
        }))
        .sort((a, b) => a.ts - b.ts);

    // Header
    const headRow = $('#forecast-blackout-head').empty().append('<th></th>');
    entries.forEach(({ts}) => {
        const label = new Date(ts * 1000)
            .toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'});
        headRow.append(`<th>${label}</th>`);
    });

    // Two data rows: R1-R2 and R3+
    function makeRow(rowLabel, getValue) {
        const tr = $('<tr>').append(`<td>${rowLabel}</td>`);
        entries.forEach(entry => {
            const pct = getValue(entry);
            const td = $('<td>');
            if (pct !== undefined) {
                td.text(pct + '%');
                td.addClass(pct < 50 ? 'bg-success-subtle' : pct < 75 ? 'bg-warning-subtle' : 'bg-danger-subtle');
            }
            tr.append(td);
        });
        return tr;
    }

    $('#forecast-blackout-tbody').empty()
        .append(makeRow('R1-R2', e => e.r1r2))
        .append(makeRow('R3 or greater', e => e.r3));
}

// Populate the ionosonde station dropdown and restore any saved selection
function populateIonosondeDropdown(data) {
    const select = $('#ionosonde-station');
    const savedUrsi = localStorage.getItem('#ionosonde-station:value');
    const savedValue = savedUrsi ? JSON.parse(savedUrsi) : null;
    select.empty();
    // Sort by station name rather than URSI because station name is what's displayed, and any out-of-order names might
    // confuse the user
    Object.values(data).sort((a, b) => a.name.localeCompare(b.name)).forEach(function (station) {
        select.append($('<option>', {value: station.ursi, text: station.name}));
    });
    // Select one by default if the user's localStorage has an existing selection for this
    if (savedValue && select.find('option[value="' + savedValue + '"]').length) {
        select.val(savedValue);
    }
}

// Render the foF2/MUF data and line chart for the currently selected station
function renderIonosondeData() {
    // First make sure that we have some data, that a station entry is selected in the drop-down box, and that the
    // data contains an entry for that station. If not, this represents an odd state (over and above just "no data for
    // this station", so bail out at this point. The user will have to reselect something from the list, or wait until
    // the API is behaving itself again.
    if (!ionosondeData) return;
    const ursi = $('#ionosonde-station').val();
    if (!ursi) return;
    const station = ionosondeData[ursi];
    if (!station) return;

    // Set up some styles, matching the k-index chart. We use Bootstrap's "primary", "danger", and "success" colours
    // not for any real reason but just to get a suitable blue, red, and green that match the other colours Spothole uses
    const style = getComputedStyle(document.documentElement);
    const fof2Color = style.getPropertyValue('--bs-primary').trim();
    const mufColor = style.getPropertyValue('--bs-success').trim();
    const lufColor = style.getPropertyValue('--bs-danger').trim();
    const textColor = style.getPropertyValue('--bs-body-color').trim() || '#666';
    const gridColor = style.getPropertyValue('--bs-border-color').trim() || 'rgba(128,128,128,0.3)';

    // Utility function to convert the dict of timestamp-to-value into just a value array in timestamp key order
    function toSeries(dict) {
        if (!dict) return [];
        return Object.entries(dict)
            .map(([tsStr, val]) => ({ts: parseFloat(tsStr), val}))
            .sort((a, b) => a.ts - b.ts);
    }

    const fof2Entries = toSeries(station.fof2);
    const mufEntries = toSeries(station.muf);
    const lufEntries = toSeries(station.luf);
    const allTs = [...fof2Entries, ...mufEntries, ...lufEntries].map(e => e.ts);
    if (allTs.length === 0) {
        $('#ionosonde-no-data').show();
        $('#ionosonde-data-rows').hide();
        $('#ionosonde-band-state').hide();
        $('#ionosonde-chart').hide();
        if (ionosondeChart) {
            ionosondeChart.destroy();
            ionosondeChart = null;
        }
        return;
    }
    $('#ionosonde-no-data').hide();
    $('#ionosonde-data-rows').show();

    // Populate latest values summary (visible on all screen sizes)
    const latestFof2 = fof2Entries.length ? fof2Entries[fof2Entries.length - 1].val : null;
    const latestMuf = mufEntries.length ? mufEntries[mufEntries.length - 1].val : null;
    const latestLuf = lufEntries.length ? lufEntries[lufEntries.length - 1].val : null;
    const minTs = allTs.length ? Math.min(...allTs) : null;
    const maxTs = allTs.length ? Math.max(...allTs) : null;
    if (maxTs != null) {
        const latestDate = moment.utc(maxTs * 1000);
        $('#ionosonde-latest-time').text(latestDate.format('DD MMM YYYY HH:mm [UTC]') + ' (' + latestDate.fromNow() + ')');
    }
    $('#ionosonde-latest-luf').text(latestLuf !== null ? latestLuf.toFixed(2) + ' MHz' : 'Unknown');
    $('#ionosonde-latest-fof2').text(latestFof2 !== null ? latestFof2.toFixed(2) + ' MHz' : 'Unknown');
    $('#ionosonde-latest-muf').text(latestMuf !== null ? latestMuf.toFixed(2) + ' MHz' : 'Unknown');
    $('#ionosonde-stale-warning').toggle(maxTs !== null && (Date.now() / 1000 - maxTs) > 12 * 3600);

    // Populate band state tables. There are actually two tables to populate, which is pretty janky, but allows us to
    // display horizontally on desktop but flip it around to become a vertical list on mobile.
    const bandStateClass = {'Closed': 'bg-danger-subtle', 'Short': 'bg-primary-subtle', 'Long': 'bg-success-subtle'};
    const bandStates = station.band_states;
    if (bandStates && Object.keys(bandStates).length > 0) {
        const headRow = $('#ionosonde-band-state-head').empty();
        const dataRow = $('#ionosonde-band-state-row').empty();
        const vBody = $('#ionosonde-band-state-body').empty();
        Object.entries(bandStates).forEach(([band, state]) => {
            const cls = bandStateClass[state] || '';
            headRow.append($('<th>').addClass('text-center').text(band));
            dataRow.append($('<td>').addClass('text-center ' + cls).text(state));
            vBody.append($('<tr>').append($('<td>').addClass('fw-bold').text(band)).append($('<td>').addClass(cls).text(state)));
        });
        $('#ionosonde-band-state').show();
    } else {
        $('#ionosonde-band-state').hide();
    }

    if (ionosondeChart) {
        ionosondeChart.destroy();
    }

    // Compute tick positions at 3-hour UTC boundaries so midnight always lands on a tick, which triggers the date being
    // printed, and in general looks nicer than arbitrary ticks based on min & max timestamp
    const tickStep = 3 * 3600;
    const tickValues = [];
    tickValues.push(minTs);
    for (let t = Math.ceil(minTs / tickStep) * tickStep; t <= maxTs; t += tickStep) {
        tickValues.push(t);
    }
    tickValues.push(maxTs);

    // Build time axis
    const timeAxis = {
        type: 'linear',
        min: minTs,
        max: maxTs,
        title: {display: true, text: 'Time (UTC)', color: textColor},
        afterBuildTicks(axis) {
            axis.ticks = tickValues.map(v => ({value: v}));
        },
        ticks: {
            color: textColor,
            maxRotation: 45,
            minRotation: 0,
            callback(value) {
                // Use the same type of display as in the k-index chart, where the labels on the axis are just HH:mm
                // unless that's 00:00, in which case add the short date as well.
                const dt = new Date(value * 1000);
                const h = dt.getUTCHours();
                const m = dt.getUTCMinutes();
                const timeStr = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
                if (h === 0 && m === 0) {
                    return [timeStr, dt.toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'})];
                }
                return timeStr;
            },
        },
        grid: {color: gridColor},
    };

    // Build frequency axis. This is pretty normal except there's no grid, because we draw extra horizontal lines for
    // the amateur radio bands which function as grid lines for the frequency axis.
    const freqAxis = {
        min: 0,
        title: {display: true, text: 'Frequency (MHz)', color: textColor},
        ticks: {color: textColor},
        grid: {display: false},
    };

    // List of ham bands for drawing horizontal lines
    const AMATEUR_BANDS = [
        {label: '160m', freq: 1.8},
        {label: '80m', freq: 3.5},
        {label: '60m', freq: 5.3515},
        {label: '40m', freq: 7.0},
        {label: '30m', freq: 10.1},
        {label: '20m', freq: 14.0},
        {label: '17m', freq: 18.068},
        {label: '15m', freq: 21.0},
        {label: '12m', freq: 24.89},
        {label: '10m', freq: 28.0},
    ];

    // Build the horizontal lines for each ham band, including a label on the right-hand side.
    const bandLinesPlugin = {
        id: 'bandLines',
        beforeDatasetsDraw(chart) {
            const {ctx, chartArea, scales} = chart;
            if (!scales.y) return;
            ctx.save();
            ctx.strokeStyle = gridColor;
            ctx.lineWidth = 1;
            ctx.setLineDash([]);
            // Add an extra horizontal line for 30MHz, which should correspond to the top of the chart and avoid having
            // no top "border" gridline
            const y30 = scales.y.getPixelForValue(30);
            if (y30 >= chartArea.top && y30 <= chartArea.bottom) {
                ctx.beginPath();
                ctx.moveTo(chartArea.left, y30);
                ctx.lineTo(chartArea.right, y30);
                ctx.stroke();
            }
            ctx.font = '10px sans-serif';
            ctx.fillStyle = textColor;
            // Add the ham band "grid lines"
            AMATEUR_BANDS.forEach(({label, freq}) => {
                const y = scales.y.getPixelForValue(freq);
                if (y < chartArea.top || y > chartArea.bottom) return;
                ctx.beginPath();
                ctx.moveTo(chartArea.left, y);
                ctx.lineTo(chartArea.right, y);
                ctx.stroke();
                ctx.textAlign = 'right';
                ctx.textBaseline = 'bottom';
                ctx.fillText(label, chartArea.right - 4, y - 2);
            });
            ctx.restore();
        }
    };

    // Create the chart itself
    ionosondeChart = new Chart(document.getElementById('ionosonde-chart'), {
        type: 'line',
        data: {
            datasets: [
                {
                    label: 'LUF',
                    data: lufEntries.map(e => ({x: e.ts, y: e.val})),
                    borderColor: lufColor,
                    backgroundColor: 'transparent',
                    pointRadius: 0,
                    tension: 0.2,
                },
                {
                    label: 'foF2',
                    data: fof2Entries.map(e => ({x: e.ts, y: e.val})),
                    borderColor: fof2Color,
                    backgroundColor: 'transparent',
                    pointRadius: 0,
                    tension: 0.2,
                },
                {
                    label: 'MUF (3000 km)',
                    data: mufEntries.map(e => ({x: e.ts, y: e.val})),
                    borderColor: mufColor,
                    backgroundColor: 'transparent',
                    pointRadius: 0,
                    tension: 0.2,
                }
            ]
        },
        options: {
            responsive: true,
            aspectRatio: 3,
            plugins: {
                legend: {display: true, labels: {color: textColor, usePointStyle: true, pointStyle: 'line'}},
                tooltip: {enabled: false}
            },
            scales: {x: timeAxis, y: freqAxis},
        },
        plugins: [bandLinesPlugin],
    });

    // Chart canvas is normally hidden until we get here with some definitely good data. Now we have that so show it
    $('#ionosonde-chart').show();
}

// Called when the ionosonde station select changes
function ionosondeStationChanged() {
    saveSettings();
    renderIonosondeData();
}

// Render the DX stats table for the currently selected DE continent
function renderDxStats() {
    if (!dxStatsData) {
        return;
    }
    const deContinent = $('#sxstats_de_continent').val();
    const deData = dxStatsData[deContinent];
    if (!deData) {
        return;
    }

    const cells = [];
    Object.entries(deData).forEach(function ([dxContinent, bands]) {
        Object.entries(bands).forEach(function ([band, count]) {
            const cell = $('#dxstats-' + dxContinent + '-' + band);
            cell.text(count);
            cells.push({cell, count});
        });
    });

    const counts = cells.map(function (c) {
        return c.count;
    });
    const min = Math.min(...counts);
    const max = Math.max(...counts);
    const range = max - min;
    cells.forEach(function ({cell, count}) {
        const t = range > 0 ? (count - min) / range : 0;
        const cls = t === 0 ? 'bg-danger-subtle' : t < 0.05 ? 'bg-warning-subtle' : 'bg-success-subtle';
        cell.removeClass('bg-danger-subtle bg-warning-subtle bg-success-subtle').addClass(cls);
    });
}

// Called when the DE continent select changes
function dxStatsContientChanged() {
    saveSettings();
    renderDxStats();
}

// Fetch DX stats from the API and render
function loadDxStats() {
    $.getJSON('/api/v2/dxstats', function (jsonData) {
        dxStatsData = jsonData;
        renderDxStats();
    });
}

// Startup
$(document).ready(function () {
    loadSettings();
    loadSolarConditions();
    loadDxStats();
});
