Compare commits

...

3 Commits

Author SHA1 Message Date
Ian Renton
fc951ead41 Fix temporary bands display header. #48 2025-10-20 21:14:21 +01:00
Ian Renton
0db674eeb2 Continue work on bands display. #48 2025-10-20 21:01:00 +01:00
Ian Renton
6ca9f28a56 Start work on bands display. #48 2025-10-20 18:44:51 +01:00
7 changed files with 495 additions and 24 deletions

View File

@@ -39,6 +39,7 @@ class WebServer:
# Routes for templated pages
bottle.get("/")(lambda: self.serve_template('webpage_spots'))
bottle.get("/map")(lambda: self.serve_template('webpage_map'))
bottle.get("/bands")(lambda: self.serve_template('webpage_bands'))
bottle.get("/alerts")(lambda: self.serve_template('webpage_alerts'))
bottle.get("/status")(lambda: self.serve_template('webpage_status'))
bottle.get("/about")(lambda: self.serve_template('webpage_about'))
@@ -206,6 +207,11 @@ class WebServer:
needs_sig = query.get(k).upper() == "TRUE"
if needs_sig:
spots = [s for s in spots if s.sig]
case "needs_sig_ref":
# If true, at least one sig ref is required, regardless of what it is, it just can't be missing.
needs_sig_ref = query.get(k).upper() == "TRUE"
if needs_sig_ref:
spots = [s for s in spots if s.sig_refs and len(s.sig_refs) > 0]
case "band":
bands = query.get(k).split(",")
spots = [s for s in spots if s.band and s.band in bands]
@@ -224,6 +230,16 @@ class WebServer:
case "comment_includes":
comment_includes = query.get(k).strip()
spots = [s for s in spots if s.comment and comment_includes.upper() in s.comment.upper()]
case "allow_qrt":
# If false, spots that are flagged as QRT are not returned.
prevent_qrt = query.get(k).upper() == "FALSE"
if prevent_qrt:
spots = [s for s in spots if not s.qrt or s.qrt == False]
case "needs_good_location":
# If true, spots require a "good" location to be returned
needs_good_location = query.get(k).upper() == "TRUE"
if needs_good_location:
spots = [s for s in spots if s.dx_location_good]
case "dedupe":
# Ensure only the latest spot of each callsign is present in the list. This relies on the list being
# in reverse time order, so if any future change allows re-ordering the list, that should be done

123
views/webpage_bands.tpl Normal file
View File

@@ -0,0 +1,123 @@
% rebase('webpage_base.tpl')
<div class="mt-3">
<div class="alert alert-warning" role="alert">
<i class="fa-solid fa-triangle-exclamation"></i> This page is a work in progress. It will be refined as Spothole heads towards v1.0.
</div>
</div>
<div class="mt-3">
<div class="row">
<div class="col-auto me-auto pt-3">
<p id="timing-container">Loading...</p>
</div>
<div class="col-auto">
<p class="d-inline-flex gap-1">
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i> Filters</button>
<button id="display-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i> Display</button>
</p>
</div>
</div>
<div id="filters-area" class="appearing-panel card mb-3">
<div class="card-header text-white bg-primary">
<div class="row">
<div class="col-auto me-auto">
Filters
</div>
<div class="col-auto d-inline-flex">
<button id="close-filters-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeFiltersPanel();"></button>
</div>
</div>
</div>
<div class="card-body">
<div class="row row-cols-1 g-4 mb-4">
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Bands</h5>
<p id="band-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
</div>
<div class="row row-cols-1 row-cols-md-4 g-4">
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">DX Continent</h5>
<p id="dx-continent-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">DE Continent</h5>
<p id="de-continent-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Modes</h5>
<p id="mode-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sources</h5>
<p id="source-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="display-area" class="appearing-panel card mb-3">
<div class="card-header text-white bg-primary">
<div class="row">
<div class="col-auto me-auto">
Display
</div>
<div class="col-auto d-inline-flex">
<button id="close-display-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeDisplayPanel();"></button>
</div>
</div>
</div>
<div class="card-body">
<div id="display-container" class="row row-cols-1 row-cols-md-4 g-4">
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Spot Age</h5>
<p class="card-text spothole-card-text">Last
<select id="max-spot-age" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="filtersUpdated();" style="width: 5em; display: inline-block;">
<option value="300">5</option>
<option value="600">10</option>
<option value="1800" selected>30</option>
<option value="3600">60</option>
</select>
minutes
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="bands-container"></div>
</div>
<script src="/js/common.js"></script>
<script src="/js/spotandmap.js"></script>
<script src="/js/bands.js"></script>
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>

View File

