Partial map implementation #42

This commit is contained in:
Ian Renton
2025-10-16 22:17:33 +01:00
parent 9594040ea4
commit 87846f09f8
5 changed files with 337 additions and 4 deletions

View File

@@ -38,6 +38,7 @@ class WebServer:
bottle.post("/api/v1/spot")(lambda: self.accept_spot())
# Routes for templated pages
bottle.get("/")(lambda: self.serve_template('webpage_spots'))
bottle.get("/map")(lambda: self.serve_template('webpage_map'))
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'))
@@ -148,14 +149,14 @@ class WebServer:
for k in query.keys():
match k:
case "since":
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC)
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp()
spots = [s for s in spots if s.time and s.time > since]
case "max_age":
max_age = int(query.get(k))
since = datetime.now(pytz.UTC) - timedelta(seconds=max_age)
since = (datetime.now(pytz.UTC) - timedelta(seconds=max_age)).timestamp()
spots = [s for s in spots if s.time and s.time > since]
case "received_since":
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC)
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp()
spots = [s for s in spots if s.received_time and s.received_time > since]
case "source":
sources = query.get(k).split(",")

View File

@@ -58,6 +58,7 @@
<div class="collapse navbar-collapse" id="navbarTogglerDemo02">
<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">Spots</a></li>
<li class="nav-item ms-4"><a href="/map" class="nav-link" id="nav-link-map">Map</a></li>
<li class="nav-item ms-4"><a href="/alerts" class="nav-link" id="nav-link-alerts">Alerts</a></li>
<li class="nav-item ms-4"><a href="/status" class="nav-link" id="nav-link-status">Status</a></li>
<li class="nav-item ms-4"><a href="/about" class="nav-link" id="nav-link-about">About</a></li>
@@ -73,7 +74,7 @@
</main>
<div class="hideonmobile">
<div class="hideonmobile hideonmap">
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
<p class="col-md-4 mb-0 text-body-secondary">Made with love by <a href="https://ianrenton.com" class="text-body-secondary">Ian, MØTRT</a> and other contributors.</p>
<p class="col-md-4 mb-0 justify-content-center text-body-secondary" style="text-align: center;">Spothole v{{software_version}}</p>

134
views/webpage_map.tpl Normal file
View File

@@ -0,0 +1,134 @@
% rebase('webpage_base.tpl')
<div id="map">
<div class="mt-3 px-3" style="z-index: 1002; position: relative;">
<div class="row">
<div class="col-auto me-auto pt-3"></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 class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Map Features</h5>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="mapShowGeodesics" value="mapShowGeodesics" oninput="updateMap();">
<label class="form-check-label" for="mapShowGeodesics">Geodesic Lines</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-extra-markers@1.2.2/dist/css/leaflet.extra-markers.min.css">
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet-providers@2.0.0/leaflet-providers.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet-extra-markers@1.2.2/src/assets/js/leaflet.extra-markers.min.js" type="module"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet.geodesic"></script>
<script src="https://cdn.jsdelivr.net/npm/@joergdietrich/leaflet.terminator@1.1.0/L.Terminator.min.js"></script>
<script src="/js/common.js"></script>
<script src="/js/map.js"></script>
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>

View File

@@ -128,6 +128,21 @@ tr.table-faded td span {
}
/* MAP */
div#map {
width: auto;
height: 100%;
margin: 0;
overflow: hidden;
cursor: default;
font-size: 16px;
}
.leaflet-container {
font-family: var(--bs-body-font-family) !important;
}
/* GENERAL MOBILE SUPPORT */
@media (max-width: 991.99px) {

182
webassets/js/map.js Normal file
View File

@@ -0,0 +1,182 @@
// How often to query the server?
const REFRESH_INTERVAL_SEC = 60;
// Storage for the spot data that the server gives us.
var spots = []
// Marker layer
var markersLayer;
// Load spots and populate the table.
function loadSpots() {
$.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) {
// Store data
spots = jsonData;
// Update map
updateMap();
});
}
// 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();
return str;
}
// Update the spots map
function updateMap() {
// Clear existing content
markersLayer.clearLayers();
// Make new markers for all spots with a good location
spots.forEach(function (s) {
if (s["dx_location_good"]) {
let m = L.marker([s["dx_latitude"], s["dx_longitude"]], {icon: getIcon(s)});
markersLayer.addLayer(m);
}
});
}
// 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: "fa-" + s["icon"],
iconColor: "white", // todo
markerColor: "black", // todo
shape: 'circle',
prefix: 'fa',
svg: true
});
}
// 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 bullets and band toggle buttons
addBandColourCSS(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);
});
}
// Dynamically add CSS code for the band bullets and band toggle buttons to be in the appropriate colour.
// Some band names contain decimal points which are not allowed in CSS classes, so we text-replace them to "p".
function addBandColourCSS(band_options) {
var $style = $('<style>');
band_options.forEach(o => {
// CSS doesn't like IDs with decimal points in, so we need to replace that
var cssFormattedBandName = o['name'] ? o['name'].replace('.', 'p') : "unknown";
$style.append(`.band-bullet-${cssFormattedBandName} { color: ${o['color']}; }`);
$style.append(`#filter-button-label-band-${cssFormattedBandName} { border-color: ${o['color']}; color: var(--bs-primary);}`);
$style.append(`.btn-check:checked + #filter-button-label-band-${cssFormattedBandName} { background-color: ${o['color']}; color: ${o['contrast_color']};}`);
});
$('html > head').append($style);
}
// Generate bands filter card. This one is a special case.
function generateBandsMultiToggleFilterCard(band_options) {
// Create a button for each option
band_options.forEach(o => {
// CSS doesn't like IDs with decimal points in, so we need to replace that in the same way as when we originally
// queried the options endpoint and set our CSS.
var cssFormattedBandName = o['name'] ? o['name'].replace('.', 'p') : "unknown";
$("#band-options").append(`<input type="checkbox" class="btn-check filter-button-band storeable-checkbox" name="options" id="filter-button-band-${cssFormattedBandName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline" id="filter-button-label-band-${cssFormattedBandName}" for="filter-button-band-${cssFormattedBandName}">${o['name']}</label> `);
});
// Create All/None buttons
$("#band-options").append(` <span style="display: inline-block"><button id="filter-button-band-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', true);">All</button>&nbsp;<button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', false);">None</button></span>`);
}
// Method called when any filter is changed to reload the spots and persist the filter settings.
function filtersUpdated() {
loadSpots();
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();
}
// Set up the map
function setUpMap() {
// Create map
map = L.map('map', {
zoomControl: false,
minZoom: 2,
maxZoom: 12
});
// Add basemap
backgroundTileLayer = L.tileLayer.provider("CartoDB.Voyager", {
opacity: 1,
edgeBufferTiles: 1
});
backgroundTileLayer.addTo(map);
backgroundTileLayer.bringToBack();
// Add marker layer
markersLayer = new L.LayerGroup();
markersLayer.addTo(map);
// Add terminator/greyline
terminator = L.terminator({
interactive: false
});
terminator.setStyle({fillColor: '#00000050'});
terminator.addTo(map);
// Display a default view.
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();
});