Merge remote-tracking branch 'origin/main'

This commit is contained in:
Ian Renton
2025-10-23 08:15:42 +01:00
7 changed files with 197 additions and 135 deletions

View File

@@ -14,6 +14,8 @@ Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), th
![Screenshot](/images/screenshot2.png) ![Screenshot](/images/screenshot2.png)
![Screenshot](/images/screenshot3.png)
### Accessing the public version ### Accessing the public version
You can access the public version's web interface at [https://spothole.app](https://spothole.app), and see [https://spothole.app/apidocs](https://spothole.app/apidocs) for the API details. You can access the public version's web interface at [https://spothole.app](https://spothole.app), and see [https://spothole.app/apidocs](https://spothole.app/apidocs) for the API details.

View File

@@ -32,7 +32,7 @@ SIGS = [
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA". # Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
CW_MODES = ["CW"] CW_MODES = ["CW"]
PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"] PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"]
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "BPSK", "PSK", "PSK31", "BPSK31", "OLIVIA"] DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "BPSK", "PSK", "PSK31", "BPSK31", "OLIVIA", "MFSK", "MFSK32"]
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
MODE_TYPES = ["CW", "PHONE", "DATA"] MODE_TYPES = ["CW", "PHONE", "DATA"]

BIN
images/screenshot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

@@ -24,6 +24,8 @@
<h4 class="mt-4">Why does this website ask me if I want to install it?</h4> <h4 class="mt-4">Why does this website ask me if I want to install it?</h4>
<p>Spothole is a Progressive Web App, which means you can install it on an Android or iOS device by opening the site in Chrome or Safari respectively, and clicking "Install" on the pop-up panel. It'll only prompt you once, so if you dismiss the prompt and change your mind, you'll find an Install / Add to Home Screen option on your browser's menu.</p> <p>Spothole is a Progressive Web App, which means you can install it on an Android or iOS device by opening the site in Chrome or Safari respectively, and clicking "Install" on the pop-up panel. It'll only prompt you once, so if you dismiss the prompt and change your mind, you'll find an Install / Add to Home Screen option on your browser's menu.</p>
<p>Installing Spothole on your phone is completely optional, the website works exactly the same way as the "app" does.</p> <p>Installing Spothole on your phone is completely optional, the website works exactly the same way as the "app" does.</p>
<h4 class="mt-4">Why hasn't my spot/alert shown up yet?</h4>
<p>To avoid putting too much load on the various servers that Spothole connects to, the Spothole server only polls them once every two minutes for spots, and once every hour for alerts. (Some sources, such as DX clusters, RBN, APRS-IS and WWBOTA use a non-polling mechanism, and their updates will therefore arrive more quickly.) Then if you are using the web interface, that has its own rate at which it reloads the data from Spothole, which is once a minute for spots or 30 minutes for alerts. So you could be waiting around three minutes to see a newly added spot, or 90 minutes to see a newly added alert.</p>
<h4 class="mt-4">What licence does Spothole use?</h4> <h4 class="mt-4">What licence does Spothole use?</h4>
<p>Spothole's source code is licenced under the Public Domain. You can write a Spothole client, run your own server, modify it however you like, you can claim you wrote it and charge people £1000 for a copy, I don't really mind. (Please don't do the last one. But if you're using my code for something cool, it would be nice to hear from you!)</p> <p>Spothole's source code is licenced under the Public Domain. You can write a Spothole client, run your own server, modify it however you like, you can claim you wrote it and charge people £1000 for a copy, I don't really mind. (Please don't do the last one. But if you're using my code for something cool, it would be nice to hear from you!)</p>
<h2 id="privacy" class="mt-4">Privacy</h2> <h2 id="privacy" class="mt-4">Privacy</h2>

View File

@@ -1,11 +1,5 @@
% rebase('webpage_base.tpl') % 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="mt-3">
<div class="row"> <div class="row">
<div class="col-auto me-auto pt-3"> <div class="col-auto me-auto pt-3">

View File