@@ -59,6 +59,7 @@
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item ms-4"><a href="/" class="nav-link" id="nav-link-spots"><i class="fa-solid fa-tower-cell"></i> Spots</a></li>
<li class="nav-item ms-4"><a href="/map" class="nav-link" id="nav-link-map"><i class="fa-solid fa-map"></i> Map</a></li>
<li class="nav-item ms-4"><a href="/bands" class="nav-link" id="nav-link-bands"><i class="fa-solid fa-ruler-vertical"></i> Bands</a></li>
<li class="nav-item ms-4"><a href="/alerts" class="nav-link" id="nav-link-alerts"><i class="fa-solid fa-bell"></i> Alerts</a></li>
<li class="nav-item ms-4"><a href="/status" class="nav-link" id="nav-link-status"><i class="fa-solid fa-chart-simple"></i> Status</a></li>
<li class="nav-item ms-4"><a href="/about" class="nav-link" id="nav-link-about"><i class="fa-solid fa-circle-info"></i> About</a></li>

View File

@@ -85,7 +85,14 @@ paths:
- IOTA
- name: needs_sig
in: query
description: "Limit the spots to only ones from a Special Interest Grous such as POTA. Because supplying all known SIGs as a `sigs` parameter is unwieldy, and leaving `sigs` blank will also return spots with *no* SIG, this parameter can be set true to return only spots with a SIG, regardless of what it is, so long as it's not blank. This is what Field Spotter uses to exclude generic cluster spots and only retrieve xOTA things."
description: "Limit the spots to only ones with a Special Interest Group such as POTA. Because supplying all known SIGs as a `sigs` parameter is unwieldy, and leaving `sigs` blank will also return spots with *no* SIG, this parameter can be set true to return only spots with a SIG, regardless of what it is, so long as it's not blank. This is what Field Spotter uses to exclude generic cluster spots and only retrieve xOTA things."
required: false
schema:
type: boolean
default: false
- name: needs_sig_ref
in: query
description: "Limit the spots to only ones which have at least one reference (e.g. a park reference) for Special Interest Groups such as POTA."
required: false
schema:
type: boolean
@@ -195,6 +202,20 @@ paths:
required: false
schema:
type: string
- name: needs_good_location
in: query
description: "Return only spots with a 'good' location. (See the spot `dx_location_good` parameter for details. Useful for map-based clients, to avoid spots with 'bad' locations e.g. loads of cluster spots ending up in the centre of the DXCC entitity.)"
required: false
schema:
type: boolean
default: false
- name: allow_qrt
in: query
description: Allow spots that are known to be QRT to be returned.
required: false
schema:
type: boolean
default: true
responses:
'200':
description: Success

View File

