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
*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:
* DX Clusters
* POTA
* WWFF
* SOTA
* GMA
* HEMA
* UKBOTA
* Parks n Peaks
* RBN
* APRS
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.
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.
(S)pothole itself is also open source, Public Domain licenced code that anyone can take and modify.
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.
### Accessing the public version
TODO
### Installing your own copy
TODO
### Writing your own Providers
@@ -34,8 +38,6 @@ Finally, simply add the appropriate config to the `providers` section of `config
### 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...
This project would not have been possible without these libraries, so many thanks to their developers.
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.

View File

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

View File

@@ -30,7 +30,7 @@ class WWFF(HTTPProvider):
sig_refs=[source_spot["reference"]],
sig_refs_names=[source_spot["reference_name"]],
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"],
longitude=source_spot["longitude"])

View File

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

View File

@@ -9,8 +9,33 @@
}
}
span.flag-wrapper {
display: inline-block;
width: 1.7em;
}
span.icon-wrapper {
display: inline-block;
width: 1.5em;
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?
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.
var options = {};
// Last time we updated the spots list on display.
@@ -11,48 +13,69 @@ function loadSpots() {
$.getJSON('/api/spots', function(jsonData) {
// Store last updated time
lastUpdateTime = moment.utc();
// Populate table with headers
let headers = Object.keys(jsonData[0]);
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>`));
jsonData.forEach(row => {
// Create row
let $tr = $('<tr>');
// Show in red if QRT
if (row["qrt"] == true) {
$tr.addClass("table-danger");
}
// Format a UTC time for display
var time = moment.utc(row["time"], moment.ISO_8601);
var time_formatted = time.format("HH:mm")
// Figure out a SIG or Source
var source = row["source"];
if (row["sig"]) {
source = row["sig"];
}
// Populate the table data
$tr.append(`<td>${time_formatted}</td>`);
$tr.append(`<td>${row["dx_flag"]}&nbsp;${row["dx_call"]}</td>`);
$tr.append(`<td>${row["freq"]}</td>`);
$tr.append(`<td>${row["mode"]}</td>`);
$tr.append('<td>' + escapeHtml(`${row["comment"]}`) + '</td>');
$tr.append(`<td><span class='icon-wrapper'><i class='fa-solid fa-${row["icon"]}'></i></span> ${source}</td>`);
$tr.append(`<td>${row["de_flag"]}&nbsp;${row["de_call"]}</td>`);
table.find('tbody').append($tr);
});
// Update DOM
$('#table-container').html(table);
updateRefreshDisplay();
// Store data
spots = jsonData;
// Update table
updateTable();
});
}
// Update the spots table
function updateTable() {
// Populate table with headers
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>');
["UTC", "DX", "Frequency", "Mode", "Comment", "Source", "Ref.", "DE"].forEach(header => table.find('thead tr').append(`<th>${header}</th>`));
spots.forEach(s => {
// Create row
let $tr = $('<tr>');
// Show in red if QRT
if (s["qrt"] == true) {
$tr.addClass("table-faded");
}
// Format a UTC time for display
var time = moment.utc(s["time"], moment.ISO_8601);
var time_formatted = time.format("HH:mm")
// Format the frequency
var mhz = Math.floor(s["freq"] / 1000.0);
var khz = Math.floor(s["freq"] - (mhz * 1000.0));
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(", ")
}
// 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><span class='flag-wrapper'>${s["dx_flag"]}</span>${s["dx_call"]}</td>`);
$tr.append(`<td>${freq_string}</td>`);
$tr.append(`<td>${s["mode"]}</td>`);
$tr.append('<td>' + escapeHtml(`${s["comment"]}`) + '</td>');
$tr.append(`<td><span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${s["source"]}</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);
});
// Update DOM
$('#table-container').html(table);
}
// Load server status
function loadStatus() {
$.getJSON('/api/status', function(jsonData) {
@@ -81,29 +104,29 @@ function updateRefreshDisplay() {
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
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);
}
}
// Utility function to escape HTML characters from a string.
function escapeHtml(str) {
if (typeof str !== 'string') {
return '';
}
const escapeCharacter = (match) => {
switch (match) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case '\'': return '&#039;';
case '`': return '&#096;';
default: return match;
if (typeof str !== 'string') {
return '';
}
};
return str.replace(/[&<>"'`]/g, escapeCharacter);
const escapeCharacter = (match) => {
switch (match) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case '\'': return '&#039;';
case '`': return '&#096;';
default: return match;
}
};
return str.replace(/[&<>"'`]/g, escapeCharacter);
}