Various UI things #7

This commit is contained in:
Ian Renton
2025-10-02 17:19:38 +01:00
parent 4f2c19b666
commit 6a3f1d2e10
7 changed files with 134 additions and 73 deletions

10
.idea/spothole.iml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 virtualenv at ~/code/spothole/.venv" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -1,20 +1,24 @@
# (S)pothole # (S)pothole
*Work in progress.* **Work in progress.**
A utility to aggregate spots from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data. (S)pothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data.
Currently supports: While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outfoor activity programmes for amateur radio, (S)pothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it.
* DX Clusters
* POTA The API is deliberately well-defined with an OpenAPI specification and auto-generated API documentation. The API delivers spots in a consistent format regardless of the data source, freeing developers from needing to know how each individual data source presents its data.
* WWFF
* SOTA (S)pothole itself is also open source, Public Domain licenced code that anyone can take and modify.
* GMA
* HEMA Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, and Parks 'n' Peaks.
* UKBOTA
* Parks n Peaks ### Accessing the public version
* RBN
* APRS TODO
### Installing your own copy
TODO
### Writing your own Providers ### Writing your own Providers
@@ -34,8 +38,6 @@ Finally, simply add the appropriate config to the `providers` section of `config
### Third Party Libraries ### Third Party Libraries
The project contains a self-hosted copy of Font Awesome's free library, in the `/fa/` directory. This is subject to Font Awesome's licence and is not covered by the overall licence declared in the `LICENSE` file. This approach was taken in preference to using their hosted kits due to the popularity of this project exceeding the page view limit for their free hosted offering. The project contains a self-hosted copy of Font Awesome's free library, in the `/webasset/fa/` directory. This is subject to Font Awesome's licence and is not covered by the overall licence declared in the `LICENSE` file. This approach was taken in preference to using their hosted kits due to the popularity of this project exceeding the page view limit for their free hosted offering.
TODO JS & Python libs... The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries such as jQuery and moment.js. This project would not have been possible without these libraries, so many thanks to their developers.
This project would not have been possible without these libraries, so many thanks to their developers.

View File

@@ -261,7 +261,7 @@ DXCC_FLAGS = {
222: "\U0001F1EB\U0001F1F4", # FAROE ISLANDS 222: "\U0001F1EB\U0001F1F4", # FAROE ISLANDS
223: "\U0001F3F4\U000E0067\U000E0062\U000E0065\U000E006E\U000E0067\U000E007F", # ENGLAND 223: "\U0001F3F4\U000E0067\U000E0062\U000E0065\U000E006E\U000E0067\U000E007F", # ENGLAND
224: "\U0001F1EB\U0001F1EE", # FINLAND 224: "\U0001F1EB\U0001F1EE", # FINLAND
225: "", # SARDINIA 225: "\U0001F1EE\U0001F1F9", # SARDINIA
226: "", # SAUDI ARABIA/IRAQ NEUT ZONE 226: "", # SAUDI ARABIA/IRAQ NEUT ZONE
227: "\U0001F1EB\U0001F1F7", # FRANCE 227: "\U0001F1EB\U0001F1F7", # FRANCE
228: "", # SERRANA BANK & RONCADOR CAY 228: "", # SERRANA BANK & RONCADOR CAY

View File

@@ -30,7 +30,7 @@ class WWFF(HTTPProvider):
sig_refs=[source_spot["reference"]], sig_refs=[source_spot["reference"]],
sig_refs_names=[source_spot["reference_name"]], sig_refs_names=[source_spot["reference_name"]],
icon="seedling", icon="seedling",
time=datetime.fromtimestamp(source_spot["spot_time"]).replace(tzinfo=pytz.UTC), time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC),
latitude=source_spot["latitude"], latitude=source_spot["latitude"],
longitude=source_spot["longitude"]) longitude=source_spot["longitude"])

View File

@@ -52,6 +52,7 @@ class WebServer:
self.last_api_access_time = datetime.now(pytz.UTC) self.last_api_access_time = datetime.now(pytz.UTC)
self.status = "OK" self.status = "OK"
response.content_type = 'application/json' response.content_type = 'application/json'
response.set_header('Cache-Control', 'no-store')
return json.dumps(data, default=serialize_everything) return json.dumps(data, default=serialize_everything)
# Serve a templated page # Serve a templated page

View File

@@ -9,8 +9,33 @@
} }
} }
span.flag-wrapper {
display: inline-block;
width: 1.7em;
}
span.icon-wrapper { span.icon-wrapper {
display: inline-block; display: inline-block;
width: 1.5em; width: 1.5em;
text-align: center; text-align: center;
} }
span.freq-mhz {
display: inline-block;
width: 2em;
text-align: right;
font-weight: bold;
}
span.freq-khz {
padding: 0 0.2em;
}
span.freq-hz {
font-size: 0.8em;
}
tr.table-faded td {
color: gray;
text-decoration: line-through !important;
}

View File

@@ -1,6 +1,8 @@
// How often to query the server? // How often to query the server?
const REFRESH_INTERVAL_SEC = 60; const REFRESH_INTERVAL_SEC = 60;
// Storage for the spot data that the server gives us.
var spots = []
// Storage for the options that the server gives us. This will define our filters. // Storage for the options that the server gives us. This will define our filters.
var options = {}; var options = {};
// Last time we updated the spots list on display. // Last time we updated the spots list on display.
@@ -11,46 +13,67 @@ function loadSpots() {
$.getJSON('/api/spots', function(jsonData) { $.getJSON('/api/spots', function(jsonData) {
// Store last updated time // Store last updated time
lastUpdateTime = moment.utc(); lastUpdateTime = moment.utc();
updateRefreshDisplay();
// Store data
spots = jsonData;
// Update table
updateTable();
});
}
// Update the spots table
function updateTable() {
// Populate table with headers // Populate table with headers
let headers = Object.keys(jsonData[0]); let headers = Object.keys(spots[0]);
let table = $('<table class="table table-striped table-hover">').append('<thead><tr class="table-primary"></tr></thead><tbody></tbody>'); let table = $('<table class="table table-striped table-hover">').append('<thead><tr class="table-primary"></tr></thead><tbody></tbody>');
["UTC", "DX", "Frequency", "Mode", "Comment", "Source", "DE"].forEach(header => table.find('thead tr').append(`<th>${header}</th>`)); ["UTC", "DX", "Frequency", "Mode", "Comment", "Source", "Ref.", "DE"].forEach(header => table.find('thead tr').append(`<th>${header}</th>`));
jsonData.forEach(row => { spots.forEach(s => {
// Create row // Create row
let $tr = $('<tr>'); let $tr = $('<tr>');
// Show in red if QRT // Show in red if QRT
if (row["qrt"] == true) { if (s["qrt"] == true) {
$tr.addClass("table-danger"); $tr.addClass("table-faded");
} }
// Format a UTC time for display // Format a UTC time for display
var time = moment.utc(row["time"], moment.ISO_8601); var time = moment.utc(s["time"], moment.ISO_8601);
var time_formatted = time.format("HH:mm") var time_formatted = time.format("HH:mm")
// Figure out a SIG or Source // Format the frequency
var source = row["source"]; var mhz = Math.floor(s["freq"] / 1000.0);
if (row["sig"]) { var khz = Math.floor(s["freq"] - (mhz * 1000.0));
source = row["sig"]; var hz = Math.floor((s["freq"] - Math.floor(s["freq"])) * 1000.0);
var hz_string = (hz > 0) ? hz.toFixed(0) : "";
var freq_string = `<span class='freq-mhz'>${mhz.toFixed(0)}</span><span class='freq-khz'>${khz.toFixed(0).padStart(3, '0')}</span><span class='freq-hz'>${hz_string}</span>`
// Format sig_refs
var sig_refs = ""
if (s["sig_refs"]) {
sig_refs = s["sig_refs"].join(", ")
} }
// Populate the table data // Format DE flag
var de_flag = "<i class='fa-solid fa-question'></i>";
if (s["de_flag"] && s["de_flag"] != "") {
de_flag = s["de_flag"];
}
// Populate the row
$tr.append(`<td>${time_formatted}</td>`); $tr.append(`<td>${time_formatted}</td>`);
$tr.append(`<td>${row["dx_flag"]}&nbsp;${row["dx_call"]}</td>`); $tr.append(`<td><span class='flag-wrapper'>${s["dx_flag"]}</span>${s["dx_call"]}</td>`);
$tr.append(`<td>${row["freq"]}</td>`); $tr.append(`<td>${freq_string}</td>`);
$tr.append(`<td>${row["mode"]}</td>`); $tr.append(`<td>${s["mode"]}</td>`);
$tr.append('<td>' + escapeHtml(`${row["comment"]}`) + '</td>'); $tr.append('<td>' + escapeHtml(`${s["comment"]}`) + '</td>');
$tr.append(`<td><span class='icon-wrapper'><i class='fa-solid fa-${row["icon"]}'></i></span> ${source}</td>`); $tr.append(`<td><span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${s["source"]}</td>`);
$tr.append(`<td>${row["de_flag"]}&nbsp;${row["de_call"]}</td>`); $tr.append(`<td>${sig_refs}</td>`);
$tr.append(`<td><span class='flag-wrapper'>${s["de_flag"]}</span>${s["de_call"]}</td>`);
table.find('tbody').append($tr); table.find('tbody').append($tr);
}); });
// Update DOM // Update DOM
$('#table-container').html(table); $('#table-container').html(table);
updateRefreshDisplay();
});
} }
// Load server status // Load server status
@@ -81,7 +104,7 @@ function updateRefreshDisplay() {
count = REFRESH_INTERVAL_SEC - secSinceUpdate; count = REFRESH_INTERVAL_SEC - secSinceUpdate;
updatingString = "Updating in " + count.toFixed(0) + " seconds..."; updatingString = "Updating in " + count.toFixed(0) + " seconds...";
} }
$("#timing-container").text("Last updated at " + lastUpdateTime.format('hh:mm') + " UTC. " + updatingString); $("#timing-container").text("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString);
} }
} }