mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2025-10-27 16:59:25 +00:00
Partial map implementation #42
This commit is contained in:
@@ -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(",")
|
||||
|
||||
@@ -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
134
views/webpage_map.tpl
Normal 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>
|
||||
@@ -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
182
webassets/js/map.js
Normal 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> <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();
|
||||
});
|
||||
Reference in New Issue
Block a user