// Storage for the options that the server gives us. This will define our filters. var options = {}; // Last time we updated the spots/alerts list on display. var lastUpdateTime; // Whether "embedded mode" is being used. This removes headers and footers, maximises the remaining content, and // uses URL params to configure the interface options rather than using the user's localstorage. var embeddedMode = false; // Load and apply any URL params. This is used for "embedded mode" where another site can embed a version of // Spothole and provide its own interface options rather than using the user's saved ones. These may select things // from the various filter & display options, so this 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 occurs.. function loadURLParams() { let params = new URLSearchParams(document.location.search); // Handle embedded mode. We set a global to e.g. suppress loading/saving settings, and apply an attribute to the // top-level html element to use CSS selectors to remove bits of UI. let embedded = params.get("embedded"); if (embedded != null && embedded === "true") { embeddedMode = true; $("html").attr("embedded-mode", "true"); } // Handle other params updateCheckboxFromParam(params, "dark-mode", "darkMode"); updateSelectFromParam(params, "time-zone", "timeZone"); // Only on Spots and Alerts pages updateSelectFromParam(params, "limit", "spots-to-fetch"); // Only on Spots page updateSelectFromParam(params, "limit", "alerts-to-fetch"); // Only on Alerts page updateSelectFromParam(params, "max_age", "max-spot-age"); // Only on Map & Bands pages updateFilterFromParam(params, "band", "band"); updateFilterFromParam(params, "sig", "sig"); updateFilterFromParam(params, "source", "source"); updateFilterFromParam(params, "mode_type", "mode_type"); updateFilterFromParam(params, "dx_continent", "dx_continent"); updateFilterFromParam(params, "de_continent", "de_continent"); } // Update an HTML checkbox element so that its selected matches the given parameter (which must have a true or false value) function updateCheckboxFromParam(params, paramName, checkboxID) { let v = params.get(paramName); if (v != null) { $("#" + checkboxID).prop("checked", (v === "true") ? true : false); // Extra check if this is the "dark mode" toggle if (checkboxID == "darkMode") { enableDarkMode((v === "true") ? true : false); } } } // Update an HTML select element so that its value matches the given parameter function updateSelectFromParam(params, paramName, selectID) { let v = params.get(paramName); if (v != null) { $("#" + selectID).prop("value", v); } } // Update a set of HTML checkbox elements describing a filter of the given name, so that any items named in the // parameter (as a comma-separated list) will be enabled, and all others disabled. e.g. if paramName is // "filter-band" and the params contain "filter-band=20m,40m", and prefix is "band", then #filter-button-band-30m // would be disabled but #filter-button-band-20m and #filter-button-band-40m would be enabled. function updateFilterFromParam(params, paramName, filterName) { let v = params.get(paramName); if (v != null) { // First uncheck all options for the filter $(".filter-button-" + filterName).prop("checked", false); // Now find out which ones should be enabled let s = v.split(","); s.forEach(val => $("#filter-button-" + filterName + "-" + val).prop("checked", true)); } } // For a parameter, such as dx_continent, get the query string for the current filter options. function getQueryStringFor(parameter) { return parameter + "=" + encodeURIComponent(getSelectedFilterOptions(parameter)); } // For a parameter, such as dx_continent, get the filter options that are currently selected in the UI. function getSelectedFilterOptions(parameter) { return $(".filter-button-" + parameter).filter(function() { return this.checked; }).map(function() { return this.value; }).get().join(","); } // For a parameter, such as dx_continent, return true if all possible options are enabled. (In this case, we don't need // to bother sending this as one of the query parameters to the API; no parameter provided implies "send everything".) function allFilterOptionsSelected(parameter) { var filter = $(".filter-button-" + parameter).filter(function() { return !this.checked; }).get(); return filter.length == 0; } // Generate a filter card with multiple toggle buttons plus All/None buttons. function generateMultiToggleFilterCard(elementID, filterQuery, options) { // Create a button for each option options.forEach(o => { $(elementID).append(` `); }); // Create All/None buttons $(elementID).append(`  `); } // Method called when "All" or "None" is clicked function toggleFilterButtons(filterQuery, state) { $(".filter-button-" + filterQuery).each(function() { $(this).prop('checked', state); }); filtersUpdated(); } // Update the refresh timing display function updateRefreshDisplay() { if (lastUpdateTime != null) { let count = REFRESH_INTERVAL_SEC; let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds(); updatingString = "Updating..." if (secSinceUpdate < REFRESH_INTERVAL_SEC) { count = REFRESH_INTERVAL_SEC - secSinceUpdate; if (count <= 60) { var number = count.toFixed(0); updatingString = "Updating in " + number + " second" + (number != "1" ? "s" : "") + "."; } else { var number = Math.round(count / 60.0).toFixed(0); updatingString = "Updating in " + number + " minute" + (number != "1" ? "s" : "") + "."; } } $("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString); } } // Utility function to escape HTML characters from a string. function escapeHtml(str) { if (typeof str !== 'string') { return ''; } const escapeCharacter = (match) => { switch (match) { case '&': return '&'; case '<': return '<'; case '>': return '>'; case '"': return '"'; case '\'': return '''; case '`': return '`'; default: return match; } }; return str.replace(/[&<>"'`]/g, escapeCharacter); } // When the "use local time" field is changed, reload the table and save settings function timeZoneUpdated() { updateTable(); saveSettings(); } // When one of the column toggle checkboxes are changed, reload the table and save settings function columnsUpdated() { updateTable(); saveSettings(); } // Calculate great circle bearing between two lat/lon points. function calcBearing(lat1, lon1, lat2, lon2) { lat1 *= Math.PI / 180; lon1 *= Math.PI / 180; lat2 *= Math.PI / 180; lon2 *= Math.PI / 180; var lonDelta = lon2 - lon1; var y = Math.sin(lonDelta) * Math.cos(lat2); var x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lonDelta); var bearing = Math.atan2(y, x); bearing = bearing * (180 / Math.PI); if ( bearing < 0 ) { bearing += 360; } return bearing; } // Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the centre point of the square. // Returns null if the grid format is invalid. function latLonForGridCentre(grid) { let [lat, lon, latCellSize, lonCellSize] = latLonForGridSWCornerPlusSize(grid); if (lat != null && lon != null && latCellSize != null && lonCellSize != null) { return [lat + latCellSize / 2.0, lon + lonCellSize / 2.0]; } else { return null; } } // Convert a Maidenhead grid reference of arbitrary precision to lat/long, including in the result the size of the // lowest grid square. This is a utility method used by the main methods that return the centre, southwest, and // northeast coordinates of a grid square. // The return type is always an array of size 4. The elements in it are null if the grid format is invalid. function latLonForGridSWCornerPlusSize(grid) { // Make sure we are in upper case so our maths works. Case is arbitrary for Maidenhead references grid = grid.toUpperCase(); // Return null if our Maidenhead string is invalid or too short let len = grid.length; if (len <= 0 || (len % 2) !== 0) { return [null, null, null, null]; } let lat = 0.0; // aggregated latitude let lon = 0.0; // aggregated longitude let latCellSize = 10; // Size in degrees latitude of the current cell. Starts at 20 and gets smaller as the calculation progresses let lonCellSize = 20; // Size in degrees longitude of the current cell. Starts at 20 and gets smaller as the calculation progresses let latCellNo; // grid latitude cell number this time let lonCellNo; // grid longitude cell number this time // Iterate through blocks (two-character sections) for (let block = 0; block * 2 < len; block += 1) { if (block % 2 === 0) { // Letters in this block lonCellNo = grid.charCodeAt(block * 2) - 'A'.charCodeAt(0); latCellNo = grid.charCodeAt(block * 2 + 1) - 'A'.charCodeAt(0); // Bail if the values aren't in range. Allowed values are A-R (0-17) for the first letter block, or // A-X (0-23) thereafter. let maxCellNo = (block === 0) ? 17 : 23; if (latCellNo < 0 || latCellNo > maxCellNo || lonCellNo < 0 || lonCellNo > maxCellNo) { return [null, null, null, null]; } } else { // Numbers in this block lonCellNo = parseInt(grid.charAt(block * 2)); latCellNo = parseInt(grid.charAt(block * 2 + 1)); // Bail if the values aren't in range 0-9.. if (latCellNo < 0 || latCellNo > 9 || lonCellNo < 0 || lonCellNo > 9) { return [null, null, null, null]; } } // Aggregate the angles lat += latCellNo * latCellSize; lon += lonCellNo * lonCellSize; // Reduce the cell size for the next block, unless we are on the last cell. if (block * 2 < len - 2) { // Still have more work to do, so reduce the cell size if (block % 2 === 0) { // Just dealt with letters, next block will be numbers so cells will be 1/10 the current size latCellSize = latCellSize / 10.0; lonCellSize = lonCellSize / 10.0; } else { // Just dealt with numbers, next block will be letters so cells will be 1/24 the current size latCellSize = latCellSize / 24.0; lonCellSize = lonCellSize / 24.0; } } } // Offset back to (-180, -90) where the grid starts lon -= 180.0; lat -= 90.0; // Return nulls on maths errors if (isNaN(lat) || isNaN(lon) || isNaN(latCellSize) || isNaN(lonCellSize)) { return [null, null, null, null]; } return [lat, lon, latCellSize, lonCellSize]; } // Function to set dark mode on or off function enableDarkMode(dark) { $("html").attr("data-bs-theme", dark ? "dark" : "light"); const metaThemeColor = document.querySelector("meta[name=theme-color]"); metaThemeColor.setAttribute("content", dark ? "black" : "white"); const metaAppleStatusBarStyle = document.querySelector("meta[name=apple-mobile-web-app-status-bar-style]"); metaAppleStatusBarStyle.setAttribute("content", dark ? "black-translucent" : "white-translucent"); } // Startup function to determine whether to use light or dark mode function usePreferredTheme() { // First, work out if we have ever explicitly saved the value of our toggle let val = localStorage.getItem("#darkMode:checked"); if (val != null) { enableDarkMode(JSON.parse(val)); } else { // Never set it before, so use the system default theme and set the toggle up to match let dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; enableDarkMode(dark); $("#darkMode").prop('checked', dark); } } // Save settings to local storage. Suppressed if "embedded mode" is in use. function saveSettings() { if (!embeddedMode) { // Find all storeable UI elements, store a key of "element id:property name" mapped to the value of that // property. For a checkbox, that's the "checked" property. $(".storeable-checkbox").each(function() { localStorage.setItem("#" + $(this)[0].id + ":checked", JSON.stringify($(this)[0].checked)); }); $(".storeable-select").each(function() { localStorage.setItem("#" + $(this)[0].id + ":value", JSON.stringify($(this)[0].value)); }); $(".storeable-text").each(function() { localStorage.setItem("#" + $(this)[0].id + ":value", JSON.stringify($(this)[0].value)); }); } } // Load settings from local storage and set up the filter selectors. Suppressed if "embedded mode" is in use. function loadSettings() { if (!embeddedMode) { // Find all local storage entries and push their data to the corresponding UI element Object.keys(localStorage).forEach(function(key) { if (key.startsWith("#") && key.includes(":")) { // Split the key back into an element ID and a property var split = key.split(":"); $(split[0]).prop(split[1], JSON.parse(localStorage.getItem(key))); } }); } } // Startup $(document).ready(function() { usePreferredTheme(); });