From 0db674eeb2423d40e044910e846136b49331c502 Mon Sep 17 00:00:00 2001 From: Ian Renton Date: Mon, 20 Oct 2025 21:01:00 +0100 Subject: [PATCH] Continue work on bands display. #48 --- server/webserver.py | 15 +++-- webassets/apidocs/openapi.yml | 13 ++++- webassets/css/style.css | 104 ++++++++++++++++++++++++++++++++++ webassets/js/bands.js | 52 ++++++++++------- webassets/js/map.js | 4 +- 5 files changed, 159 insertions(+), 29 deletions(-) diff --git a/server/webserver.py b/server/webserver.py index 91cc741..4e34811 100644 --- a/server/webserver.py +++ b/server/webserver.py @@ -207,6 +207,11 @@ class WebServer: needs_sig = query.get(k).upper() == "TRUE" if needs_sig: spots = [s for s in spots if s.sig] + case "needs_sig_ref": + # If true, at least one sig ref is required, regardless of what it is, it just can't be missing. + needs_sig_ref = query.get(k).upper() == "TRUE" + if needs_sig_ref: + spots = [s for s in spots if s.sig_refs and len(s.sig_refs) > 0] case "band": bands = query.get(k).split(",") spots = [s for s in spots if s.band and s.band in bands] @@ -230,11 +235,11 @@ class WebServer: prevent_qrt = query.get(k).upper() == "FALSE" if prevent_qrt: spots = [s for s in spots if not s.qrt or s.qrt == False] - case "needs_location": - # If true, spots require a location to be returned - needs_location = query.get(k).upper() == "TRUE" - if needs_location: - spots = [s for s in spots if s.latitude and s.longitude] + case "needs_good_location": + # If true, spots require a "good" location to be returned + needs_good_location = query.get(k).upper() == "TRUE" + if needs_good_location: + spots = [s for s in spots if s.dx_location_good] case "dedupe": # Ensure only the latest spot of each callsign is present in the list. This relies on the list being # in reverse time order, so if any future change allows re-ordering the list, that should be done diff --git a/webassets/apidocs/openapi.yml b/webassets/apidocs/openapi.yml index 44b508a..6b36373 100644 --- a/webassets/apidocs/openapi.yml +++ b/webassets/apidocs/openapi.yml @@ -85,7 +85,14 @@ paths: - IOTA - name: needs_sig in: query - description: "Limit the spots to only ones from a Special Interest Grous such as POTA. Because supplying all known SIGs as a `sigs` parameter is unwieldy, and leaving `sigs` blank will also return spots with *no* SIG, this parameter can be set true to return only spots with a SIG, regardless of what it is, so long as it's not blank. This is what Field Spotter uses to exclude generic cluster spots and only retrieve xOTA things." + description: "Limit the spots to only ones with a Special Interest Group such as POTA. Because supplying all known SIGs as a `sigs` parameter is unwieldy, and leaving `sigs` blank will also return spots with *no* SIG, this parameter can be set true to return only spots with a SIG, regardless of what it is, so long as it's not blank. This is what Field Spotter uses to exclude generic cluster spots and only retrieve xOTA things." + required: false + schema: + type: boolean + default: false + - name: needs_sig_ref + in: query + description: "Limit the spots to only ones which have at least one reference (e.g. a park reference) for Special Interest Groups such as POTA." required: false schema: type: boolean @@ -195,9 +202,9 @@ paths: required: false schema: type: string - - name: needs_location + - name: needs_good_location in: query - description: Return only spots with a location set. + description: "Return only spots with a 'good' location. (See the spot `dx_location_good` parameter for details. Useful for map-based clients, to avoid spots with 'bad' locations e.g. loads of cluster spots ending up in the centre of the DXCC entitity.)" required: false schema: type: boolean diff --git a/webassets/css/style.css b/webassets/css/style.css index 9f2ebd7..33e2a41 100644 --- a/webassets/css/style.css +++ b/webassets/css/style.css @@ -150,6 +150,110 @@ div#map { } +/* BANDS PANEL */ + +div#bands-container { + margin: 0; + padding: 0; + overflow-x: auto; + overflow-y: auto; + white-space: nowrap; + display: flex; + overscroll-behavior-x: none; +} + +/* Bands panel inner layout */ +div.bandCol { + height: 100%; + min-width: 8em; + display: flex; + flex-flow: column; + overflow-y: clip; +} + +div.bandColHeader { + flex: 0 1 auto; +} + +div.bandColMiddle { + 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; + font-weight: bold; + padding: 0.5em; +} + +div.bandColMiddle { + margin-left: 3px; + border-left: 2px dotted var(--text); +} + +div.bandColSpot { + display: block; + border-radius: 3px; + padding: 3px; + background: lightyellow; + margin-right: 2em; +} + +span.bandColSpot { + vertical-align: bottom; + display: inline !important; +} + +/* Don't wrap frequencies */ +span.bandColSpotFreq { + white-space: nowrap; + display: inline !important; +} + +span.bandColSpotMode { + padding-left: 0.5em; + font-size: 0.8em; + line-height: 0.4em; +} + + /* GENERAL MOBILE SUPPORT */ @media (max-width: 991.99px) { diff --git a/webassets/js/bands.js b/webassets/js/bands.js index 4129333..ffcda9a 100644 --- a/webassets/js/bands.js +++ b/webassets/js/bands.js @@ -30,22 +30,20 @@ function updateBands() { // Stop here if nothing to display var bandsPanel = $("#bands-container"); if (spots.length === 0) { - // todo bootstrapify - bandsPanel.html("