@@ -153,104 +153,95 @@ div#map {
/* BANDS PANEL */ /* BANDS PANEL */
div#bands-container { div#bands-container {
min-height: 64em;
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow-x: auto; overflow-x: auto;
overflow-y: auto;
white-space: nowrap; white-space: nowrap;
display: flex;
overscroll-behavior-x: none; overscroll-behavior-x: none;
} }
/* Bands panel inner layout */ #bands-table {
div.bandCol { min-width: 100%;
height: 100%;
min-width: 8em;
display: flex;
flex-flow: column;
overflow-y: clip;
} }
div.bandColHeader { #bands-table th {
flex: 0 1 auto; width: 20%;
} max-height: 40px;
min-width: 12em;
div.bandColMiddle { padding: 0.5em;
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; text-align: center;
font-weight: bold; font-weight: bold;
padding: 0.5em;
} }
div.bandColMiddle { #bands-table td {
margin-left: 3px; width: 20%;
border-left: 2px dotted var(--text); min-width: 12em;
height: 62em;
} }
div.bandColSpot { div.band-container {
display: block; height: 62em;
width: 20%;
min-width: 12em;
position: relative;
}
div.band-markers {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 13;
border-left: 2px dotted black;
}
div.band-spots {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 15;
}
canvas.band-lines-canvas {
width: 5em;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 11;
}
div.band-spot {
position: absolute;
left: 5em;
padding: 0 0.25em;
background-color: white;
border-radius: 3px; border-radius: 3px;
padding: 3px; cursor: default;
background: lightyellow;
margin-right: 2em;
} }
span.bandColSpot { div.band-spot:hover {
vertical-align: bottom; z-index: 999;
display: inline !important;
} }
/* Don't wrap frequencies */ div.band-spot span.band-spot-call {
span.bandColSpotFreq { display: inline;
white-space: nowrap;
display: inline !important;
} }
span.bandColSpotMode { div.band-spot:hover span.band-spot-call {
padding-left: 0.5em; display: none;
font-size: 0.8em; }
line-height: 0.4em;
div.band-spot span.band-spot-info {
display: none;
}
div.band-spot:hover span.band-spot-info {
display: inline;
} }

View File

