// 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; BAND_COLUMN_SPOT_DIV_HEIGHT_PX = BAND_COLUMN_FONT_SIZE * 1.6; // 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); } }); // Track if any columns end up taller than expected, so we can resize the container and avoid vertical scroll. var maxHeightBand = 0; // 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("-
"); } } // Prepare the spots list var bandSpotsDiv = $("
"); var lastSpotPxDownBand = -999; // Sort by frequency so have a consistent order in which to plan where they will appear on the band div. spotList.sort(function(a, b) { return a.freq - b.freq; }); // First calculate how we should be displaying the spots. There are three "modes" to try to place them in a // visually appealing way: // 1) Spaced normally, not going over the end of the band, so we populate them forwards. // 2) Would go over the end, but the spots don't fill the band, so we populate them backwards. // 3) Spots totally fill the band (or more), so we space them evenly starting at the top. // In each case, we don't add anything to the DOM yet, we just calculate "pxDownBandLabel" (how far the *top* of // the label is from the top of the div) and add that as a property to the spot for later use. if (spotList.length >= BAND_COLUMN_HEIGHT_PX / BAND_COLUMN_SPOT_DIV_HEIGHT_PX) { // Mode 3. // Just lay out all spots simply, starting at 0px offset and working down with each one touching. lastSpotPxDownBand = 0 - BAND_COLUMN_SPOT_DIV_HEIGHT_PX; spotList.forEach(s => { lastSpotPxDownBand = lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX; s["pxDownBandLabel"] = lastSpotPxDownBand; }); } else { // Mode 1 or 2. Run through adding things to the list forwards as a test. 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 pxDownBand = percentDownBand * BAND_COLUMN_HEIGHT_PX; if (pxDownBand < lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX) { pxDownBand = lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX; // Prevent overlap } s["pxDownBandLabel"] = pxDownBand; lastSpotPxDownBand = pxDownBand; }); // Work out if we overflowed the end. if (lastSpotPxDownBand <= BAND_COLUMN_HEIGHT_PX) { // Mode 1. Current positions are fine and there's nothing to do. } else { // Mode 2. Repeat the process but backwards, starting at the end and working upwards. lastSpotPxDownBand = 999999; spotList.reverse().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 pxDownBand = percentDownBand * BAND_COLUMN_HEIGHT_PX; if (pxDownBand > lastSpotPxDownBand - BAND_COLUMN_SPOT_DIV_HEIGHT_PX) { pxDownBand = lastSpotPxDownBand - BAND_COLUMN_SPOT_DIV_HEIGHT_PX; // Prevent overlap } s["pxDownBandLabel"] = pxDownBand; lastSpotPxDownBand = pxDownBand; }); } } // Now each spot is tagged with how far down the div it should go, add them to the DOM. spotList.forEach(s => { bandSpotsDiv.append(`
${s.dx_call}${s.dx_call} ${(s.freq/1000000).toFixed(3)} ${s.mode}
`); }); // Work out how tall the canvas should be. Normally this is matching the normal band column height, but if some // spots have gone off the end of the band markers and stretched their div, we need to resize the canvas to // match, otherwise we have nowhere to draw their connecting lines. var canvasHeight = Math.max(BAND_COLUMN_HEIGHT_PX, lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX); maxHeightBand = Math.max(maxHeightBand, canvasHeight); // Draw horizontal or diagonal lines to join up the "real" frequency with where the spot div ended up var bandLinesCanvas = $(``); 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 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 var pxDownBandLabel = s["pxDownBandLabel"] + (BAND_COLUMN_SPOT_DIV_HEIGHT_PX / 1.75); // line should be to the vertical text-centre spot, not to the top corner // 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); // Increase the height of the bands container so we don't have any vertical scroll bars except the browser ones bandsContainer.css("min-height", `${maxHeightBand + 42}px`); // 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); });