// SSE event source let evtSource; // Table row count, to alternate shading let rowCount = 0; // Load spots and populate the table. function loadSpots() { $.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) { // Store last updated time lastUpdateTime = moment.utc(); updateRefreshDisplay(); // Store data spots = jsonData; // Update table updateTable(); // Start SSE connection to fetch updates in the background restartSSEConnection(); }); } // 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 restartSSEConnection() { if (evtSource != null) { evtSource.close(); } evtSource = new EventSource('/api/v1/spots/stream'); evtSource.onmessage = function(event) { // Store last updated time lastUpdateTime = moment.utc(); updateRefreshDisplay(); // 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); spots = spots.slice(0, -1); // Add spot to table addSpotToTopOfTable(newSpot, true); }; evtSource.onerror = function(err) { evtSource.close(); setTimeout(restartSSEConnection, 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_type", "source", "band", "sig"].forEach(fn => { if (!allFilterOptionsSelected(fn)) { str = str + getQueryStringFor(fn) + "&"; } }); str = str + "limit=" + $("#spots-to-fetch option:selected").val(); if ($("#filter-dx-call").val() != "") { str = str + "&dx_call_includes=" + encodeURIComponent($("#filter-dx-call").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.'); } spots.reverse(); spots.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 = $(''); if (highlightNew) { $tr.addClass("new"); } // 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"); } // 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 = "???"; } 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 = $(""); if (rowCount % 2 == 1) { $tr2.addClass("table-active"); } if (s["qrt"] == true) { $tr2.addClass("table-faded"); } $td2 = $(""); if (showType) { $td2.append(` ${typeText} `); } if (showRef) { $td2.append(`${sig_refs} `); } if (showBearing) { $td2.append(`   Bearing: ${bearingText} `); } 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; // 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"]); generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]); generateMultiToggleFilterCard("#source-options", "source", options["spot_sources"]); // Populate the Display panel options["web-ui-options"]["spot-count"].forEach(sc => $("#spots-to-fetch").append($('