// SSE event source let evtSource; let restartSSEOnErrorTimeoutId; // Table row count, to alternate shading let rowCount = 0; // Load spots and populate the table. function loadSpots() { // If we have an ongoing SSE connection, stop it so it doesn't interfere with our reload if (evtSource != null) { evtSource.close(); } // Make the new query $.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) { // Store data spots = jsonData; // Update table updateTable(); // Start SSE connection to fetch updates in the background, if we are in "run" mode let run = $('#runButton:checked').val(); if (run) { startSSEConnection(); } }); } // Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the // fly. function startSSEConnection() { if (evtSource != null) { evtSource.close(); } evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString()); evtSource.onmessage = function(event) { // Get the new spot newSpot = JSON.parse(event.data); // Awful fudge to ensure new incoming spots at the top of the list don't have timestamps that make them look // like they belong further down the list. If the spot is older than the latest one we already have, bump its // time up to match it. This isn't great but since we poll spot providers every 2 minutes anyway, it shouldn't // be too far wrong. if (spots.length > 0) { newSpot["time"] = Math.max(newSpot["time"], Math.max(...spots.map(s => s["time"]))) } // Add spot to internal data store spots.unshift(newSpot); // Work out if we need to remove an old spot if (spots.length > $("#spots-to-fetch option:selected").val()) { spots = spots.slice(0, -1); // Drop oldest spot off the end of the table. This is two rows because of the mobile view extra rows $("#table tbody tr").last().remove(); $("#table tbody tr").last().remove(); } // If we had zero spots before (i.e. one now), the table will have a "No spots" row that we need to remove now // that we have one. if (spots.length == 1) { $("#table tbody tr").last().remove(); } // Add the new spot to table addSpotToTopOfTable(newSpot, true); }; evtSource.onerror = function(err) { if (evtSource != null) { evtSource.close(); } clearTimeout(restartSSEOnErrorTimeoutId) restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000); }; } // Build a query string for the API, based on the filters that the user has selected. function buildQueryString() { var str = "?"; ["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => { if (!allFilterOptionsSelected(fn)) { str = str + getQueryStringFor(fn) + "&"; } }); str = str + "limit=" + $("#spots-to-fetch option:selected").val(); if ($("#search").val() != "") { str = str + "&text_includes=" + encodeURIComponent($("#search").val()); } return str; } // Update the spots table function updateTable() { // Use local time instead of UTC? var useLocalTime = $("#timeZone")[0].value == "local"; // Get user grid if valid, this will be null if it's not. var userPos = latLonForGridCentre($("#userGrid").val()); // Table data toggles var showTime = $("#tableShowTime")[0].checked; var showDX = $("#tableShowDX")[0].checked; var showFreq = $("#tableShowFreq")[0].checked; var showMode = $("#tableShowMode")[0].checked; var showComment = $("#tableShowComment")[0].checked; var showBearing = $("#tableShowBearing")[0].checked && userPos != null; var showType = $("#tableShowType")[0].checked; var showRef = $("#tableShowRef")[0].checked; var showDE = $("#tableShowDE")[0].checked; // Populate table with headers let table = $("#table"); table.find('thead tr').empty(); if (showTime) { table.find('thead tr').append(`${useLocalTime ? "Local" : "UTC"}`); } if (showDX) { table.find('thead tr').append(`DX`); } if (showFreq) { table.find('thead tr').append(`Frequency`); } if (showMode) { table.find('thead tr').append(`Mode`); } if (showComment) { table.find('thead tr').append(`Comment`); } if (showBearing) { table.find('thead tr').append(`Bearing`); } if (showType) { table.find('thead tr').append(`Type`); } if (showRef) { table.find('thead tr').append(`Ref.`); } if (showDE) { table.find('thead tr').append(`DE`); } table.find('tbody').empty(); if (spots.length == 0) { table.find('tbody').append('No spots match your filters.'); } let spotsNewestFirst = spots.toReversed(); spotsNewestFirst.forEach(s => addSpotToTopOfTable(s, false)); } // Add rows corresponding to a new spot to the top of the table // highlightNew = false for an initial load, true for new SSE-loaded spots function addSpotToTopOfTable(s, highlightNew) { let rows = createNewTableRowsForSpot(s, highlightNew); $("#table").find('tbody').prepend(rows[1]); $("#table").find('tbody').prepend(rows[0]); } // Turn a spot into a set of table rows to represent it. This is actually two table rows because we need a second // separate row for the mobile view. // highlightNew = false for an initial load, true for new SSE-loaded spots function createNewTableRowsForSpot(s, highlightNew) { // Use local time instead of UTC? var useLocalTime = $("#timeZone")[0].value == "local"; // Get user grid if valid, this will be null if it's not. var userPos = latLonForGridCentre($("#userGrid").val()); // Table data toggles var showTime = $("#tableShowTime")[0].checked; var showDX = $("#tableShowDX")[0].checked; var showFreq = $("#tableShowFreq")[0].checked; var showMode = $("#tableShowMode")[0].checked; var showComment = $("#tableShowComment")[0].checked; var showBearing = $("#tableShowBearing")[0].checked && userPos != null; var showType = $("#tableShowType")[0].checked; var showRef = $("#tableShowRef")[0].checked; var showDE = $("#tableShowDE")[0].checked; // Create row let $tr = $(''); // Apply striping to the table. We can't just use Bootstrap's table-striped class because we have all sorts of // extra faff to deal with, like the mobile view having extra rows, and the On Now / Next 24h / Later banners // which cause the table-striped colouring to go awry. if (rowCount % 2 == 1) { $tr.addClass("table-active"); } // Show faded out if QRT if (s["qrt"] == true) { $tr.addClass("table-faded"); } // If we are asked to highlight new rows (i.e. this row is being added "live" via the SSE client and not as a bulk // reload of the whole table) if (highlightNew) { $tr.addClass("new"); } // Format a UTC or local time for display var time = moment.unix(s["time"]).utc(); if (useLocalTime) { time.local(); } var time_formatted = time.format("HH:mm"); // Format DX call var dx_call = s["dx_call"]; if (dx_call == null) { dx_call = ""; dx_flag = ""; } if (s["dx_ssid"] != null) { dx_call = dx_call + "-" + s["dx_ssid"]; } // Format dx country var dx_country = s["dx_country"]; if (dx_country == null) { dx_country = "Unknown or not a country"; } // Format DX flag var dx_flag = ""; if (s["dx_dxcc_id"] && s["dx_dxcc_id"] != null && s["dx_dxcc_id"] != 0) { dx_flag = `${dx_country}`; } // Format the frequency var freq_string = "Unknown" if (s["freq"] != null) { var mhz = Math.floor(s["freq"] / 1000000.0); var khz = Math.floor((s["freq"] - (mhz * 1000000.0)) / 1000.0); var hz = Math.floor(s["freq"] - (mhz * 1000000.0) - (khz * 1000.0)); var hz_string = (hz > 0) ? hz.toFixed(0)[0] : ""; freq_string = `${mhz.toFixed(0)}${khz.toFixed(0).padStart(3, '0')}${hz_string}` } // Format the mode mode_string = s["mode"]; if (s["mode"] == null) { mode_string = ""; } else if (s["mode_source"] == "BANDPLAN") { mode_string = mode_string + ""; } // Format comment var commentText = ""; if (s["comment"] != null) { commentText = escapeHtml(s["comment"]); } // Format bearing text var bearingText = "---"; if (userPos != null && s["dx_latitude"] != null && s["dx_longitude"] != null) { var bearing = calcBearing(userPos[0], userPos[1], s["dx_latitude"], s["dx_longitude"]); bearingText = bearing.toFixed(0).padStart(3, '0') + "°"; if (s["dx_location_good"] == null || s["dx_location_good"] == false) { if (s["dx_location_source"] == "HOME QTH") { bearingText = bearingText + ""; } else { bearingText = bearingText + ""; } } } // Format "type" (Sig or fallback to source) var typeText = s["source"]; if (s["sig"]) { typeText = s["sig"]; } // Format sig_refs var sig_refs = ""; if (s["sig_refs"] != null) { var items = [] for (var i = 0; i < s["sig_refs"].length; i++) { if (s["sig_refs"][i]["url"] != null) { items[i] = `${s["sig_refs"][i]["id"]}` } else { items[i] = `${s["sig_refs"][i]["id"]}` } } sig_refs = items.join(", "); } // Format de country var de_country = s["de_country"]; if (de_country == null) { de_country = "Unknown or not a country"; } // Format DE flag var de_flag = ""; if (s["de_dxcc_id"] && s["de_dxcc_id"] != null && s["de_dxcc_id"] != 0) { de_flag = `${de_country}`; } // Format de call var de_call = s["de_call"]; if (de_call == null) { de_call = ""; de_flag = ""; } if (s["de_ssid"] != null) { de_call = de_call + "-" + s["de_ssid"]; } // Format band name var bandFullName = s['band'] ? s['band'] + " band": "Unknown band"; // Populate the row if (showTime) { $tr.append(`${time_formatted}`); } if (showDX) { $tr.append(`${dx_flag}${dx_call}`); } if (showFreq) { $tr.append(`${freq_string}`); } if (showMode) { $tr.append(`${mode_string}`); } if (showComment) { $tr.append(`${commentText}`); } if (showBearing) { $tr.append(`${bearingText}`); } if (showType) { $tr.append(` ${typeText}`); } if (showRef) { $tr.append(`${sig_refs}`); } if (showDE) { $tr.append(`${de_flag}${de_call}`); } // Second row for mobile view only, containing type, ref & comment $tr2 = $(""); // Apply styles as per the first row if (rowCount % 2 == 1) { $tr2.addClass("table-active"); } if (s["qrt"] == true) { $tr2.addClass("table-faded"); } if (highlightNew) { $tr2.addClass("new"); } $td2 = $(""); $td2floatleft = $(`
`); if (showType) { $td2floatleft.append(` ${typeText} `); } if (showRef) { $td2floatleft.append(`${sig_refs} `); } $td2.append($td2floatleft); $td2floatright = $(`
`); if (showBearing) { $td2floatright.append(`${bearingText}  `); } if (showDE) { $td2floatright.append(` de ${de_call}  `); } $td2.append($td2floatright); $td2.append(`
`); if (showComment) { $td2.append(`${commentText}`); } $tr2.append($td2); rowCount++; return [$tr, $tr2]; } // Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query // spots repeatedly. function loadOptions() { $.getJSON('/api/v1/options', function(jsonData) { // Store options options = jsonData; // First pass loading settings, so we can load the band colour scheme before the filters that need to use it loadSettings(); setColorScheme($("#color-scheme option:selected").val()); setBandColorScheme($("#band-color-scheme option:selected").val()); // Add CSS for band toggle buttons addBandToggleColourCSS(options["bands"]); // Populate the filters panel generateBandsMultiToggleFilterCard(options["bands"]); generateSIGsMultiToggleFilterCard(options["sigs"]); generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]); generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]); generateModesMultiToggleFilterCard(options["modes"]); generateSourcesMultiToggleFilterCard(options["spot_sources"], spotProvidersEnabledByDefault); // Load URL params. These may select things from the various filter & display options, so the function needs // to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress // loading settings, so this needs to be called before that. loadURLParams(); // Load settings from settings storage now all the controls are available loadSettings(); // Extra setting - the toggle for the "bearing" column is disabled if the user has not entered a valid grid, and // normally this logic is handled on user input to the grid field, but we might have just loaded a value direct // into the field, so apply the same logic here. $("#tableShowBearing").prop('disabled', !isUserGridValid()); if (!isUserGridValid()) { $("#tableShowBearing").prop('checked', false); } // Load spots (this will also set up the SSE connection to update them too) loadSpots(); }); } // Work out if the user's entered grid is a valid Maidenhead grid function isUserGridValid() { userGrid = $("#userGrid").val().toUpperCase(); return latLonForGridCentre(userGrid) != null; } // Method called when the user's grid input is changed. function userGridUpdated() { var userGridValid = isUserGridValid(); if (userGridValid) { updateTable(); } // Enable/disable bearing column depending on grid validity $("#tableShowBearing").prop('disabled', !userGridValid); if (!userGridValid) { $("#tableShowBearing").prop('checked', false); } // Save settings even if not a valid grid, this allows the user to clear their grid and have it save. saveSettings(); } // React to toggling/closing panels function toggleFiltersPanel() { // If we are going to show the filters panel, hide the display and add spot panels if (!$("#filters-area").is(":visible") && $("#display-area").is(":visible")) { $("#display-area").hide(); $("#display-button").button("toggle"); } $("#filters-area").toggle(); } function closeFiltersPanel() { $("#filters-button").button("toggle"); $("#filters-area").hide(); } function toggleDisplayPanel() { // If we are going to show the display panel, hide the filters and add spot panels if (!$("#display-area").is(":visible") && $("#filters-area").is(":visible")) { $("#filters-area").hide(); $("#filters-button").button("toggle"); } $("#display-area").toggle(); } function closeDisplayPanel() { $("#display-button").button("toggle"); $("#display-area").hide(); } // Display the intro box, unless the user has already dismissed it once. function displayIntroBox() { if (localStorage.getItem("intro-box-dismissed") == null) { $("#intro-box").show(); } $("#intro-box-dismiss").click(function() { localStorage.setItem("intro-box-dismissed", true); }); } // Startup $(document).ready(function() { // Call loadOptions(), this will then trigger loading spots and setting up timers. loadOptions(); // Display intro box displayIntroBox(); // Set up run/pause toggles $("#runButton").change(function() { // Need to start the SSE connection but also do a full re-query to catch up anything that we missed, so we // might as well just call loadSpots again which will trigger it all loadSpots(); }); $("#pauseButton").change(function() { // If we are pausing and have an open SSE connection, stop it if (evtSource != null) { evtSource.close(); } }); });