From 517cf400b342ed916e4bc09bdb1a2dd66b2c4583 Mon Sep 17 00:00:00 2001 From: Tanner Collin Date: Wed, 1 Apr 2026 12:22:45 -0600 Subject: [PATCH] feat: Add initial WSPR live data querying scripts --- index.js | 0 js/Base.js | 257 ++++++++++++++++++++++++++++ js/QuerierWsprLive.js | 378 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 635 insertions(+) create mode 100644 index.js create mode 100644 js/Base.js create mode 100644 js/QuerierWsprLive.js diff --git a/index.js b/index.js new file mode 100644 index 0000000..e69de29 diff --git a/js/Base.js b/js/Base.js new file mode 100644 index 0000000..c032563 --- /dev/null +++ b/js/Base.js @@ -0,0 +1,257 @@ +/* +Copyright (c) 2023-forever Douglas Malnati. All rights reserved. + +See the /faq/tos page for details. + +(If this generated header is stamped on a file which is a 3rd party file or under a different license or copyright, then ignore this copyright statement and use that file's terms.) +*/ + +import * as utl from '/js/Utl.js'; + +import { Timeline } from '/js/Timeline.js'; + + +// fold this functionality back into the original Event.js later +class Event +{ + static handlerList = []; + + AddHandler(handler) + { + Event.handlerList.push(handler); + } + + Emit(evt) + { + if (typeof(evt) == "string") + { + evt = { + type: evt + }; + } + + for (const evtHandler of Event.handlerList) + { + if (evtHandler.OnEvent) + { + evtHandler.OnEvent(evt); + } + } + } +} + + +export class Base +extends Event +{ + static globalDebugAnyway = false; + + constructor(t) + { + super(); + + this.AddHandler(this); + + // timeline + this.t = t ?? new Timeline(); + + + // + // logging levels + // + + // debug + this.debug = false; + + // info + this.info = true; + } + + // only to be called once, from main application, when everything is + // constructed and ready to go + Run() + { + urlStateProxy.OnPageLoad(); + } + + SetDebug(tf) + { + this.debug = tf; + } + + SetGlobalDebug(tf) + { + Base.globalDebugAnyway = tf; + } + + Debug(str) + { + if (this.debug || Base.globalDebugAnyway) + { + console.log(str); + } + } + + DebugTable(val) + { + if (this.debug || Base.globalDebugAnyway) + { + console.table(val); + } + } + + SetInfo(tf) + { + this.info = tf; + } + + Info(str) + { + if (this.info) + { + console.log(str); + } + } + + Err(from, str) + { + console.log(`ERR: ${from} - ${str}`); + } +} + + + +// A class that does the work of helping components interact with the URL +// and the variables captured within the URL. +// +// Also manages browser history and gives the typical user experience of +// how history works, without page reloads. +class UrlStateProxy +extends Base +{ + constructor() + { + super(); + + this.urlPrior = ""; + + window.addEventListener("popstate", (event) => { + this.DoUrlSet(); + }); + } + + OnPageLoad() + { + this.DoUrlSet(); + this.DoUrlGet(); + } + + DoUrlSet() + { + // capture current url and its search params, and parse + const url = new URL(window.location); + const params = new URLSearchParams(url.search); + + // console.log(`window.location: ${window.location}`) + // console.log(`urlIn.origin: ${url.origin}`) + // console.log(`urlIn.pathname: ${url.pathname}`) + // console.log(`urlIn.search: ${url.search}`) + // console.log(`paramsIn : ${params.toString()}`); + + // synchronously send to interested listeners + this.Emit({ + type: "ON_URL_SET", + + Get: (param, defaultValue) => { + return utl.GetSearchParam(param, defaultValue); + }, + }); + } + + DoUrlGet() + { + // synchronously send to interested listeners + let paramsOut = new URLSearchParams(``); + let evt = { + type: "ON_URL_GET", + + Set: (param, value) => { + paramsOut.set(param, value); + }, + + allowBlank: false, + }; + this.Emit(evt); + + const urlIn = new URL(window.location); + let paramsIn = new URLSearchParams(urlIn.search); + + // filter out blank parameters unless requested not to + if (evt.allowBlank == false) + { + for (let [key, value] of Array.from(paramsOut.entries())) + { + if (value === "") + { + paramsOut.delete(key); + } + } + } + + const urlOut = new URL(`${urlIn.origin}${urlIn.pathname}?${paramsOut.toString()}`); + + // console.log("") + // console.log("") + // console.log(`old : ${urlIn.href}`); + // console.log(`params : ${paramsOut.toString()}`); + // console.log(`new : ${urlOut.href}`); + + let paramsInSorted = new URLSearchParams(paramsIn.toString()); + paramsInSorted.sort(); + let pIn = paramsInSorted.toString(); + + let paramsOutSorted = new URLSearchParams(paramsOut.toString()); + paramsOutSorted.sort(); + let pOut = paramsOutSorted.toString(); + + let didNewHistoryEntry = false; + if (pIn != pOut) + { + // console.log("params are different, updating url and history") + // console.log(pIn) + // console.log(pOut) + + history.pushState({}, "", urlOut.href); + + didNewHistoryEntry = true; + } + + return didNewHistoryEntry; + } + + OnEvent(evt) + { + switch (evt.type) { + case "REQ_URL_GET": this.OnReqUrlGet(); break; + } + } + + OnReqUrlGet() + { + // the purpose of this is to blow away the forward history when (say) + // a user clicks search, which triggers a URL re-evaluation. + let didNewHistoryEntry = this.DoUrlGet(); + + if (didNewHistoryEntry == false) + { + // force new history, request came in to re-evaluate, clearly + // a change has occurred. + history.pushState({}, "", window.location); + } + } +} + +// global single instance +let urlStateProxy = new UrlStateProxy(); + + diff --git a/js/QuerierWsprLive.js b/js/QuerierWsprLive.js new file mode 100644 index 0000000..6f4f509 --- /dev/null +++ b/js/QuerierWsprLive.js @@ -0,0 +1,378 @@ +/* +Copyright (c) 2023-forever Douglas Malnati. All rights reserved. + +See the /faq/tos page for details. + +(If this generated header is stamped on a file which is a 3rd party file or under a different license or copyright, then ignore this copyright statement and use that file's terms.) +*/ + +import * as utl from '/js/Utl.js'; + +import { Base } from './Base.js'; +import { WSPR } from '/js/WSPR.js'; + + +const QUERY_URL_BASE = 'https://db1.wspr.live/'; + +export class QuerierWsprLive +extends Base +{ + constructor() + { + super(); + + // database is in UTC, enabling this allows queriers to use the + // time of day that makes sense to them, but search will be + // performed in converted UTC + this.autoConvertTimeToUtc = true; + } + + async SearchRegularType1(band, min, callsign, timeStart, timeEnd, limit) + { + let query = this.GetSearchRegularType1Query(band, min, callsign, timeStart, timeEnd, limit) + + return this.DoQuery(query); + } + + async SearchTelemetry(band, min, id1, id3, timeStart, timeEnd, limit) + { + let query = this.GetSearchTelemetryQuery(band, min, id1, id3, timeStart, timeEnd, limit); + + return this.DoQuery(query); + } + + + async SearchRegularType1AnyMinute(band, callsign, timeStart, timeEnd, limit) + { + let query = this.GetSearchRegularType1AnyMinuteQuery(band, callsign, timeStart, timeEnd, limit) + + return this.DoQuery(query); + } + + +// private: + + GetDbEnumValForBand(band) + { + band = WSPR.GetDefaultBandIfNotValid(band); + + let band__dbBand = new Map(); + + band__dbBand.set("2190m", -1); + band__dbBand.set("630m", 0); + band__dbBand.set("160m", 1); + band__dbBand.set("80m", 3); + band__dbBand.set("60m", 5); + band__dbBand.set("40m", 7); + band__dbBand.set("30m", 10); + band__dbBand.set("20m", 14); + band__dbBand.set("17m", 18); + band__dbBand.set("15m", 21); + band__dbBand.set("12m", 24); + band__dbBand.set("10m", 28); + band__dbBand.set("6m", 50); + band__dbBand.set("4m", 70); + band__dbBand.set("2m", 144); + band__dbBand.set("70cm", 432); + band__dbBand.set("23cm", 1296); + + let retVal = band__dbBand.get(band); + + return retVal; + } + + async DoQueryRaw(query) + { + // make actual wspr.live query url + let urlWsprLiveMaker = new URL(QUERY_URL_BASE); + urlWsprLiveMaker.searchParams.set("query", query); + let urlWsprLive = urlWsprLiveMaker.href + " FORMAT JSONCompact"; + + // make debug url + let urlQueryTableMaker = new URL(`/pro/query/`, window.location); + urlQueryTableMaker.searchParams.set("query", query); + let urlQueryTable = urlQueryTableMaker.href; + + this.Info(urlQueryTable); + + let retVal = { + "queryRequest": { + "query": query, + "queryUrl": urlWsprLive, + }, + "queryReply" : {}, + "err": "", + }; + + try { + let response = await fetch(urlWsprLive); + + if (response.ok) + { + let queryReply = await response.json(); + + retVal.queryReply = queryReply; + } + else + { + retVal.err = await response.text(); + } + } catch (e) { + console.log("ERR: Query: " + e); + } + + return retVal; + } + + async DoQuery(query) + { + let queryResultObj = await this.DoQueryRaw(query); + + let resultList = []; + + // check for errors + if (queryResultObj.err == "") + { + // convert result to convenient object format + + // array of objects, eg: + // [ + // { + // name: "xxx", + // type: "Float64", // or whatever + // }, + // { + // name: "yyy", + // type: "Float64", // or whatever + // } + // ] + // + // These are the column headers of the data returned in a separate array + let fieldMetaList = Array.isArray(queryResultObj.queryReply?.meta) + ? queryResultObj.queryReply.meta + : []; + + // list of lists + // 0 or more rows, each row containing the number of fields specified in metadata, eg + // [ + // [xxxVal, yyyVal], + // [xxxVal, yyyVal], + // ] + let dataRowList = Array.isArray(queryResultObj.queryReply?.data) + ? queryResultObj.queryReply.data + : []; + + if (!Array.isArray(queryResultObj.queryReply?.data)) + { + this.Err("QuerierWsprLive::DoQuery", `Unexpected reply shape for query: ${queryResultObj.queryRequest?.queryUrl ?? query}`); + } + + // create a set of objects, each representing one row of results, eg: + // [ + // { + // xxx: xxxVal, + // yyy: yyyVal, + // }, + // { + // xxx: xxxVal, + // yyy: yyyVal, + // }, + // ] + for (let dataRow of dataRowList) + { + if (!Array.isArray(dataRow)) + { + continue; + } + + let result = {}; + + for (let i = 0; i < fieldMetaList.length; ++i) + { + result[fieldMetaList[i].name] = dataRow[i]; + } + + resultList.push(result); + } + } + + return resultList; + } + + GetSearchRegularType1Query(band, min, callsign, timeStart, timeEnd, limit) + { + if (this.autoConvertTimeToUtc) + { + timeStart = utl.ConvertLocalToUtc(timeStart); + if (timeEnd != "") + { + timeEnd = utl.ConvertLocalToUtc(timeEnd); + } + } + + band = WSPR.GetDefaultBandIfNotValid(band); + limit = (limit == undefined || limit < 1) ? 0 : limit; + + let dialFreq = WSPR.GetDialFreqFromBandStr(band); + let freqFloor = (dialFreq + 1500 - 100); + + let dbBand = this.GetDbEnumValForBand(band); + + let timeCriteria = `time between '${timeStart}' and '${timeEnd}'`; + if (timeEnd == "") + { + timeCriteria = `time >= '${timeStart}'`; + } + + let query = ` +select + time + , toMinute(time) % 10 as min + , tx_sign as callsign + , substring(tx_loc, 1, 4) as grid4 + , tx_loc as gridRaw + , power as powerDbm + , rx_sign as rxCallsign + , rx_loc as rxGrid + , frequency +from wspr.rx + +where + ${timeCriteria} + and band = ${dbBand} /* ${band} */ + and min = ${min} + and callsign = '${callsign}' + +${this.debug ? ("order by (time, rxCallsign) asc") : "/* order by (time, rxCallsign) asc */"} +${limit ? ("limit " + limit) : ""} + +`; + + return query; + } + + + + GetSearchTelemetryQuery(band, min, id1, id3, timeStart, timeEnd, limit) + { + if (this.autoConvertTimeToUtc) + { + timeStart = utl.ConvertLocalToUtc(timeStart); + + if (timeEnd != "") + { + timeEnd = utl.ConvertLocalToUtc(timeEnd); + } + } + + band = WSPR.GetDefaultBandIfNotValid(band); + limit = (limit == undefined || limit < 1) ? 0 : limit; + + let dialFreq = WSPR.GetDialFreqFromBandStr(band); + let freqFloor = (dialFreq + 1500 - 100); + + let dbBand = this.GetDbEnumValForBand(band); + + let timeCriteria = `time between '${timeStart}' and '${timeEnd}'`; + if (timeEnd == "") + { + timeCriteria = `time >= '${timeStart}'`; + } + + let query = ` +select + time + , substring(tx_sign, 1, 1) as id1 + , substring(tx_sign, 3, 1) as id3 + , toMinute(time) % 10 as min + , tx_sign as callsign + , tx_loc as grid4 + , power as powerDbm + , rx_sign as rxCallsign + , rx_loc as rxGrid + , frequency +from wspr.rx + +where + ${timeCriteria} + and band = ${dbBand} /* ${band} */ + and id1 = '${id1}' + and id3 = '${id3}' + and min = ${min} + and length(callsign) = 6 + and length(grid4) = 4 + +${this.debug ? ("order by (time, rxCallsign) asc") : "/* order by (time, rxCallsign) asc */"} +${limit ? ("limit " + limit) : ""} + +`; + + return query; + } + + + + + + + + GetSearchRegularType1AnyMinuteQuery(band, callsign, timeStart, timeEnd, limit) + { + if (this.autoConvertTimeToUtc) + { + timeStart = utl.ConvertLocalToUtc(timeStart); + if (timeEnd != "") + { + timeEnd = utl.ConvertLocalToUtc(timeEnd); + } + } + + band = WSPR.GetDefaultBandIfNotValid(band); + limit = (limit == undefined || limit < 1) ? 0 : limit; + + let dialFreq = WSPR.GetDialFreqFromBandStr(band); + let freqFloor = (dialFreq + 1500 - 100); + + let dbBand = this.GetDbEnumValForBand(band); + + let timeCriteria = `time between '${timeStart}' and '${timeEnd}'`; + if (timeEnd == "") + { + timeCriteria = `time >= '${timeStart}'`; + } + + let query = ` +select + time + , toMinute(time) % 10 as min + , tx_sign as callsign + , substring(tx_loc, 1, 4) as grid4 + , tx_loc as gridRaw + , power as powerDbm + , rx_sign as rxCallsign + , rx_loc as rxGrid + , frequency +from wspr.rx + +where + ${timeCriteria} + and band = ${dbBand} /* ${band} */ + and callsign = '${callsign}' + +${this.debug ? ("order by (time, rxCallsign) asc") : "/* order by (time, rxCallsign) asc */"} +${limit ? ("limit " + limit) : ""} + +`; + + return query; + } + + + + + + + + +} +