// SSE event source
let evtSource;
// 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 last updated time
lastUpdateTime = moment.utc();
updateTimingDisplayRunPause();
// 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() {
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString());
evtSource.onmessage = function(event) {
// Store last updated time
lastUpdateTime = moment.utc();
updateTimingDisplayRunPause();
// 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) {
evtSource.close();
setTimeout(startSSEConnection, 1000);
};
}
// Update the special timing display for the live spots page, which varies depending on run/pause selection.
function updateTimingDisplayRunPause() {
let run = $('#runButton:checked').val();
$("#timing-container").html((run ? "Connected to server. Last update at " : "Paused at ") + lastUpdateTime.format('HH:mm') + " UTC.");
}
// 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 ($("#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.
');
}
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 = ``;
}
// 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 = ``;
}
// 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(`
`);
}
// 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($('