mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-04-29 18:25:58 +00:00
481 lines
17 KiB
JavaScript
481 lines
17 KiB
JavaScript
// How often to query the server?
|
|
const REFRESH_INTERVAL_SEC = 60;
|
|
|
|
// Colours
|
|
const MAIDENHEAD_GRID_COLOR_LIGHT = 'rgba(200, 140, 140, 1.0)';
|
|
const CQ_ZONES_COLOR_LIGHT = 'rgba(140, 200, 140, 1.0)';
|
|
const ITU_ZONES_COLOR_LIGHT = 'rgba(200, 200, 140, 1.0)';
|
|
const WAB_WAI_GRID_COLOR_LIGHT = 'rgba(140, 140, 200, 1.0)';
|
|
const MAIDENHEAD_GRID_COLOR_DARK = 'rgba(120, 60, 60, 1.0)';
|
|
const CQ_ZONES_COLOR_DARK = 'rgba(60, 120, 60, 1.0)';
|
|
const ITU_ZONES_COLOR_DARK = 'rgba(120, 120, 60, 1.0)';
|
|
const WAB_WAI_GRID_COLOR_DARK = 'rgba(60, 60, 120, 1.0)';
|
|
|
|
// Map layers
|
|
var backgroundTileLayer;
|
|
var markersLayer;
|
|
var geodesicsLayer;
|
|
var terminator;
|
|
var maidenheadGrid;
|
|
var cqZones;
|
|
var ituZones;
|
|
var wabwaiGrid;
|
|
// Tracks the currently-loaded basemap provider string to avoid unnecessary tile reloads
|
|
var loadedBasemap;
|
|
// Tracks whether this is the first display of markers after page load
|
|
var firstLoad = true;
|
|
|
|
// Load spots and populate the map.
|
|
function loadSpots() {
|
|
$.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) {
|
|
// Store data
|
|
spots = jsonData;
|
|
// Update map
|
|
updateMap();
|
|
if ($("#showTerminator")[0].checked) {
|
|
terminator.setTime();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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", "source", "band", "sig"].forEach(fn => {
|
|
if (!allFilterOptionsSelected(fn)) {
|
|
str = str + getQueryStringFor(fn) + "&";
|
|
}
|
|
});
|
|
str = str + "max_age=" + $("#max-spot-age option:selected").val();
|
|
// Additional filters for the map view: No dupes, no QRT, only spots with good locations
|
|
str = str + "&dedupe=true&allow_qrt=false&needs_good_location=true";
|
|
return str;
|
|
}
|
|
|
|
// Update the spots map
|
|
function updateMap() {
|
|
// Clear existing content
|
|
markersLayer.clearLayers();
|
|
geodesicsLayer.clearLayers();
|
|
|
|
// Make new markers for all spots that match the filter
|
|
spots.forEach(function (s) {
|
|
var m = L.marker([s["dx_latitude"], s["dx_longitude"]], {icon: getIcon(s)});
|
|
m.bindPopup(getTooltipText(s));
|
|
markersLayer.addLayer(m);
|
|
|
|
// Create geodesics if required
|
|
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
|
|
try {
|
|
var geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
|
|
color: bandToColor(s['band']),
|
|
wrap: false,
|
|
steps: 5
|
|
});
|
|
geodesicsLayer.addLayer(geodesic);
|
|
} catch (e) {
|
|
// Not sure what causes these but better to continue than to crash out
|
|
}
|
|
}
|
|
});
|
|
|
|
// On first load, zoom to the extent of the markers
|
|
if (firstLoad) {
|
|
if (markersLayer.getLayers().length >= 2) {
|
|
var group = new L.featureGroup(markersLayer.getLayers());
|
|
map.fitBounds(group.getBounds().pad(0.1));
|
|
}
|
|
firstLoad = false;
|
|
}
|
|
}
|
|
|
|
// Get an icon for a spot, based on its band, using PSK Reporter colours, its program etc.
|
|
function getIcon(s) {
|
|
return L.ExtraMarkers.icon({
|
|
icon: sigToIcon(s["sig"], "fa-tower-cell"),
|
|
iconColor: bandToContrastColor(s["band"]),
|
|
markerColor: bandToColor(s["band"]),
|
|
shape: 'circle',
|
|
prefix: 'fa',
|
|
svg: true
|
|
});
|
|
}
|
|
|
|
// Tooltip text for the markers
|
|
function getTooltipText(s) {
|
|
// 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 flag
|
|
var dx_flag = "<i class='fa-solid fa-globe-africa'></i>";
|
|
if (s["dx_flag"] && s["dx_flag"] != null && s["dx_flag"] != "") {
|
|
dx_flag = s["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 = `<span class='freq-mhz freq-mhz-pad'>${mhz.toFixed(0)}</span><span class='freq-khz'>${khz.toFixed(0).padStart(3, '0')}</span><span class='freq-hz hideonmobile'>${hz_string}</span>`
|
|
}
|
|
|
|
// Format comment
|
|
var commentText = "";
|
|
if (s["comment"] != null) {
|
|
commentText = escapeHtml(s["comment"]);
|
|
}
|
|
|
|
// Sig or fallback to source
|
|
var sigSourceText = s["source"];
|
|
if (s["sig"]) {
|
|
sigSourceText = 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] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a>`
|
|
} else {
|
|
items[i] = `${s["sig_refs"][i]["id"]}`
|
|
}
|
|
}
|
|
sig_refs = items.join(", ");
|
|
}
|
|
|
|
// DX
|
|
ttt = `<span class='nowrap'><span class='icon-wrapper'>${dx_flag}</span> <a href='https://www.qrz.com/db/${dx_call}' target='_blank' class="dx-link">${dx_call}</a></span><br/>`;
|
|
|
|
// Frequency & band
|
|
ttt += `<span class='icon-wrapper'><i class='fa-solid fa-radio markerPopupIcon'></i></span> ${freq_string}`;
|
|
if (s["band"] != null) {
|
|
ttt += ` (${s["band"]})`;
|
|
}
|
|
// Mode
|
|
if (s["mode"] != null) {
|
|
ttt += ` <i class='fa-solid fa-wave-square markerPopupIcon'></i> ${s["mode"]}`;
|
|
}
|
|
ttt += "<br/>";
|
|
|
|
// Source / SIG / Ref
|
|
ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], "fa-tower-cell")}'></i></span> ${sigSourceText} ${sig_refs}</span><br/>`;
|
|
|
|
// Time
|
|
ttt += `<span class='icon-wrapper'><i class='fa-solid fa-clock markerPopupIcon'></i></span> ${moment.unix(s["time"]).fromNow()}`;
|
|
|
|
// Comment
|
|
if (commentText.length > 0) {
|
|
ttt += `<br/><span class='icon-wrapper'><i class='fa-solid fa-comment markerPopupIcon'></i></span> ${commentText}`;
|
|
}
|
|
|
|
return ttt;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// First pass loading settings, so we can load the band colour scheme before the filters that need to use it
|
|
loadSettings();
|
|
setColorScheme($("#color-scheme option:selected").val());
|
|
setBandColorScheme($("#band-color-scheme option:selected").val());
|
|
|
|
// 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"]);
|
|
generateModesMultiToggleFilterCard(options["modes"]);
|
|
generateSourcesMultiToggleFilterCard(options["spot_sources"], spotProvidersEnabledByDefault);
|
|
|
|
// Load URL params. These may select things from the various filter & display options, so the 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.
|
|
loadURLParams();
|
|
loadMapURLParams();
|
|
|
|
// Load settings from settings storage now all the controls are available
|
|
loadSettings();
|
|
|
|
// If no basemap has been explicitly saved and the UI is in dark mode, default to dark Mapnik
|
|
if (localStorage.getItem("#basemap:value") === null) {
|
|
if (document.documentElement.getAttribute("data-bs-theme") === "dark") {
|
|
$("#basemap").val("OpenStreetMap.Mapnik.Dark");
|
|
}
|
|
}
|
|
|
|
// Apply basemap and overlay settings now that controls have their saved values
|
|
setBasemap($("#basemap").val());
|
|
setBasemapOpacity(parseFloat($("#basemapOpacity").val()));
|
|
enableTerminator($("#showTerminator")[0].checked);
|
|
enableMaidenheadGrid($("#showMaidenheadGrid")[0].checked);
|
|
enableCQZones($("#showCQZones")[0].checked);
|
|
enableITUZones($("#showITUZones")[0].checked);
|
|
enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
|
|
|
|
// 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();
|
|
setBasemap($("#basemap").val());
|
|
setBasemapOpacity(parseFloat($("#basemapOpacity").val()));
|
|
enableTerminator($("#showTerminator")[0].checked);
|
|
enableMaidenheadGrid($("#showMaidenheadGrid")[0].checked);
|
|
enableCQZones($("#showCQZones")[0].checked);
|
|
enableITUZones($("#showITUZones")[0].checked);
|
|
enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
|
|
saveSettings();
|
|
}
|
|
|
|
// Set the basemap
|
|
function setBasemap(basemapname) {
|
|
// Only change if we have to, to avoid a flash of reloading content
|
|
if (loadedBasemap !== basemapname) {
|
|
loadedBasemap = basemapname;
|
|
if (typeof backgroundTileLayer !== 'undefined') {
|
|
map.removeLayer(backgroundTileLayer);
|
|
}
|
|
// OpenStreetMap.Mapnik.Dark is a synthetic variant that uses Mapnik tiles with a CSS filter applied
|
|
const providerName = basemapname === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : basemapname;
|
|
backgroundTileLayer = L.tileLayer.provider(providerName, {
|
|
opacity: parseFloat($("#basemapOpacity").val()),
|
|
edgeBufferTiles: 1
|
|
});
|
|
backgroundTileLayer.addTo(map);
|
|
backgroundTileLayer.bringToBack();
|
|
if (basemapname === "OpenStreetMap.Mapnik.Dark") {
|
|
var container = backgroundTileLayer.getContainer();
|
|
if (container) {
|
|
container.style.filter = 'invert(100%) hue-rotate(180deg) brightness(80%)';
|
|
}
|
|
}
|
|
|
|
// Identify dark basemaps to ensure we use white text for unselected icons
|
|
// and change the background colour appropriately
|
|
const basemapIsDark = basemapname === "CartoDB.DarkMatter" || basemapname === "Esri.WorldImagery" || basemapname === "OpenStreetMap.Mapnik.Dark";
|
|
$("#map").css('background-color', basemapIsDark ? "black" : "white");
|
|
|
|
// Change the colour of the grid and zone overlays to match
|
|
if (basemapIsDark) {
|
|
maidenheadGrid.options.color = MAIDENHEAD_GRID_COLOR_DARK;
|
|
cqZones.options.color = CQ_ZONES_COLOR_DARK;
|
|
ituZones.options.color = ITU_ZONES_COLOR_DARK;
|
|
wabwaiGrid.options.color = WAB_WAI_GRID_COLOR_DARK;
|
|
} else {
|
|
maidenheadGrid.options.color = MAIDENHEAD_GRID_COLOR_LIGHT;
|
|
cqZones.options.color = CQ_ZONES_COLOR_LIGHT;
|
|
ituZones.options.color = ITU_ZONES_COLOR_LIGHT;
|
|
wabwaiGrid.options.color = WAB_WAI_GRID_COLOR_LIGHT;
|
|
}
|
|
|
|
// Force regenerate overlays in the new colours
|
|
map.removeLayer(maidenheadGrid);
|
|
map.removeLayer(cqZones);
|
|
map.removeLayer(ituZones);
|
|
map.removeLayer(wabwaiGrid);
|
|
enableMaidenheadGrid($("#showMaidenheadGrid")[0].checked);
|
|
enableCQZones($("#showCQZones")[0].checked);
|
|
enableITUZones($("#showITUZones")[0].checked);
|
|
enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
|
|
}
|
|
}
|
|
|
|
// Set the basemap opacity
|
|
function setBasemapOpacity(opacity) {
|
|
if (typeof backgroundTileLayer !== 'undefined') {
|
|
backgroundTileLayer.setOpacity(opacity);
|
|
}
|
|
}
|
|
|
|
// Shows/hides the terminator/greyline overlay
|
|
function enableTerminator(show) {
|
|
if (show) {
|
|
terminator.setTime();
|
|
terminator.addTo(map);
|
|
} else {
|
|
map.removeLayer(terminator);
|
|
}
|
|
}
|
|
|
|
// Shows/hides the Maidenhead grid overlay
|
|
function enableMaidenheadGrid(show) {
|
|
if (show) {
|
|
maidenheadGrid.addTo(map);
|
|
backgroundTileLayer.bringToBack();
|
|
} else {
|
|
map.removeLayer(maidenheadGrid);
|
|
}
|
|
}
|
|
|
|
// Shows/hides the CQ zone overlay
|
|
function enableCQZones(show) {
|
|
if (show) {
|
|
cqZones.addTo(map);
|
|
backgroundTileLayer.bringToBack();
|
|
} else {
|
|
map.removeLayer(cqZones);
|
|
}
|
|
}
|
|
|
|
// Shows/hides the ITU zone overlay
|
|
function enableITUZones(show) {
|
|
if (show) {
|
|
ituZones.addTo(map);
|
|
backgroundTileLayer.bringToBack();
|
|
} else {
|
|
map.removeLayer(ituZones);
|
|
}
|
|
}
|
|
|
|
// Shows/hides the WAB/WAI grid overlay
|
|
function enableWABWAIGrid(show) {
|
|
if (show) {
|
|
wabwaiGrid.addTo(map);
|
|
backgroundTileLayer.bringToBack();
|
|
} else {
|
|
map.removeLayer(wabwaiGrid);
|
|
}
|
|
}
|
|
|
|
// Load map-specific URL parameters for center position and zoom level.
|
|
// These set Leaflet state directly rather than form controls, so they live here rather than in loadURLParams().
|
|
// If any parameter is applied, firstLoad is set to false so updateMap() does not override the position.
|
|
function loadMapURLParams() {
|
|
let params = new URLSearchParams(document.location.search);
|
|
let lat = parseFloat(params.get("map-center-lat"));
|
|
let lon = parseFloat(params.get("map-center-lon"));
|
|
let zoom = parseFloat(params.get("map-zoom"));
|
|
|
|
let hasLatLon = !isNaN(lat) && !isNaN(lon);
|
|
let hasZoom = !isNaN(zoom);
|
|
|
|
if (hasLatLon || hasZoom) {
|
|
if (hasLatLon && hasZoom) {
|
|
map.setView([lat, lon], zoom);
|
|
} else if (hasLatLon) {
|
|
map.setView([lat, lon], map.getZoom());
|
|
} else {
|
|
map.setZoom(zoom);
|
|
}
|
|
firstLoad = false;
|
|
}
|
|
}
|
|
|
|
// Set up the map
|
|
function setUpMap() {
|
|
// Create map
|
|
map = L.map('map', {
|
|
zoomControl: false,
|
|
minZoom: 2,
|
|
maxZoom: 12
|
|
});
|
|
|
|
// Add basemap
|
|
loadedBasemap = $("#basemap").val();
|
|
const initialProviderName = loadedBasemap === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : loadedBasemap;
|
|
backgroundTileLayer = L.tileLayer.provider(initialProviderName, {
|
|
opacity: parseFloat($("#basemapOpacity").val()),
|
|
edgeBufferTiles: 1
|
|
});
|
|
backgroundTileLayer.addTo(map);
|
|
backgroundTileLayer.bringToBack();
|
|
if (loadedBasemap === "OpenStreetMap.Mapnik.Dark") {
|
|
var container = backgroundTileLayer.getContainer();
|
|
if (container) {
|
|
container.style.filter = 'invert(100%) hue-rotate(180deg) brightness(80%)';
|
|
}
|
|
}
|
|
|
|
// Add marker layer
|
|
markersLayer = new L.LayerGroup();
|
|
markersLayer.addTo(map);
|
|
|
|
// Add geodesic layer
|
|
geodesicsLayer = new L.LayerGroup();
|
|
geodesicsLayer.addTo(map);
|
|
|
|
// Add terminator/greyline (toggleable)
|
|
terminator = L.terminator({
|
|
interactive: false
|
|
});
|
|
terminator.setStyle({fillColor: '#00000050'});
|
|
if ($("#showTerminator")[0].checked) {
|
|
terminator.addTo(map);
|
|
}
|
|
|
|
// Add Maidenhead grid (toggleable)
|
|
maidenheadGrid = L.maidenhead({
|
|
color : MAIDENHEAD_GRID_COLOR_LIGHT
|
|
});
|
|
if ($("#showMaidenheadGrid")[0].checked) {
|
|
maidenheadGrid.addTo(map);
|
|
backgroundTileLayer.bringToBack();
|
|
}
|
|
|
|
// Add CQ zone layer (toggleable)
|
|
cqZones = L.cqzones({
|
|
color : CQ_ZONES_COLOR_LIGHT
|
|
});
|
|
if ($("#showCQZones")[0].checked) {
|
|
cqZones.addTo(map);
|
|
backgroundTileLayer.bringToBack();
|
|
}
|
|
|
|
// Add ITU zone layer (toggleable)
|
|
ituZones = L.ituzones({
|
|
color : ITU_ZONES_COLOR_LIGHT
|
|
});
|
|
if ($("#showITUZones")[0].checked) {
|
|
ituZones.addTo(map);
|
|
backgroundTileLayer.bringToBack();
|
|
}
|
|
|
|
// Add WAB/WAI grid layer (toggleable)
|
|
wabwaiGrid = L.workedAllBritainIreland({
|
|
color : WAB_WAI_GRID_COLOR_LIGHT
|
|
});
|
|
if ($("#showWABWAIGrid")[0].checked) {
|
|
wabwaiGrid.addTo(map);
|
|
backgroundTileLayer.bringToBack();
|
|
}
|
|
|
|
// Display a default view. This will only last until the spots are first loaded, at which point the map will zoom
|
|
// to the extent of ths spots.
|
|
map.setView([30, 0], 3);
|
|
}
|
|
|
|
// Startup
|
|
$(document).ready(function() {
|
|
// Hide the extra things that need to be hidden on this page
|
|
$(".hideonmap").hide();
|
|
// Set up map
|
|
setUpMap();
|
|
// Call loadOptions(), this will then trigger loading spots and setting up timers.
|
|
loadOptions();
|
|
// Prevent mouse scroll and touch actions in the popup menus being passed through to the map
|
|
L.DomEvent.disableScrollPropagation(document.getElementById('settingsButtonRowMap'));
|
|
L.DomEvent.disableClickPropagation(document.getElementById('settingsButtonRowMap'));
|
|
}); |