@@ -1,3 +1,12 @@
// A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we
// can't just ask the elements themselves for their dimensions.
BAND_COLUMN_HEIGHT_EM = 62;
BAND_COLUMN_CANVAS_WIDTH_EM = 4;
BAND_COLUMN_FONT_SIZE = 16;
BAND_COLUMN_HEIGHT_PX = BAND_COLUMN_HEIGHT_EM * BAND_COLUMN_FONT_SIZE;
BAND_COLUMN_CANVAS_WIDTH_PX = BAND_COLUMN_CANVAS_WIDTH_EM * BAND_COLUMN_FONT_SIZE;
BAND_COLUMN_SPOT_DIV_HEIGHT_PX = BAND_COLUMN_FONT_SIZE * 1.6;
// Load spots and populate the bands display. // Load spots and populate the bands display.
function loadSpots() { function loadSpots() {
$.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) { $.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) {
@@ -28,9 +37,9 @@ function buildQueryString() {
// Update the bands display // Update the bands display
function updateBands() { function updateBands() {
// Stop here if nothing to display // Stop here if nothing to display
var bandsPanel = $("#bands-container"); var bandsContainer = $("#bands-container");
if (spots.length === 0) { if (spots.length === 0) {
bandsPanel.html("<div class='alert alert-danger' role='alert'>No spots match your filters.</div>"); bandsContainer.html("<div class='alert alert-danger' role='alert'>No spots match your filters.</div>");
return; return;
} }
@@ -51,73 +60,137 @@ function updateBands() {
} }
}); });
// Build up HTML content for each band // Track if any columns end up taller than expected, so we can resize the container and avoid vertical scroll.
let html = ""; var maxHeightBand = 0;
const columnWidthPercent = Math.max(30, 100 / bandToSpots.size);
let columnIndex = 0; // Build up table content for each band
var table = $('<table id="bands-table">').append('<thead><tr></tr></thead><tbody><tr></tr></tbody>');
bandToSpots.forEach(function (spotList, bandName) { bandToSpots.forEach(function (spotList, bandName) {
// Get the colours for the band from the first spot, and prepare the header // Get the colours for the band from the first spot, and prepare the header
html += "<div class='bandCol' style='width:" + columnWidthPercent + "%'>"; table.find('thead tr').append(`<th style='background-color:${spotList[0].band_color}; color:${spotList[0].band_contrast_color}'>${spotList[0].band}</th>`);
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 // Get the band data to fetch start and end frequencies
let band = options["bands"].filter(function (b) { let band = options["bands"].filter(function (b) {
return b.name === bandName; return b.name === bandName;
})[0]; })[0];
// Start printing the band
// Print the frequency band markers. This is 41 steps to divide the band evenly into 40 markers. One in every
// four will show the actual frequency, the others will just be dashes.
bandMarkersDiv = $('<div class="band-markers">');
const freqStep = (band.end_freq - band.start_freq) / 40.0; 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++) { 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) { if (i % 4 === 0) {
html += "<li><span>&mdash;" + ((band.start_freq + i * freqStep)/1000000).toFixed(3) + "</span></li>"; bandMarkersDiv.append("&mdash;" + ((band.start_freq + i * freqStep)/1000000).toFixed(3) + "<br/>");
} else if (i % 4 === 2) { } else if (i % 4 === 2) {
html += "<li><span>&ndash;</span></li>"; bandMarkersDiv.append("&ndash;<br/>");
} else { } else {
html += "<li><span>-</span></li>"; bandMarkersDiv.append("-<br/>");
} }
} }
}
html += "<li><span>-</span></li>";
html += "</ul>";
html += "</div></div>"; // Prepare the spots list
columnIndex++; var bandSpotsDiv = $("<div class='band-spots'>");
var lastSpotPxDownBand = -999;
// Sort by frequency so have a consistent order in which to plan where they will appear on the band div.
spotList.sort(function(a, b) { return a.freq - b.freq; });
// First calculate how we should be displaying the spots. There are three "modes" to try to place them in a
// visually appealing way:
// 1) Spaced normally, not going over the end of the band, so we populate them forwards.
// 2) Would go over the end, but the spots don't fill the band, so we populate them backwards.
// 3) Spots totally fill the band (or more), so we space them evenly starting at the top.
// In each case, we don't add anything to the DOM yet, we just calculate "pxDownBandLabel" (how far the *top* of
// the label is from the top of the div) and add that as a property to the spot for later use.
if (spotList.length >= BAND_COLUMN_HEIGHT_PX / BAND_COLUMN_SPOT_DIV_HEIGHT_PX) {
// Mode 3.
// Just lay out all spots simply, starting at 0px offset and working down with each one touching.
lastSpotPxDownBand = 0 - BAND_COLUMN_SPOT_DIV_HEIGHT_PX;
spotList.forEach(s => {
lastSpotPxDownBand = lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX;
s["pxDownBandLabel"] = lastSpotPxDownBand;
}); });
} else {
// Mode 1 or 2. Run through adding things to the list forwards as a test.
spotList.forEach(s => {
// Work out how far down the div to draw it
var percentDownBand = (s.freq - band.start_freq) / (band.end_freq - band.start_freq) * 0.97; // not 100% due to fudge, the first and last dashes are not exactly at the top and bottom of the div as some space is needed for text
var pxDownBand = percentDownBand * BAND_COLUMN_HEIGHT_PX;
if (pxDownBand < lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX) {
pxDownBand = lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX; // Prevent overlap
}
s["pxDownBandLabel"] = pxDownBand;
lastSpotPxDownBand = pxDownBand;
});
// Work out if we overflowed the end.
if (lastSpotPxDownBand <= BAND_COLUMN_HEIGHT_PX) {
// Mode 1. Current positions are fine and there's nothing to do.
} else {
// Mode 2. Repeat the process but backwards, starting at the end and working upwards.
lastSpotPxDownBand = 999999;
spotList.reverse().forEach(s => {
// Work out how far down the div to draw it
var percentDownBand = (s.freq - band.start_freq) / (band.end_freq - band.start_freq) * 0.97; // not 100% due to fudge, the first and last dashes are not exactly at the top and bottom of the div as some space is needed for text
var pxDownBand = percentDownBand * BAND_COLUMN_HEIGHT_PX;
if (pxDownBand > lastSpotPxDownBand - BAND_COLUMN_SPOT_DIV_HEIGHT_PX) {
pxDownBand = lastSpotPxDownBand - BAND_COLUMN_SPOT_DIV_HEIGHT_PX; // Prevent overlap
}
s["pxDownBandLabel"] = pxDownBand;
lastSpotPxDownBand = pxDownBand;
});
}
}
// Now each spot is tagged with how far down the div it should go, add them to the DOM.
spotList.forEach(s => {
bandSpotsDiv.append(`<div class="band-spot" style="top: ${s['pxDownBandLabel']}px; border-top: 1px solid ${s.band_color}; border-left: 5px solid ${s.band_color}; border-bottom: 1px solid ${s.band_color}; border-right: 1px solid ${s.band_color};"><span class="band-spot-call">${s.dx_call}</span><span class="band-spot-info">${s.dx_call} ${(s.freq/1000000).toFixed(3)} ${s.mode}</span></div>`);
});
// Work out how tall the canvas should be. Normally this is matching the normal band column height, but if some
// spots have gone off the end of the band markers and stretched their div, we need to resize the canvas to
// match, otherwise we have nowhere to draw their connecting lines.
var canvasHeight = Math.max(BAND_COLUMN_HEIGHT_PX, lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX);
maxHeightBand = Math.max(maxHeightBand, canvasHeight);
// Draw horizontal or diagonal lines to join up the "real" frequency with where the spot div ended up
var bandLinesCanvas = $(`<canvas class='band-lines-canvas' width='${BAND_COLUMN_CANVAS_WIDTH_PX}px' height='${canvasHeight}px' style='height:${canvasHeight}px !important;'>`);
spotList.forEach(s => {
// Work out how far down the div to draw it
var percentDownBand = (s.freq - band.start_freq) / (band.end_freq - band.start_freq) * 0.97; // not 100% due to fudge, the first and last dashes are not exactly at the top and bottom of the div as some space is needed for text
var pxDownBandFreq = (percentDownBand + 0.015) * BAND_COLUMN_HEIGHT_PX; // same fudge but add half to put the left end of the line in the right place
var pxDownBandLabel = s["pxDownBandLabel"] + (BAND_COLUMN_SPOT_DIV_HEIGHT_PX / 1.75); // line should be to the vertical text-centre spot, not to the top corner
// Draw the line on the canvas
var ctx = bandLinesCanvas[0].getContext('2d');
ctx.beginPath();
ctx.lineWidth = 2;
ctx.lineCap = "round";
ctx.strokeStyle = s.band_color;
ctx.moveTo(0, pxDownBandFreq);
ctx.lineTo(BAND_COLUMN_CANVAS_WIDTH_PX, pxDownBandLabel);
ctx.stroke();
});
// Assemble the table cell
td = $("<td>");
container = $("<div class='band-container'>");
container.append(bandLinesCanvas);
container.append(bandMarkersDiv);
container.append(bandSpotsDiv);
td.append(container);
table.find('tbody tr').append(td);
});
// Update the DOM with the band HTML // Update the DOM with the band HTML
bandsPanel.html(html); bandsContainer.html(table);
// Increase the height of the bands container so we don't have any vertical scroll bars except the browser ones
bandsContainer.css("min-height", `${maxHeightBand + 42}px`);
// Desktop mouse wheel to scroll bands horizontally if used on the headers // Desktop mouse wheel to scroll bands horizontally if used on the headers
// noinspection JSDeprecatedSymbols table.find('thead tr').on("wheel", () => {
$(".bandColHeader").on("wheel", () => bandsPanel.scrollLeft(bandsPanel.scrollLeft() + event.deltaY / 10.0)); bandsContainer.scrollLeft(bandsContainer.scrollLeft() + event.deltaY / 10.0);
return false;
});
} }
// Iterate through a temporary list of spots, merging duplicates in a way suitable for the band panel. If two or more // Iterate through a temporary list of spots, merging duplicates in a way suitable for the band panel. If two or more