// A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we // can't just ask the elements themselves for their dimensions. BAND_COLUMN_HEIGHT_EM = 62; BAND_COLUMN_CANVAS_WIDTH_EM = 4; BAND_COLUMN_FONT_SIZE = 16; BAND_COLUMN_HEIGHT_PX = BAND_COLUMN_HEIGHT_EM * BAND_COLUMN_FONT_SIZE; BAND_COLUMN_CANVAS_WIDTH_PX = BAND_COLUMN_CANVAS_WIDTH_EM * BAND_COLUMN_FONT_SIZE; // Load spots and populate the bands display. function loadSpots() { $.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) { // Store last updated time lastUpdateTime = moment.utc(); updateRefreshDisplay(); // Store data spots = jsonData; // Update bands display updateBands(); }); } // 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"].forEach(fn => { if (!allFilterOptionsSelected(fn)) { str = str + getQueryStringFor(fn) + "&"; } }); str = str + "max_age=" + $("#max-spot-age option:selected").val(); // Additional filters for the bands view: No dupes, no QRT str = str + "&dedupe=true&allow_qrt=false"; return str; } // Update the bands display function updateBands() { // Stop here if nothing to display var bandsContainer = $("#bands-container"); if (spots.length === 0) { bandsContainer.html(""); return; } // Do some harsher de-duping. Because we only display callsign, frequency and mode here, the previous // de-duplication could have let some through that don't look like dupes on the map, but would do here. // Typically that's a person activating two programs at the same time, e.g. POTA & WWFF. spotList = removeDuplicatesForBandPanel(spots); // Convert to a map of band names to the spots on that band. Bands with no // spots in view will not be present. const bandToSpots = new Map(); options["bands"].forEach(function (band) { const matchingSpots = spotList.filter(function (s) { return s.band === band.name; }); if (matchingSpots.length > 0) { bandToSpots.set(band.name, matchingSpots); } }); // Build up table content for each band var table = $('').append(''); bandToSpots.forEach(function (spotList, bandName) { // Get the colours for the band from the first spot, and prepare the header table.find('thead tr').append(``); // Get the band data to fetch start and end frequencies let band = options["bands"].filter(function (b) { return b.name === bandName; })[0]; // Print the frequency band markers. This is 41 steps to divide the band evenly into 40 markers. One in every // four will show the actual frequency, the others will just be dashes. bandMarkersDiv = $('
'); const freqStep = (band.end_freq - band.start_freq) / 40.0; for (let i = 0; i <= 40; i++) { if (i % 4 === 0) { bandMarkersDiv.append("—" + ((band.start_freq + i * freqStep)/1000000).toFixed(3) + "
"); } else if (i % 4 === 2) { bandMarkersDiv.append("–
"); } else { bandMarkersDiv.append("-
"); } } // Print the spots on the band. Each one is a div with the details, shifted down if necessary to prevent // overlap, and a horizontal or diagonal line on the canvas linking it to where it properly appears on the // band. bandSpotsDiv = $("
"); bandLinesCanvas = $(``); // Sort by frequency so we print higher frequencies later, and can bump them further down the track to prevent // overlaps spotList.sort(function(a, b) { return a.freq - b.freq; }); var lastSpotEmDownBand = -999; // Iterate through the spots list spotList.forEach(s => { // Work out how far down the div to draw it var percentDownBand = (s.freq - band.start_freq) / (band.end_freq - band.start_freq) * 0.97; // not 100% due to fudge, the first and last dashes are not exactly at the top and bottom of the div as some space is needed for text var emDownBand = percentDownBand * BAND_COLUMN_HEIGHT_EM; var pxDownBandFreq = (percentDownBand + 0.015) * BAND_COLUMN_HEIGHT_PX; // same fudge but add half to put the left end of the line in the right place if (emDownBand < lastSpotEmDownBand + 1.6) { emDownBand = lastSpotEmDownBand + 1.6; // Prevent overlap } lastSpotEmDownBand = emDownBand; var pxDownBandLabel = (emDownBand * BAND_COLUMN_FONT_SIZE) + (0.015 * BAND_COLUMN_HEIGHT_PX); // Add spot div to DOM bandSpotsDiv.append(`
${s.dx_call}${s.dx_call} ${(s.freq/1000000).toFixed(3)} ${s.mode}
`); // Draw the line on the canvas var ctx = bandLinesCanvas[0].getContext('2d'); ctx.beginPath(); ctx.lineWidth = 2; ctx.lineCap = "round"; ctx.strokeStyle = s.band_color; ctx.moveTo(0, pxDownBandFreq); ctx.lineTo(BAND_COLUMN_CANVAS_WIDTH_PX, pxDownBandLabel); ctx.stroke(); }); // Assemble the table cell td = $("
${spotList[0].band}"); container = $("
"); container.append(bandLinesCanvas); container.append(bandMarkersDiv); container.append(bandSpotsDiv); td.append(container); table.find('tbody tr').append(td); }); // Update the DOM with the band HTML bandsContainer.html(table); // Desktop mouse wheel to scroll bands horizontally if used on the headers table.find('thead tr').on("wheel", () => { bandsContainer.scrollLeft(bandsContainer.scrollLeft() + event.deltaY / 10.0); return false; }); } // Iterate through a temporary list of spots, merging duplicates in a way suitable for the band panel. If two or more // spots with the activator, mode and frequency are found, these will be merged and reduced until only one remains, // with the best data. Note that unlike removeDuplicates(), which operates on the main spot map, this operates only // on the temporary array of spots provided as an argument, and returns the output, for use when constructing the // band panel. function removeDuplicatesForBandPanel(spotList) { const spotsToRemove = []; spotList.forEach(function (check) { spotList.forEach(function (s) { if (s !== check) { if (s.dx_call === check.dx_call && s.freq === check.freq && s.mode === check.mode) { // Find which one to keep and which to delete const checkSpotNewer = check.time > s.time; const keepSpot = checkSpotNewer ? check : s; const deleteSpot = checkSpotNewer ? s : check; // Aggregate list of spots to remove spotsToRemove.push(deleteSpot.uid); } } }); }); // Perform the removal return spotList.filter(s => !spotsToRemove.includes(s.uid)); } // 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"]); 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"]); // Load settings from settings storage now all the controls are available loadSettings(); // Load spots and set up the timer loadSpots(); setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000); }); } // Method called when any display property is changed to reload the map and persist the display settings. function displayUpdated() { updateMap(); saveSettings(); } // React to toggling/closing panels function toggleFiltersPanel() { // If we are going to show the filters panel, hide the display panel 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 panel 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(); } // Startup $(document).ready(function() { // Call loadOptions(), this will then trigger loading spots and setting up timers. loadOptions(); // Update the refresh timing display every second setInterval(updateRefreshDisplay, 1000); });