There are no spots matching your filters.

"); + bandsPanel.html(""); return; } // Do some harsher de-duping. Because we only display callsign, frequency and mode here, the previous // de-duplication could have let some through that don't look like dupes on the map, but would do here. // Typically that's a person activating two programs at the same time, e.g. POTA & WWFF. - // todo fix from here - spotList = removeDuplicatesForBandPanel(spotList); + spotList = removeDuplicatesForBandPanel(spots); // Convert to a map of band names to the spots on that band. Bands with no // spots in view will not be present. const bandToSpots = new Map(); options["bands"].forEach(function (band) { - const matchingSpots = spots.filter(function (s) { + const matchingSpots = spotList.filter(function (s) { return s.band === band.name; }); if (matchingSpots.length > 0) { @@ -60,7 +58,7 @@ function updateBands() { bandToSpots.forEach(function (spotList, bandName) { // Get the colours for the band from the first spot, and prepare the header html += "
"; - html += "
" + spotList[0].band + "
"; + html += "
" + spotList[0].band + "
"; html += "
"; // Get the band data to fetch start and end frequencies @@ -68,7 +66,7 @@ function updateBands() { return b.name === bandName; })[0]; // Start printing the band - const freqStep = (band.stopFreq - band.startFreq) / 40.0; + const freqStep = (band.end_freq - band.start_freq) / 40.0; html += "
    "; html += "
  • -
  • "; @@ -76,12 +74,12 @@ function updateBands() { for (let i = 0; i <= 40; i++) { // Work out if there are any spots in this step - const freqStepStart = band.startFreq + i * freqStep; + 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.stopFreq)); + return s.freq >= freqStepStart && (s.freq < freqStepEnd || (s.freq === freqStepEnd && freqStepEnd === band.end_freq)); }); if (spotsInStep.length > 0) { @@ -89,15 +87,7 @@ function updateBands() { html += "
  • "; spotsInStep.sort((a, b) => (a.freq > b.freq) ? 1 : ((b.freq > a.freq) ? -1 : 0)); spotsInStep.forEach(function (s) { - // Figure out the class to use for the spot's div, which defines its colour. - let spotDivClass = "bandColSpotCurrent"; - if (currentPopupSpotUID === s.uid) { - spotDivClass = "bandColSpotSelected"; - } else if (preQSYStatusShouldShowGrey(s.preqsy)) { - spotDivClass = "bandColSpotOld"; - } - - html += "
    " + s.activator + "
    " + getFormattedFrequency(s.freq) + ""; + html += "
    " + s.dx_call + "
    " + (s.freq/1000000) + ""; if (s.mode != null && s.mode.length > 0 && s.mode !== "Unknown") { html += "" + s.mode + ""; } @@ -108,7 +98,7 @@ function updateBands() { } 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) { - html += "
  • —" + (band.startFreq + i * freqStep).toFixed(3) + "
  • "; + html += "
  • —" + ((band.start_freq + i * freqStep)/1000000).toFixed(3) + "
  • "; } else if (i % 4 === 2) { html += "
  • "; } else { @@ -128,7 +118,31 @@ function updateBands() { // Desktop mouse wheel to scroll bands horizontally if used on the headers // noinspection JSDeprecatedSymbols $(".bandColHeader").on("wheel", () => bandsPanel.scrollLeft(bandsPanel.scrollLeft() + event.deltaY / 10.0)); +} +// Iterate through a temporary list of spots, merging duplicates in a way suitable for the band panel. If two or more +// spots with the activator, mode and frequency are found, these will be merged and reduced until only one remains, +// with the best data. Note that unlike removeDuplicates(), which operates on the main spot map, this operates only +// on the temporary array of spots provided as an argument, and returns the output, for use when constructing the +// band panel. +function removeDuplicatesForBandPanel(spotList) { + const spotsToRemove = []; + spotList.forEach(function (check) { + spotList.forEach(function (s) { + if (s !== check) { + if (s.dx_call === check.dx_call && s.freq === check.freq && s.mode === check.mode) { + // Find which one to keep and which to delete + const checkSpotNewer = check.time > s.time; + const keepSpot = checkSpotNewer ? check : s; + const deleteSpot = checkSpotNewer ? s : check; + // Aggregate list of spots to remove + spotsToRemove.push(deleteSpot.uid); + } + } + }); + }); + // Perform the removal + return spotList.filter(s => !spotsToRemove.includes(s.uid)); } // Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query diff --git a/webassets/js/map.js b/webassets/js/map.js index c6849d5..273364c 100644 --- a/webassets/js/map.js +++ b/webassets/js/map.js @@ -23,8 +23,8 @@ function buildQueryString() { } }); str = str + "max_age=" + $("#max-spot-age option:selected").val(); - // Additional filters for the map view: No dupes, no QRT, only spots with locations - str = str + "&dedupe=true&allow_qrt=false&needs_location=true"; + // Additional filters for the map view: No dupes, no QRT, only spots with good locations + str = str + "&dedupe=true&allow_qrt=false&needs_good_location=true"; return str; }