@@ -150,13 +150,117 @@ div#map {
}
/* BANDS PANEL */
div#bands-container {
margin: 0;
padding: 0;
overflow-x: auto;
overflow-y: auto;
white-space: nowrap;
display: flex;
overscroll-behavior-x: none;
}
/* Bands panel inner layout */
div.bandCol {
height: 100%;
min-width: 8em;
display: flex;
flex-flow: column;
overflow-y: clip;
}
div.bandColHeader {
flex: 0 1 auto;
}
div.bandColMiddle {
flex: 1 1 auto;
}
div.bandColMiddle ul {
display: table;
table-layout: fixed;
width: 100%;
min-height: 100%;
margin: 0;
padding: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
div.bandColMiddle ul li {
display: table-row;
line-height: 0.5em;
}
/*noinspection CssUnusedSymbol*/
div.bandColMiddle ul li.withSpots {
line-height: 1em;
}
div.bandColMiddle ul li span {
display: table-cell;
vertical-align: middle;
}
div.bandColMiddle ul {
display: table;
table-layout: fixed;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
border-left: 2px dotted;
}
div.bandColHeader {
text-align: center;
font-weight: bold;
padding: 0.5em;
}
div.bandColMiddle {
margin-left: 3px;
border-left: 2px dotted var(--text);
}
div.bandColSpot {
display: block;
border-radius: 3px;
padding: 3px;
background: lightyellow;
margin-right: 2em;
}
span.bandColSpot {
vertical-align: bottom;
display: inline !important;
}
/* Don't wrap frequencies */
span.bandColSpotFreq {
white-space: nowrap;
display: inline !important;
}
span.bandColSpotMode {
padding-left: 0.5em;
font-size: 0.8em;
line-height: 0.4em;
}
/* GENERAL MOBILE SUPPORT */
@media (max-width: 991.99px) {
.hideonmobile {
display: none !important;
}
div#map, div#table-container {
div#map, div#table-container, div#bands-container {
margin-left: -1em;
margin-right: -1em;
}

213
webassets/js/bands.js Normal file
View File

@@ -0,0 +1,213 @@
// 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 bandsPanel = $("#bands-container");
if (spots.length === 0) {
bandsPanel.html("<div class='alert alert-danger' role='alert'>No spots match your filters.</div>");
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 HTML content for each band
let html = "";
const columnWidthPercent = Math.max(30, 100 / bandToSpots.size);
let columnIndex = 0;
bandToSpots.forEach(function (spotList, bandName) {
// Get the colours for the band from the first spot, and prepare the header
html += "<div class='bandCol' style='width:" + columnWidthPercent + "%'>";
html += "<div class='bandColHeader' style='background-color:" + spotList[0].band_color + "; color:" + spotList[0].band_contrast_color + "'>" + spotList[0].band + "</div>";
html += "<div class='bandColMiddle'>";
// Get the band data to fetch start and end frequencies
let band = options["bands"].filter(function (b) {
return b.name === bandName;
})[0];
// Start printing the band
const freqStep = (band.end_freq - band.start_freq) / 40.0;
html += "<ul>";
html += "<li><span>-</span></li>";
// Do 40 steps down the band
for (let i = 0; i <= 40; i++) {
// Work out if there are any spots in this step
const freqStepStart = band.start_freq + i * freqStep;
const freqStepEnd = freqStepStart + freqStep;
const spotsInStep = spotList.filter(function (s) {
// Normally we do >= start and < end, but in the special case where this is the last step and there is a spot
// right at the end of the band, we include this too
return s.freq >= freqStepStart && (s.freq < freqStepEnd || (s.freq === freqStepEnd && freqStepEnd === band.end_freq));
});
if (spotsInStep.length > 0) {
// If this step has spots in it, print them
html += "<li class='withSpots'><span>";
spotsInStep.sort((a, b) => (a.freq > b.freq) ? 1 : ((b.freq > a.freq) ? -1 : 0));
spotsInStep.forEach(function (s) {
html += "<div class='bandColSpot'><span class='bandColSpot'>" + s.dx_call + "<br/><span class='bandColSpotFreq'>" + (s.freq/1000000) + "</span>";
if (s.mode != null && s.mode.length > 0 && s.mode !== "Unknown") {
html += "<span class='bandColSpotMode'>" + s.mode + "</span>";
}
html += "</span></div>";
});
html += "</li></span>";
} else {
// Step had no spots in it, so just print a marker. This is a frequency on multiples of 4, or a dash otherwise.
if (i % 4 === 0) {
html += "<li><span>&mdash;" + ((band.start_freq + i * freqStep)/1000000).toFixed(3) + "</span></li>";
} else if (i % 4 === 2) {
html += "<li><span>&ndash;</span></li>";
} else {
html += "<li><span>-</span></li>";
}
}
}
html += "<li><span>-</span></li>";
html += "</ul>";
html += "</div></div>";
columnIndex++;
});
// Update the DOM with the band HTML
bandsPanel.html(html);
// Desktop mouse wheel to scroll bands horizontally if used on the headers
// noinspection JSDeprecatedSymbols
$(".bandColHeader").on("wheel", () => bandsPanel.scrollLeft(bandsPanel.scrollLeft() + event.deltaY / 10.0));
}
// 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);
});

View File

@@ -3,7 +3,7 @@ var markersLayer;
var geodesicsLayer;
var terminator;
// Load spots and populate the table.
// Load spots and populate the map.
function loadSpots() {
$.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) {
// Store data
@@ -23,6 +23,8 @@ function buildQueryString() {
}
});
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;
}
@@ -32,29 +34,20 @@ function updateMap() {
markersLayer.clearLayers();
geodesicsLayer.clearLayers();
// Make new markers for all spots with a good location, not QRT, and not a duplicate spot within the data set.
var callsAlreadyDisplayed = [];
// Make new markers for all spots that match the filter
spots.forEach(function (s) {
if (s["dx_location_good"] && (s["qrt"] == null || s["qrt"] == false)) {
if (!callsAlreadyDisplayed.includes(s["dx_call"])) {
var m = L.marker([s["dx_latitude"], s["dx_longitude"]], {icon: getIcon(s)});
m.bindPopup(getTooltipText(s));
markersLayer.addLayer(m);
// OK, create the marker
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) {
var geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
color: s["band_color"],
wrap: false,
steps: 5
});
geodesicsLayer.addLayer(geodesic);
}
}
callsAlreadyDisplayed.push(s["dx_call"]);
// Create geodesics if required
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
var geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
color: s["band_color"],
wrap: false,
steps: 5
});
geodesicsLayer.addLayer(geodesic);
}
});
}